diff --git a/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/archive-report.md b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/archive-report.md new file mode 100644 index 0000000..bc8d746 --- /dev/null +++ b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/archive-report.md @@ -0,0 +1,32 @@ +# Archive Report: OpenCode managed setup convergence + +**Status**: ARCHIVED
+**Oracle verdict**: PASS
+**Archive path**: `openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/` + +## Completed scope + +- US1 / FR-001, FR-002, FR-003, FR-010, FR-011 / SC-001, SC-006: global and project OpenCode setup now converges older, newer, absent, malformed, and same-version-diverged state from current packed assets, replaces the whole managed target and plugin entry link-safely, and repeats as an exact no-op. +- US2 / FR-005, FR-006, FR-007, FR-008 / SC-002, SC-004: temporary target-bound signed journals provide in-process and crash restoration, invalid evidence resets safely, successful setup removes durable rollback evidence, and cleanup faults return bounded success warnings with later retry. +- US3 / FR-004 / SC-003: JSONC precedence, parseable unrelated-setting preservation, canonical MCP repair, and raw-byte non-colliding malformed-config quarantine are implemented. +- US4 / FR-009, FR-010, FR-011 / SC-005, SC-006, SC-007: plan mode stays zero-write, human/JSON result semantics and restart guidance agree, packed global/project flows pass, and all repository gates pass. + +## Verification lineage + +- `plan-review.md` records optional Oracle `[OKAY]` before implementation. +- The first `verify-report.md` result identified ORA-VERIFY-001, a junction-ancestor journal containment defect; convergence task T024 added physical trusted-root validation and global/project outside-sentinel regressions. +- `verify-report.md` now records independent oracle PASS with executed evidence for FR-001 through FR-011 and SC-001 through SC-007. +- Final Oracle coherence review confirmed the behavior-neutral `[ADDED cli]` and `[ADDED packaging]` delta refinement preserves the PASS result and has no canonical title collision. + +## Canonical specification sync + +- Updated: `cli`, `packaging`. +## Deviations and residual warnings + +- The accepted contract intentionally permits destructive replacement, downgrade, and removal of post-success OpenCode rollback evidence; this is recorded as a scoped P5 justified exception while CLI names, fields, statuses, and exit codes remain stable. +- One POSIX-only file-mode test was skipped on Windows; Oracle reviewed the explicit `0o600` request, while Windows junction security tests executed and passed. +- No real user-home setup, host restart, publication, release, or generated `dist/` edit was performed. + +## Follow-up + +- None required for the verified change. Any real-host smoke or publication remains a separately authorized stateful operation. diff --git a/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/checklists/requirements.md b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/checklists/requirements.md new file mode 100644 index 0000000..04c7485 --- /dev/null +++ b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/checklists/requirements.md @@ -0,0 +1,24 @@ +# Requirements checklist: OpenCode managed setup convergence + +**Activation reason**: The confirmed contract intentionally permits destructive replacement, downgrade, malformed-config recreation, invalid-journal discard, and removal of durable rollback evidence across global and project OpenCode scopes. These choices require explicit completeness, security, recovery, and migration auditing before implementation. + +## Initial validation + +- [x] CHK001 [Completeness] Do US1-US4 cover the user, operator, normal convergence, exact no-op, config repair, interruption recovery, invalid evidence, cleanup failure, planning, packed delivery, and restart flows? Evidence: every named flow has at least one Given/When/Then scenario and maps to FR-001 through FR-011. +- [x] CHK002 [Clarity] Does each FR state one observable policy without relying on “safe”, “managed”, or “current” as undefined authority? Evidence: FR-001 defines canonical path ownership, FR-002 defines exact no-op versus convergence, FR-003 defines full replacement/link behavior, and FR-005 through FR-008 define journal lifecycle and cleanup outcomes. +- [x] CHK003 [Consistency] Are stories, requirements, assumptions, dependencies, and non-goals consistent about full-directory deletion, any-direction version convergence, no `--force`, transient-only rollback, and both OpenCode scopes? Evidence: US1/US2, FR-001/FR-002/FR-007, assumptions 1-2, and out-of-scope items use the same rules; Codex/Claude remain excluded. +- [x] CHK004 [Measurability] Does every FR have a public or isolated verification seam and does every SC define countable/pass-fail evidence? Evidence: plan requirement mapping covers FR-001 through FR-011 and SC-001, SC-002, SC-003, SC-004, SC-005, SC-006, and SC-007; the spec passed the Full `specify` validator gate. +- [x] CHK005 [Coverage] Is every US, FR, SC, actor, failure mode, and confirmed constraint represented in traceability? Evidence: every story has `Covers`, every FR appears in the plan table, every SC appears in the evidence table, and edge cases enumerate metadata, target kinds, config candidates, crash boundaries, receipt isolation, path spaces, and concurrency. + +## Domain lenses + +- [x] CHK006 [Security] Do requirements prevent link traversal, path escape, secret leakage, and cross-target receipt deletion despite permissive ownership? Evidence: FR-003 removes the final link without following it, FR-006 forbids embedded-path deletion, FR-007 is harness/scope/target bounded, FR-011 preflights boundaries, and SC-002/SC-003 require traversal and diagnostic checks. +- [x] CHK007 [Migration] Are older, newer, missing, malformed, and same-version states all assigned deterministic outcomes, including legacy receipts? Evidence: US1 scenarios, FR-001/FR-002/FR-010, edge cases, and the plan's legacy receipt migration risk cover each state; downgrade is explicitly accepted. +- [x] CHK008 [Recovery] Are live failure, process crash, valid journal, invalid journal, post-verification crash, and cleanup failure distinguishable? Evidence: US2 has five scenarios; FR-005 through FR-008 specify restoration, reset, verify-before-delete, warning, and retry semantics. +- [x] CHK009 [User data] Is the exception for malformed external configuration explicit and recoverable? Evidence: US3 and FR-004 require a byte-exact non-colliding backup before recreation, path-only disclosure, unrelated-setting preservation for parseable input, and JSONC precedence. +- [x] CHK010 [Public contract] Are changed and unchanged CLI behaviors explicit? Evidence: FR-009 preserves plan zero-write behavior, result status/changed semantics, and restart guidance; out of scope excludes process control, while the plan records the P5 justified exception for post-success rollback removal. +- [x] CHK011 [Harness isolation] Can permissive OpenCode behavior leak into Codex or Claude? Evidence: affected capabilities are CLI/packaging, FRs name OpenCode, out of scope excludes both other harnesses, and plan design requires an OpenCode-only branch plus regression suites. + +## Revalidation + +- Not required: The checklist was created after the specification passed `specify` and the technical plan passed `plan`; the plan added implementation detail without changing any confirmed requirement, story, criterion, assumption, or non-goal. diff --git a/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/plan-review.md b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/plan-review.md new file mode 100644 index 0000000..f052cce --- /dev/null +++ b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/plan-review.md @@ -0,0 +1,78 @@ +--- +schema: thoth-agents/sdd-plan-review/v1 +artifact: plan-review +change: opencode-managed-setup-convergence +gate: oracle-review +status: "[OKAY]" +reviewer_role: oracle +reviewed_at: 2026-07-20T21:47:38.384088200Z +pipeline: full +persistence_mode: openspec +override: + occurred: false + at: null + surface: null + context: null +reviewed_artifacts: + - role: spec + path: openspec/changes/opencode-managed-setup-convergence/spec.md + required: true + sha256: sha256:2db7e77062ecd8571a3c5b037517b9c67aefcf5683f53123fd635c8705aba3d3 + - role: plan + path: openspec/changes/opencode-managed-setup-convergence/plan.md + required: true + sha256: sha256:3675b1bdda984180958f3307028bd0ecc2e721cfcb3203414030f735e004139c + - role: tasks + path: openspec/changes/opencode-managed-setup-convergence/tasks.md + required: true + sha256: sha256:70c3f9a399906e59614a1b5c74b4f6b3f1b02d0ba2fb084c557d116a0b0105ea + - role: checklist + path: openspec/changes/opencode-managed-setup-convergence/checklists/requirements.md + required: true + sha256: sha256:e11e63b9382a3c95c3c72b8f65820e40e167daf30d96bca6999cbda1520d380a + - role: constitution + path: openspec/memory/constitution.md + required: true + sha256: sha256:4203d40b3a13c45d6862beeee99eb762e76785a50f7e680f61bf5412a9bb04aa +--- + +# Plan Review: OpenCode managed setup convergence + +**Status**: [OKAY] + +## Oracle Result + +[OKAY] + +## Comments + +- FR-001 through FR-011 and SC-001 through SC-007 map to executable T001-T023 work. +- Named engine, filesystem, receipt, lock, CLI, and test anchors exist with reusable transaction, checkpoint, fault-injection, isolation, and packed-install seams. +- Tasks are sequential, one-writer safe, and production changes are preceded by failing tests. +- Target-bound recovery, link-safe replacement, config quarantine, cleanup retry, both scopes, and harness isolation have viable implementation paths without external prerequisites. + +## Non-Blocking Notes + +- Honor FR-011 preflight ordering before any journal restoration that mutates targets; the sequence diagram is intentionally abbreviated. +- Preserve malformed configuration as raw bytes rather than UTF-8 round-tripping; the current string-only filesystem payload may need a small byte/copy extension. +- Use `integrations/inventory.json` as the canonical runtime asset preflight authority. + +## Blockers + +- None. + +## User Override Context + +None. + +## Source SHA-256 + +- `openspec/changes/opencode-managed-setup-convergence/spec.md`: `sha256:2db7e77062ecd8571a3c5b037517b9c67aefcf5683f53123fd635c8705aba3d3` +- `openspec/changes/opencode-managed-setup-convergence/plan.md`: `sha256:3675b1bdda984180958f3307028bd0ecc2e721cfcb3203414030f735e004139c` +- `openspec/changes/opencode-managed-setup-convergence/tasks.md`: `sha256:70c3f9a399906e59614a1b5c74b4f6b3f1b02d0ba2fb084c557d116a0b0105ea` +- `openspec/changes/opencode-managed-setup-convergence/checklists/requirements.md`: `sha256:e11e63b9382a3c95c3c72b8f65820e40e167daf30d96bca6999cbda1520d380a` +- `openspec/memory/constitution.md`: `sha256:4203d40b3a13c45d6862beeee99eb762e76785a50f7e680f61bf5412a9bb04aa` + +## Recovery Decision + +This result satisfies only optional plan review while all source digests remain unchanged. It does not authorize implementation or satisfy final Oracle verify. diff --git a/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/plan.md b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/plan.md new file mode 100644 index 0000000..0159f5d --- /dev/null +++ b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/plan.md @@ -0,0 +1,177 @@ +# Implementation Plan: OpenCode managed setup convergence + +## Technical context + +`inspectAndPlanSetup()` currently treats OpenCode state as managed only when canonical metadata has the running package version and all config/assets match exactly. Any stale or malformed metadata becomes a non-forceable conflict before `executeOpenCodeSetup()` can use the existing atomic filesystem transaction. Successful OpenCode mutations retain signed V1 receipts and backups for public rollback, while any invalid receipt in the shared receipt base blocks all setup. + +The change makes the executing package authoritative for both global and project OpenCode scopes while leaving Codex and Claude Code behavior untouched. Exact desired state remains the no-op predicate, but every other canonical OpenCode state converges automatically. OpenCode mutation will use a target-bound temporary signed journal for crash recovery, remove durable rollback evidence after verification, repair configuration under the confirmed precedence rules, replace links without following them, and preserve the existing `SetupResult` JSON shape and exit-code mapping. + +Primary route: `docs/agent/managed-delivery.md`, with `engineering.md`, `testing.md`, and `surfaces.md` overlays. No network lookup, real-home mutation, release, or generated `dist/` edit is authorized. + +## Constitution Check (pre-design) + +- **P1 — Compact, Workflow-Level MCP Surface**: PASS — the change touches only CLI setup internals and adds no MCP tool or registration. +- **P2 — Deterministic-First Retrieval With Safe Degradation**: PASS — retrieval, indexing, and semantic fallback behavior are not touched. +- **P3 — Harness-Agnostic Memory Contract**: PASS — OpenCode-specific ownership remains at the setup adapter boundary and does not enter memory requests, storage, or lifecycle semantics. +- **P4 — Token-Efficient, Bounded Recall Outputs**: PASS — recall output contracts are unaffected. +- **P5 — Stable Public Contract With Explicit Deprecation Discipline**: JUSTIFIED EXCEPTION — command names, request flags, result fields, statuses, and exit codes remain stable, but the user explicitly selected destructive OpenCode convergence and removal of post-success rollback evidence without a compatibility period. The exception is confined to OpenCode setup state; Codex/Claude receipts and explicit historical rollback validation remain unchanged until a successful OpenCode convergence removes receipts bound to that target. + +## Design + +### 1. Separate exact-state verification from OpenCode mutation authority + +`src/setup/engine.ts` will keep exact current metadata/config/assets/plugin equality as the only `managed=true` no-op. For OpenCode only, metadata absence, schema/version mismatch, malformed metadata, target kind mismatch, and same-version drift will become convergence reasons rather than ownership conflicts. `--force` will not be required and will not change the OpenCode plan. Codex inspection and proof rules remain on their current branch. + +`SetupInspection` will carry the selected OpenCode config path and bounded convergence facts needed by plan and execution; it will never include raw config or asset content. Config candidate selection will prefer `opencode.jsonc` whenever it exists, including when `opencode.json` also exists. Missing source assets or an unsafe target boundary will still fail before recovery state or target mutation. + +### 2. Make OpenCode configuration repair explicit + +`src/setup/harnesses/opencode.ts` will expose a deterministic convergence plan: + +- a parseable object preserves unrelated root settings and normalizes only `mcp.thoth-mem`; +- a parseable non-object `mcp` value is replaced by the canonical managed object while other root settings remain; +- malformed or non-object root input is classified for full recreation rather than leaked through diagnostics; +- an exact canonical owned value remains unchanged. + +For malformed/non-object-root input, `src/setup/engine.ts` will add a transaction-owned durable backup file at a non-colliding sibling path derived from the transaction id, with owner-only permissions where enforceable. The byte-exact backup is created as part of the same atomic change set, removed again if the transaction fails, retained after success, and disclosed only by path. The selected config is then recreated as minimal valid JSON/JSONC containing canonical `mcp.thoth-mem`. When both candidates exist, only JSONC changes and JSON remains untouched. + +### 3. Extend the filesystem transaction for authoritative kind replacement + +`src/setup/filesystem.ts` will add an opt-in replacement mode used only by OpenCode convergence. Default callers keep the current same-kind and no-link rules. + +The opt-in path will: + +- accept missing, file, directory, or link/junction state at a canonical managed target; +- validate containment using the real parent while never resolving through the final link entry; +- snapshot and back up links by their raw link identity without reading or mutating the destination; +- preserve link entries when backing up an old managed directory, while continuing to reject links in packaged source trees; +- allow staged file/directory content to replace a different prior entry kind; +- restore the exact prior entry kind and bytes/link identity on any in-process failure; +- retain existing pre-state race checks, atomic sibling rename, post-write snapshot verification, bounded diagnostics, and fault injection. + +This supports complete replacement of `.thoth-mem` and `plugins/thoth-mem.js`, including unlinking a managed symlink/junction and creating a normal packaged entry. + +### 4. Reuse signed V1 evidence as a target-bound temporary journal + +OpenCode setup will reuse `SetupReceiptV1` step evidence instead of introducing a second receipt schema, but new temporary journals will live in a target-bound namespace: + +- global: `/setup/opencode-journals///receipt.json`; +- project: `/.thoth/setup/opencode-journals///receipt.json`. + +The target hash is derived from the canonical selected target, never from untrusted metadata. `src/setup/receipt.ts` will allow an explicit key path so OpenCode journals use a dedicated key under their target-bound journal namespace and cannot rotate or invalidate the shared key used by existing Codex/Claude/legacy receipts. + +The journal is persisted after verified backups and before the first target mutation, checkpointed after every changed path, and marked complete only after exact OpenCode post-state verification. An optional `config-backup` filesystem step records malformed-config quarantine creation. Public `SetupResult.receipt` remains `null` after successful cleanup. + +### 5. Recover or reset journals before inspection-driven mutation + +Under the existing setup target lock, `src/setup/engine.ts` will process the target-bound OpenCode journal namespace before normal convergence: + +1. If a single valid in-progress journal is bound to the selected harness/scope/canonical target and all pre-state backups verify, restore its complete pre-state and remove that transaction tree. +2. If a journal, key, topology, backup, or binding is invalid or ambiguous, remove only the computed target-bound journal namespace and dedicated key without following receipt contents, then force a fresh canonical convergence. +3. If a valid complete journal remains because cleanup previously failed, do not restore it; verify/repair current desired state and retry cleanup. +4. Never use an untrusted embedded path to select a deletion target. + +Existing valid legacy OpenCode receipts in the shared receipt base may be read for bounded target matching and post-success cleanup. Invalid legacy receipts that cannot be securely bound are not followed or allowed to block OpenCode convergence; they remain isolated and produce at most a bounded cleanup warning. Codex and Claude continue using strict shared-receipt scanning. + +### 6. Verify first, then remove target rollback evidence + +After mutation, `inspectSetup()` must verify exact current metadata, complete packaged directory contents, canonical plugin entry, and canonical owned config. Only then will a target-bounded cleanup helper in `src/setup/receipt.ts` remove: + +- the current OpenCode journal and temporary backups; +- other target-bound OpenCode journal residue; +- every valid historical receipt/backup in the shared receipt base whose signed harness, scope, and canonical target match. + +Cleanup never deletes another harness, scope, or target and never deletes the durable malformed-config backup. Cleanup failure does not invalidate an already verified installation: the result remains `complete`, includes a bounded warning, returns `receipt: null`, and a later setup retries cleanup before returning a clean no-op. Cleanup-only mutation does not set `changed=true` or request a host restart. + +A changed successful install returns `complete`, `changed=true`, and a manual action to restart OpenCode. An exact no-op returns `complete`, `changed=false`, creates no journal, and has no restart action. Human and JSON rendering continue through the existing `SetupResult` fields. + +### 7. Preserve plan-only and harness isolation + +Plan mode uses only inspection and deterministic path derivation. It reports full asset replacement, plugin-entry replacement, config merge/recreation and backup, temporary journaling, receipt cleanup, and restart when applicable, but creates no directory, key, receipt, backup, or config change. OpenCode-specific recovery and permissive ownership branches are guarded by `request.harness === 'opencode'`; Codex and Claude tests must prove no behavior drift. + +### Transaction sequence + +```mermaid +sequenceDiagram + participant CLI + participant Lock + participant Journal + participant FS as Atomic filesystem + participant Verify + + CLI->>Lock: acquire selected OpenCode target + CLI->>Journal: inspect target-bound journal + alt valid in-progress journal + Journal->>FS: restore verified pre-state + FS-->>Journal: remove recovered transaction + else invalid target-bound journal + Journal->>Journal: discard computed namespace/key only + end + CLI->>CLI: inspect desired package/config state + alt exact state and cleanup clean + CLI-->>CLI: complete, changed=false + else convergence required + CLI->>FS: create verified temporary backups + FS->>Journal: persist signed in-progress evidence + Journal-->>FS: authorize first mutation + FS->>FS: atomically replace config/assets/entry + FS->>Journal: checkpoint each path + FS->>Verify: inspect exact desired state + Verify-->>Journal: mark complete + Journal->>Journal: remove target receipts/backups + CLI-->>CLI: complete, changed=true, restart action + end +``` + +### Requirement mapping + +| Requirement | Technical decision | Files/interfaces | Verification seam | +| --- | --- | --- | --- | +| FR-001, FR-002 | OpenCode exact equality is no-op; every other canonical state is convergence without force | `src/setup/engine.ts` (`inspectSetup`, `inspectAndPlanSetup`) | Global/project engine fixtures for older/newer/missing/malformed/current drift | +| FR-003 | Opt-in cross-kind, link-safe whole-target replacement | `src/setup/filesystem.ts` (`FilesystemChange`, backup/snapshot/restore helpers) | Real filesystem tests with files, directories, symlinks/junctions, and removed extras | +| FR-004 | JSONC precedence, parseable semantic merge, malformed durable quarantine and recreation | `src/setup/harnesses/opencode.ts`, `src/setup/engine.ts` | Managed-config plus transaction tests; secret content absent from diagnostics | +| FR-005, FR-006 | Target-bound signed V1 temporary journal, dedicated key, automatic restore/reset | `src/setup/engine.ts`, `src/setup/receipt.ts` | Receipt/filesystem fault matrix and simulated process-interruption fixtures | +| FR-007, FR-008 | Verify-before-cleanup; target-matched purge; success warning and retry on cleanup failure | `src/setup/engine.ts`, `src/setup/receipt.ts`, `src/setup/types.ts` only if internal result evidence needs extension | Cleanup fault tests, receipt isolation tests, cleanup-only repeated run | +| FR-009 | Existing result schema with deterministic steps/diagnostics/manual restart action | `src/setup/engine.ts`, `src/cli.ts` rendering unchanged unless tests expose a gap | `tests/cli.test.ts`, plan snapshots, human/JSON agreement | +| FR-010 | Packed prior/newer/missing/diverged targets converge from tarball only | `tests/packaging/packed-install.test.ts` and fixtures | Isolated global/project packed-install scenarios and immediate no-op rerun | +| FR-011 | Source inventory and canonical boundary preflight precede all mutation | `src/setup/engine.ts`, `src/setup/filesystem.ts` | Missing source/escape/link-boundary tests assert zero journal and zero writes | + +### Success criterion mapping + +| Criterion | Evidence plan | +| --- | --- | +| SC-001 | Focused OpenCode engine matrix for 2 scopes and all named drift classes, followed by exact no-op assertions | +| SC-002 | Existing filesystem/receipt fault points plus recovery fixtures that compare pre/post snapshots byte-for-byte | +| SC-003 | Config planner/engine tests for JSONC precedence, unrelated settings, malformed backup, minimal recreated config, and canonical MCP state | +| SC-004 | Receipt directory assertions after success, injected cleanup failure warning, and later cleanup-only retry | +| SC-005 | Fake-filesystem plan tests prove zero writes; CLI tests compare human and JSON status/manual actions | +| SC-006 | Packed tarball installed into disposable homes/projects, converged without source checkout, then rerun once as no-op | +| SC-007 | Focused tests, `pnpm run integration:verify`, `pnpm run build`, and `pnpm test` | + +## Optional support artifacts + +- `research.md`: Not needed; repository source, tests, and existing transaction/receipt contracts resolve the design questions without external technology selection. +- `data-model.md`: Not needed; the design reuses signed `SetupReceiptV1` evidence transiently and adds no durable application data model. +- `contracts/`: Not needed; `SetupRequest`, `SetupResult`, CLI command names, JSON fields, statuses, and exit codes remain structurally unchanged. +- `quickstart.md`: Not needed; operator syntax remains `thoth-mem setup opencode` and changed results provide the restart action. + +## Risks and migrations + +- **Destructive managed ownership**: the complete `.thoth-mem` target and canonical plugin entry may contain edits or foreign files. This deletion is the explicitly confirmed product rule. Containment, target locking, link non-traversal, plan mode, and isolated tests limit the blast radius. +- **Intentional downgrade**: any version mismatch converges to the executing package. Diagnostics identify current convergence without blocking version direction; users control the desired version by choosing the executable package. +- **No post-success rollback**: OpenCode receipts/backups are removed after verification. Temporary evidence covers in-process and crash recovery only; the retained malformed-config quarantine is the sole explicit user-data recovery exception. +- **Legacy receipt migration**: valid historical OpenCode receipts matching the target are removed only after successful verification. Unbindable invalid shared receipts are never followed and cannot block OpenCode, but may remain as bounded cleanup residue. +- **Shared infrastructure regression**: filesystem and receipt changes are opt-in/default-preserving; focused Codex/Claude setup and rollback suites guard their existing strict ownership behavior. +- **Link restoration portability**: Windows junction and POSIX symlink behavior differs. The implementation stores raw link identity, does not traverse destinations, and uses platform-appropriate isolated tests; inability to create a verified temporary backup aborts before mutation. +- **Malformed config sensitivity**: the durable backup may contain secrets. It is never rendered, receives owner-only permissions where enforceable, and only its safe path is reported. +- **Cleanup after verified success**: cleanup faults return success with warning by explicit decision. Residue remains target-bounded and is retried on the next setup run. +- **Durable spec migration**: archive must add OpenCode-specific ownership, receipt, rollback, and packed-convergence requirements to `openspec/specs/cli/spec.md` and `openspec/specs/packaging/spec.md` while preserving existing Codex/Claude requirements and scenarios. +- **Rollback of this code change**: revert source/tests before release. Runtime setup itself intentionally has no durable post-success OpenCode rollback; failed or interrupted mutation uses the temporary journal path described above. + +## Constitution Check (post-design) + +- **P1 — Compact, Workflow-Level MCP Surface**: PASS — design changes no MCP registration or workflow tool. +- **P2 — Deterministic-First Retrieval With Safe Degradation**: PASS — no retrieval path is involved. +- **P3 — Harness-Agnostic Memory Contract**: PASS — every permissive behavior is guarded at the OpenCode setup boundary; memory/storage contracts and other harness adapters remain unchanged. +- **P4 — Token-Efficient, Bounded Recall Outputs**: PASS — no recall output or limit changes. +- **P5 — Stable Public Contract With Explicit Deprecation Discipline**: JUSTIFIED EXCEPTION — the design preserves CLI syntax, result shape, statuses, exit codes, and bounded diagnostics but intentionally changes OpenCode ownership and successful rollback availability per the confirmed decision map. Exact scope, destructive consequences, tests, durable spec delta, and release risk are explicit rather than hidden. diff --git a/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/spec.md b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/spec.md new file mode 100644 index 0000000..3d7c213 --- /dev/null +++ b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/spec.md @@ -0,0 +1,131 @@ +# Feature Specification: OpenCode managed setup convergence + +**Change ID**: `opencode-managed-setup-convergence`
+**Route**: Full
+**Status**: Draft + +## Intent and scope + +**Why**: Running `thoth-mem setup opencode` from a newly installed package currently rejects an existing managed OpenCode installation whenever its recorded package version differs, forcing manual deletion instead of performing the update the setup command owns.
+**Impact**: Global and project-scoped OpenCode setup will converge the canonical managed installation to the assets and configuration of the currently executing package. This is intentionally destructive inside the managed asset directory, permits both upgrades and downgrades, repairs same-version drift without `--force`, and removes durable rollback state after successful setup.
+**Affected capabilities**: `cli`, `packaging` + +## User stories + +### US1 - Converge an existing OpenCode installation (Priority: P1) + +As an OpenCode user, I can run setup from the package version I intend to use so that an existing installation is upgraded, downgraded, adopted, or repaired without deleting plugin files manually. + +**Independent test**: Run global and project-scoped setup against isolated targets containing older metadata, newer metadata, missing metadata, and same-version asset drift, then verify every target converges to the current package and a repeated run is a no-op. + +**Covers**: FR-001, FR-002, FR-003, FR-010, SC-001, SC-006 + +**Acceptance scenarios**: + +1. **Given** the canonical OpenCode managed asset target contains an older or newer package version, **When** setup runs without `--force`, **Then** setup replaces the complete managed directory and canonical plugin entry with the current package and reports `complete` with `changed=true`. +2. **Given** the managed asset target exists without valid installation metadata, **When** setup runs, **Then** directory existence authorizes adoption and setup writes current canonical metadata instead of requiring manual deletion. +3. **Given** metadata names the current package version but any managed asset, metadata field, plugin entry, or owned configuration value differs, **When** setup runs, **Then** setup repairs the full managed state automatically. +4. **Given** every current managed asset, metadata value, plugin entry, and owned configuration value matches, **When** setup runs again, **Then** it returns `complete` with `changed=false` and performs zero mutation. + +### US2 - Survive interrupted replacement (Priority: P1) + +As an operator, I can rerun setup after an I/O failure or process interruption so that the command restores a deterministic baseline and completes without manual filesystem surgery. + +**Independent test**: Inject failures and interrupted journals at every OpenCode mutation boundary and verify immediate restoration, next-run restore-and-retry, invalid-journal discard, and exact post-state convergence. + +**Covers**: FR-005, FR-006, FR-007, FR-008, SC-002, SC-004 + +**Acceptance scenarios**: + +1. **Given** setup has begun replacing OpenCode state, **When** a filesystem mutation fails while the process remains alive, **Then** setup restores the complete pre-run state and returns `failed` without claiming completion. +2. **Given** a prior process stopped with a valid in-progress journal, **When** setup runs again, **Then** it restores the journal's verified pre-state and retries setup from a clean baseline. +3. **Given** an in-progress journal fails signature, path, topology, or hash validation, **When** setup runs again, **Then** it discards only canonical journal artifacts without following embedded paths and performs a fresh canonical installation. +4. **Given** setup verifies the new post-state, **When** completion succeeds, **Then** no durable OpenCode rollback receipt or pre-version backup for that target remains. +5. **Given** post-success cleanup cannot remove every journal or backup artifact, **When** setup renders its result, **Then** it returns `complete` with a bounded warning and retries canonical cleanup on the next run. + +### US3 - Repair OpenCode configuration deterministically (Priority: P1) + +As an OpenCode user, I can have setup restore its owned MCP activation while keeping recoverable evidence whenever unrelated configuration cannot be parsed. + +**Independent test**: Exercise missing, valid JSON, valid JSONC, simultaneous JSON/JSONC, and malformed selected configuration fixtures and verify the selected file, preserved settings, backup, and canonical `mcp.thoth-mem` value. + +**Covers**: FR-004, SC-003 + +**Acceptance scenarios**: + +1. **Given** the selected OpenCode configuration parses successfully, **When** setup runs, **Then** it normalizes only `mcp.thoth-mem` and preserves unrelated settings. +2. **Given** both `opencode.json` and `opencode.jsonc` exist, **When** setup runs, **Then** it selects JSONC and leaves JSON unchanged. +3. **Given** the selected configuration is malformed, **When** setup runs, **Then** it persists a byte-exact non-colliding backup, recreates a minimal valid configuration containing canonical `mcp.thoth-mem`, and reports the backup path. + +### US4 - Preview and verify destructive convergence (Priority: P2) + +As an operator, I can preview and inspect setup results so that destructive replacement, recovery, cleanup, and host restart requirements are explicit and testable from a packed artifact. + +**Independent test**: Compare plan, human, and JSON results from isolated source and packed-package fixtures while asserting plan mode writes nothing and changed results request an OpenCode restart. + +**Covers**: FR-009, FR-011, SC-005, SC-007 + +**Acceptance scenarios**: + +1. **Given** a target requires replacement or repair, **When** setup runs with `--plan`, **Then** it reports the selected config, whole managed-directory replacement, plugin-entry replacement, temporary recovery evidence, cleanup, and restart requirement while performing zero writes. +2. **Given** mutating setup changes OpenCode state successfully, **When** results are rendered, **Then** human and JSON output agree on `complete`, `changed=true`, bounded warnings, and the manual OpenCode restart action. +3. **Given** the current package lacks a required OpenCode source asset, **When** setup inspects the package, **Then** it fails before creating a journal, backup, or target mutation. + +## Edge cases + +- Metadata may be absent, malformed, legacy-named, current, older, or newer than the executing package. +- The canonical managed asset path may be missing, a regular file, a directory, a symlink, a junction, or another reparse-point-like entry. +- The managed directory may contain modified, missing, obsolete, or foreign files; successful convergence deletes all of them. +- The canonical plugin entry may be missing, stale, modified, linked, or unrelated; selected-scope setup replaces it. +- Both OpenCode config candidates may exist, and the selected JSONC file may itself be malformed. +- A malformed config backup path may already exist; setup must choose a non-colliding path and disclose it. +- A process may fail before journal creation, after any checkpoint, after post-state verification, or during cleanup. +- Receipt storage may contain valid, invalid, stale, or unrelated harness/scope/target receipts; cleanup must remain target-bounded. +- Packaged paths and targets may contain spaces and must work on Windows and POSIX systems. +- Concurrent setup attempts must remain serialized by the existing setup lock. + +## Functional requirements + +- **FR-001 — Converge installer-owned OpenCode state**: `[ADDED cli]` Global and project-scoped OpenCode setup MUST treat any existing canonical `.thoth-mem` asset-path entry as installer-owned and MUST converge it, the canonical `plugins/thoth-mem.js` entry, installation metadata, and `mcp.thoth-mem` configuration to the currently executing package without requiring metadata validity, receipt proof, manual deletion, or `--force`. +- **FR-002 — Repair every non-current state**: `[ADDED cli]` OpenCode setup MUST replace or repair its managed state for any package-version mismatch, including a downgrade, and for any same-version content or configuration divergence; it MUST mutate nothing only when the complete desired state already matches exactly. +- **FR-003 — Replace the whole managed asset target safely**: `[ADDED packaging]` Convergence MUST delete every prior entry inside the selected-scope managed asset target and install only the current packaged layout; if the target itself is a symlink, junction, or equivalent link, setup MUST remove the link without traversing or modifying its destination before creating a normal directory. +- **FR-004 — Select and repair configuration deterministically**: `[ADDED cli]` Setup MUST prefer `opencode.jsonc` when both config candidates exist, MUST preserve unrelated settings when the selected file parses, and MUST persist a byte-exact non-colliding backup before replacing a malformed selected file with a minimal valid configuration containing canonical `mcp.thoth-mem`. +- **FR-005 — Journal replacement before mutation**: `[ADDED cli]` Before the first OpenCode mutation, setup MUST durably persist target-bounded temporary journal and backup evidence sufficient to restore every selected pre-run state; an in-process failure MUST restore that state before returning `failed` whenever restoration remains possible. +- **FR-006 — Recover interrupted setup automatically**: `[ADDED cli]` A subsequent setup run MUST validate any canonical in-progress OpenCode journal, restore its verified pre-state, and retry from a clean baseline; an invalid journal MUST be discarded without following any embedded path before a fresh canonical installation proceeds. +- **FR-007 — Remove durable rollback state after success**: `[ADDED cli]` After exact post-state verification, setup MUST remove temporary journal data plus all prior setup receipts and backups bound to the same OpenCode harness, scope, and target so no successful OpenCode setup retains a usable durable rollback to the prior installation. +- **FR-008 — Degrade cleanup without false installation failure**: `[ADDED cli]` If verified setup succeeds but target-bounded receipt or backup cleanup is incomplete, setup MUST still return `complete`, MUST emit a bounded warning naming only safe cleanup context, and MUST retry cleanup before declaring a later no-op. +- **FR-009 — Preserve truthful planning and results**: `[ADDED cli]` Plan mode MUST report deterministic convergence, recovery, cleanup, and restart actions with zero writes; a changed successful result MUST request an OpenCode restart without attempting process control, while an exact no-op MUST return `complete` with `changed=false` and no restart action. +- **FR-010 — Verify current packed-package convergence**: `[ADDED packaging]` Packed-artifact verification MUST exercise global and project OpenCode convergence from older, newer, missing, malformed, and same-version-diverged metadata and assets, and MUST prove an exact repeated no-op without using the source checkout or a real user home. +- **FR-011 — Preflight authoritative package assets**: `[INTERNAL]` Setup MUST validate every required current OpenCode package asset and canonical target boundary before creating recovery state or mutating configuration, plugin entries, assets, or metadata. + +## Success criteria + +- **SC-001** `[buildable]`: Focused setup tests cover both scopes and at least older-version, newer-version, missing-metadata, malformed-metadata, same-version-drift, and exact-current fixtures; every non-current fixture converges with `changed=true`, and every repeated exact run returns `changed=false`. +- **SC-002** `[buildable]`: Failure-injection tests at every OpenCode mutation checkpoint restore the byte-equivalent pre-run state, and valid interrupted journals restore then retry successfully while invalid journals are discarded without traversing embedded paths. +- **SC-003** `[buildable]`: All configuration tests pass while proving JSONC precedence, unrelated-setting preservation for parseable input, byte-exact backup plus minimal recreation for malformed input, and canonical `mcp.thoth-mem` verification. +- **SC-004** `[buildable]`: A successful OpenCode setup leaves zero usable receipts or pre-version backups for its target; injected cleanup failure still returns `complete` with a warning and a later run removes the residue. +- **SC-005** `[buildable]`: Plan snapshots prove zero writes and enumerate replacement/recovery/cleanup/restart actions; human and JSON changed results agree and expose the restart action. +- **SC-006** `[buildable]`: Packed-install tests update an isolated prior-release-shaped OpenCode target using only tarball assets, verify the complete current layout and metadata, and prove at least 1 immediate repeated packed setup run is a no-op. +- **SC-007** `[buildable]`: Focused setup, rollback/recovery, and packed-install suites pass together with `pnpm run build` and the full `pnpm test` suite. + +## Assumptions + +- The canonical `.thoth-mem` asset target and `plugins/thoth-mem.js` entry are exclusively owned by thoth-mem setup within the selected global or project OpenCode scope. +- The package executing setup is the user's authoritative desired version; replacing a newer installed version with an older executing package is intentional. +- The current package contents, not a network registry or historical manifest, define the desired installed layout. +- A durable backup of malformed unrelated OpenCode configuration is required even though successful plugin setup retains no durable version rollback. +- Existing setup locking remains the concurrency boundary for inspection, recovery, mutation, verification, and cleanup. + +## Dependencies + +- Existing OpenCode setup inspection, configuration merge, filesystem transaction, setup lock, signed receipt/journal, and packed-install verification infrastructure. +- Current packaged OpenCode plugin, shared runtime assets, and bundled skill inventory. + +## Out of scope + +- Changing Codex or Claude Code setup, ownership, receipt, or rollback behavior. +- Downloading or resolving updates from a registry or network endpoint. +- Preserving files inside the canonical OpenCode managed asset directory. +- Retaining post-success OpenCode rollback receipts or backups. +- Automatically stopping or restarting OpenCode processes. +- Mutating shared user skill directories outside the managed plugin asset target. diff --git a/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/tasks.md b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/tasks.md new file mode 100644 index 0000000..a5a75b9 --- /dev/null +++ b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/tasks.md @@ -0,0 +1,61 @@ +# Tasks: OpenCode managed setup convergence + +## MVP scope + +US1 is the first independently testable slice: global and project OpenCode setup accepts older, newer, missing, malformed, and same-version-diverged managed state, replaces the complete canonical assets and plugin entry without force, writes current metadata, and immediately reruns as an exact no-op. Completion evidence is T001-T004 for FR-001, FR-002, FR-003, FR-011, SC-001, and the filesystem portion of SC-002. + +## Dependencies + +`T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 -> T009 -> T010 -> T011 -> T012 -> T013 -> T014 -> T015 -> T016 -> T017 -> T018 -> T019 -> T020 -> T021 -> T022 -> T023`; shared setup transaction types, orchestration, and fixtures require one sequential writer. US2 recovery depends on the US1 filesystem primitive, US3 configuration tests precede engine integration, and US4 packed verification follows all runtime behavior. + +## Story US1 - Converge an existing OpenCode installation + +- [x] T001 [US1] Add failing global/project convergence and exact no-op tests covering FR-001, FR-002, FR-011 and SC-001 in `tests/setup/engine.test.ts` | Verify: stale, missing, malformed, newer, and same-version-diverged fixtures fail for the currently missing behavior while exact state remains a no-op +- [x] T002 [US1] Implement OpenCode-only desired-state classification, convergence reasons, source preflight, and no-force planning covering FR-001, FR-002, FR-011 and SC-001 in `src/setup/engine.ts` | Verify: T001 passes and existing Codex and Claude ownership outcomes remain unchanged +- [x] T003 [US1] Add failing authoritative cross-kind and link/junction replacement tests covering FR-003 and SC-001, SC-002 in `tests/setup/engine.test.ts` | Verify: real-filesystem cases fail because current transactions reject kind changes and linked canonical entries +- [x] T004 [US1] Implement opt-in whole-target replacement, raw-link backup/snapshot, non-traversing containment, and exact restoration covering FR-003 and SC-001, SC-002 in `src/setup/filesystem.ts` | Verify: T003 and the existing filesystem fault matrix pass while every link destination remains byte-identical + +## Story US3 - Repair OpenCode configuration deterministically + +- [x] T005 [US3] Add failing JSONC precedence, parseable-parent repair, malformed recreation, and secret-safe diagnostic tests covering FR-004 and SC-003 in `tests/setup/engine.test.ts` | Verify: both-config, invalid-parent, and malformed-root cases fail for the missing convergence behavior +- [x] T006 [US3] Implement parseable OpenCode config normalization and malformed-root classification covering FR-004 and SC-003 in `src/setup/harnesses/opencode.ts` | Verify: planner tests preserve unrelated parseable settings and never include raw invalid content in conflicts +- [x] T007 [US3] Integrate transaction-owned non-colliding malformed-config quarantine and JSONC selection covering FR-004 and SC-003 in `src/setup/engine.ts` | Verify: T005 passes, failure removes the quarantine, success retains a byte-exact owner-protected backup, and output discloses only its path + +## Story US2 - Survive interrupted replacement + +- [x] T008 [US2] Add failing target-bound journal, valid restore-and-retry, invalid reset, target-isolated cleanup, and cleanup-warning retry tests covering FR-005, FR-006, FR-007, FR-008 and SC-002, SC-004 in `tests/setup/rollback.test.ts` | Verify: tests expose current shared receipt blocking and post-success durable rollback state +- [x] T009 [US2] Implement target-bound OpenCode journal keys, safe namespace reset, tolerant legacy matching, and target-confined cleanup covering FR-005, FR-006, FR-007, FR-008 and SC-002, SC-004 in `src/setup/receipt.ts` | Verify: helper evidence proves no shared-key rotation, cross-target deletion, embedded-path traversal, or secret-bearing diagnostics +- [x] T010 [US2] Integrate pre-inspection recovery, full pre-state restoration, verify-before-cleanup, receipt-null success, and cleanup retry covering FR-005, FR-006, FR-007, FR-008 and SC-002, SC-004 in `src/setup/engine.ts` | Verify: T008 passes across injected filesystem and receipt faults and a later cleanup-only run leaves exact OpenCode state unchanged + +## Story US4 - Preview and verify destructive convergence + +- [x] T011 [US4] Add failing plan, cleanup-only, restart-action, and harness-isolation tests covering FR-009, FR-011 and SC-005 in `tests/setup/engine.test.ts` | Verify: plan fixtures expose current force-conflict wording and missing journal, cleanup, and restart steps while performing zero writes +- [x] T012 [US4] Add failing human and JSON result agreement tests covering FR-009 and SC-005 in `tests/cli.test.ts` | Verify: changed, no-op, cleanup-warning, and cleanup-only results expose any status, receipt, warning, or manual-action mismatch +- [x] T013 [US4] Implement truthful plan steps, bounded diagnostics, cleanup-only semantics, and restart actions covering FR-009, FR-011 and SC-005 in `src/setup/engine.ts` | Verify: T011-T012 pass with unchanged result fields and exit codes and no restart action for no-op or cleanup-only runs +- [x] T014 [US4] Add failing packed-artifact convergence cases covering FR-010, FR-011 and SC-006 in `tests/packaging/packed-install.test.ts` | Verify: prior, newer, missing, malformed, and same-version-diverged global and project targets demonstrate any remaining source-checkout or manual-deletion dependency +- [x] T015 [US4] Complete isolated packed fixture wiring and convergence assertions covering FR-010, FR-011 and SC-006 in `tests/packaging/packed-install.test.ts` | Verify: every tarball-only fixture converges and at least 1 immediate repeated setup is a verified no-op + +## Cross-story regression and closeout + +- [x] T016 Add explicit Codex and Claude setup isolation regressions covering FR-001, FR-005, FR-007 and SC-002 in `tests/setup/engine.test.ts` | Verify: permissive ownership, dedicated keys, and cleanup remain unreachable from non-OpenCode setup +- [x] T017 Preserve historical explicit rollback and recovery regressions covering FR-005, FR-006, FR-007 and SC-002, SC-004 in `tests/setup/rollback.test.ts` | Verify: old valid OpenCode receipts remain bounded until successful convergence and Codex and Claude receipt behavior is unchanged +- [x] T018 Simplify OpenCode orchestration without changing FR-001 through FR-011 or SC-001 through SC-006 in `src/setup/engine.ts` | Verify: focused tests stay green and transaction ordering remains explicit +- [x] T019 Simplify link-safe transaction helpers without changing FR-003, FR-005 or SC-002 in `src/setup/filesystem.ts` | Verify: fault and link tests stay green with no duplicated containment or snapshot branch +- [x] T020 Simplify journal and cleanup helpers without changing FR-005 through FR-008 or SC-002, SC-004 in `src/setup/receipt.ts` | Verify: receipt isolation and cleanup fault tests stay green +- [x] T021 Simplify config convergence without changing FR-004 or SC-003 in `src/setup/harnesses/opencode.ts` | Verify: config preservation, recreation, and privacy tests stay green +- [x] T022 Run focused delivery and package checks covering FR-001 through FR-011 and SC-001 through SC-006 from `package.json` | Verify: focused setup, rollback, CLI, and packed-install Vitest files plus the integration package verifier all pass +- [x] T023 Run repository gates and record independent evidence covering FR-001 through FR-011 and SC-001 through SC-007 in `openspec/changes/opencode-managed-setup-convergence/verify-report.md` | Verify: build and full tests pass, Oracle returns PASS or actionable defects, and root records every FR and buildable SC result without self-approval + +## Parallel execution + +- None: every implementation slice shares setup transaction types, engine orchestration, or mutable fixtures, and one writer is required to prevent overlapping edits and stale transaction assumptions. + +## Final verification + +The final task is the mandatory independent Oracle verification boundary. Root may mark it complete only after Oracle reviews the final diff and executed evidence; implementation writers do not approve their own work. + +## Convergence + +### ORA-VERIFY-001 — partial + +- [x] T024 [US2] Remediate ORA-VERIFY-001 by enforcing physical existing-ancestor containment before journal scan, reset, recovery, and cleanup for FR-006, FR-007, FR-011 and SC-002 in `src/setup/receipt.ts` | Verify: global and project junction-ancestor regressions preserve outside sentinels with zero recovery mutation, focused setup/rollback tests pass, and Oracle no longer reproduces outside-target deletion diff --git a/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/verify-report.md b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/verify-report.md new file mode 100644 index 0000000..e0c259e --- /dev/null +++ b/openspec/changes/archive/2026-07-20-opencode-managed-setup-convergence/verify-report.md @@ -0,0 +1,64 @@ +# Verification Report: OpenCode managed setup convergence + +**Reviewer**: oracle
+**Independent from implementer**: Yes
+**Verdict**: PASS + +## Review dimensions + +- **Completeness**: PASS — every FR-001 through FR-011 and SC-001 through SC-007 has implementation and executed evidence. +- **Correctness**: PASS — original project and global junction probes preserve outside sentinels and perform zero managed-target mutation. +- **Coherence**: PASS — containment now matches the specification, plan-review note, T024 convergence task, tests, and runtime results. + +## Compliance matrix + +| Requirement | Implementation evidence | Executed check | Result | +| --- | --- | --- | --- | +| FR-001 | `src/setup/engine.ts:363-547,672-680,1868-2174`; `tests/setup/engine.test.ts:1126-1325` | Focused and packed suites | PASS | +| FR-002 | `src/setup/engine.ts:363-547,1868-2174` | Older/newer/missing/malformed/drift fixtures | PASS | +| FR-003 | `src/setup/filesystem.ts:274-415,490-515,517-764`; `tests/setup/engine.test.ts:1954-2025` | Windows junction replacement/restoration | PASS | +| FR-004 | `src/setup/harnesses/opencode.ts:46-88`; `src/setup/engine.ts:1868-1981` | Config and raw-byte quarantine tests | PASS | +| FR-005 | `src/setup/engine.ts:1288-1395`; `src/setup/filesystem.ts:139-214` | Ordering and fault tests | PASS | +| FR-006 | `src/setup/receipt.ts:329-550,1118-1154`; `src/setup/engine.ts:1528-1603,3500-3605` | Valid/five-step recovery, invalid reset, project/global probes | PASS | +| FR-007 | `src/setup/engine.ts:1129-1157`; `src/setup/receipt.ts:474-529` | Successful/legacy cleanup and ancestor probes | PASS | +| FR-008 | `src/setup/engine.ts:2100-2174`; `tests/setup/rollback.test.ts:1125` | Complete-with-warning and retry tests | PASS | +| FR-009 | `src/setup/engine.ts:682-840`; `tests/cli.test.ts:288-328` | Focused engine and CLI suites | PASS | +| FR-010 | `tests/packaging/packed-install.test.ts:1162-1242` | Packed-install suite: 23 passed | PASS | +| FR-011 | `src/setup/engine.ts:309-361,3447-3472`; `src/setup/receipt.ts:329-550` | Integration verifier and project/global boundary probes | PASS | +| SC-001 `[buildable]` | `tests/setup/engine.test.ts:1126-1325`; `tests/packaging/packed-install.test.ts:1162-1242` | Focused suite | PASS | +| SC-002 `[buildable]` | `tests/setup/engine.test.ts:1845-2099`; `tests/setup/rollback.test.ts:1162-1272` | Fault/link/recovery and both junction cases | PASS | +| SC-003 `[buildable]` | Config planner and quarantine tests | Focused suite | PASS | +| SC-004 `[buildable]` | Transient journal, cleanup warning/retry, zero receipts | Focused rollback suite | PASS | +| SC-005 `[buildable]` | Zero-write plan and human/JSON agreement | Focused engine and CLI suites | PASS | +| SC-006 `[buildable]` | Tarball-only global/project convergence and no-op rerun | Packed-install suite | PASS | +| SC-007 `[buildable]` | All required repository gates | Focused, integration, build, full suite | PASS | + +## Commands and results + +- `pnpm exec vitest run tests/setup/engine.test.ts tests/setup/rollback.test.ts tests/cli.test.ts tests/packaging/packed-install.test.ts`: 4 files passed; 159 passed, 1 skipped, 0 failed. +- `pnpm run integration:verify`: 16 native integration assets verified; 0 failures. +- `pnpm run build`: passed TypeScript, package build, and dashboard Vite build. +- `pnpm test`: 70 files passed; 1053 passed, 1 skipped, 0 failed. +- `pnpm exec vitest run tests/setup/rollback.test.ts --reporter=verbose`: 46 passed, 1 skipped; global and project journal ancestor rejection plus intentionally linked data-directory compatibility passed. The skip is the POSIX-only existing file-mode case on Windows. +- Original project junction probe: exit 3, `requires_user_action`, `changed=false`, outside sentinel preserved. +- Global journal-ancestor probe with isolated data root: exit 3, `requires_user_action`, `changed=false`, outside sentinel preserved, config remained absent. +- `git diff --check`: passed; eight intended tracked files plus the OpenSpec change directory only. +- IDE diagnostics: no errors in any touched source file; only non-blocking warnings. + +## Findings + +None. Prior critical finding ORA-VERIFY-001 is resolved. + +## Critical issues + +None. + +## Warnings + +- The single skipped test is POSIX-only and appropriately skipped on Windows; Windows junction security tests executed successfully. +- IDE warnings are non-blocking style/control-flow inspections; TypeScript compilation is clean. + +## Residual risks + +- POSIX file-mode enforcement was code-reviewed but not executable on this Windows verifier; the implementation explicitly requests mode `0o600`. +- No other material residual risk was identified within the accepted contract. diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index bea6101..21ec694 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -541,3 +541,239 @@ Legacy installation metadata MUST determine freshness from stable package identi - WHEN modern migration evaluates that metadata - THEN it MUST use the metadata only for claims its schema actually proves - AND it MUST require additional exact ownership evidence or return `requires_user_action` + +### Requirement: Converge installer-owned OpenCode state + +Global and project-scoped OpenCode setup MUST treat any existing canonical `.thoth-mem` asset-path entry as installer-owned and MUST converge it, the canonical `plugins/thoth-mem.js` entry, installation metadata, and `mcp.thoth-mem` configuration to the currently executing package without requiring metadata validity, receipt proof, manual deletion, or `--force`. + +#### Scenario: US1 - Converge an existing OpenCode installation 1 + +- **GIVEN** the canonical OpenCode managed asset target contains an older or newer package version +- **WHEN** setup runs without `--force` +- **THEN** setup replaces the complete managed directory and canonical plugin entry with the current package and reports `complete` with `changed=true` + +#### Scenario: US1 - Converge an existing OpenCode installation 2 + +- **GIVEN** the managed asset target exists without valid installation metadata +- **WHEN** setup runs +- **THEN** directory existence authorizes adoption and setup writes current canonical metadata instead of requiring manual deletion + +#### Scenario: US1 - Converge an existing OpenCode installation 3 + +- **GIVEN** metadata names the current package version but any managed asset, metadata field, plugin entry, or owned configuration value differs +- **WHEN** setup runs +- **THEN** setup repairs the full managed state automatically + +#### Scenario: US1 - Converge an existing OpenCode installation 4 + +- **GIVEN** every current managed asset, metadata value, plugin entry, and owned configuration value matches +- **WHEN** setup runs again +- **THEN** it returns `complete` with `changed=false` and performs zero mutation + +### Requirement: Repair every non-current state + +OpenCode setup MUST replace or repair its managed state for any package-version mismatch, including a downgrade, and for any same-version content or configuration divergence; it MUST mutate nothing only when the complete desired state already matches exactly. + +#### Scenario: US1 - Converge an existing OpenCode installation 1 + +- **GIVEN** the canonical OpenCode managed asset target contains an older or newer package version +- **WHEN** setup runs without `--force` +- **THEN** setup replaces the complete managed directory and canonical plugin entry with the current package and reports `complete` with `changed=true` + +#### Scenario: US1 - Converge an existing OpenCode installation 2 + +- **GIVEN** the managed asset target exists without valid installation metadata +- **WHEN** setup runs +- **THEN** directory existence authorizes adoption and setup writes current canonical metadata instead of requiring manual deletion + +#### Scenario: US1 - Converge an existing OpenCode installation 3 + +- **GIVEN** metadata names the current package version but any managed asset, metadata field, plugin entry, or owned configuration value differs +- **WHEN** setup runs +- **THEN** setup repairs the full managed state automatically + +#### Scenario: US1 - Converge an existing OpenCode installation 4 + +- **GIVEN** every current managed asset, metadata value, plugin entry, and owned configuration value matches +- **WHEN** setup runs again +- **THEN** it returns `complete` with `changed=false` and performs zero mutation + +### Requirement: Select and repair configuration deterministically + +Setup MUST prefer `opencode.jsonc` when both config candidates exist, MUST preserve unrelated settings when the selected file parses, and MUST persist a byte-exact non-colliding backup before replacing a malformed selected file with a minimal valid configuration containing canonical `mcp.thoth-mem`. + +#### Scenario: US3 - Repair OpenCode configuration deterministically 1 + +- **GIVEN** the selected OpenCode configuration parses successfully +- **WHEN** setup runs +- **THEN** it normalizes only `mcp.thoth-mem` and preserves unrelated settings + +#### Scenario: US3 - Repair OpenCode configuration deterministically 2 + +- **GIVEN** both `opencode.json` and `opencode.jsonc` exist +- **WHEN** setup runs +- **THEN** it selects JSONC and leaves JSON unchanged + +#### Scenario: US3 - Repair OpenCode configuration deterministically 3 + +- **GIVEN** the selected configuration is malformed +- **WHEN** setup runs +- **THEN** it persists a byte-exact non-colliding backup, recreates a minimal valid configuration containing canonical `mcp.thoth-mem`, and reports the backup path + +### Requirement: Journal replacement before mutation + +Before the first OpenCode mutation, setup MUST durably persist target-bounded temporary journal and backup evidence sufficient to restore every selected pre-run state; an in-process failure MUST restore that state before returning `failed` whenever restoration remains possible. + +#### Scenario: US2 - Survive interrupted replacement 1 + +- **GIVEN** setup has begun replacing OpenCode state +- **WHEN** a filesystem mutation fails while the process remains alive +- **THEN** setup restores the complete pre-run state and returns `failed` without claiming completion + +#### Scenario: US2 - Survive interrupted replacement 2 + +- **GIVEN** a prior process stopped with a valid in-progress journal +- **WHEN** setup runs again +- **THEN** it restores the journal's verified pre-state and retries setup from a clean baseline + +#### Scenario: US2 - Survive interrupted replacement 3 + +- **GIVEN** an in-progress journal fails signature, path, topology, or hash validation +- **WHEN** setup runs again +- **THEN** it discards only canonical journal artifacts without following embedded paths and performs a fresh canonical installation + +#### Scenario: US2 - Survive interrupted replacement 4 + +- **GIVEN** setup verifies the new post-state +- **WHEN** completion succeeds +- **THEN** no durable OpenCode rollback receipt or pre-version backup for that target remains + +#### Scenario: US2 - Survive interrupted replacement 5 + +- **GIVEN** post-success cleanup cannot remove every journal or backup artifact +- **WHEN** setup renders its result +- **THEN** it returns `complete` with a bounded warning and retries canonical cleanup on the next run + +### Requirement: Recover interrupted setup automatically + +A subsequent setup run MUST validate any canonical in-progress OpenCode journal, restore its verified pre-state, and retry from a clean baseline; an invalid journal MUST be discarded without following any embedded path before a fresh canonical installation proceeds. + +#### Scenario: US2 - Survive interrupted replacement 1 + +- **GIVEN** setup has begun replacing OpenCode state +- **WHEN** a filesystem mutation fails while the process remains alive +- **THEN** setup restores the complete pre-run state and returns `failed` without claiming completion + +#### Scenario: US2 - Survive interrupted replacement 2 + +- **GIVEN** a prior process stopped with a valid in-progress journal +- **WHEN** setup runs again +- **THEN** it restores the journal's verified pre-state and retries setup from a clean baseline + +#### Scenario: US2 - Survive interrupted replacement 3 + +- **GIVEN** an in-progress journal fails signature, path, topology, or hash validation +- **WHEN** setup runs again +- **THEN** it discards only canonical journal artifacts without following embedded paths and performs a fresh canonical installation + +#### Scenario: US2 - Survive interrupted replacement 4 + +- **GIVEN** setup verifies the new post-state +- **WHEN** completion succeeds +- **THEN** no durable OpenCode rollback receipt or pre-version backup for that target remains + +#### Scenario: US2 - Survive interrupted replacement 5 + +- **GIVEN** post-success cleanup cannot remove every journal or backup artifact +- **WHEN** setup renders its result +- **THEN** it returns `complete` with a bounded warning and retries canonical cleanup on the next run + +### Requirement: Remove durable rollback state after success + +After exact post-state verification, setup MUST remove temporary journal data plus all prior setup receipts and backups bound to the same OpenCode harness, scope, and target so no successful OpenCode setup retains a usable durable rollback to the prior installation. + +#### Scenario: US2 - Survive interrupted replacement 1 + +- **GIVEN** setup has begun replacing OpenCode state +- **WHEN** a filesystem mutation fails while the process remains alive +- **THEN** setup restores the complete pre-run state and returns `failed` without claiming completion + +#### Scenario: US2 - Survive interrupted replacement 2 + +- **GIVEN** a prior process stopped with a valid in-progress journal +- **WHEN** setup runs again +- **THEN** it restores the journal's verified pre-state and retries setup from a clean baseline + +#### Scenario: US2 - Survive interrupted replacement 3 + +- **GIVEN** an in-progress journal fails signature, path, topology, or hash validation +- **WHEN** setup runs again +- **THEN** it discards only canonical journal artifacts without following embedded paths and performs a fresh canonical installation + +#### Scenario: US2 - Survive interrupted replacement 4 + +- **GIVEN** setup verifies the new post-state +- **WHEN** completion succeeds +- **THEN** no durable OpenCode rollback receipt or pre-version backup for that target remains + +#### Scenario: US2 - Survive interrupted replacement 5 + +- **GIVEN** post-success cleanup cannot remove every journal or backup artifact +- **WHEN** setup renders its result +- **THEN** it returns `complete` with a bounded warning and retries canonical cleanup on the next run + +### Requirement: Degrade cleanup without false installation failure + +If verified setup succeeds but target-bounded receipt or backup cleanup is incomplete, setup MUST still return `complete`, MUST emit a bounded warning naming only safe cleanup context, and MUST retry cleanup before declaring a later no-op. + +#### Scenario: US2 - Survive interrupted replacement 1 + +- **GIVEN** setup has begun replacing OpenCode state +- **WHEN** a filesystem mutation fails while the process remains alive +- **THEN** setup restores the complete pre-run state and returns `failed` without claiming completion + +#### Scenario: US2 - Survive interrupted replacement 2 + +- **GIVEN** a prior process stopped with a valid in-progress journal +- **WHEN** setup runs again +- **THEN** it restores the journal's verified pre-state and retries setup from a clean baseline + +#### Scenario: US2 - Survive interrupted replacement 3 + +- **GIVEN** an in-progress journal fails signature, path, topology, or hash validation +- **WHEN** setup runs again +- **THEN** it discards only canonical journal artifacts without following embedded paths and performs a fresh canonical installation + +#### Scenario: US2 - Survive interrupted replacement 4 + +- **GIVEN** setup verifies the new post-state +- **WHEN** completion succeeds +- **THEN** no durable OpenCode rollback receipt or pre-version backup for that target remains + +#### Scenario: US2 - Survive interrupted replacement 5 + +- **GIVEN** post-success cleanup cannot remove every journal or backup artifact +- **WHEN** setup renders its result +- **THEN** it returns `complete` with a bounded warning and retries canonical cleanup on the next run + +### Requirement: Preserve truthful planning and results + +Plan mode MUST report deterministic convergence, recovery, cleanup, and restart actions with zero writes; a changed successful result MUST request an OpenCode restart without attempting process control, while an exact no-op MUST return `complete` with `changed=false` and no restart action. + +#### Scenario: US4 - Preview and verify destructive convergence 1 + +- **GIVEN** a target requires replacement or repair +- **WHEN** setup runs with `--plan` +- **THEN** it reports the selected config, whole managed-directory replacement, plugin-entry replacement, temporary recovery evidence, cleanup, and restart requirement while performing zero writes + +#### Scenario: US4 - Preview and verify destructive convergence 2 + +- **GIVEN** mutating setup changes OpenCode state successfully +- **WHEN** results are rendered +- **THEN** human and JSON output agree on `complete`, `changed=true`, bounded warnings, and the manual OpenCode restart action + +#### Scenario: US4 - Preview and verify destructive convergence 3 + +- **GIVEN** the current package lacks a required OpenCode source asset +- **WHEN** setup inspects the package +- **THEN** it fails before creating a journal, backup, or target mutation diff --git a/openspec/specs/packaging/spec.md b/openspec/specs/packaging/spec.md index 6c8ed57..da4bb09 100644 --- a/openspec/specs/packaging/spec.md +++ b/openspec/specs/packaging/spec.md @@ -337,3 +337,59 @@ OpenCode rollback MUST restore or remove the bundled skill only through the exis - **GIVEN** a managed installation with the bundled skill - **WHEN** rollback is applied - **THEN** the receipt-owned plugin assets are restored or removed according to the receipt and no shared OpenCode skill directory is mutated + +### Requirement: Replace the whole managed asset target safely + +Convergence MUST delete every prior entry inside the selected-scope managed asset target and install only the current packaged layout; if the target itself is a symlink, junction, or equivalent link, setup MUST remove the link without traversing or modifying its destination before creating a normal directory. + +#### Scenario: US1 - Converge an existing OpenCode installation 1 + +- **GIVEN** the canonical OpenCode managed asset target contains an older or newer package version +- **WHEN** setup runs without `--force` +- **THEN** setup replaces the complete managed directory and canonical plugin entry with the current package and reports `complete` with `changed=true` + +#### Scenario: US1 - Converge an existing OpenCode installation 2 + +- **GIVEN** the managed asset target exists without valid installation metadata +- **WHEN** setup runs +- **THEN** directory existence authorizes adoption and setup writes current canonical metadata instead of requiring manual deletion + +#### Scenario: US1 - Converge an existing OpenCode installation 3 + +- **GIVEN** metadata names the current package version but any managed asset, metadata field, plugin entry, or owned configuration value differs +- **WHEN** setup runs +- **THEN** setup repairs the full managed state automatically + +#### Scenario: US1 - Converge an existing OpenCode installation 4 + +- **GIVEN** every current managed asset, metadata value, plugin entry, and owned configuration value matches +- **WHEN** setup runs again +- **THEN** it returns `complete` with `changed=false` and performs zero mutation + +### Requirement: Verify current packed-package convergence + +Packed-artifact verification MUST exercise global and project OpenCode convergence from older, newer, missing, malformed, and same-version-diverged metadata and assets, and MUST prove an exact repeated no-op without using the source checkout or a real user home. + +#### Scenario: US1 - Converge an existing OpenCode installation 1 + +- **GIVEN** the canonical OpenCode managed asset target contains an older or newer package version +- **WHEN** setup runs without `--force` +- **THEN** setup replaces the complete managed directory and canonical plugin entry with the current package and reports `complete` with `changed=true` + +#### Scenario: US1 - Converge an existing OpenCode installation 2 + +- **GIVEN** the managed asset target exists without valid installation metadata +- **WHEN** setup runs +- **THEN** directory existence authorizes adoption and setup writes current canonical metadata instead of requiring manual deletion + +#### Scenario: US1 - Converge an existing OpenCode installation 3 + +- **GIVEN** metadata names the current package version but any managed asset, metadata field, plugin entry, or owned configuration value differs +- **WHEN** setup runs +- **THEN** setup repairs the full managed state automatically + +#### Scenario: US1 - Converge an existing OpenCode installation 4 + +- **GIVEN** every current managed asset, metadata value, plugin entry, and owned configuration value matches +- **WHEN** setup runs again +- **THEN** it returns `complete` with `changed=false` and performs zero mutation diff --git a/src/setup/engine.ts b/src/setup/engine.ts index ae5f981..6ce47c6 100644 --- a/src/setup/engine.ts +++ b/src/setup/engine.ts @@ -1,5 +1,5 @@ import {createHash, randomUUID} from 'node:crypto'; -import {lstat, readFile} from 'node:fs/promises'; +import {lstat, readFile, readlink} from 'node:fs/promises'; import {basename, dirname, isAbsolute, join, relative, resolve} from 'node:path'; import {getConfig} from '../config.js'; @@ -47,13 +47,16 @@ import { type SetupRoots, } from './paths.js'; import { + cleanupMatchingSetupReceipts, codexManagedFragmentFromReceiptEvidence, createCodexManagedFragmentReceiptEvidence, createSetupReceipt, loadSetupReceipt, persistSetupReceipt, resolveSetupReceiptPaths, + resetSetupReceiptNamespace, scanSetupReceipts, + setupReceiptNamespaceHasTrustedContainment, type ReceiptFaultEvent, type ReceiptPaths, type SetupReceipt, @@ -139,6 +142,7 @@ interface SetupInspection { managed: boolean; conflicts: SetupConflict[]; sourceAssetsAvailable: boolean; + configRecreation: boolean; codexLegacyState: 'absent' | 'managed' | 'ambiguous' | null; } @@ -150,6 +154,7 @@ const OPENCODE_MCP_VALUE = { const OPENCODE_PLUGIN_ENTRY = 'export { default } from \'./.thoth-mem/opencode/plugin.mjs\';\n'; const MANAGED_METADATA_NAME = 'thoth-mem.installation.json'; const LEGACY_MANAGED_METADATA_NAME = '.thoth-mem-managed.json'; +const OPENCODE_CLEANUP_STEP_NAME = 'Remove target-bound OpenCode recovery and rollback evidence'; function setupAssetLayout( request: SetupRequest, @@ -282,12 +287,88 @@ function setupExecutablePath(options: SetupEngineOptions): string { : resolve(executable ?? 'thoth-mem'); } +async function resolveMalformedConfigBackupPath( + configPath: string, + transactionId: string, +): Promise { + const basePath = `${configPath}.thoth-mem-backup-${transactionId}`; + for (let suffix = 0; suffix < 1000; suffix++) { + const candidate = suffix === 0 ? basePath : `${basePath}-${suffix}`; + try { + await lstat(candidate); + } catch (error) { + if (isMissingPathError(error)) { + return candidate; + } + throw error; + } + } + throw new Error('malformed-config-backup-path-unavailable'); +} + +async function preflightOpenCodeInventory( + paths: SetupPaths, + fileSystem: SetupFileSystem, +): Promise { + const packageRoot = dirname(dirname(paths.sourceAssetsPath)); + const inventoryPath = join(packageRoot, 'integrations', 'inventory.json'); + if (await fileSystem.pathType(inventoryPath) !== 'file') { + throw new Error('packaged-inventory-unavailable'); + } + let parsed: unknown; + try { + parsed = JSON.parse(await fileSystem.readText(inventoryPath)); + } catch { + throw new Error('packaged-inventory-invalid'); + } + if (!isRecord(parsed) || parsed.schemaVersion !== 1 || !Array.isArray(parsed.assets)) { + throw new Error('packaged-inventory-invalid'); + } + const assets = parsed.assets.filter((asset): asset is { + harness: string; + role: string; + path: string; + } => isRecord(asset) + && typeof asset.harness === 'string' + && typeof asset.role === 'string' + && typeof asset.path === 'string'); + if (assets.length !== parsed.assets.length) { + throw new Error('packaged-inventory-invalid'); + } + const required = assets.filter((asset) => ( + asset.harness === 'opencode' + || asset.path.startsWith('plugin/skills/thoth-mem/') + )); + const requiredPaths = new Set(required.map((asset) => asset.path)); + for (const expected of [ + 'integrations/opencode/plugin.mjs', + 'integrations/opencode/memory-protocol.md', + 'integrations/shared/hook-runner.mjs', + 'plugin/skills/thoth-mem/SKILL.md', + 'plugin/skills/thoth-mem/references/opencode.md', + ]) { + if (!requiredPaths.has(expected)) { + throw new Error('packaged-inventory-incomplete'); + } + } + for (const asset of required) { + const sourcePath = resolve(packageRoot, asset.path); + if (hasPathEscape(relative(packageRoot, sourcePath)) + || await fileSystem.pathType(sourcePath) !== 'file') { + throw new Error('packaged-inventory-asset-unavailable'); + } + } +} + async function inspectSetup( request: SetupRequest, unresolvedPaths: SetupPaths, fileSystem: SetupFileSystem, options: SetupEngineOptions, ): Promise { + if (request.harness === 'opencode') { + await preflightOpenCodeInventory(unresolvedPaths, fileSystem); + } const sourceAssetsType = await fileSystem.pathType(unresolvedPaths.sourceAssetsPath); const sourceAssetsAvailable = sourceAssetsType === 'directory'; if (request.harness === 'opencode' && !sourceAssetsAvailable) { @@ -311,9 +392,9 @@ async function inspectSetup( type: await fileSystem.pathType(path), }))); const existingConfigFiles = candidateTypes.filter((candidate) => candidate.type === 'file'); - const selectedConfigPath = existingConfigFiles.length === 1 - ? existingConfigFiles[0]!.path - : unresolvedPaths.configPath; + const selectedConfigPath = candidateTypes.find((candidate) => ( + candidate.type === 'file' && candidate.path.endsWith('opencode.jsonc') + ))?.path ?? existingConfigFiles[0]?.path ?? unresolvedPaths.configPath; const paths = selectedConfigPath === unresolvedPaths.configPath ? unresolvedPaths : { ...unresolvedPaths, configPath: selectedConfigPath }; @@ -321,13 +402,7 @@ async function inspectSetup( const configType = selectedCandidate?.type ?? 'missing'; const conflicts: SetupConflict[] = []; - if (existingConfigFiles.length > 1) { - conflicts.push({ - path: paths.targetRoot, - diagnostic: `Multiple OpenCode configuration files exist: ${paths.configCandidates.join(', ')}`, - forceable: false, - }); - } + for (const candidate of candidateTypes) { if (candidate.type === 'directory' || candidate.type === 'other') { conflicts.push({ @@ -358,8 +433,13 @@ async function inspectSetup( const assetType = await fileSystem.pathType(paths.assetPath); const canonicalMetadataPath = join(paths.assetPath, MANAGED_METADATA_NAME); - const canonicalMetadataType = await fileSystem.pathType(canonicalMetadataPath); - const legacyMetadataType = await fileSystem.pathType(paths.metadataPath); + const inspectManagedMetadata = assetType === 'directory'; + const canonicalMetadataType = inspectManagedMetadata + ? await fileSystem.pathType(canonicalMetadataPath) + : 'missing'; + const legacyMetadataType = inspectManagedMetadata + ? await fileSystem.pathType(paths.metadataPath) + : 'missing'; if (assetType === 'file' || assetType === 'other') { conflicts.push({ path: paths.assetPath, @@ -461,6 +541,7 @@ async function inspectSetup( managed, conflicts, sourceAssetsAvailable, + configRecreation: !baselineConfigPlan.verification.beforeValid, codexLegacyState, }; } @@ -520,6 +601,19 @@ function invalidPathResult(request: SetupRequest): SetupResult { }; } +function unsafeOpenCodeRecoveryBoundaryResult( + request: SetupRequest, + paths: SetupPaths, +): SetupResult { + return requiresSetupActionResult( + request, + paths, + 'The target-bound OpenCode recovery boundary escapes its trusted setup root.', + 'Replace linked recovery ancestors with real directories inside the selected setup root, then retry.', + 'Validate target-bound OpenCode recovery boundary', + ); +} + function requiresSetupActionResult( request: SetupRequest, paths: SetupPaths, @@ -571,10 +665,17 @@ function filesystemStepOutcome( return 'planned'; } +function isOpenCodeBoundaryConflict(conflict: SetupConflict): boolean { + return conflict.diagnostic.startsWith('Conflict at managed configuration target:'); +} + function hasBlockingConflict( request: SetupRequest, inspection: SetupInspection, ): boolean { + if (request.harness === 'opencode') { + return inspection.conflicts.some(isOpenCodeBoundaryConflict); + } return inspection.conflicts.some((conflict) => !conflict.forceable || !request.force); } @@ -587,10 +688,27 @@ function planSteps( ): SetupStep[] { const harness = displayHarness(request); const filesystemOutcome = filesystemStepOutcome(request, inspection); + if (request.harness === 'opencode') { + const verificationOutcome: SetupStepOutcome = inspection.managed ? 'confirmed' : 'planned'; + return [ + { name: `Inspect packaged ${harness} assets: ${paths.sourceAssetsPath}`, outcome: 'confirmed' }, + { name: `Inspect ${harness} configuration: ${paths.configPath}`, outcome: 'confirmed' }, + { name: 'Prepare target-bound temporary OpenCode recovery journal', outcome: filesystemOutcome }, + { name: `Replace complete OpenCode managed assets: ${paths.assetPath}`, outcome: filesystemOutcome }, + { name: `Replace canonical OpenCode plugin entry: ${paths.pluginEntryPath}`, outcome: filesystemOutcome }, + { + name: `${inspection.configRecreation ? 'Recreate' : 'Merge managed'} OpenCode configuration: ${paths.configPath}`, + outcome: filesystemOutcome, + }, + { name: 'Verify exact OpenCode setup post-state', outcome: verificationOutcome }, + { name: OPENCODE_CLEANUP_STEP_NAME, outcome: filesystemOutcome }, + { name: 'Report manual OpenCode restart requirement', outcome: filesystemOutcome }, + ]; + } const codexComplete = codexEvidence.marketplace === 'confirmed' && codexEvidence.plugin === 'confirmed'; const verificationOutcome: SetupStepOutcome = inspection.managed - && (request.harness === 'opencode' || codexComplete) + && codexComplete ? 'confirmed' : 'planned'; @@ -630,6 +748,23 @@ function planSteps( return steps; } +function finalizeOpenCodePlanSteps( + steps: SetupStep[], + cleanupComplete: boolean, +): SetupStep[] { + return steps.map((step) => { + if (step.outcome !== 'planned') { + return step; + } + return { + ...step, + outcome: !cleanupComplete && step.name === OPENCODE_CLEANUP_STEP_NAME + ? 'unavailable' + : 'confirmed', + }; + }); +} + function planDiagnostics( request: SetupRequest, paths: SetupPaths, @@ -639,10 +774,18 @@ function planDiagnostics( ): string[] { const diagnostics = inspection.conflicts.map((conflict) => conflict.diagnostic); + if (request.harness === 'opencode' && !inspection.managed) { + diagnostics.push('OpenCode convergence replaces the complete managed asset directory and canonical plugin entry.'); + diagnostics.push('Temporary target-bound recovery evidence is created before mutation and removed only after exact verification.'); + diagnostics.push('Changed OpenCode setup requires a manual host restart.'); + if (inspection.configRecreation) { + diagnostics.push(`Malformed selected OpenCode configuration will be backed up byte-exactly before minimal recreation: ${paths.configPath}`); + } + } if (!inspection.managed && inspection.configType === 'file') { diagnostics.push(`Backup required before mutation: ${paths.configPath}`); } - if (request.force) { + if (request.force && request.harness !== 'opencode') { for (const conflict of inspection.conflicts.filter((candidate) => candidate.forceable)) { diagnostics.push(`Force would replace only: ${conflict.path}`); } @@ -672,6 +815,9 @@ function manualActions( const actions: string[] = []; for (const conflict of inspection.conflicts) { + if (request.harness === 'opencode' && !isOpenCodeBoundaryConflict(conflict)) { + continue; + } if (conflict.forceable && !request.force) { actions.push(`Review the conflict and re-run with --force only if thoth-mem may own: ${conflict.path}`); } else if (!conflict.forceable) { @@ -926,6 +1072,7 @@ interface ReceiptBackedChangeResult { interface BoundSetupReceipt { receipt: SetupReceiptV1; paths: SetupPaths; + configBackupStep?: SetupReceiptStep; configStep: SetupReceiptStep; assetStep: SetupReceiptStep; pluginStep: SetupReceiptStep; @@ -958,6 +1105,57 @@ function setupReceiptBasePath( return join(projectRoot, '.thoth', 'setup', 'receipts'); } +function openCodeJournalBasePath( + request: SetupRequest, + paths: SetupPaths, + dataDir: string, + canonicalTarget: string, +): string { + const targetHash = createHash('sha256').update(canonicalTarget).digest('hex'); + const setupRoot = request.scope === 'global' + ? join(dataDir, 'setup') + : join(paths.targetRoot, '.thoth', 'setup'); + return join(setupRoot, 'opencode-journals', targetHash); +} + +function openCodeReceiptTrustedRoot( + request: SetupRequest, + paths: SetupPaths, + dataDir: string, +): string { + return request.scope === 'global' ? dataDir : paths.targetRoot; +} + +async function cleanupOpenCodeEvidence(input: { + request: SetupRequest; + paths: SetupPaths; + dataDir: string; + canonicalTarget: string; + journalBasePath: string; + journalKeyPath: string; + trustedRoot: string; + options: SetupEngineOptions; +}): Promise { + const journalCleanup = await resetSetupReceiptNamespace(input.journalBasePath, { + dataDir: input.dataDir, + keyPath: input.journalKeyPath, + trustedRoot: input.trustedRoot, + fault: input.options.transaction?.receiptFault, + }); + const legacyCleanup = await cleanupMatchingSetupReceipts( + setupReceiptBasePath(input.request, input.paths, input.dataDir), + { + dataDir: input.dataDir, + trustedRoot: input.trustedRoot, + fault: input.options.transaction?.receiptFault, + }, + (candidate) => candidate.harness === 'opencode' + && candidate.scope === input.request.scope + && resolve(candidate.target) === resolve(input.canonicalTarget), + ); + return journalCleanup && legacyCleanup.ok; +} + function transactionNow(options: SetupEngineOptions): string { return (options.transaction?.now?.() ?? new Date()).toISOString(); } @@ -981,7 +1179,7 @@ async function traceSetup( } } -function fileContentSnapshot(content: string): string { +function fileContentSnapshot(content: string | Uint8Array): string { return `file:${createHash('sha256').update(content).digest('hex')}`; } @@ -1075,10 +1273,14 @@ async function persistReceiptCheckpoint( receipt: SetupReceipt, dataDir: string, options: SetupEngineOptions, + receiptKeyPath?: string, + receiptTrustedRoot?: string, ) { return persistSetupReceipt(receiptPath, receipt, { dataDir, expectedBasePath: receiptBasePath, + ...(receiptKeyPath ? { keyPath: receiptKeyPath } : {}), + ...(receiptTrustedRoot ? { trustedRoot: receiptTrustedRoot } : {}), fault: options.transaction?.receiptFault, }); } @@ -1092,6 +1294,8 @@ async function applyReceiptBackedChanges(input: { receipt: T; changes: FilesystemChange[]; options: SetupEngineOptions; + receiptKeyPath?: string; + receiptTrustedRoot?: string; postHash: (path: string) => Promise; afterWriteAhead?: () => void | Promise; changeTraceKind?: string; @@ -1119,6 +1323,8 @@ async function applyReceiptBackedChanges(input: { activeReceipt, input.dataDir, input.options, + input.receiptKeyPath, + input.receiptTrustedRoot, ); if (!persisted.ok) { throw new Error('receipt-write-ahead-failed'); @@ -1142,6 +1348,8 @@ async function applyReceiptBackedChanges(input: { activeReceipt, input.dataDir, input.options, + input.receiptKeyPath, + input.receiptTrustedRoot, ); if (!persisted.ok) { throw new Error('receipt-checkpoint-failed'); @@ -1160,6 +1368,8 @@ async function markRestoredFailure( receiptBasePath: string, dataDir: string, options: SetupEngineOptions, + receiptKeyPath?: string, + receiptTrustedRoot?: string, ): Promise { if (!result.initialReceiptPersisted || result.filesystem.changed) { return result.receipt; @@ -1178,6 +1388,8 @@ async function markRestoredFailure( failedReceipt, dataDir, options, + receiptKeyPath, + receiptTrustedRoot, ); return persisted.ok ? persisted.receipt : result.receipt; } @@ -1246,14 +1458,18 @@ function bindSetupReceipt( || resolve(receiptPath) !== resolve( resolveSetupReceiptPaths(receiptBasePath, receipt.id).receiptPath, ) - || receipt.steps.length !== 4 + || (receipt.steps.length !== 4 && receipt.steps.length !== 5) ) { return null; } + const expectedIds = receipt.steps.length === 5 + ? ['config-backup', 'config', 'assets', 'plugin', 'verify'] + : ['config', 'assets', 'plugin', 'verify']; const byId = new Map(receipt.steps.map((step) => [step.id, step])); - if (byId.size !== 4) { + if (byId.size !== expectedIds.length || expectedIds.some((id) => !byId.has(id))) { return null; } + const configBackupStep = byId.get('config-backup'); const configStep = byId.get('config'); const assetStep = byId.get('assets'); const pluginStep = byId.get('plugin'); @@ -1275,6 +1491,15 @@ function bindSetupReceipt( || !pluginStep.post_hash || verifyStep?.kind !== 'verification' || verifyStep.path !== undefined + || (receipt.steps.length === 5 && ( + configBackupStep?.kind !== 'filesystem' + || configBackupStep.pre_hash !== 'missing' + || !configBackupStep.post_hash + || configBackupStep.backup_path !== undefined + || !configBackupStep.path + || dirname(configBackupStep.path) !== dirname(configStep.path) + || !basename(configBackupStep.path).startsWith(`${basename(configStep.path)}.thoth-mem-backup-`) + )) ) { return null; } @@ -1293,19 +1518,102 @@ function bindSetupReceipt( return { receipt, paths: { ...unresolvedPaths, configPath: configStep.path }, + ...(configBackupStep ? { configBackupStep } : {}), configStep, assetStep, pluginStep, }; } +async function restoreOpenCodeJournal( + bound: BoundSetupReceipt, + receiptPath: string, + dataDir: string, + receiptKeyPath: string, + trustedRoot: string, + options: SetupEngineOptions, +): Promise { + const receiptBasePath = dirname(dirname(receiptPath)); + if (!await setupReceiptNamespaceHasTrustedContainment(receiptBasePath, { + dataDir, + keyPath: receiptKeyPath, + trustedRoot, + })) { + return false; + } + const changes: FilesystemChange[] = []; + const steps = [ + ...(bound.configBackupStep ? [bound.configBackupStep] : []), + bound.configStep, + bound.assetStep, + bound.pluginStep, + ]; + for (const step of steps) { + if (!step.path || !step.pre_hash) { + return false; + } + if (step.pre_hash === 'missing') { + changes.push({ kind: 'remove', targetPath: step.path }); + continue; + } + if (!step.backup_path) { + return false; + } + try { + if (await filesystemEntrySnapshot(step.backup_path) !== step.pre_hash) { + return false; + } + const details = await lstat(step.backup_path); + if (details.isSymbolicLink()) { + changes.push({ + kind: 'link', + targetPath: step.path, + linkTarget: await readlink(step.backup_path), + replaceExisting: true, + }); + } else if (details.isFile()) { + changes.push({ + kind: 'file', + targetPath: step.path, + content: await readFile(step.backup_path), + replaceExisting: true, + }); + } else if (details.isDirectory()) { + changes.push({ + kind: 'directory', + targetPath: step.path, + entries: [{ sourcePath: step.backup_path, targetRelativePath: '.' }], + replaceExisting: true, + }); + } else { + return false; + } + } catch { + return false; + } + } + const receiptRoot = dirname(receiptPath); + const restored = await applyAtomicFilesystemChanges({ + targetRoot: bound.paths.targetRoot, + sourceRoot: receiptRoot, + backupRoot: join(receiptRoot, 'recovery-current-state'), + changes, + }, { fault: options.transaction?.filesystemFault }); + return restored.outcome === 'confirmed'; +} + async function readRegularFileOrNull(path: string): Promise { + const content = await readRegularFileBytesOrNull(path); + return content?.toString('utf8') ?? null; +} + +async function readRegularFileBytesOrNull(path: string): Promise { try { const details = await lstat(path); if (!details.isFile() || details.isSymbolicLink()) { throw new Error('setup-file-not-regular'); } - return readFile(path, 'utf8'); + return readFile(path); } catch (error) { if (isMissingPathError(error)) { return null; @@ -1564,12 +1872,15 @@ async function executeOpenCodeSetup( dataDir: string, canonicalTarget: string, receiptBasePath: string, + receiptKeyPath: string, + receiptTrustedRoot: string, options: SetupEngineOptions, ): Promise { const paths = inspection.paths; - const configBefore = inspection.configType === 'file' - ? await readRegularFileOrNull(paths.configPath) + const configBeforeBytes = inspection.configType === 'file' + ? await readRegularFileBytesOrNull(paths.configPath) : null; + const configBefore = configBeforeBytes?.toString('utf8') ?? null; const configPlan = planOpenCodeManagedConfig({ before: configBefore, force: request.force, @@ -1594,12 +1905,28 @@ async function executeOpenCodeSetup( } catch { return failedInspectionResult(request, paths.targetRoot, paths.sourceAssetsPath); } - const configPreHash = inspectOpenCodeOwnedState(configBefore).hash; - const configPostHash = inspectOpenCodeOwnedState(configPlan.after).hash; + const configPreHash = configBeforeBytes + ? await filesystemEntrySnapshot(paths.configPath) + : 'missing'; + const configPostHash = fileContentSnapshot(configPlan.after); const metadata = setupMetadata(request, paths, options); - const receiptPaths = resolveSetupReceiptPaths(receiptBasePath, nextReceiptId(options)); + const receiptId = nextReceiptId(options); + const receiptPaths = resolveSetupReceiptPaths(receiptBasePath, receiptId); + const malformedConfigBackupPath = configBeforeBytes && !configPlan.verification.beforeValid + ? await resolveMalformedConfigBackupPath(paths.configPath, receiptId) + : null; const startedAt = transactionNow(options); const steps: SetupReceiptStep[] = [ + ...(malformedConfigBackupPath && configBeforeBytes + ? [{ + id: 'config-backup', + kind: 'filesystem' as const, + outcome: 'planned' as const, + path: malformedConfigBackupPath, + pre_hash: 'missing', + post_hash: fileContentSnapshot(configBeforeBytes), + }] + : []), { id: 'config', kind: 'filesystem', @@ -1641,6 +1968,14 @@ async function executeOpenCodeSetup( steps, }); const changes: FilesystemChange[] = [ + ...(malformedConfigBackupPath && configBeforeBytes + ? [{ + kind: 'file' as const, + targetPath: malformedConfigBackupPath, + content: configBeforeBytes, + mode: 0o600, + }] + : []), ...(configPlan.changed ? [{ kind: 'file' as const, targetPath: paths.configPath, content: configPlan.after }] : []), @@ -1653,8 +1988,14 @@ async function executeOpenCodeSetup( content: metadata, mode: 0o600, }], + replaceExisting: true, + }, + { + kind: 'file', + targetPath: paths.pluginEntryPath, + content: OPENCODE_PLUGIN_ENTRY, + replaceExisting: true, }, - { kind: 'file', targetPath: paths.pluginEntryPath, content: OPENCODE_PLUGIN_ENTRY }, ]; const plannedSteps = planSteps( request, @@ -1672,9 +2013,9 @@ async function executeOpenCodeSetup( receipt, changes, options, - postHash: async (path) => path === paths.configPath - ? inspectOpenCodeOwnedState(await readRegularFileOrNull(path)).hash - : filesystemEntrySnapshot(path), + receiptKeyPath, + receiptTrustedRoot, + postHash: filesystemEntrySnapshot, }); if (applied.filesystem.outcome === 'failed') { await markRestoredFailure( @@ -1683,6 +2024,8 @@ async function executeOpenCodeSetup( receiptBasePath, dataDir, options, + receiptKeyPath, + receiptTrustedRoot, ); return receiptFailureResult( request, @@ -1702,13 +2045,51 @@ async function executeOpenCodeSetup( verified = false; } if (!verified) { - return receiptFailureResult( + const bound = bindSetupReceipt( request, paths, - true, + canonicalTarget, + receiptBasePath, receiptPaths.receiptPath, - 'OpenCode setup post-state verification failed.', + applied.receipt, ); + const restored = bound + ? await restoreOpenCodeJournal( + bound, + receiptPaths.receiptPath, + dataDir, + receiptKeyPath, + receiptTrustedRoot, + options, + ) + : false; + const recoveryEvidenceRemoved = restored + && await resetSetupReceiptNamespace(receiptBasePath, { + dataDir, + keyPath: receiptKeyPath, + trustedRoot: receiptTrustedRoot, + fault: options.transaction?.receiptFault, + }); + if (restored) { + await traceSetup(options, 'journal_restored'); + } + return { + status: 'failed', + changed: !restored, + harness: request.harness, + scope: request.scope, + target: paths.targetRoot, + steps: [{ name: 'Verify exact OpenCode setup post-state', outcome: 'failed' }], + diagnostics: [recoveryEvidenceRemoved + ? 'OpenCode setup post-state verification failed; the complete pre-run state was restored.' + : restored + ? 'OpenCode setup post-state verification failed; the complete pre-run state was restored, but recovery evidence cleanup is incomplete.' + : 'OpenCode setup post-state verification failed and complete restoration could not be confirmed.'], + manual_actions: recoveryEvidenceRemoved + ? ['Retry OpenCode setup from the restored pre-run state.'] + : ['Inspect the target-bound recovery evidence before retrying.'], + receipt: recoveryEvidenceRemoved ? null : receiptPaths.receiptPath, + }; } const completeReceipt: SetupReceiptV1 = { @@ -1725,29 +2106,70 @@ async function executeOpenCodeSetup( completeReceipt, dataDir, options, + receiptKeyPath, + receiptTrustedRoot, ); if (!persisted.ok) { + const cleanupComplete = await cleanupOpenCodeEvidence({ + request, + paths, + dataDir, + canonicalTarget, + journalBasePath: receiptBasePath, + journalKeyPath: receiptKeyPath, + trustedRoot: receiptTrustedRoot, + options, + }); + if (cleanupComplete) { + return { + status: 'complete', + changed: true, + harness: request.harness, + scope: request.scope, + target: paths.targetRoot, + steps: finalizeOpenCodePlanSteps(plannedSteps, true), + diagnostics: ['OpenCode setup verified; its final journal checkpoint was unavailable, but temporary recovery evidence was removed.'], + manual_actions: ['Restart OpenCode to load the updated thoth-mem integration.'], + receipt: null, + }; + } return receiptFailureResult( request, paths, true, receiptPaths.receiptPath, - 'OpenCode setup completed but its final receipt could not be confirmed.', + 'OpenCode setup verified, but neither its final journal checkpoint nor cleanup could be confirmed.', ); } await traceSetup(options, 'receipt_complete', receiptPaths.receiptPath); + const cleanupComplete = await cleanupOpenCodeEvidence({ + request, + paths, + dataDir, + canonicalTarget, + journalBasePath: receiptBasePath, + journalKeyPath: receiptKeyPath, + trustedRoot: receiptTrustedRoot, + options, + }); return { status: 'complete', changed: true, harness: request.harness, scope: request.scope, target: paths.targetRoot, - steps: plannedSteps.map((step) => ( - step.outcome === 'planned' ? { ...step, outcome: 'confirmed' as const } : step - )), - diagnostics: receiptKeyDiagnostic(applied.keyProtection), - manual_actions: [], - receipt: receiptPaths.receiptPath, + steps: finalizeOpenCodePlanSteps(plannedSteps, cleanupComplete), + diagnostics: [ + ...receiptKeyDiagnostic(applied.keyProtection), + ...(malformedConfigBackupPath + ? [`Malformed OpenCode configuration backup: ${malformedConfigBackupPath}`] + : []), + ...(!cleanupComplete + ? ['OpenCode setup succeeded, but target-bound recovery evidence cleanup is incomplete and will be retried.'] + : []), + ], + manual_actions: ['Restart OpenCode to load the updated thoth-mem integration.'], + receipt: null, }; } @@ -3012,18 +3434,41 @@ export async function inspectAndPlanSetup( const needsTargetLock = canMutateOpenCode || canMutateCodex || canMutateCodexRollback; let dataDir: string; let receiptBasePath: string; + let receiptKeyPath: string | undefined; + let receiptTrustedRoot: string | undefined; let canonicalTarget: string; try { dataDir = canonicalDataDir(options, roots, needsTargetLock); if (!isAbsolute(dataDir)) { return invalidPathResult(request); } - receiptBasePath = setupReceiptBasePath(request, paths, dataDir); canonicalTarget = await canonicalizeSetupTarget(paths.targetRoot); + const legacyReceiptBasePath = setupReceiptBasePath(request, paths, dataDir); + const usesOpenCodeJournal = request.harness === 'opencode' + && request.rollbackReceipt === undefined; + receiptBasePath = usesOpenCodeJournal + ? openCodeJournalBasePath(request, paths, dataDir, canonicalTarget) + : legacyReceiptBasePath; + receiptKeyPath = usesOpenCodeJournal ? join(receiptBasePath, 'journal.key') : undefined; + receiptTrustedRoot = usesOpenCodeJournal + ? openCodeReceiptTrustedRoot(request, paths, dataDir) + : undefined; } catch { return invalidPathResult(request); } + if ( + canMutateOpenCode + && receiptTrustedRoot + && !await setupReceiptNamespaceHasTrustedContainment(receiptBasePath, { + dataDir, + ...(receiptKeyPath ? { keyPath: receiptKeyPath } : {}), + trustedRoot: receiptTrustedRoot, + }) + ) { + return unsafeOpenCodeRecoveryBoundaryResult(request, paths); + } + let releaseLock: (() => Promise) | undefined; if (canMutateCodex) { const noOp = await inspectVerifiedCodexNoOpBeforeLock( @@ -3062,10 +3507,44 @@ export async function inspectAndPlanSetup( if (releaseLock) { await traceSetup(options, 'lock_acquired'); } - const scanned = await scanSetupReceipts(receiptBasePath, { + if (request.harness === 'opencode' && canMutateOpenCode) { + try { + await inspectSetup(request, paths, createNodeSetupFileSystem(), options); + } catch { + return failedInspectionResult(request, paths.targetRoot, paths.sourceAssetsPath); + } + } + let scanned = await scanSetupReceipts(receiptBasePath, { dataDir, expectedBasePath: receiptBasePath, + ...(receiptKeyPath ? { keyPath: receiptKeyPath } : {}), + ...(receiptTrustedRoot ? { trustedRoot: receiptTrustedRoot } : {}), }); + if ( + !scanned.ok + && scanned.reason === 'receipt_namespace_unsafe' + && request.harness === 'opencode' + && canMutateOpenCode + ) { + return unsafeOpenCodeRecoveryBoundaryResult(request, paths); + } + if (!scanned.ok && request.harness === 'opencode' && canMutateOpenCode) { + const reset = await resetSetupReceiptNamespace(receiptBasePath, { + dataDir, + keyPath: receiptKeyPath!, + ...(receiptTrustedRoot ? { trustedRoot: receiptTrustedRoot } : {}), + fault: options.transaction?.receiptFault, + }); + if (!reset) { + return requiresSetupActionResult( + request, + paths, + 'Invalid target-bound OpenCode recovery evidence could not be reset safely.', + 'Remove only the reported target-bound recovery namespace and retry.', + ); + } + scanned = { ok: true, receipts: [] }; + } if (!scanned.ok) { return requiresSetupActionResult( request, @@ -3080,9 +3559,55 @@ export async function inspectAndPlanSetup( && receipt.scope === request.scope && resolve(receipt.target) === resolve(canonicalTarget) )); - const blockingIncomplete = request.rollbackReceipt - ? incomplete.filter(({ path }) => resolve(path) !== resolve(request.rollbackReceipt!)) - : incomplete; + if (request.harness === 'opencode' && canMutateOpenCode && incomplete.length > 0) { + let restored = false; + if (incomplete.length === 1) { + const candidate = incomplete[0]!; + const bound = candidate.receipt.schema_version === 1 ? bindSetupReceipt( + request, + paths, + canonicalTarget, + receiptBasePath, + candidate.path, + candidate.receipt, + ) : null; + if (bound) { + restored = await restoreOpenCodeJournal( + bound, + candidate.path, + dataDir, + receiptKeyPath!, + receiptTrustedRoot!, + options, + ); + } + } + if (restored) { + await traceSetup(options, 'journal_restored'); + } + const reset = await resetSetupReceiptNamespace(receiptBasePath, { + dataDir, + keyPath: receiptKeyPath!, + ...(receiptTrustedRoot ? { trustedRoot: receiptTrustedRoot } : {}), + fault: options.transaction?.receiptFault, + }); + if (!reset) { + return requiresSetupActionResult( + request, + paths, + 'Target-bound OpenCode recovery evidence could not be cleared safely.', + 'Remove only the reported target-bound recovery namespace and retry.', + ); + } + if (!restored && incomplete.length === 1) { + await traceSetup(options, 'journal_reset'); + } + } + const blockingIncomplete = request.harness === 'opencode' && canMutateOpenCode + ? [] + : request.rollbackReceipt + ? incomplete.filter(({ path }) => resolve(path) !== resolve(request.rollbackReceipt!)) + : incomplete; if (blockingIncomplete.length > 0) { return { status: 'requires_user_action', @@ -3144,6 +3669,19 @@ export async function inspectAndPlanSetup( return failedInspectionResult(request, paths.targetRoot, paths.sourceAssetsPath); } paths = inspection.paths; + let openCodeCleanupWarning = false; + if (request.harness === 'opencode' && canMutateOpenCode && inspection.managed) { + openCodeCleanupWarning = !await cleanupOpenCodeEvidence({ + request, + paths, + dataDir, + canonicalTarget, + journalBasePath: receiptBasePath, + journalKeyPath: receiptKeyPath!, + trustedRoot: receiptTrustedRoot!, + options, + }); + } if ( request.harness === 'codex' && inspection.codexLegacyState === 'ambiguous' @@ -3245,6 +3783,8 @@ export async function inspectAndPlanSetup( dataDir, canonicalTarget, receiptBasePath, + receiptKeyPath!, + receiptTrustedRoot!, options, ); } @@ -3256,7 +3796,12 @@ export async function inspectAndPlanSetup( scope: request.scope, target: paths.targetRoot, steps: planSteps(request, paths, inspection, codexEvidence), - diagnostics: planDiagnostics(request, paths, inspection, codexEvidence), + diagnostics: [ + ...planDiagnostics(request, paths, inspection, codexEvidence), + ...(openCodeCleanupWarning + ? ['OpenCode setup is current, but target-bound recovery evidence cleanup remains incomplete.'] + : []), + ], manual_actions: manualActions(request, inspection, codexEvidence, needsFileChanges), receipt: null, }; diff --git a/src/setup/filesystem.ts b/src/setup/filesystem.ts index d15e22c..3728bea 100644 --- a/src/setup/filesystem.ts +++ b/src/setup/filesystem.ts @@ -5,10 +5,12 @@ import { mkdir, open, readFile, + readlink, realpath, readdir, rename, rm, + symlink, } from 'node:fs/promises'; import { basename, @@ -52,7 +54,9 @@ export interface FilesystemFaultOptions { export interface FilesystemFileChange { kind: 'file'; targetPath: string; - content: string; + content: string | Uint8Array; + mode?: number; + replaceExisting?: boolean; } export interface FilesystemDirectoryEntry { @@ -71,6 +75,14 @@ export interface FilesystemDirectoryChange { targetPath: string; entries: FilesystemDirectoryEntry[]; generatedFiles?: FilesystemGeneratedFile[]; + replaceExisting?: boolean; +} + +export interface FilesystemLinkChange { + kind: 'link'; + targetPath: string; + linkTarget: string; + replaceExisting?: boolean; } export interface FilesystemRemoveChange { @@ -81,6 +93,7 @@ export interface FilesystemRemoveChange { export type FilesystemChange = | FilesystemFileChange | FilesystemDirectoryChange + | FilesystemLinkChange | FilesystemRemoveChange; export interface AtomicFilesystemPlan { @@ -93,7 +106,7 @@ export interface AtomicFilesystemPlan { export interface FilesystemBackup { targetPath: string; backupPath: string; - kind: 'file' | 'directory'; + kind: 'file' | 'directory' | 'link'; } export interface AtomicFilesystemResult { @@ -106,7 +119,7 @@ export interface AtomicFilesystemResult { diagnostics: string[]; } -type EntryKind = 'missing' | 'file' | 'directory' | 'other'; +type EntryKind = 'missing' | 'file' | 'directory' | 'link' | 'other'; interface PreparedChange { change: FilesystemChange; @@ -270,7 +283,7 @@ async function prepareChanges(plan: AtomicFilesystemPlan): Promise { + return allowsAuthoritativeReplacement(change) + ? isRealParentContained(root, targetPath) + : isRealPathContained(root, targetPath); +} + +async function isRealParentContained(root: string, candidate: string): Promise { + return isContained(root, candidate) + && isContained( + await resolveFromNearestExistingAncestor(root), + await resolveFromNearestExistingAncestor(dirname(candidate)), + ); +} + async function isRealPathContained(root: string, candidate: string): Promise { if (!isContained(root, candidate)) { return false; @@ -404,7 +442,7 @@ async function entryDetails(path: string): Promise<{ try { const details = await lstat(path); if (details.isSymbolicLink()) { - return { kind: 'other' }; + return { kind: 'link' }; } if (details.isFile()) { return { kind: 'file', mode: details.mode & 0o777 }; @@ -459,10 +497,13 @@ async function createBackup( } if (backup.kind === 'file') { await copyFile(backup.targetPath, backup.backupPath); - } else { + await syncEntry(backup.backupPath); + } else if (backup.kind === 'directory') { await copyTree(backup.targetPath, backup.backupPath); + await syncEntry(backup.backupPath); + } else { + await symlink(await readlink(backup.targetPath), backup.backupPath); } - await syncEntry(backup.backupPath); if (!await filesystemEntriesEqual(backup.targetPath, backup.backupPath)) { throw new SafeFilesystemError('backup-verification-failed'); } @@ -481,7 +522,7 @@ async function applyChange( ): Promise { const targetPath = item.change.targetPath; await mkdir(dirname(targetPath), { recursive: true }); - if (!await isRealPathContained(item.targetRoot, targetPath)) { + if (!await isChangeTargetContained(item.change, item.targetRoot, targetPath)) { throw new SafeFilesystemError('target-containment-changed'); } let stagePath: string | undefined; @@ -500,10 +541,12 @@ async function applyChange( item.change.content, options, targetPath, - item.originalMode, + item.originalMode ?? item.change.mode, ); - } else { + } else if (item.change.kind === 'directory') { await writeStagedDirectory(stagePath, item.change, options); + } else { + await symlink(item.change.linkTarget, stagePath); } expectedSnapshot = await snapshotEntry(stagePath); } @@ -514,7 +557,7 @@ async function applyChange( ...(stagePath ? { stagePath } : {}), }); if ( - !await isRealPathContained(item.targetRoot, targetPath) + !await isChangeTargetContained(item.change, item.targetRoot, targetPath) || await snapshotMaybe(targetPath) !== item.originalSnapshot ) { throw new SafeFilesystemError('target-prestate-changed'); @@ -536,6 +579,7 @@ async function applyChange( stagePath!, targetPath, activeArtifacts, + allowsAuthoritativeReplacement(item.change), ); activeArtifacts.delete(stagePath!); } @@ -581,14 +625,14 @@ async function applyChange( async function writeStagedFile( stagePath: string, - content: string, + content: string | Uint8Array, options: FilesystemFaultOptions, targetPath: string, originalMode?: number, ): Promise { - const handle = await open(stagePath, 'wx'); + const handle = await open(stagePath, 'wx', originalMode ?? 0o666); try { - await handle.writeFile(content, 'utf8'); + await handle.writeFile(typeof content === 'string' ? Buffer.from(content, 'utf8') : content); if (originalMode !== undefined && process.platform !== 'win32') { await handle.chmod(originalMode); } @@ -634,12 +678,13 @@ async function renameIntoPlace( stagePath: string, targetPath: string, activeArtifacts: Set, + replaceExisting: boolean, ): Promise { try { await rename(stagePath, targetPath); return undefined; } catch (error) { - if (await entryKind(targetPath) !== 'directory') { + if (!replaceExisting && await entryKind(targetPath) !== 'directory') { throw error; } } @@ -684,10 +729,13 @@ async function restoreAppliedChanges( } else if (item.backup) { if (item.backup.kind === 'file') { await copyFile(item.backup.backupPath, restoreStage); - } else { + await syncEntry(restoreStage); + } else if (item.backup.kind === 'directory') { await copyTree(item.backup.backupPath, restoreStage); + await syncEntry(restoreStage); + } else { + await symlink(await readlink(item.backup.backupPath), restoreStage); } - await syncEntry(restoreStage); await replaceForRestore(restoreStage, targetPath); restoreStage = undefined; await syncDirectoryBestEffort(dirname(targetPath)); @@ -807,6 +855,9 @@ async function snapshotEntry(path: string): Promise { const content = await readFile(path); return `file:${createHash('sha256').update(content).digest('hex')}`; } + if (kind === 'link') { + return `link:${await readlink(path)}`; + } if (kind !== 'directory') { throw new SafeFilesystemError('snapshot-target-invalid'); } diff --git a/src/setup/harnesses/opencode.ts b/src/setup/harnesses/opencode.ts index 2ddbf01..1fc577b 100644 --- a/src/setup/harnesses/opencode.ts +++ b/src/setup/harnesses/opencode.ts @@ -7,11 +7,7 @@ import { type ParseError, } from 'jsonc-parser'; -import { - createManagedConfigConflictPlan, - INVALID_ROOT_REASON, - type ManagedConfigPlan, -} from '../managed-config.js'; +import type { ManagedConfigPlan } from '../managed-config.js'; const OWNED_LOCATION = 'mcp.thoth-mem'; const JSONC_PARSE_OPTIONS = { @@ -54,52 +50,25 @@ export function planOpenCodeManagedConfig( const parsed = parseJsonc(source); if (parsed === null || !isRecord(parsed)) { - return createManagedConfigConflictPlan( - options.before, - [OWNED_LOCATION], - { location: 'root', reason: INVALID_ROOT_REASON, forceable: false }, - false, - ); - } - - const currentMcp = parsed.mcp; - if (currentMcp !== undefined && !isRecord(currentMcp)) { - return createManagedConfigConflictPlan( - options.before, - [OWNED_LOCATION], - { - location: 'mcp', - reason: 'The parent configuration value is not an object.', - forceable: false, - }, - true, - ); + const after = `${JSON.stringify({ + mcp: { 'thoth-mem': options.mcpValue }, + }, null, 2)}\n`; + return successfulPlan(options.before, after, false, options.mcpValue, false); } - const currentOwned = currentMcp?.['thoth-mem']; + const currentMcpValue = parsed.mcp; + const currentMcpObject = isRecord(currentMcpValue) ? currentMcpValue : null; + const currentOwned = currentMcpObject?.['thoth-mem']; const hasOwnedValue = currentOwned !== undefined; if (hasOwnedValue && valuesEqual(currentOwned, options.mcpValue)) { return successfulPlan(options.before, source, false, options.mcpValue); } - if (hasOwnedValue && !options.force) { - return createManagedConfigConflictPlan( - options.before, - [OWNED_LOCATION], - { - location: OWNED_LOCATION, - reason: 'The managed configuration value differs from the requested value.', - forceable: true, - }, - true, - ); - } - - const path = currentMcp === undefined ? ['mcp'] : ['mcp', 'thoth-mem']; - const value = currentMcp === undefined - ? { 'thoth-mem': options.mcpValue } - : options.mcpValue; - const after = applyEdits(source, modify(source, path, value, currentMcp === undefined + const path = currentMcpObject ? ['mcp', 'thoth-mem'] : ['mcp']; + const value = currentMcpObject + ? options.mcpValue + : { 'thoth-mem': options.mcpValue }; + const after = applyEdits(source, modify(source, path, value, currentMcpValue === undefined ? { formattingOptions: { insertSpaces: true, @@ -113,7 +82,7 @@ export function planOpenCodeManagedConfig( return successfulPlan( options.before, after, - hasOwnedValue, + options.force && hasOwnedValue, options.mcpValue, ); } @@ -289,6 +258,7 @@ function successfulPlan( after: string, forced: boolean, desired: OpenCodeManagedConfigOptions['mcpValue'], + beforeValid = true, ): ManagedConfigPlan { const parsed = parseJsonc(after); const owned = isRecord(parsed) && isRecord(parsed.mcp) @@ -303,7 +273,7 @@ function successfulPlan( ownedLocations: [OWNED_LOCATION], conflicts: [], verification: { - beforeValid: true, + beforeValid, afterValid: parsed !== null && isRecord(parsed), ownedValuesMatch: valuesEqual(owned, desired), }, diff --git a/src/setup/receipt.ts b/src/setup/receipt.ts index f1b0dfa..7179560 100644 --- a/src/setup/receipt.ts +++ b/src/setup/receipt.ts @@ -43,7 +43,8 @@ export type ReceiptFaultPoint = | 'receipt-stage' | 'receipt-sync' | 'receipt-rename' - | 'receipt-parent-sync'; + | 'receipt-parent-sync' + | 'receipt-cleanup'; export interface ReceiptFaultEvent { point: ReceiptFaultPoint; @@ -160,6 +161,8 @@ export interface ReceiptPaths { export interface ReceiptPersistenceOptions { dataDir: string; expectedBasePath?: string; + keyPath?: string; + trustedRoot?: string; fault?: (event: ReceiptFaultEvent) => void | Promise; } @@ -347,6 +350,9 @@ export async function persistSetupReceipt( return { ok: false, reason: 'receipt_schema_invalid' }; } const basePath = options.expectedBasePath ?? dirname(dirname(receiptPath)); + if (!await setupReceiptNamespaceHasTrustedContainment(basePath, options)) { + return { ok: false, reason: 'receipt_namespace_unsafe' }; + } if (!await receiptPathMatches(receiptPath, basePath, receipt.id)) { return { ok: false, reason: 'receipt_path_invalid' }; } @@ -394,6 +400,9 @@ export async function loadSetupReceipt( options: ReceiptPersistenceOptions, ): Promise { const basePath = options.expectedBasePath ?? dirname(dirname(receiptPath)); + if (!await setupReceiptNamespaceHasTrustedContainment(basePath, options)) { + return { ok: false, reason: 'receipt_namespace_unsafe' }; + } if (!await receiptPathHasValidTopology(receiptPath, basePath)) { return { ok: false, reason: 'receipt_path_invalid' }; } @@ -419,7 +428,7 @@ export async function loadSetupReceipt( return { ok: false, reason: 'receipt_path_invalid' }; } - const keyResult = await readReceiptKey(options.dataDir, false); + const keyResult = await readReceiptKey(options.dataDir, false, options.keyPath); if (!keyResult.ok) { return keyResult; } @@ -436,6 +445,9 @@ export async function scanSetupReceipts( basePath: string, options: ReceiptPersistenceOptions, ): Promise { + if (!await setupReceiptNamespaceHasTrustedContainment(basePath, options)) { + return { ok: false, reason: 'receipt_namespace_unsafe' }; + } let receiptPaths: string[]; try { receiptPaths = await listReceiptPaths(basePath); @@ -459,6 +471,84 @@ export async function scanSetupReceipts( return { ok: true, receipts }; } +export async function resetSetupReceiptNamespace( + basePath: string, + options: ReceiptPersistenceOptions, +): Promise { + if (!await setupReceiptNamespaceHasTrustedContainment(basePath, options)) { + return false; + } + if (!isAbsolute(basePath)) { + return false; + } + if (options.keyPath && resolve(dirname(options.keyPath)) !== resolve(basePath)) { + return false; + } + try { + await invokeFault(options, 'receipt-cleanup', basePath); + await rm(basePath, { recursive: true, force: true }); + return true; + } catch { + return false; + } +} + +export async function cleanupMatchingSetupReceipts( + basePath: string, + options: ReceiptPersistenceOptions, + matches: (receipt: SetupReceipt) => boolean, +): Promise<{ ok: boolean; removed: number }> { + if (!await setupReceiptNamespaceHasTrustedContainment(basePath, options)) { + return { ok: false, removed: 0 }; + } + let receiptPaths: string[]; + try { + receiptPaths = await listReceiptPaths(basePath); + } catch { + return { ok: false, removed: 0 }; + } + let removed = 0; + let ok = true; + for (const receiptPath of receiptPaths) { + const loaded = await loadSetupReceipt(receiptPath, { + ...options, + expectedBasePath: basePath, + }); + if (!loaded.ok || !matches(loaded.receipt)) { + continue; + } + try { + await invokeFault(options, 'receipt-cleanup', receiptPath); + await rm(dirname(receiptPath), { recursive: true, force: true }); + removed++; + } catch { + ok = false; + } + } + return { ok, removed }; +} + +export async function setupReceiptNamespaceHasTrustedContainment( + basePath: string, + options: ReceiptPersistenceOptions, +): Promise { + if (!options.trustedRoot) { + return true; + } + if ( + !isAbsolute(basePath) + || !isAbsolute(options.trustedRoot) + || (options.keyPath && resolve(dirname(options.keyPath)) !== resolve(basePath)) + ) { + return false; + } + if (!await pathHasTrustedAncestors(basePath, options.trustedRoot)) { + return false; + } + return !options.keyPath + || pathHasTrustedAncestors(options.keyPath, options.trustedRoot); +} + function signReceipt(receipt: SetupReceipt, key: Buffer): string { const { hmac_sha256: _ignored, ...unsignedReceipt } = receipt; return createHmac('sha256', key) @@ -869,7 +959,7 @@ async function ensureReceiptKey( options: ReceiptPersistenceOptions, receiptBasePath: string, ): Promise { - const existing = await readReceiptKey(options.dataDir, true); + const existing = await readReceiptKey(options.dataDir, true, options.keyPath); if (existing.ok || existing.reason !== 'receipt_key_missing') { return existing; } @@ -881,8 +971,11 @@ async function ensureReceiptKey( return { ok: false, reason: 'receipt_scan_failed' }; } - const keyPath = getReceiptKeyPath(options.dataDir); + const keyPath = options.keyPath ?? getReceiptKeyPath(options.dataDir); const keyDirectory = dirname(keyPath); + if (options.keyPath && resolve(keyDirectory) !== resolve(receiptBasePath)) { + return { ok: false, reason: 'receipt_key_path_invalid' }; + } const stagePath = join(keyDirectory, `.receipt-key.thoth-mem-stage-${randomUUID()}`); try { await mkdir(keyDirectory, { recursive: true }); @@ -906,7 +999,7 @@ async function ensureReceiptKey( } await rm(stagePath, { force: true }); await syncDirectoryBestEffort(keyDirectory); - return readReceiptKey(options.dataDir, true); + return readReceiptKey(options.dataDir, true, options.keyPath); } catch { await rm(stagePath, { force: true }).catch(() => undefined); return { ok: false, reason: 'receipt_key_creation_failed' }; @@ -916,8 +1009,9 @@ async function ensureReceiptKey( async function readReceiptKey( dataDir: string, enforcePermissions: boolean, + explicitKeyPath?: string, ): Promise { - const keyPath = getReceiptKeyPath(dataDir); + const keyPath = explicitKeyPath ?? getReceiptKeyPath(dataDir); try { const details = await lstat(keyPath); if (!details.isFile() || details.isSymbolicLink() || details.size > 256) { @@ -1021,6 +1115,44 @@ async function receiptPathMatches( && await receiptPathHasValidTopology(receiptPath, expectedBasePath); } +async function pathHasTrustedAncestors( + path: string, + trustedRoot: string, +): Promise { + const root = resolve(trustedRoot); + const candidate = resolve(path); + const relativePath = relative(root, candidate); + if (isPathEscape(relativePath)) { + return false; + } + try { + const canonicalRoot = await resolveFromNearestExistingAncestor(root); + let current = root; + for (const segment of relativePath.split(/[\\/]/).filter(Boolean)) { + current = join(current, segment); + const canonicalCurrent = await resolveFromNearestExistingAncestor(current); + if (!isPathContained(canonicalRoot, canonicalCurrent)) { + return false; + } + } + return true; + } catch { + return false; + } +} + +function isPathEscape(path: string): boolean { + return isAbsolute(path) + || path === '..' + || path.startsWith('../') + || path.startsWith('..\\'); +} + +function isPathContained(root: string, candidate: string): boolean { + const relativePath = relative(resolve(root), resolve(candidate)); + return relativePath === '' || !isPathEscape(relativePath); +} + async function resolveFromNearestExistingAncestor(path: string): Promise { let current = resolve(path); const missingSegments: string[] = []; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 3d81869..ede19c6 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -288,7 +288,48 @@ describe('runCli', () => { expect(captured.exitCode).toBe(2); }); - it('dispatches claude setup requests through the existing Claude Code setup runner', async () => { + it('keeps OpenCode human and JSON setup results aligned for changed and exact no-op outcomes', async () => { + const changed: SetupResult = { + status: 'complete', + changed: true, + harness: 'opencode', + scope: 'global', + target: 'C:\\\\Users\\\\Example User\\\\.config\\\\opencode', + steps: [{ name: 'Verify exact OpenCode setup post-state', outcome: 'confirmed' }], + diagnostics: [ + 'OpenCode setup succeeded, but target-bound recovery evidence cleanup is incomplete and will be retried.', + ], + manual_actions: ['Restart OpenCode to load the updated thoth-mem integration.'], + receipt: null, + }; + const changedRunner = vi.fn().mockResolvedValue(changed); + const json = await captureCli(['setup', 'opencode', '--json'], { setupRunner: changedRunner }); + const human = await captureCli(['setup', 'opencode'], { setupRunner: changedRunner }); + + expect(json.exitCode).toBe(0); + expect(JSON.parse(json.stdout)).toEqual(changed); + expect(human.exitCode).toBe(0); + expect(human.stdout).toContain('Status: complete'); + expect(human.stdout).toContain('Changed: yes'); + expect(human.stdout).toContain(changed.diagnostics[0]); + expect(human.stdout).toContain(changed.manual_actions[0]); + expect(human.stdout).toContain('Receipt: none'); + + const noOp: SetupResult = { + ...changed, + changed: false, + diagnostics: [], + manual_actions: [], + }; + const noOpHuman = await captureCli(['setup', 'opencode'], { + setupRunner: vi.fn().mockResolvedValue(noOp), + }); + expect(noOpHuman.exitCode).toBe(0); + expect(noOpHuman.stdout).toContain('Changed: no'); + expect(noOpHuman.stdout).not.toContain('Restart OpenCode'); + }); + + it('dispatches claude setup requests through the existing Claude Code setup runner', async () => { const result: SetupResult = { status: 'complete', changed: false, diff --git a/tests/packaging/packed-install.test.ts b/tests/packaging/packed-install.test.ts index 1482bbe..460401b 100644 --- a/tests/packaging/packed-install.test.ts +++ b/tests/packaging/packed-install.test.ts @@ -406,6 +406,10 @@ async function createSourceSetupFixture( mkdir(dirname(executablePath), { recursive: true }), ]; if (harness === 'opencode') { + copies.push(cp( + join(repositoryRoot, 'integrations', 'inventory.json'), + join(packageRoot, 'integrations', 'inventory.json'), + )); copies.push(cp( join(repositoryRoot, 'integrations', 'shared'), join(packageRoot, 'integrations', 'shared'), @@ -808,7 +812,7 @@ describe('managed installation metadata source contract', () => { } }); - it('keeps package, topology, packaged content, and owned content drift actionable', async () => { + it('plans package, topology, packaged content, and owned content drift for convergence', async () => { const fixture = await createSourceSetupFixture(); const paths = resolveSetupPaths(fixture.request, fixture.roots); try { @@ -833,7 +837,8 @@ describe('managed installation metadata source contract', () => { dataDir: fixture.dataDir, executablePath: fixture.executablePath, }); - expect(blocked.status).toBe('requires_user_action'); + expect(blocked).toMatchObject({ status: 'complete', changed: false }); + expect(blocked.steps.some((step) => step.outcome === 'planned')).toBe(true); } await writeFile(metadataPath, JSON.stringify(metadata), 'utf8'); @@ -851,7 +856,7 @@ describe('managed installation metadata source contract', () => { dataDir: fixture.dataDir, executablePath: fixture.executablePath, }); - expect(packagedDrift.status).toBe('requires_user_action'); + expect(packagedDrift).toMatchObject({ status: 'complete', changed: false }); await writeFile(sourcePlugin, sourceBefore, 'utf8'); await writeFile(installedPlugin, 'export default { installedDrift: true };\n', 'utf8'); @@ -860,7 +865,7 @@ describe('managed installation metadata source contract', () => { dataDir: fixture.dataDir, executablePath: fixture.executablePath, }); - expect(ownedDrift.status).toBe('requires_user_action'); + expect(ownedDrift).toMatchObject({ status: 'complete', changed: false }); } finally { await rm(fixture.root, { recursive: true, force: true }); } @@ -900,7 +905,7 @@ describe('managed installation metadata source contract', () => { }); expect(plan).toMatchObject({ status: 'complete', changed: false }); expect(plan.steps.some((step) => step.outcome === 'planned')).toBe(true); - expect(plan.steps.some((step) => /remove/i.test(step.name))).toBe(false); + expect(plan.steps.some((step) => /remove/i.test(step.name))).toBe(true); await expect(lstat(join(paths.assetPath, LEGACY_METADATA_NAME))).resolves.toMatchObject({}); } finally { await rm(fixture.root, { recursive: true, force: true }); @@ -1154,7 +1159,89 @@ describe('packed OpenCode installation', () => { }, PACKAGE_TIMEOUT_MS); }); -describe('packed disposable runtime evidence', () => { +describe('packed OpenCode managed convergence', () => { + it('converges prior-release-shaped global and project targets from tarball assets only', async () => { + const fixture = await getPackedFixture(); + const root = join(fixture.root, 'OpenCode convergence matrix'); + const unrelatedCwd = join(root, 'unrelated cwd'); + await mkdir(unrelatedCwd, { recursive: true }); + const cases = [ + { name: 'older', metadata: '0.0.1' }, + { name: 'newer', metadata: '999.0.0' }, + { name: 'missing', metadata: null }, + { name: 'malformed', metadata: 'malformed' }, + { name: 'same-version-diverged', metadata: getVersion() }, + ] as const; + + for (const scope of ['global', 'project'] as const) { + for (const fixtureCase of cases) { + const harnessRoot = join(root, scope, fixtureCase.name); + const projectPath = join(harnessRoot, 'project with spaces'); + const env = cliEnvironment(fixture, harnessRoot); + const targetRoot = scope === 'global' + ? join(env.XDG_CONFIG_HOME!, 'opencode') + : projectPath; + const configPath = join(targetRoot, 'opencode.json'); + const assetRoot = scope === 'global' + ? join(targetRoot, 'plugins', '.thoth-mem') + : join(targetRoot, '.opencode', 'plugins', '.thoth-mem'); + const pluginEntry = scope === 'global' + ? join(targetRoot, 'plugins', 'thoth-mem.js') + : join(targetRoot, '.opencode', 'plugins', 'thoth-mem.js'); + await mkdir(assetRoot, { recursive: true }); + await writeFile(configPath, '{ "theme": "preserve" }\n', 'utf8'); + await writeFile(join(assetRoot, 'obsolete-private.txt'), 'must-be-removed', 'utf8'); + await writeFile(pluginEntry, 'stale-plugin-entry', 'utf8'); + if (fixtureCase.metadata === 'malformed') { + await writeFile(join(assetRoot, CANONICAL_METADATA_NAME), '{', 'utf8'); + } else if (fixtureCase.metadata !== null) { + await writeFile(join(assetRoot, CANONICAL_METADATA_NAME), `${JSON.stringify({ + schemaVersion: 1, + packageVersion: fixtureCase.metadata, + executable: fixture.entryPath, + harness: 'opencode', + scope, + target: targetRoot, + configPath, + assetsPath: assetRoot, + verified: true, + }, null, 2)}\n`, 'utf8'); + } + const args = [ + 'setup', 'opencode', + ...(scope === 'project' ? ['--scope', 'project', '--project', projectPath] : []), + '--json', + ]; + const converged = runPackedCli(fixture, args, { cwd: unrelatedCwd, env }); + expect(converged.status, `${scope}/${fixtureCase.name}: ${converged.stderr} ${JSON.stringify(converged.json)}`).toBe(0); + expect(converged.json).toMatchObject({ + status: 'complete', + changed: true, + scope, + receipt: null, + manual_actions: ['Restart OpenCode to load the updated thoth-mem integration.'], + }); + await expect(stat(join(assetRoot, 'obsolete-private.txt'))) + .rejects.toMatchObject({ code: 'ENOENT' }); + expect(JSON.parse(await readFile(join(assetRoot, CANONICAL_METADATA_NAME), 'utf8'))) + .toMatchObject({ packageVersion: getVersion(), scope, target: targetRoot }); + expect(await readFile(pluginEntry, 'utf8')) + .toBe("export { default } from './.thoth-mem/opencode/plugin.mjs';\n"); + + const repeated = runPackedCli(fixture, args, { cwd: unrelatedCwd, env }); + expect(repeated.status, repeated.stderr).toBe(0); + expect(repeated.json).toMatchObject({ + status: 'complete', + changed: false, + receipt: null, + manual_actions: [], + }); + } + } + }, PACKAGE_TIMEOUT_MS); + }); + + describe('packed disposable runtime evidence', () => { it('executes every packed harness through the real six-tool memory port', async () => { const fixture = await getPackedFixture(); const harnesses = buildDisposableHarnesses('packed-runtime-real-port'); diff --git a/tests/setup/engine.test.ts b/tests/setup/engine.test.ts index 1324b19..5ab8505 100644 --- a/tests/setup/engine.test.ts +++ b/tests/setup/engine.test.ts @@ -1,10 +1,13 @@ import { + lstat, mkdir, mkdtemp, readFile, readdir, + readlink, rm, stat, + symlink, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -419,14 +422,27 @@ function managedMetadata(request: SetupRequest, paths: SetupPaths): string { } function seedPackagedAssets(fileSystem: FakeSetupFileSystem, paths: SetupPaths): void { + fileSystem.file(join(dirname(dirname(paths.sourceAssetsPath)), 'integrations', 'inventory.json'), JSON.stringify({ + schemaVersion: 1, + assets: [ + { harness: 'opencode', role: 'plugin', path: 'integrations/opencode/plugin.mjs' }, + { harness: 'opencode', role: 'instruction', path: 'integrations/opencode/memory-protocol.md' }, + { harness: 'opencode', role: 'runner', path: 'integrations/shared/hook-runner.mjs' }, + { harness: 'shared', role: 'skill', path: 'plugin/skills/thoth-mem/SKILL.md' }, + { harness: 'shared', role: 'skill-reference-opencode', path: 'plugin/skills/thoth-mem/references/opencode.md' }, + ], + })); fileSystem.directory(paths.sourceAssetsPath); if (paths.sourceSharedPath) { fileSystem.directory(paths.sourceSharedPath); fileSystem.file(join(paths.sourceAssetsPath, 'plugin.mjs'), 'packaged-opencode-plugin'); + fileSystem.file(join(paths.sourceAssetsPath, 'memory-protocol.md'), 'packaged-memory-protocol'); fileSystem.file(join(paths.sourceSharedPath, 'hook-runner.mjs'), 'packaged-hook-runner'); fileSystem.directory(dirname(paths.sourceSkillPath!)); fileSystem.directory(paths.sourceSkillPath!); fileSystem.file(join(paths.sourceSkillPath!, 'SKILL.md'), 'packaged-skill'); + fileSystem.directory(join(paths.sourceSkillPath!, 'references')); + fileSystem.file(join(paths.sourceSkillPath!, 'references', 'opencode.md'), 'packaged-opencode-reference'); } else { fileSystem.file(join(paths.sourceAssetsPath, 'codex.mcp.json'), 'packaged-codex-mcp'); } @@ -454,12 +470,18 @@ function seedManagedSetup( fileSystem.directory(join(paths.assetPath, 'shared')); fileSystem.directory(join(paths.assetPath, 'opencode', 'skills')); fileSystem.file(join(paths.assetPath, 'opencode', 'plugin.mjs'), 'packaged-opencode-plugin'); + fileSystem.file(join(paths.assetPath, 'opencode', 'memory-protocol.md'), 'packaged-memory-protocol'); fileSystem.file(join(paths.assetPath, 'shared', 'hook-runner.mjs'), 'packaged-hook-runner'); fileSystem.directory(join(paths.assetPath, 'opencode', 'skills', 'thoth-mem')); fileSystem.file( join(paths.assetPath, 'opencode', 'skills', 'thoth-mem', 'SKILL.md'), 'packaged-skill', ); + fileSystem.directory(join(paths.assetPath, 'opencode', 'skills', 'thoth-mem', 'references')); + fileSystem.file( + join(paths.assetPath, 'opencode', 'skills', 'thoth-mem', 'references', 'opencode.md'), + 'packaged-opencode-reference', + ); fileSystem.file(paths.pluginEntryPath, 'export { default } from \'./.thoth-mem/opencode/plugin.mjs\';\n'); } else { fileSystem.file(join(paths.assetPath, 'codex.mcp.json'), 'packaged-codex-mcp'); @@ -987,8 +1009,8 @@ describe('inspects and plans with zero writes', () => { const request = setupRequest('opencode'); const paths = resolveSetupPaths(request, ROOTS); const fileSystem = new FakeSetupFileSystem(); - fileSystem.directory(paths.sourceAssetsPath); - fileSystem.directory(paths.sourceSharedPath!); + seedPackagedAssets(fileSystem, paths); + fileSystem.entries.delete(paths.sourceSkillPath!); const result = await inspectAndPlanSetup(request, { roots: ROOTS, fileSystem }); @@ -1025,13 +1047,21 @@ describe('inspects and plans with zero writes', () => { 'planned', 'planned', 'planned', + 'planned', + 'planned', + 'planned', + 'planned', ]); expect(planned.steps.map((step) => step.name)).toEqual([ `Inspect packaged OpenCode assets: ${paths.sourceAssetsPath}`, `Inspect OpenCode configuration: ${paths.configPath}`, - `Install OpenCode assets: ${paths.assetPath}`, + 'Prepare target-bound temporary OpenCode recovery journal', + `Replace complete OpenCode managed assets: ${paths.assetPath}`, + `Replace canonical OpenCode plugin entry: ${paths.pluginEntryPath}`, `Merge managed OpenCode configuration: ${paths.configPath}`, - 'Verify OpenCode setup', + 'Verify exact OpenCode setup post-state', + 'Remove target-bound OpenCode recovery and rollback evidence', + 'Report manual OpenCode restart requirement', ]); expectZeroWrites(fileSystem, before); @@ -1045,7 +1075,38 @@ describe('inspects and plans with zero writes', () => { expectZeroWrites(fileSystem, before); }); - it('returns an idempotent no-op for verified OpenCode project setup', async () => { + it('plans destructive OpenCode convergence, temporary recovery, cleanup, and restart with zero writes', async () => { + const request = setupRequest('opencode'); + const paths = resolveSetupPaths(request, ROOTS); + const fileSystem = new FakeSetupFileSystem(); + seedPackagedAssets(fileSystem, paths); + fileSystem.directory(paths.assetPath); + fileSystem.file(join(paths.assetPath, 'obsolete-private.txt'), 'private'); + const before = fileSystem.snapshot(); + + const result = await inspectAndPlanSetup(request, { roots: ROOTS, fileSystem }); + + expect(result).toMatchObject({ status: 'complete', changed: false, receipt: null }); + expect(result.steps.map((step) => step.name)).toEqual([ + `Inspect packaged OpenCode assets: ${paths.sourceAssetsPath}`, + `Inspect OpenCode configuration: ${paths.configPath}`, + 'Prepare target-bound temporary OpenCode recovery journal', + `Replace complete OpenCode managed assets: ${paths.assetPath}`, + `Replace canonical OpenCode plugin entry: ${paths.pluginEntryPath}`, + `Merge managed OpenCode configuration: ${paths.configPath}`, + 'Verify exact OpenCode setup post-state', + 'Remove target-bound OpenCode recovery and rollback evidence', + 'Report manual OpenCode restart requirement', + ]); + expect(result.steps.slice(2).every((step) => step.outcome === 'planned')).toBe(true); + expect(result.diagnostics).toContain( + 'OpenCode convergence replaces the complete managed asset directory and canonical plugin entry.', + ); + expect(result.diagnostics).toContain('Changed OpenCode setup requires a manual host restart.'); + expectZeroWrites(fileSystem, before); + }); + + it('returns an idempotent no-op for verified OpenCode project setup', async () => { const request = setupRequest('opencode', { scope: 'project', projectPath: PROJECT_PATH, @@ -1065,18 +1126,81 @@ describe('inspects and plans with zero writes', () => { 'confirmed', 'skipped', 'skipped', + 'skipped', + 'skipped', 'confirmed', + 'skipped', + 'skipped', ]); expect(JSON.stringify(result)).not.toContain('must-not-leak'); expect(fileSystem.reads.some((path) => path.startsWith(ROOTS.xdgConfigHome!))).toBe(false); expectZeroWrites(fileSystem, before); }); - it('treats the bundled OpenCode skill as part of managed asset drift', async () => { + it.each([ + { scope: 'global' as const, projectPath: undefined }, + { scope: 'project' as const, projectPath: PROJECT_PATH }, + ])('plans every non-current OpenCode state for convergence without force in $scope scope', async ({ + scope, + projectPath, + }) => { + const request = setupRequest('opencode', { + scope, + ...(projectPath ? { projectPath } : {}), + }); + const paths = resolveSetupPaths(request, ROOTS); + const metadataPath = join(paths.assetPath, 'thoth-mem.installation.json'); + const cases: Array<{ name: string; mutate: (fileSystem: FakeSetupFileSystem) => void }> = [ + { + name: 'older metadata', + mutate: (fileSystem) => fileSystem.file( + metadataPath, + managedMetadata(request, paths).replace(getVersion(), '0.0.1'), + ), + }, + { + name: 'newer metadata', + mutate: (fileSystem) => fileSystem.file( + metadataPath, + managedMetadata(request, paths).replace(getVersion(), '999.0.0'), + ), + }, + { name: 'missing metadata', mutate: (fileSystem) => fileSystem.entries.delete(metadataPath) }, + { name: 'malformed metadata', mutate: (fileSystem) => fileSystem.file(metadataPath, '{') }, + { + name: 'same-version asset drift', + mutate: (fileSystem) => fileSystem.file( + join(paths.assetPath, 'opencode', 'plugin.mjs'), + 'drifted-private-value', + ), + }, + ]; + + for (const fixture of cases) { + const fileSystem = new FakeSetupFileSystem(); + seedManagedSetup(fileSystem, request, paths); + fixture.mutate(fileSystem); + const before = fileSystem.snapshot(); + + const result = await inspectAndPlanSetup(request, { roots: ROOTS, fileSystem }); + + expect(result, fixture.name).toMatchObject({ + status: 'complete', + changed: false, + receipt: null, + }); + expect(result.steps.find((step) => step.name.startsWith('Replace complete OpenCode managed assets'))?.outcome) + .toBe('planned'); + expect(result.manual_actions.join('\n')).not.toContain('--force'); + expect(JSON.stringify(result)).not.toContain('drifted-private-value'); + expectZeroWrites(fileSystem, before); + } + }); + + it('treats the bundled OpenCode skill as part of managed asset drift', async () => { const request = setupRequest('opencode', { scope: 'project', projectPath: PROJECT_PATH, - planOnly: false, }); const paths = resolveSetupPaths(request, ROOTS); const installedSkillPath = join(paths.assetPath, 'opencode', 'skills', 'thoth-mem'); @@ -1088,9 +1212,9 @@ describe('inspects and plans with zero writes', () => { fileSystem.file(join(installedSkillPath, 'SKILL.md'), 'drifted-private-value'); const drifted = await inspectAndPlanSetup(request, { roots: ROOTS, fileSystem }); - expect(drifted.status).toBe('requires_user_action'); - expect(drifted.steps.find((step) => step.name.startsWith('Install OpenCode assets'))?.outcome) - .toBe('unavailable'); + expect(drifted.status).toBe('complete'); + expect(drifted.steps.find((step) => step.name.startsWith('Replace complete OpenCode managed assets'))?.outcome) + .toBe('planned'); expect(JSON.stringify(drifted)).not.toContain('drifted-private-value'); }); @@ -1128,10 +1252,12 @@ describe('inspects and plans with zero writes', () => { { ...request, force: true }, { roots: ROOTS, fileSystem: ambiguousFileSystem }, ); - expect(blocked.status).toBe('requires_user_action'); - expect(blocked.diagnostics).toContain( - `Multiple OpenCode configuration files exist: ${paths.configCandidates.join(', ')}`, - ); + expect(blocked.status).toBe('complete'); + expect(blocked.steps).toContainEqual({ + name: `Merge managed OpenCode configuration: ${paths.configCandidates[1]}`, + outcome: 'planned', + }); + expect(blocked.diagnostics.join('\n')).not.toContain('Multiple OpenCode configuration files'); }); it('does not trust matching metadata when managed config or assets drift', async () => { @@ -1148,9 +1274,9 @@ describe('inspects and plans with zero writes', () => { roots: ROOTS, fileSystem: configDrift, }); - expect(configBlocked.status).toBe('requires_user_action'); + expect(configBlocked.status).toBe('complete'); expect(configBlocked.steps.find((step) => step.name.startsWith('Merge managed'))?.outcome) - .toBe('unavailable'); + .toBe('planned'); expect(JSON.stringify(configBlocked)).not.toContain('must-not-leak'); const assetDrift = new FakeSetupFileSystem(); @@ -1160,9 +1286,9 @@ describe('inspects and plans with zero writes', () => { roots: ROOTS, fileSystem: assetDrift, }); - expect(assetBlocked.status).toBe('requires_user_action'); - expect(assetBlocked.steps.find((step) => step.name.startsWith('Install OpenCode assets'))?.outcome) - .toBe('unavailable'); + expect(assetBlocked.status).toBe('complete'); + expect(assetBlocked.steps.find((step) => step.name.startsWith('Replace complete OpenCode managed assets'))?.outcome) + .toBe('planned'); expect(JSON.stringify(assetBlocked)).not.toContain('drifted-secret'); const forced = await inspectAndPlanSetup( @@ -1170,7 +1296,7 @@ describe('inspects and plans with zero writes', () => { { roots: ROOTS, fileSystem: assetDrift }, ); expect(forced.status).toBe('complete'); - expect(forced.steps.find((step) => step.name.startsWith('Install OpenCode assets'))?.outcome) + expect(forced.steps.find((step) => step.name.startsWith('Replace complete OpenCode managed assets'))?.outcome) .toBe('planned'); }); @@ -1184,9 +1310,10 @@ describe('inspects and plans with zero writes', () => { const before = fileSystem.snapshot(); const blocked = await inspectAndPlanSetup(request, { roots: ROOTS, fileSystem }); - expect(blocked.status).toBe('requires_user_action'); + expect(blocked.status).toBe('complete'); expect(blocked.changed).toBe(false); - expect(blocked.diagnostics).toContain(`Conflict at managed asset target: ${paths.assetPath}`); + expect(blocked.steps.find((step) => step.name.startsWith('Replace complete OpenCode managed assets'))?.outcome) + .toBe('planned'); expectZeroWrites(fileSystem, before); const forced = await inspectAndPlanSetup({ ...request, force: true }, { @@ -1195,9 +1322,9 @@ describe('inspects and plans with zero writes', () => { }); expect(forced.status).toBe('complete'); expect(forced.changed).toBe(false); - expect(forced.steps.find((step) => step.name.startsWith('Install OpenCode assets'))?.outcome) + expect(forced.steps.find((step) => step.name.startsWith('Replace complete OpenCode managed assets'))?.outcome) .toBe('planned'); - expect(forced.diagnostics).toContain(`Force would replace only: ${paths.assetPath}`); + expect(forced.diagnostics.join('\n')).not.toContain('Force would replace'); expectZeroWrites(fileSystem, before); }); @@ -1506,7 +1633,40 @@ describe('merges only managed configuration', () => { }); }); - it('treats exact OpenCode ownership as idempotent and force-replaces only conflicts', () => { + it('normalizes OpenCode owned and invalid parent values without force and recreates malformed roots', () => { + const ownedDrift = planOpenCodeManagedConfig({ + before: '{ "theme": "keep", "mcp": { "thoth-mem": { "command": ["wrong"] } } }', + force: false, + mcpValue: OPENCODE_MCP_VALUE, + }); + expect(ownedDrift).toMatchObject({ changed: true, forced: false, conflicts: [] }); + expect(ownedDrift.after).toContain('"theme": "keep"'); + expect(ownedDrift.after).not.toContain('wrong'); + + const invalidParent = planOpenCodeManagedConfig({ + before: '{ "theme": "keep", "mcp": false }', + force: false, + mcpValue: OPENCODE_MCP_VALUE, + }); + expect(invalidParent).toMatchObject({ changed: true, conflicts: [] }); + expect(invalidParent.after).toContain('"theme": "keep"'); + expect(invalidParent.verification.ownedValuesMatch).toBe(true); + + const malformed = planOpenCodeManagedConfig({ + before: Buffer.from([0xff, 0x7b, 0x22, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74]).toString('latin1'), + force: false, + mcpValue: OPENCODE_MCP_VALUE, + }); + expect(malformed).toMatchObject({ + changed: true, + conflicts: [], + verification: { beforeValid: false, afterValid: true, ownedValuesMatch: true }, + }); + expect(malformed.after).not.toContain('secret'); + expect(JSON.parse(malformed.after)).toEqual({ mcp: { 'thoth-mem': OPENCODE_MCP_VALUE } }); + }); + + it('keeps exact OpenCode ownership idempotent and converges drift without force', () => { const installed = planOpenCodeManagedConfig({ before: null, force: false, @@ -1534,12 +1694,10 @@ describe('merges only managed configuration', () => { force: false, mcpValue: OPENCODE_MCP_VALUE, }); - expect(blocked.changed).toBe(false); - expect(blocked.after).toBe(before); - expect(blocked.conflicts).toEqual([expect.objectContaining({ - location: 'mcp.thoth-mem', - forceable: true, - })]); + expect(blocked.changed).toBe(true); + expect(blocked.conflicts).toEqual([]); + expect(blocked.after).toContain('"theme": "unchanged"'); + expect(blocked.after).not.toContain('wrong-command'); const forced = planOpenCodeManagedConfig({ before, @@ -1554,7 +1712,7 @@ describe('merges only managed configuration', () => { expect(forced.verification.ownedValuesMatch).toBe(true); }); - it('fails closed on malformed OpenCode config without leaking values', () => { + it('recreates malformed OpenCode config without leaking values', () => { const before = '{ "api_token": "do-not-leak",'; const plan = planOpenCodeManagedConfig({ before, @@ -1562,14 +1720,11 @@ describe('merges only managed configuration', () => { mcpValue: OPENCODE_MCP_VALUE, }); - expect(plan.changed).toBe(false); - expect(plan.after).toBe(before); + expect(plan.changed).toBe(true); expect(plan.verification.beforeValid).toBe(false); - expect(plan.conflicts).toEqual([expect.objectContaining({ - location: 'root', - forceable: false, - })]); - expect(JSON.stringify(plan.conflicts)).not.toContain('do-not-leak'); + expect(plan.conflicts).toEqual([]); + expect(plan.after).not.toContain('do-not-leak'); + expect(JSON.parse(plan.after)).toEqual({ mcp: { 'thoth-mem': OPENCODE_MCP_VALUE } }); }); it('appends one plugin-scoped Codex policy block while preserving unrelated TOML bytes', () => { @@ -1799,7 +1954,79 @@ describe('backs up and applies atomically', () => { }); }); - it('stages contained OpenCode assets and rejects source or target escapes', async () => { + it('authoritatively replaces cross-kind and final linked targets without following destinations', async (context) => { + await withTemporarySetupRoot(async (root) => { + const targetRoot = join(root, 'managed target'); + const sourceRoot = join(root, 'package source'); + const source = join(sourceRoot, 'assets'); + const outside = join(root, 'outside destination'); + const targetPath = join(targetRoot, 'plugins', '.thoth-mem'); + const sentinel = join(outside, 'sentinel.txt'); + await mkdir(source, { recursive: true }); + await mkdir(dirname(targetPath), { recursive: true }); + await mkdir(outside, { recursive: true }); + await writeFile(join(source, 'current.txt'), 'current', 'utf8'); + await writeFile(sentinel, 'destination-unchanged', 'utf8'); + try { + await symlink(outside, targetPath, process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + const code = error instanceof Error && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + if (code === 'EPERM' || code === 'EACCES') { + context.skip(); + return; + } + throw error; + } + const originalLink = await readlink(targetPath); + + const replaced = await applyAtomicFilesystemChanges({ + targetRoot, + sourceRoot, + backupRoot: join(root, 'backups'), + changes: [{ + kind: 'directory', + targetPath, + entries: [{ sourcePath: source, targetRelativePath: '.' }], + replaceExisting: true, + }], + }); + + expect(replaced.outcome).toBe('confirmed'); + expect((await lstat(targetPath)).isDirectory()).toBe(true); + expect(await readFile(join(targetPath, 'current.txt'), 'utf8')).toBe('current'); + expect(await readFile(sentinel, 'utf8')).toBe('destination-unchanged'); + + await rm(targetPath, { recursive: true, force: true }); + await symlink(outside, targetPath, process.platform === 'win32' ? 'junction' : 'dir'); + const restored = await applyAtomicFilesystemChanges({ + targetRoot, + sourceRoot, + backupRoot: join(root, 'restore backups'), + changes: [{ + kind: 'directory', + targetPath, + entries: [{ sourcePath: source, targetRelativePath: '.' }], + replaceExisting: true, + }], + }, { + fault: ({ point }) => { + if (point === 'after-rename') { + throw new Error('restore linked pre-state'); + } + }, + }); + + expect(restored.outcome).toBe('failed'); + expect(restored.unrestored).toEqual([]); + expect((await lstat(targetPath)).isSymbolicLink()).toBe(true); + expect(await readlink(targetPath)).toBe(originalLink); + expect(await readFile(sentinel, 'utf8')).toBe('destination-unchanged'); + }); + }); + + it('stages contained OpenCode assets and rejects source or target escapes', async () => { await withTemporarySetupRoot(async (root) => { const sourceRoot = join(root, 'package with spaces', 'integrations'); const openCodeSource = join(sourceRoot, 'opencode'); diff --git a/tests/setup/rollback.test.ts b/tests/setup/rollback.test.ts index e93c129..04ab711 100644 --- a/tests/setup/rollback.test.ts +++ b/tests/setup/rollback.test.ts @@ -5,7 +5,6 @@ import { mkdtemp, readFile, readdir, - rename, rm, stat, symlink, @@ -14,7 +13,7 @@ import { } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; -import { basename, dirname, join, relative } from 'node:path'; +import { dirname, join, relative } from 'node:path'; import { parse } from 'jsonc-parser'; import { describe, expect, it } from 'vitest'; @@ -22,6 +21,7 @@ import { describe, expect, it } from 'vitest'; import { getVersion } from '../../src/version.js'; import { applyAtomicFilesystemChanges, + filesystemEntrySnapshot, type FilesystemFaultPoint, } from '../../src/setup/filesystem.js'; import { inspectAndPlanSetup } from '../../src/setup/engine.js'; @@ -190,6 +190,20 @@ async function withTemporaryFixture( await mkdir(join(packageRoot, 'integrations', 'shared'), { recursive: true }); await mkdir(join(packageRoot, 'plugin', 'skills', 'thoth-mem'), { recursive: true }); await mkdir(dirname(executablePath), { recursive: true }); + await writeFile( + join(packageRoot, 'integrations', 'inventory.json'), + `${JSON.stringify({ + schemaVersion: 1, + assets: [ + { harness: 'opencode', role: 'plugin', path: 'integrations/opencode/plugin.mjs' }, + { harness: 'opencode', role: 'instruction', path: 'integrations/opencode/memory-protocol.md' }, + { harness: 'opencode', role: 'runner', path: 'integrations/shared/hook-runner.mjs' }, + { harness: 'shared', role: 'skill', path: 'plugin/skills/thoth-mem/SKILL.md' }, + { harness: 'shared', role: 'skill-reference-opencode', path: 'plugin/skills/thoth-mem/references/opencode.md' }, + ], + }, null, 2)}\n`, + 'utf8', + ); await writeFile( join(packageRoot, 'integrations', 'opencode', 'plugin.mjs'), 'export default {};\n', @@ -210,6 +224,12 @@ async function withTemporaryFixture( '# packaged skill\n', 'utf8', ); + await mkdir(join(packageRoot, 'plugin', 'skills', 'thoth-mem', 'references'), { recursive: true }); + await writeFile( + join(packageRoot, 'plugin', 'skills', 'thoth-mem', 'references', 'opencode.md'), + '# packaged OpenCode reference\n', + 'utf8', + ); await writeFile(executablePath, '#!/usr/bin/env node\n', 'utf8'); try { @@ -1063,29 +1083,354 @@ describe('write-ahead setup receipts', () => { status: 'complete', changed: true, harness: 'opencode', - receipt: expect.any(String), + receipt: null, }); expect(trace.indexOf('lock_acquired')).toBeLessThan(trace.indexOf('backup_synced')); expect(trace.indexOf('backup_synced')).toBeLessThan(trace.indexOf('receipt_in_progress')); expect(trace.indexOf('receipt_in_progress')).toBeLessThan(trace.indexOf('target_renamed')); expect(trace.indexOf('target_renamed')).toBeLessThan(trace.indexOf('receipt_complete')); - const loaded = await loadVerifiedReceipt(fixture, result.receipt!); - expect(loaded).toMatchObject({ - ok: true, - receipt: { - schema_version: 1, - operation: 'setup', - status: 'complete', - package_version: getVersion(), - hmac_sha256: expect.stringMatching(/^[0-9a-f]{64}$/), - }, - }); + expect(await readFile(fixture.paths.configPath, 'utf8')).toContain('"theme": "keep"'); expect(ownedConfig(await readFile(fixture.paths.configPath, 'utf8'))).toBeDefined(); }); }); - it('performs no target mutation when the initial receipt cannot be persisted', async () => { + it('uses target-bound transient OpenCode journals and removes successful rollback evidence', async () => { + await withTemporaryFixture(async (fixture) => { + const result = await runSetup(fixture, fixture.request, { ids: ['transient-journal'] }); + + expect(result).toMatchObject({ status: 'complete', changed: true, receipt: null }); + const targetHash = createHash('sha256') + .update(fixture.paths.targetRoot) + .digest('hex'); + const journalRoot = join( + fixture.dataDir, + 'setup', + 'opencode-journals', + targetHash, + ); + await expect(stat(journalRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await receiptDirectoryCount(fixture)).toBe(0); + + const repeated = await runSetup(fixture, fixture.request, { ids: ['unused'] }); + expect(repeated).toMatchObject({ + status: 'complete', + changed: false, + receipt: null, + manual_actions: [], + }); + }); + }); + + it('warns on cleanup failure and retries cleanup during an exact later no-op', async () => { + await withTemporaryFixture(async (fixture) => { + const targetHash = createHash('sha256') + .update(fixture.paths.targetRoot) + .digest('hex'); + const journalRoot = join(fixture.dataDir, 'setup', 'opencode-journals', targetHash); + const first = await runSetup(fixture, fixture.request, { + ids: ['cleanup-warning'], + receiptFault: ({ point }) => { + if (point === 'receipt-cleanup') { + throw new Error('private cleanup failure'); + } + }, + }); + + expect(first).toMatchObject({ status: 'complete', changed: true, receipt: null }); + expect(first.diagnostics).toContain( + 'OpenCode setup succeeded, but target-bound recovery evidence cleanup is incomplete and will be retried.', + ); + expect(first.steps).toContainEqual({ + name: 'Remove target-bound OpenCode recovery and rollback evidence', + outcome: 'unavailable', + }); + expect(JSON.stringify(first)).not.toContain('private cleanup failure'); + expect(await stat(journalRoot)).toBeDefined(); + + const repeated = await runSetup(fixture, fixture.request, { ids: ['unused'] }); + expect(repeated).toMatchObject({ + status: 'complete', + changed: false, + receipt: null, + manual_actions: [], + }); + await expect(stat(journalRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); + + it('discards invalid target-bound journal evidence without following embedded paths', async () => { + await withTemporaryFixture(async (fixture) => { + const targetHash = createHash('sha256') + .update(fixture.paths.targetRoot) + .digest('hex'); + const journalRoot = join(fixture.dataDir, 'setup', 'opencode-journals', targetHash); + const outside = join(fixture.root, 'outside-must-survive'); + const sentinel = join(outside, 'sentinel.txt'); + await mkdir(join(journalRoot, 'invalid'), { recursive: true }); + await mkdir(outside, { recursive: true }); + await writeFile(sentinel, 'unchanged', 'utf8'); + await writeFile(join(journalRoot, 'journal.key'), 'corrupt-key', 'utf8'); + await writeFile(join(journalRoot, 'invalid', 'receipt.json'), JSON.stringify({ + target: outside, + backup_path: sentinel, + hmac_sha256: '0'.repeat(64), + }), 'utf8'); + + const result = await runSetup(fixture, fixture.request, { ids: ['fresh-after-reset'] }); + + expect(result).toMatchObject({ status: 'complete', changed: true, receipt: null }); + expect(await readFile(sentinel, 'utf8')).toBe('unchanged'); + await expect(stat(journalRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); + + it.each(['global', 'project'] as const)( + 'rejects an OpenCode %s journal ancestor escape before mutation', + async (scope, context) => { + await withTemporaryFixture(async (fixture) => { + const outside = join(fixture.root, `outside-${scope}-journal`); + await mkdir(outside, { recursive: true }); + const linkedAncestor = scope === 'global' + ? join(fixture.dataDir, 'setup') + : join(fixture.projectPath, '.thoth'); + await mkdir(dirname(linkedAncestor), { recursive: true }); + try { + await symlink(outside, linkedAncestor, process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + const code = error instanceof Error && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + if (code === 'EPERM' || code === 'EACCES') { + context.skip(); + return; + } + throw error; + } + + const targetHash = createHash('sha256') + .update(fixture.paths.targetRoot) + .digest('hex'); + const journalRoot = scope === 'global' + ? join(linkedAncestor, 'opencode-journals', targetHash) + : join(linkedAncestor, 'setup', 'opencode-journals', targetHash); + await mkdir(join(journalRoot, 'invalid'), { recursive: true }); + await writeFile(join(journalRoot, 'journal.key'), 'corrupt-key', 'utf8'); + await writeFile(join(journalRoot, 'invalid', 'receipt.json'), '{"invalid":true}\n', 'utf8'); + await writeFile(join(journalRoot, 'outside-sentinel.txt'), 'unchanged', 'utf8'); + const managedTargets = [ + fixture.paths.configPath, + fixture.paths.assetPath, + fixture.paths.pluginEntryPath, + ]; + const targetsBefore = await Promise.all(managedTargets.map(filesystemEntrySnapshot)); + const outsideBefore = await filesystemEntrySnapshot(outside); + + const result = await runSetup(fixture, fixture.request, { ids: [`blocked-${scope}`] }); + + expect(result).toMatchObject({ + status: 'requires_user_action', + changed: false, + receipt: null, + diagnostics: expect.arrayContaining([expect.stringContaining('recovery boundary')]), + }); + expect(await Promise.all(managedTargets.map(filesystemEntrySnapshot))).toEqual(targetsBefore); + expect(await filesystemEntrySnapshot(outside)).toBe(outsideBefore); + }, scope); + }, + ); + + it('allows an intentionally linked data directory as the trusted global root', async (context) => { + await withTemporaryFixture(async (fixture) => { + const trustedDestination = join(fixture.root, 'linked-data-destination'); + await mkdir(trustedDestination, { recursive: true }); + try { + await symlink( + trustedDestination, + fixture.dataDir, + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch (error) { + const code = error instanceof Error && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + if (code === 'EPERM' || code === 'EACCES') { + context.skip(); + return; + } + throw error; + } + + const result = await runSetup(fixture); + + expect(result).toMatchObject({ status: 'complete', changed: true, receipt: null }); + expect(ownedConfig(await readFile(fixture.paths.configPath, 'utf8'))).toBeDefined(); + }); + }); + + it('restores a valid interrupted target-bound journal before retrying convergence', async () => { + await withTemporaryFixture(async (fixture) => { + await mkdir(dirname(fixture.paths.configPath), { recursive: true }); + await mkdir(fixture.paths.assetPath, { recursive: true }); + await writeFile(fixture.paths.configPath, '{ "theme": "before" }\n', 'utf8'); + await writeFile(join(fixture.paths.assetPath, 'old.txt'), 'old-assets', 'utf8'); + await writeFile(fixture.paths.pluginEntryPath, 'old-plugin', 'utf8'); + + const targetHash = createHash('sha256') + .update(fixture.paths.targetRoot) + .digest('hex'); + const journalRoot = join(fixture.dataDir, 'setup', 'opencode-journals', targetHash); + const receiptPaths = resolveSetupReceiptPaths(journalRoot, 'interrupted'); + const targets = [ + { id: 'config', path: fixture.paths.configPath, owned_key: 'mcp.thoth-mem' }, + { id: 'assets', path: fixture.paths.assetPath }, + { id: 'plugin', path: fixture.paths.pluginEntryPath }, + ]; + const steps: SetupReceiptStep[] = []; + for (const target of targets) { + const backupPath = join( + receiptPaths.backupRoot, + relative(fixture.paths.targetRoot, target.path), + ); + await mkdir(dirname(backupPath), { recursive: true }); + await cp(target.path, backupPath, { recursive: true }); + steps.push({ + id: target.id, + kind: 'filesystem', + outcome: 'confirmed', + ...(target.owned_key ? { owned_key: target.owned_key } : {}), + path: target.path, + pre_hash: await filesystemEntrySnapshot(backupPath), + post_hash: 'pending-interrupted-post-state', + backup_path: backupPath, + }); + } + steps.push({ id: 'verify', kind: 'verification', outcome: 'planned' }); + const interrupted = createSetupReceipt({ + id: 'interrupted', + operation: 'setup', + status: 'in_progress', + harness: 'opencode', + scope: fixture.request.scope, + target: fixture.paths.targetRoot, + package_version: getVersion(), + force: false, + started_at: '2026-07-09T12:00:00.000Z', + updated_at: '2026-07-09T12:00:00.000Z', + steps, + }); + expect((await persistSetupReceipt(receiptPaths.receiptPath, interrupted, { + dataDir: fixture.dataDir, + expectedBasePath: journalRoot, + keyPath: join(journalRoot, 'journal.key'), + })).ok).toBe(true); + + await writeFile(fixture.paths.configPath, '{ "interrupted": true }\n', 'utf8'); + await rm(fixture.paths.assetPath, { recursive: true, force: true }); + await mkdir(fixture.paths.assetPath, { recursive: true }); + await writeFile(join(fixture.paths.assetPath, 'partial.txt'), 'partial', 'utf8'); + await writeFile(fixture.paths.pluginEntryPath, 'partial-plugin', 'utf8'); + const trace: string[] = []; + + const result = await runSetup(fixture, fixture.request, { + ids: ['retry-after-restore'], + trace: ({ kind }) => trace.push(kind), + }); + + expect(result).toMatchObject({ status: 'complete', changed: true, receipt: null }); + expect(trace).toContain('journal_restored'); + const config = parse(await readFile(fixture.paths.configPath, 'utf8')) as Record; + expect(config.theme).toBe('before'); + expect(ownedConfig(await readFile(fixture.paths.configPath, 'utf8'))).toBeDefined(); + await expect(stat(journalRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); + + it('restores a valid five-step interrupted journal before retrying convergence', async () => { + await withTemporaryFixture(async (fixture) => { + await mkdir(dirname(fixture.paths.configPath), { recursive: true }); + await mkdir(fixture.paths.assetPath, { recursive: true }); + await writeFile(fixture.paths.configPath, '{ "theme": "before" }\n', 'utf8'); + await writeFile(join(fixture.paths.assetPath, 'old.txt'), 'old-assets', 'utf8'); + await writeFile(fixture.paths.pluginEntryPath, 'old-plugin', 'utf8'); + + const targetHash = createHash('sha256') + .update(fixture.paths.targetRoot) + .digest('hex'); + const journalRoot = join(fixture.dataDir, 'setup', 'opencode-journals', targetHash); + const receiptPaths = resolveSetupReceiptPaths(journalRoot, 'interrupted-with-config-backup'); + const configBackupPath = `${fixture.paths.configPath}.thoth-mem-backup-interrupted`; + await writeFile(configBackupPath, 'malformed-original', 'utf8'); + const steps: SetupReceiptStep[] = [{ + id: 'config-backup', + kind: 'filesystem', + outcome: 'confirmed', + path: configBackupPath, + pre_hash: 'missing', + post_hash: await filesystemEntrySnapshot(configBackupPath), + }]; + for (const target of [ + { id: 'config', path: fixture.paths.configPath, owned_key: 'mcp.thoth-mem' }, + { id: 'assets', path: fixture.paths.assetPath }, + { id: 'plugin', path: fixture.paths.pluginEntryPath }, + ]) { + const backupPath = join(receiptPaths.backupRoot, relative(fixture.paths.targetRoot, target.path)); + await mkdir(dirname(backupPath), { recursive: true }); + await cp(target.path, backupPath, { recursive: true }); + steps.push({ + id: target.id, + kind: 'filesystem', + outcome: 'confirmed', + ...(target.owned_key ? { owned_key: target.owned_key } : {}), + path: target.path, + pre_hash: await filesystemEntrySnapshot(backupPath), + post_hash: 'pending-interrupted-post-state', + backup_path: backupPath, + }); + } + steps.push({ id: 'verify', kind: 'verification', outcome: 'planned' }); + const interrupted = createSetupReceipt({ + id: 'interrupted-with-config-backup', + operation: 'setup', + status: 'in_progress', + harness: 'opencode', + scope: fixture.request.scope, + target: fixture.paths.targetRoot, + package_version: getVersion(), + force: false, + started_at: '2026-07-09T12:00:00.000Z', + updated_at: '2026-07-09T12:00:00.000Z', + steps, + }); + expect((await persistSetupReceipt(receiptPaths.receiptPath, interrupted, { + dataDir: fixture.dataDir, + expectedBasePath: journalRoot, + keyPath: join(journalRoot, 'journal.key'), + })).ok).toBe(true); + + await writeFile(fixture.paths.configPath, '{ "interrupted": true }\n', 'utf8'); + await rm(fixture.paths.assetPath, { recursive: true, force: true }); + await mkdir(fixture.paths.assetPath, { recursive: true }); + await writeFile(join(fixture.paths.assetPath, 'partial.txt'), 'partial', 'utf8'); + await writeFile(fixture.paths.pluginEntryPath, 'partial-plugin', 'utf8'); + const trace: string[] = []; + + const result = await runSetup(fixture, fixture.request, { + ids: ['retry-after-five-step-restore'], + trace: ({ kind }) => trace.push(kind), + }); + + expect(result).toMatchObject({ status: 'complete', changed: true, receipt: null }); + expect(trace).toContain('journal_restored'); + const config = parse(await readFile(fixture.paths.configPath, 'utf8')) as Record; + expect(config.theme).toBe('before'); + expect(ownedConfig(await readFile(fixture.paths.configPath, 'utf8'))).toBeDefined(); + await expect(stat(configBackupPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(journalRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); + + it('performs no target mutation when the initial receipt cannot be persisted', async () => { await withTemporaryFixture(async (fixture) => { await mkdir(dirname(fixture.paths.configPath), { recursive: true }); const original = '{ "theme": "unchanged" }\n'; @@ -1108,58 +1453,49 @@ describe('write-ahead setup receipts', () => { }); }); - it('propagates the selected data directory to key and global receipt resolution', async () => { + it('uses the selected data directory without retaining a successful OpenCode key or receipt', async () => { await withTemporaryFixture(async (fixture) => { const result = await runSetup(fixture); - expect(result.status).toBe('complete'); - expect(result.receipt).toBe( - join(fixture.dataDir, 'setup', 'receipts', 'setup-receipt', 'receipt.json'), - ); - expect(await stat(getReceiptKeyPath(fixture.dataDir))).toBeDefined(); + expect(result).toMatchObject({ status: 'complete', receipt: null }); + await expect(stat(getReceiptKeyPath(fixture.dataDir))) + .rejects.toMatchObject({ code: 'ENOENT' }); + expect(await receiptDirectoryCount(fixture)).toBe(0); }); }); - it('detects every durable same-target in-progress checkpoint before managed no-op inspection', async () => { + it('removes a valid historical shared OpenCode receipt only after successful convergence', async () => { await withTemporaryFixture(async (fixture) => { - const installed = await runSetup(fixture); - expect(installed.status).toBe('complete'); - const complete = await loadVerifiedReceipt(fixture, installed.receipt!); - expect(complete.ok).toBe(true); - if (!complete.ok) { - return; - } + const legacyBase = receiptBasePath(fixture); + const legacyPaths = resolveSetupReceiptPaths(legacyBase, 'historical-open-code'); + const historical = createSetupReceipt({ + id: 'historical-open-code', + operation: 'setup', + status: 'complete', + harness: 'opencode', + scope: fixture.request.scope, + target: fixture.paths.targetRoot, + package_version: '0.0.1', + force: false, + started_at: '2026-07-09T11:00:00.000Z', + updated_at: '2026-07-09T11:00:00.000Z', + steps: [{ id: 'verify', kind: 'verification', outcome: 'confirmed' }], + }); + expect((await persistSetupReceipt(legacyPaths.receiptPath, historical, { + dataDir: fixture.dataDir, + expectedBasePath: legacyBase, + })).ok).toBe(true); + expect(await stat(legacyPaths.receiptPath)).toBeDefined(); - for (let confirmedCount = 0; confirmedCount <= complete.receipt.steps.length; confirmedCount++) { - const id = `interrupted-${confirmedCount}`; - const receiptPaths = resolveSetupReceiptPaths(receiptBasePath(fixture), id); - const steps = complete.receipt.steps.map((step, index) => ({ - ...step, - outcome: index < confirmedCount ? 'confirmed' as const : 'planned' as const, - })); - const interrupted = createSetupReceipt({ - ...complete.receipt, - id, - status: 'in_progress', - started_at: '2026-07-09T12:01:00.000Z', - updated_at: '2026-07-09T12:01:00.000Z', - steps, - }); - const persisted = await persistSetupReceipt( - receiptPaths.receiptPath, - interrupted, - { dataDir: fixture.dataDir }, - ); - expect(persisted.ok).toBe(true); + const result = await runSetup(fixture, fixture.request, { ids: ['converge-current'] }); - const result = await runSetup(fixture, fixture.request, { ids: [`blocked-${confirmedCount}`] }); - expect(result.status).toBe('requires_user_action'); - expect(result.changed).toBe(false); - expect(result.diagnostics).toContain(`Incomplete setup receipt: ${receiptPaths.receiptPath}`); - } + expect(result).toMatchObject({ status: 'complete', changed: true, receipt: null }); + await expect(stat(legacyPaths.receiptRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await stat(getReceiptKeyPath(fixture.dataDir))).toBeDefined(); }); }); + it('serializes concurrent setup attempts with a target-scoped exclusive lock', async () => { await withTemporaryFixture(async (fixture) => { let releaseFirst!: () => void; @@ -1195,147 +1531,39 @@ describe('write-ahead setup receipts', () => { }); }); -describe('strict receipt integrity and binding', () => { - it('verifies semantic JSON reordering but rejects any signed-field mutation', async () => { - await withTemporaryFixture(async (fixture) => { - const result = await runSetup(fixture); - const raw = JSON.parse(await readFile(result.receipt!, 'utf8')) as Record; - const reordered = Object.fromEntries(Object.entries(raw).reverse()); - await writeFile(result.receipt!, JSON.stringify(reordered, null, 7), 'utf8'); - expect((await loadVerifiedReceipt(fixture, result.receipt!)).ok).toBe(true); - - reordered.force = !reordered.force; - await writeFile(result.receipt!, JSON.stringify(reordered), 'utf8'); - const tampered = await loadVerifiedReceipt(fixture, result.receipt!); - expect(tampered).toMatchObject({ ok: false, reason: 'hmac_mismatch' }); - }); - }); - - it('refuses a tampered receipt even when rollback is forced', async () => { - await withTemporaryFixture(async (fixture) => { - const setup = await runSetup(fixture); - const raw = JSON.parse(await readFile(setup.receipt!, 'utf8')) as Record; - raw.status = 'rolled_back'; - await writeFile(setup.receipt!, JSON.stringify(raw), 'utf8'); - const before = await readFile(fixture.paths.configPath, 'utf8'); - - const rollback = await runSetup(fixture, { - ...fixture.request, - force: true, - rollbackReceipt: setup.receipt!, - }, { ids: ['rollback'] }); - - expect(rollback.status).toBe('requires_user_action'); - expect(rollback.changed).toBe(false); - expect(await readFile(fixture.paths.configPath, 'utf8')).toBe(before); - }); - }); - - it('refuses a receipt-owned backup whose signed pre-state no longer matches', async () => { - await withTemporaryFixture(async (fixture) => { - await mkdir(dirname(fixture.paths.configPath), { recursive: true }); - await writeFile(fixture.paths.configPath, JSON.stringify({ - theme: 'keep', - mcp: { - 'thoth-mem': { +describe('OpenCode configuration convergence', () => { + it('prefers JSONC and quarantines malformed bytes exactly before minimal recreation', async () => { + await withTemporaryFixture(async (fixture) => { + const jsonPath = fixture.paths.configCandidates[0]!; + const jsoncPath = fixture.paths.configCandidates[1]!; + const jsonBefore = '{ "theme": "json-must-remain" }\\n'; + const malformed = Buffer.from([0xff, 0x00, 0x7b, 0x22, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74]); + await mkdir(dirname(jsonPath), { recursive: true }); + await writeFile(jsonPath, jsonBefore, 'utf8'); + await writeFile(jsoncPath, malformed); + + const result = await runSetup(fixture, fixture.request, { ids: ['config-quarantine'] }); + + expect(result).toMatchObject({ status: 'complete', changed: true }); + expect(await readFile(jsonPath, 'utf8')).toBe(jsonBefore); + expect(ownedConfig(await readFile(jsoncPath, 'utf8'))).toEqual({ type: 'local', - command: ['previous-thoth'], + command: ['thoth-mem', 'mcp', '--no-http'], enabled: true, - }, - }, - }, null, 2), 'utf8'); - const setup = await runSetup(fixture, { - ...fixture.request, - force: true, + }); + const diagnostic = result.diagnostics.find((value) => ( + value.startsWith('Malformed OpenCode configuration backup: ') + )); + expect(diagnostic).toBeDefined(); + const backupPath = diagnostic!.slice('Malformed OpenCode configuration backup: '.length); + expect(await readFile(backupPath)).toEqual(malformed); + expect(JSON.stringify(result)).not.toContain('secret'); + expect(JSON.stringify(result)).not.toContain(malformed.toString('latin1')); + }); }); - const loaded = await loadVerifiedReceipt(fixture, setup.receipt!); - expect(loaded.ok).toBe(true); - if (!loaded.ok) { - return; - } - const configStep = loaded.receipt.steps.find((step) => step.id === 'config'); - expect(configStep?.backup_path).toEqual(expect.any(String)); - await writeFile(configStep!.backup_path!, JSON.stringify({ - theme: 'keep', - mcp: { 'thoth-mem': { command: ['tampered-backup'] } }, - }), 'utf8'); - const current = await readFile(fixture.paths.configPath, 'utf8'); - - const rollback = await runSetup(fixture, { - ...fixture.request, - force: true, - rollbackReceipt: setup.receipt!, - }, { ids: ['must-not-rollback'] }); - - expect(rollback.status).toBe('requires_user_action'); - expect(rollback.changed).toBe(false); - expect(await readFile(fixture.paths.configPath, 'utf8')).toBe(current); }); - }); - it('does not rotate a missing or corrupt key while selected receipts exist', async () => { - await withTemporaryFixture(async (fixture) => { - const setup = await runSetup(fixture); - const keyPath = getReceiptKeyPath(fixture.dataDir); - await unlink(keyPath); - - const missingKey = await runSetup(fixture, fixture.request, { ids: ['must-not-create'] }); - expect(missingKey.status).toBe('requires_user_action'); - await expect(stat(keyPath)).rejects.toMatchObject({ code: 'ENOENT' }); - - await writeFile(keyPath, 'not-a-valid-key', 'utf8'); - const corruptKey = await runSetup(fixture, { - ...fixture.request, - rollbackReceipt: setup.receipt!, - }, { ids: ['must-not-rotate'] }); - expect(corruptKey.status).toBe('requires_user_action'); - expect(await readFile(keyPath, 'utf8')).toBe('not-a-valid-key'); - }); - }); - - it('rejects a valid signed receipt copied across canonical project targets', async () => { - await withTemporaryFixture(async (source) => { - const setup = await runSetup(source); - await withTemporaryFixture(async (destination) => { - await mkdir(dirname(getReceiptKeyPath(destination.dataDir)), { recursive: true }); - await cp(getReceiptKeyPath(source.dataDir), getReceiptKeyPath(destination.dataDir)); - const copiedRoot = join(receiptBasePath(destination), basename(dirname(setup.receipt!))); - await mkdir(dirname(copiedRoot), { recursive: true }); - await cp(dirname(setup.receipt!), copiedRoot, { recursive: true }); - const copiedReceipt = join(copiedRoot, 'receipt.json'); - expect((await loadVerifiedReceipt(destination, copiedReceipt)).ok).toBe(true); - - const result = await runSetup(destination, { - ...destination.request, - force: true, - rollbackReceipt: copiedReceipt, - }, { ids: ['cross-project-rollback'] }); - expect(result.status).toBe('requires_user_action'); - expect(result.changed).toBe(false); - }, 'project'); - }, 'project'); - }); - - it('records owner-only key intent without claiming POSIX enforcement on Windows', async () => { - await withTemporaryFixture(async (fixture) => { - const result = await runSetup(fixture); - const key = await stat(getReceiptKeyPath(fixture.dataDir)); - - if (process.platform === 'win32') { - expect(result.diagnostics).toContain( - 'Receipt key owner-only permissions are best-effort on Windows.', - ); - } else { - expect(key.mode & 0o777).toBe(0o600); - expect(result.diagnostics).not.toContain( - 'Receipt key owner-only permissions are best-effort on Windows.', - ); - } - }); - }); -}); - -describe('receipt-owned rollback', () => { +describe('Codex receipt-owned rollback and OpenCode idempotence', () => { it('recomposes and restores only the exact Codex managed fragment around later edits', async () => { const codexHarness: Record = await import('../../src/setup/harnesses/codex.js'); const planFragment: unknown = codexHarness.planCodexManagedFragment; @@ -1393,148 +1621,27 @@ describe('receipt-owned rollback', () => { expect(plan.changed).toBe(false); expect(plan.conflicts).toEqual([expect.objectContaining({ forceable: false })]); }); - it('preserves unrelated post-install config additions while restoring only the owned key', async () => { - await withTemporaryFixture(async (fixture) => { - await mkdir(dirname(fixture.paths.configPath), { recursive: true }); - await writeFile(fixture.paths.configPath, '{ "theme": "before" }\n', 'utf8'); - const setup = await runSetup(fixture, fixture.request, { ids: ['setup'] }); - const installed = await readFile(fixture.paths.configPath, 'utf8'); - await writeFile( - fixture.paths.configPath, - installed.replace('{', '{\n "late_setting": "preserve",'), - 'utf8', - ); - const rollback = await runSetup(fixture, { - ...fixture.request, - rollbackReceipt: setup.receipt!, - }, { ids: ['rollback'] }); - expect(rollback).toMatchObject({ status: 'complete', changed: true }); - expect(rollback.receipt).not.toBe(setup.receipt); - const restored = parse(await readFile(fixture.paths.configPath, 'utf8')) as Record; - expect(restored.theme).toBe('before'); - expect(restored.late_setting).toBe('preserve'); - expect((restored.mcp as Record | undefined)?.['thoth-mem']).toBeUndefined(); - await expect(stat(fixture.paths.assetPath)).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(stat(fixture.paths.pluginEntryPath)).rejects.toMatchObject({ code: 'ENOENT' }); - }); - }); - - it('rolls back the bundled skill only from receipt-owned OpenCode assets', async () => { - await withTemporaryFixture(async (fixture) => { - const sharedSkillRoot = join(fixture.paths.targetRoot, 'skills'); - const sharedSentinel = join(sharedSkillRoot, 'user-owned-skill', 'SKILL.md'); - await mkdir(dirname(sharedSentinel), { recursive: true }); - await writeFile(sharedSentinel, '# user owned\n', 'utf8'); - - const setup = await runSetup(fixture, fixture.request, { ids: ['setup-with-skill'] }); - expect(setup, JSON.stringify(setup)).toMatchObject({ - status: 'complete', - changed: true, - }); - const installedSkill = join( - fixture.paths.assetPath, - 'opencode', - 'skills', - 'thoth-mem', - 'SKILL.md', - ); - expect(await readFile(installedSkill, 'utf8')).toBe('# packaged skill\n'); - const rollback = await runSetup(fixture, { - ...fixture.request, - rollbackReceipt: setup.receipt!, - }, { ids: ['rollback-with-skill'] }); - - expect(rollback, JSON.stringify(rollback)).toMatchObject({ - status: 'complete', - changed: true, - }); - await expect(stat(fixture.paths.assetPath)).rejects.toMatchObject({ code: 'ENOENT' }); - expect(await readFile(sharedSentinel, 'utf8')).toBe('# user owned\n'); - }); - }); - - it('requires action for owned divergence and bounds force to receipt-owned locations', async () => { - await withTemporaryFixture(async (fixture) => { - const setup = await runSetup(fixture, fixture.request, { ids: ['setup'] }); - const installed = await readFile(fixture.paths.configPath, 'utf8'); - const diverged = installed - .replace('"thoth-mem",\n "mcp"', '"different-command"') - .replace('{', '{\n "unrelated_after": true,'); - await writeFile(fixture.paths.configPath, diverged, 'utf8'); - - const blocked = await runSetup(fixture, { - ...fixture.request, - rollbackReceipt: setup.receipt!, - }, { ids: ['blocked'] }); - expect(blocked.status).toBe('requires_user_action'); - expect(blocked.changed).toBe(false); - expect(await readFile(fixture.paths.configPath, 'utf8')).toBe(diverged); - - const forced = await runSetup(fixture, { - ...fixture.request, - force: true, - rollbackReceipt: setup.receipt!, - }, { ids: ['forced-rollback'] }); - expect(forced).toMatchObject({ status: 'complete', changed: true }); - const restored = parse(await readFile(fixture.paths.configPath, 'utf8')) as Record; - expect(restored.unrelated_after).toBe(true); - expect((restored.mcp as Record | undefined)?.['thoth-mem']).toBeUndefined(); - - const rollbackReceipt = await loadVerifiedReceipt(fixture, forced.receipt!); - expect(rollbackReceipt).toMatchObject({ - ok: true, - receipt: { operation: 'rollback', status: 'complete', force: true }, - }); - if (rollbackReceipt.ok) { - const configStep = rollbackReceipt.receipt.steps.find((step) => step.owned_key === 'mcp.thoth-mem'); - expect(configStep?.backup_path).toEqual(expect.any(String)); - expect(await readFile(configStep!.backup_path!, 'utf8')).toBe(diverged); - } - }); - }); - - it('keeps repeated verified setup and completed rollback idempotent', async () => { + it('keeps repeated verified OpenCode setup idempotent without durable receipts', async () => { await withTemporaryFixture(async (fixture) => { const setup = await runSetup(fixture, fixture.request, { ids: ['setup'] }); + expect(setup).toMatchObject({ status: 'complete', changed: true, receipt: null }); const afterSetupCount = await receiptDirectoryCount(fixture); - const repeatedSetup = await runSetup(fixture, fixture.request, { ids: ['unused-setup'] }); - expect(repeatedSetup).toMatchObject({ status: 'complete', changed: false, receipt: null }); - expect(await receiptDirectoryCount(fixture)).toBe(afterSetupCount); - const rollback = await runSetup(fixture, { - ...fixture.request, - rollbackReceipt: setup.receipt!, - }, { ids: ['rollback'] }); - expect(rollback.status).toBe('complete'); - const afterRollbackCount = await receiptDirectoryCount(fixture); - const repeatedRollback = await runSetup(fixture, { - ...fixture.request, - rollbackReceipt: setup.receipt!, - }, { ids: ['unused-rollback'] }); - expect(repeatedRollback).toMatchObject({ status: 'complete', changed: false }); - expect(await receiptDirectoryCount(fixture)).toBe(afterRollbackCount); + const repeated = await runSetup(fixture, fixture.request, { ids: ['unused-setup'] }); + + expect(repeated).toMatchObject({ status: 'complete', changed: false, receipt: null }); + expect(await receiptDirectoryCount(fixture)).toBe(afterSetupCount); }); }); - it('removes a setup-created config only when no unrelated later content exists', async () => { - await withTemporaryFixture(async (fixture) => { - const setup = await runSetup(fixture, fixture.request, { ids: ['setup'] }); - const rollback = await runSetup(fixture, { - ...fixture.request, - rollbackReceipt: setup.receipt!, - }, { ids: ['rollback'] }); - expect(rollback.status).toBe('complete'); - await expect(stat(fixture.paths.configPath)).rejects.toMatchObject({ code: 'ENOENT' }); - }); - }); }); describe('OpenCode failure status mapping', () => { - it('maps setup post-state verification failure to failed/1 while retaining changed recovery evidence', async () => { + it('restores the complete pre-run state when setup post-state verification fails', async () => { await withTemporaryFixture(async (fixture) => { const result = await runSetup(fixture, fixture.request, { trace: async ({ kind, path }) => { @@ -1546,120 +1653,71 @@ describe('OpenCode failure status mapping', () => { expect(result).toMatchObject({ status: 'failed', - changed: true, - receipt: expect.any(String), - manual_actions: expect.arrayContaining([expect.stringContaining('in-progress receipt')]), + changed: false, + receipt: null, + diagnostics: expect.arrayContaining([expect.stringContaining('pre-run state was restored')]), }); expect(getSetupExitCode(result.status)).toBe(1); + await expect(stat(fixture.paths.configPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(fixture.paths.assetPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(fixture.paths.pluginEntryPath)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await receiptDirectoryCount(fixture)).toBe(0); }); }); - it('maps setup final-receipt persistence failure to failed/1 with the durable receipt path', async () => { + it('reports restored target state even when post-failure journal cleanup is incomplete', async () => { await withTemporaryFixture(async (fixture) => { - let receiptRenames = 0; const result = await runSetup(fixture, fixture.request, { + trace: async ({ kind, path }) => { + if (kind === 'target_renamed' && path === fixture.paths.pluginEntryPath) { + await writeFile(fixture.paths.configPath, '{}\n', 'utf8'); + } + }, receiptFault: ({ point }) => { - if (point === 'receipt-rename' && ++receiptRenames === 5) { - throw new Error('final setup receipt failure'); + if (point === 'receipt-cleanup') { + throw new Error('private cleanup failure'); } }, }); expect(result).toMatchObject({ status: 'failed', - changed: true, + changed: false, receipt: expect.any(String), + diagnostics: expect.arrayContaining([expect.stringContaining('cleanup is incomplete')]), }); - expect(getSetupExitCode(result.status)).toBe(1); + await expect(stat(fixture.paths.configPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(fixture.paths.assetPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(fixture.paths.pluginEntryPath)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(JSON.stringify(result)).not.toContain('private cleanup failure'); }); }); - it('maps rollback filesystem failure to failed/1 while reporting an unrestored change', async () => { + it('reports verified setup complete when final journal persistence fails but cleanup succeeds', async () => { await withTemporaryFixture(async (fixture) => { - const setup = await runSetup(fixture, fixture.request, { ids: ['setup'] }); - let parentSyncFailed = false; - const result = await runSetup(fixture, { - ...fixture.request, - rollbackReceipt: setup.receipt!, - }, { - ids: ['rollback'], - filesystemFault: ({ point }) => { - if (point === 'parent-sync' && !parentSyncFailed) { - parentSyncFailed = true; - throw new Error('rollback parent sync failure'); - } - if (point === 'restore') { - throw new Error('rollback restore failure'); + let receiptRenames = 0; + const result = await runSetup(fixture, fixture.request, { + receiptFault: ({ point }) => { + if (point === 'receipt-rename' && ++receiptRenames === 5) { + throw new Error('final setup receipt failure'); } }, }); expect(result).toMatchObject({ - status: 'failed', + status: 'complete', changed: true, - receipt: expect.any(String), + receipt: null, + diagnostics: expect.arrayContaining([expect.stringContaining('final journal checkpoint was unavailable')]), }); - expect(getSetupExitCode(result.status)).toBe(1); + expect(getSetupExitCode(result.status)).toBe(0); + expect(await receiptDirectoryCount(fixture)).toBe(0); }); }); - it('maps rollback post-state verification failure to failed/1 with recovery evidence', async () => { - await withTemporaryFixture(async (fixture) => { - const setup = await runSetup(fixture, fixture.request, { ids: ['setup'] }); - const result = await runSetup(fixture, { - ...fixture.request, - rollbackReceipt: setup.receipt!, - }, { - ids: ['rollback'], - trace: async ({ kind, path }) => { - if (kind === 'target_renamed' && path === fixture.paths.pluginEntryPath) { - await mkdir(dirname(fixture.paths.configPath), { recursive: true }); - await writeFile(fixture.paths.configPath, JSON.stringify({ - mcp: { - 'thoth-mem': { - type: 'local', - command: ['unexpected'], - enabled: true, - }, - }, - }), 'utf8'); - } - }, - }); - expect(result).toMatchObject({ - status: 'failed', - changed: true, - receipt: expect.any(String), - }); - expect(getSetupExitCode(result.status)).toBe(1); - }); - }); - it('maps rollback final-receipt persistence failure to failed/1 with the rollback receipt', async () => { - await withTemporaryFixture(async (fixture) => { - const setup = await runSetup(fixture, fixture.request, { ids: ['setup'] }); - let receiptRenames = 0; - const result = await runSetup(fixture, { - ...fixture.request, - rollbackReceipt: setup.receipt!, - }, { - ids: ['rollback'], - receiptFault: ({ point }) => { - if (point === 'receipt-rename' && ++receiptRenames === 6) { - throw new Error('final rollback receipt failure'); - } - }, - }); - expect(result).toMatchObject({ - status: 'failed', - changed: true, - receipt: expect.any(String), - }); - expect(getSetupExitCode(result.status)).toBe(1); - }); - }); }); describe('filesystem transaction hardening', () => {