From 467f54b751f30a36415d45cfb27fe9b0d218d257 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Thu, 30 Jul 2026 16:40:13 +0200 Subject: [PATCH 01/12] spec: proposal for version aware cli + update flow --- .../changes/add-update-flow/.openspec.yaml | 2 + openspec/changes/add-update-flow/design.md | 387 ++++++++++++++++++ openspec/changes/add-update-flow/proposal.md | 83 ++++ .../specs/release-versioning/spec.md | 143 +++++++ .../add-update-flow/specs/update-flow/spec.md | 255 ++++++++++++ openspec/changes/add-update-flow/tasks.md | 107 +++++ 6 files changed, 977 insertions(+) create mode 100644 openspec/changes/add-update-flow/.openspec.yaml create mode 100644 openspec/changes/add-update-flow/design.md create mode 100644 openspec/changes/add-update-flow/proposal.md create mode 100644 openspec/changes/add-update-flow/specs/release-versioning/spec.md create mode 100644 openspec/changes/add-update-flow/specs/update-flow/spec.md create mode 100644 openspec/changes/add-update-flow/tasks.md diff --git a/openspec/changes/add-update-flow/.openspec.yaml b/openspec/changes/add-update-flow/.openspec.yaml new file mode 100644 index 0000000..a15fd7e --- /dev/null +++ b/openspec/changes/add-update-flow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: ns-workflow +created: 2026-07-30 diff --git a/openspec/changes/add-update-flow/design.md b/openspec/changes/add-update-flow/design.md new file mode 100644 index 0000000..6119f77 --- /dev/null +++ b/openspec/changes/add-update-flow/design.md @@ -0,0 +1,387 @@ +# Design + +## Architecture + +The update feature is additive to the existing CLI and installer architecture. It does not move installation ownership into the shared CLI: each native harness remains responsible for its own staged plugin, Pi remains package-owned, and OpenCode/fallback installs continue through the existing installer. + +The CLI adds an update coordinator that separates four concerns: + +1. inventory: determine what is installed and how it is owned; +2. discovery: resolve local and latest versions without mutation; +3. planning: produce deterministic, reviewable update actions; and +4. execution: run approved actions through typed strategies and summarize results. + +```text +packages/core/src/cli.ts + | + v +packages/core/src/update/coordinator.ts + | | | + v v v + inventory version sources target strategies + | | | + +------------+-------------+ + | + v + sanitized UpdateSummary +``` + +The coordinator never performs OAuth. Existing setup/auth modules are outside the update dependency graph. + +Release preparation is a separate maintainer-side script. Runtime update code reads version metadata but never edits repository release files. + +## Module Boundaries + +### Runtime update modules + +`packages/core/src/update/types.ts` + +- Defines target, plan, result, summary, version, and error contracts. +- Contains no filesystem, network, or process behavior. + +`packages/core/src/update/coordinator.ts` + +- Resolves requested scope (`cli`, one harness, or all detected targets). +- Produces the plan before mutation. +- Applies confirmation rules. +- Executes targets sequentially and isolates per-target failures. +- Owns overall exit/result semantics, not target-specific commands. + +`packages/core/src/update/inventory.ts` + +- Reuses harness adapters and tracking readers to classify each harness as native, fallback, package-owned, or not installed. +- Reads the running CLI/package metadata. +- Adds optional version discovery without changing existing installation detection contracts. + +`packages/core/src/update/version-source.ts` + +- Reads and validates `latest` metadata from npm for `nsolid-plugin` and `nsolid-pi-plugin`. +- Reads the GitHub-root `bundle.json` once for native Git targets. +- Applies bounded request timeouts and semantic-version validation. +- Returns `unknown` rather than treating missing version evidence as current. + +`packages/core/src/update/package-manager.ts` + +- Detects npm or pnpm only from positive installation-path/package-manager evidence. +- Produces a fixed executable plus argument array. +- Returns unsupported for workspaces, `npx`, local checkouts, and ambiguous launchers. + +`packages/core/src/update/command-runner.ts` + +- Wraps `spawn`/`spawnSync` with `shell: false`. +- Accepts executable and argument arrays, controlled environment additions, timeout, and output mode. +- Redacts tokens, authorization headers, and credential paths from captured diagnostics. +- Is injected in tests so no real package manager or harness command runs. + +`packages/core/src/update/strategies/*.ts` + +- One strategy per ownership model: CLI package, Claude, Codex, Antigravity, Pi, and fallback. +- Strategies receive an immutable plan item and execution context. +- Strategies cannot broaden scope or switch from native to fallback ownership after failure. + +`packages/core/src/update/antigravity-transaction.ts` + +- Resolves only known NodeSource staged plugin paths. +- Creates a temporary backup before replacement. +- Validates the newly staged root by checking `plugin.json`, `bundle.json`, and canonical skill presence. +- Restores the backup if reinstall or validation fails. + +### Existing modules extended + +`packages/core/src/cli.ts` + +- Adds `version` and `update` cases, with bare `--version` as an alias for human-readable version reporting. +- Adds `--check` and `--all`. +- Rejects `--all` with `--harness` before calling the coordinator. +- Keeps JSON on stdout and progress/diagnostics on stderr. + +`packages/core/src/index.ts` + +- Exports programmatic `getVersionInfo()`, `checkUpdates()`, and `update()` functions and their public types. +- Existing setup/install/uninstall APIs remain unchanged. + +`packages/core/src/harnesses/` + +- Native detection may expose optional installed version and staged root. +- Existing adapter methods keep their signatures; additive optional methods or helper functions are preferred. + +`packages/core/src/skills/skill-tracker.ts` + +- Fallback tracking may add an optional `bundleVersion` for future checks. +- Readers must accept existing tracking files that omit it. + +### Release modules + +`scripts/prepare-release.mjs` + +- Accepts `patch`, `minor`, `major`, or an explicit greater semantic version. +- Treats root `bundle.json.version` as the canonical current release version. +- Snapshots every controlled file before mutation. +- Updates source version files, runs existing bundle/root generators, validates results, and restores snapshots on failure. +- Never invokes Git mutation, pack, publish, or registry authentication. + +`scripts/check-release-version.mjs` + +- Compares package and generated versions with the root bundle. +- Calls/reuses existing bundle and root-manifest checks. +- Activates release mode only when invoked through `pnpm release:check --release`. +- In release mode, compares the explicit plugin payload allowlist from the Release Versioning specification with the latest semantic-version tag and rejects an unchanged version. + +Root package scripts: + +```json +{ + "release:prepare": "node scripts/prepare-release.mjs", + "release:check": "node scripts/check-release-version.mjs" +} +``` + +The private root package version remains `0.0.0`. + +## Interfaces and Contracts + +```typescript +export type UpdateTarget = + | 'cli' + | 'claude' + | 'codex' + | 'opencode' + | 'antigravity' + | 'pi' + +export type UpdateOwnership = + | 'global-package' + | 'native-plugin' + | 'package-owned' + | 'fallback' + +export type UpdateStatus = + | 'current' + | 'update-available' + | 'updated' + | 'skipped' + | 'not-installed' + | 'unknown' + | 'failed' + +export interface VersionInfo { + current?: string + latest?: string + status: 'current' | 'update-available' | 'newer-than-registry' | 'unknown' +} + +export interface UpdateOptions { + harness?: HarnessType + all?: boolean + check?: boolean + yes?: boolean + json?: boolean + verbose?: boolean + noColor?: boolean + commandRunner?: CommandRunner + confirm?: UpdateConfirmation +} + +export interface UpdatePlanItem { + target: UpdateTarget + ownership: UpdateOwnership + installed: boolean + version: VersionInfo + executable?: string + args?: readonly string[] + requiresConfirmation: boolean + restartHint?: string +} + +export interface UpdateResult { + target: UpdateTarget + ownership: UpdateOwnership + status: UpdateStatus + currentVersion?: string + resultingVersion?: string + changed: boolean + restartHint?: string + rollbackCommand?: string + error?: { + code: string + message: string + } +} + +export interface UpdateSummary { + checkOnly: boolean + results: UpdateResult[] + counts: Record + success: boolean +} + +export interface CommandSpec { + executable: string + args: readonly string[] + cwd?: string + timeoutMs: number +} + +export interface CommandRunner { + run(spec: CommandSpec): Promise +} + +export interface UpdateStrategy { + readonly target: UpdateTarget + plan(context: UpdateContext): Promise + execute(item: UpdatePlanItem, context: UpdateContext): Promise +} +``` + +Rules enforced by these contracts: + +- `check` stops after planning/version resolution and never calls `execute`. +- Command arguments are arrays; a shell command string is not part of the contract. +- `error.message` is sanitized and suitable for JSON output. +- An absent version is represented as `unknown`, never coerced to `current`. +- Strategies return data; the CLI formatter owns human-readable output. +- A completed check whose result is `update-available` is successful and exits zero; lookup, validation, or execution failures remain non-zero. + +### Fixed harness command plans + +| Target | Native/package action | Success guidance | +|---|---|---| +| CLI npm | `npm install -g nsolid-plugin@` | invoke CLI again | +| CLI pnpm | `pnpm add -g nsolid-plugin@` | invoke CLI again | +| Claude | `claude plugin update nsolid-plugin@nodesource` | `/reload-plugins` or restart | +| Codex | `codex plugin marketplace upgrade nodesource` | start a new session | +| Antigravity | `agy plugin uninstall nsolid-plugin`, then install Git URL | restart AGY | +| Pi | `pi update npm:nsolid-pi-plugin` | `/reload` or restart | +| Fallback/OpenCode | latest published CLI executes `install --harness ` | restart harness if needed | + +No user-derived string is interpolated into an executable shell command. + +The Codex command plan is provisional until Task 6 verifies it against a disposable real installation. Implementing the Codex strategy is blocked on evidence that `marketplace upgrade` refreshes the already-installed plugin, not only marketplace metadata. If it does not, the design and specification must be amended before implementation to use the documented plugin remove/add lifecycle and to cover configuration preservation. + +## Data Flow + +### Check-only flow + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Coordinator + participant Inventory + participant Registry + + User->>CLI: update [scope] --check + CLI->>Coordinator: checkUpdates(options) + Coordinator->>Inventory: detect targets and local versions + Inventory-->>Coordinator: installed targets + Coordinator->>Registry: resolve latest versions + Registry-->>Coordinator: validated versions or unknown/error + Coordinator-->>CLI: UpdateSummary(checkOnly=true) + CLI-->>User: human output or one JSON document + Note over Coordinator: No strategy execute method is called +``` + +### Mutating update flow + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Coordinator + participant Strategy + participant ExternalCLI + + User->>CLI: update [scope] + CLI->>Coordinator: build plan + Coordinator-->>CLI: ordered plan + CLI-->>User: display plan and request confirmation + User-->>CLI: confirm or --yes + loop each target, sequentially + Coordinator->>Strategy: execute(planItem) + Strategy->>ExternalCLI: spawn executable + fixed args + ExternalCLI-->>Strategy: exit/status/output + Strategy-->>Coordinator: sanitized UpdateResult + end + Coordinator-->>CLI: aggregate summary + CLI-->>User: per-target result and restart guidance +``` + +CLI self-update is planned first, but the running process does not dynamically import the newly installed package. Remaining already-planned harness strategies execute from the current process. The user must invoke the CLI again to use new CLI code. + +### Antigravity replacement transaction + +```mermaid +sequenceDiagram + participant Updater + participant FS + participant AGY + + Updater->>FS: locate known staged N|Solid root + Updater->>FS: copy staged root to temporary backup + Updater->>AGY: uninstall nsolid-plugin + Updater->>AGY: install GitHub root + alt install and validation succeed + Updater->>FS: remove temporary backup + else install or validation fails + Updater->>FS: restore backup to staged root + Updater-->>Updater: return failed + rollback status + end +``` + +### Release preparation + +```mermaid +sequenceDiagram + participant Maintainer + participant Prepare + participant Files + participant Generators + participant Check + + Maintainer->>Prepare: release:prepare -- patch|minor|major| + Prepare->>Files: read and snapshot controlled files + Prepare->>Prepare: validate increasing semver + Prepare->>Files: update three source versions + Prepare->>Generators: bundle sync + root manifest generation + Prepare->>Check: validate complete synchronization + alt validation succeeds + Prepare-->>Maintainer: version + changed-file summary + else any stage fails + Prepare->>Files: restore all snapshots + Prepare-->>Maintainer: failing stage, non-zero exit + end +``` + +## Error Handling and Safety + +- Network lookups have explicit timeouts and schema validation. +- Missing executables use a distinct error code from command failure. +- Process output is bounded before being retained in results. +- Existing logger redaction is applied to verbose diagnostics. +- `--all` catches errors at the target boundary and continues with independent targets. +- Confirmation is mandatory for mutable non-interactive operations unless `--yes` is present. +- Antigravity backup paths are created with restrictive permissions in an OS temporary directory and always cleaned after success. +- Update does not invoke setup, login, or auth modules. +- Release scripts snapshot only an explicit allowlist; rollback never performs broad Git or recursive workspace resets. + +## Migration Strategy + +This is an additive migration. + +1. Add pure types, semantic-version comparison, command runner, and version sources with unit tests. +2. Add inventory and target strategies behind programmatic APIs. +3. Add CLI parsing/formatting and integration tests. +4. Add release preparation/check scripts and fixture tests. +5. Add optional fallback tracking version while preserving reads of legacy tracking files. +6. Update README/package documentation. +7. Ship the feature in a new minor CLI release because it adds public commands; existing `1.0.x` install/setup behavior remains compatible. + +Deployment order: + +1. Merge version-bearing root manifests and update implementation. +2. Publish the new `nsolid-plugin` package. +3. Publish the same-version `nsolid-pi-plugin` package. +4. Push the matching semantic Git tag. +5. Verify update checks and actual updates from clean fixture homes for every harness. + +The update command is useful immediately for future releases; the first release containing it is still installed through the existing manual npm/native update instructions. diff --git a/openspec/changes/add-update-flow/proposal.md b/openspec/changes/add-update-flow/proposal.md new file mode 100644 index 0000000..e53968a --- /dev/null +++ b/openspec/changes/add-update-flow/proposal.md @@ -0,0 +1,83 @@ +# Proposal + +## Problem Statement + +N|Solid Plugin is distributed through several independent owners: + +- the `nsolid-plugin` CLI is published to npm; +- Claude and Codex clone a Git-backed marketplace and cache installed plugin versions; +- Antigravity stages a copy of the GitHub plugin without exposing a plugin update command; +- Pi owns its skills through the `nsolid-pi-plugin` npm package; and +- OpenCode receives skills and MCP configuration through the fallback CLI installer. + +Publishing new skills or runtime fixes therefore does not produce one consistent update experience. The current CLI has no `version`, `update`, or update-check command, users must know harness-specific commands, and a push to `main` can remain invisible to version-keyed caches when release metadata is not bumped. Maintainers must also update several version fields and generated manifests manually, which makes a partially versioned release possible. + +## Proposed Solution + +Add an explicit, version-aware update workflow for both maintainers and users. + +For users: + +- add `nsolid-plugin version` (with bare `nsolid-plugin --version` as an alias) and `nsolid-plugin update`; +- make plain `update` target the npm CLI, `--harness ` target one harness installation, and `--all` target the CLI plus every detected N|Solid installation; +- add `--check` for a read-only status check and retain `--json`, `--yes`, `--verbose`, and `--no-color` behavior where applicable; +- delegate native updates to the owning harness: + - Claude: refresh/update `nsolid-plugin@nodesource`; + - Codex: upgrade the `nodesource` Git marketplace and refresh the installed version; + - Antigravity: safely reinstall the GitHub-root plugin with backup/rollback; + - Pi: update `npm:nsolid-pi-plugin`; + - OpenCode, which has no native package owner, and other fallback installations: reinstall from the latest published CLI bundle; +- preserve credentials and non-NodeSource configuration throughout updates; +- isolate failures during `--all` so one harness failure does not prevent remaining updates, while returning a failing exit status and a per-target summary. + +For maintainers: + +- establish one release version across `bundle.json`, the core npm package, the Pi npm package, and generated version-bearing manifests; +- add release preparation/check tooling that performs or validates version propagation without publishing, tagging, or pushing; +- require update-visible releases to increment the version before generated root manifests are committed. + +## Rollback Plan + +- The CLI update path records the previously installed CLI version and prints the exact package-manager command needed to restore it. +- Native harness updates rely on the harness owner’s cache where available. Antigravity creates a temporary backup of the staged NodeSource plugin and restores it if reinstall fails. +- Fallback installers continue using their existing config backups and idempotent merge behavior. +- Update operations never delete shared NodeSource credentials. +- The feature can be reverted by removing the new command/service modules and scripts; existing `setup`, `install`, `doctor`, `restore`, and `uninstall` contracts remain unchanged. +- A bad release can be rolled back by republishing or reinstalling the prior known-good package/plugin version and restoring generated manifests from the corresponding Git tag. + +## Affected Components + +- `packages/core/src/cli.ts` — new commands and update flags. +- `packages/core/src/index.ts` — public update/version API surface. +- `packages/core/src/update/` — update planning, version comparison, command execution, result contracts, and package-manager detection. +- `packages/core/src/harnesses/` — harness-owned update strategies and native/fallback installation detection. +- `packages/core/src/index.ts` (the `doctor` function) and formatting utilities — optional update availability in health/status output. +- `packages/core/test/unit/update/` — version, planning, detection, safety, and output tests. +- `packages/core/test/integration/` — mocked CLI/harness update flows, partial failures, rollback, and exit codes. +- `bundle.json`, `packages/core/package.json`, `packages/pi-plugin/package.json` — coordinated release version. +- `scripts/` and root `package.json` — release preparation and drift checks. +- `.claude-plugin/marketplace.json`, `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, and `packages/core/bundle.json` — generated version-bearing outputs. +- `README.md` and package READMEs — user and maintainer update instructions. +- `openspec/specs/installation-and-auth/spec.md` — referenced compatibility contract that update must preserve; unchanged by this proposal. + +## Success Criteria + +- `nsolid-plugin version` and its bare `--version` alias report the running CLI and bundled plugin versions without network access; the command form also supports JSON output. +- `nsolid-plugin update --check` performs no writes or subprocess mutations and clearly reports whether the CLI is current. +- Each supported harness has a deterministic update strategy with actionable output when its CLI is unavailable or its installation type is unsupported. +- `nsolid-plugin update --all` updates all detected targets, preserves credentials and user-owned configuration, summarizes every target, and exits non-zero on any failure. +- Interactive destructive/replacement steps require confirmation; `--yes` enables non-interactive automation. +- Network, registry, missing-binary, permission, corrupt-state, and partial-update failures are covered by tests and never expose credentials. +- Release preparation propagates one requested semantic version to every version-bearing source/generated file and never publishes, tags, commits, or pushes. +- Release checking fails when package, bundle, or generated manifest versions drift. +- Existing installation, authentication, uninstall, restore, doctor, lint, build, and test behavior remains green. + +Acceptance tests: + +1. Mock npm reporting a newer CLI version and verify check-only, confirmed update, declined update, and rollback guidance. +2. Mock current and newer Claude/Codex plugin versions and verify the owning marketplace/update commands and restart guidance. +3. Simulate an Antigravity reinstall failure and verify restoration of the prior staged plugin. +4. Mock a Pi package update and an OpenCode fallback refresh from the latest CLI bundle. +5. Run `--all` with one failed target and verify later targets still run, credentials remain untouched, and the final exit code is non-zero. +6. Prepare a patch version in a fixture repository and verify all version-bearing files become equal while no publish/tag/push command executes. +7. Introduce version drift in each controlled file and verify the release check identifies the exact mismatch. diff --git a/openspec/changes/add-update-flow/specs/release-versioning/spec.md b/openspec/changes/add-update-flow/specs/release-versioning/spec.md new file mode 100644 index 0000000..8dcca8a --- /dev/null +++ b/openspec/changes/add-update-flow/specs/release-versioning/spec.md @@ -0,0 +1,143 @@ +# Release Versioning Specification + +## ADDED Requirements + +### Requirement: Atomic release version preparation + +Release tooling SHALL accept `patch`, `minor`, `major`, or an explicit increasing semantic version and propagate it across source packages and generated version-bearing manifests without publishing or Git mutation. + +#### Scenario: Prepare a patch release + +**Given** every controlled version is synchronized at a valid stable semantic version +**And** the working tree contains the intended release changes +**When** the maintainer runs the release preparation command with `patch` +**Then** the command computes the next patch version +**And** writes it to `bundle.json`, `packages/core/package.json`, and `packages/pi-plugin/package.json` +**And** synchronizes `packages/core/bundle.json` +**And** regenerates `.claude-plugin/marketplace.json`, `.claude-plugin/plugin.json`, and `.codex-plugin/plugin.json` +**And** leaves manifests without an explicit version field schema-valid +**And** prints the resulting version and changed files + +#### Scenario: Prepare a minor or major release + +**Given** every controlled version is synchronized at a valid stable semantic version +**When** the maintainer runs the release preparation command with `minor` or `major` +**Then** the command increments the requested semantic-version component +**And** resets lower-order components to zero +**And** propagates the resulting version to every controlled version-bearing file +**And** refreshes generated manifests through existing generators + +#### Scenario: Prepare an explicit semantic version + +**Given** the maintainer supplies a version greater than the current stable version +**When** release preparation runs +**Then** the exact supplied version is propagated to every controlled version-bearing file +**And** generated manifests are refreshed through existing generators +**And** no unrelated source file is modified + +#### Scenario: Release preparation has no external side effects + +**Given** release preparation succeeds +**When** the command completes +**Then** it has not committed, tagged, pushed, packed, published, authenticated, or contacted a package registry +**And** package-local materialized skill directories are absent +**And** the maintainer can review the Git diff before external release actions + +#### Scenario: Reject invalid or non-incrementing versions + +**Given** the requested version is invalid, equal to the current version, or lower +**When** release preparation validates the request +**Then** it fails before writing any file +**And** identifies the invalid current/requested relationship +**And** leaves the working tree unchanged + +#### Scenario: Atomic release preparation failure + +**Given** the requested version is valid +**When** writing or generation fails after preparation starts +**Then** every controlled file is restored to its pre-command content +**And** the command exits non-zero +**And** reports the failing stage +**And** no partially synchronized release remains + +### Requirement: Release version drift detection + +Release checking SHALL compare every controlled version and generated artifact with canonical `bundle.json.version` without repairing in check mode. + +#### Scenario: Check synchronized release versions + +**Given** the repository contains source and generated release metadata +**When** the maintainer runs the release version check +**Then** it compares package and generated versions with `bundle.json.version` +**And** validates root manifests against existing generators +**And** validates `packages/core/bundle.json` against the root bundle +**And** succeeds only when every controlled value and artifact is synchronized + +#### Scenario: Release version drift is detected + +**Given** one or more controlled files contain a different version or stale content +**When** the release version check runs +**Then** it exits non-zero +**And** lists every drifted file with expected and actual versions when available +**And** recommends preparation or synchronization +**And** does not repair files + +### Requirement: Plugin payload changes require an update-visible version + +Release checking SHALL reject payload changes whose explicit bundle version still matches the most recent release tag. + +Release mode SHALL be activated only by `pnpm release:check --release`. For this comparison, “plugin payload files” is the following explicit allowlist: + +- `skills/**` +- `bundle.json` +- `.claude-plugin/marketplace.json` +- `.claude-plugin/plugin.json` +- `.agents/plugins/marketplace.json` +- `.codex-plugin/plugin.json` +- `.claude-mcp.json` +- `.mcp.json` +- `plugin.json` +- `mcp_config.json` +- `scripts/mcp-wrapper.js` + +#### Scenario: Skill changes retain the previous release version + +**Given** committed plugin payload files differ from the most recent release tag +**And** `bundle.json.version` still equals the version represented by that tag +**When** the maintainer runs `pnpm release:check --release` +**Then** it fails with guidance to prepare a new semantic version +**And** prevents a release that version-keyed harness caches would treat as unchanged + +### Requirement: Manual publication remains ordered and external + +Release preparation SHALL leave publication to the maintainer while defining the required package and Git ordering. + +#### Scenario: Manual publication order + +**Given** preparation and quality checks succeeded +**When** the maintainer performs the external release +**Then** `nsolid-plugin@` is published before `nsolid-pi-plugin@` +**And** Pi resolves its `workspace:*` dependency to the same core version +**And** the commit and tag containing generated root manifests are pushed +**And** publication remains outside the preparation command + +#### Scenario: Interrupted package materialization is cleaned + +**Given** pack or publish materialized package-local skills +**When** publication is interrupted or only one package completes +**Then** the existing cleanup command removes `packages/core/skills/` and `packages/pi-plugin/skills/` +**And** canonical root `skills/` remains unchanged + +### Requirement: Preserve canonical release boundaries + +Release tooling SHALL keep root skills/bundle canonical and exclude non-release metadata from version synchronization. + +#### Scenario: Preserve canonical and private package state + +**Given** release preparation or checking runs +**When** it evaluates controlled files +**Then** root `skills/` and `bundle.json` remain canonical +**And** existing generators remain the writers of generated manifests +**And** Antigravity metadata remains schema-valid while staged `bundle.json` carries its version +**And** the private workspace root package remains `0.0.0` +**And** existing plugin and bundle synchronization commands remain available diff --git a/openspec/changes/add-update-flow/specs/update-flow/spec.md b/openspec/changes/add-update-flow/specs/update-flow/spec.md new file mode 100644 index 0000000..3292c98 --- /dev/null +++ b/openspec/changes/add-update-flow/specs/update-flow/spec.md @@ -0,0 +1,255 @@ +# Update Flow Specification + +## ADDED Requirements + +### Requirement: Running version reporting + +The CLI SHALL expose the running npm package version and bundled plugin version without network access or mutation. + +#### Scenario: Report running versions + +**Given** the `nsolid-plugin` CLI is runnable +**When** the user runs `nsolid-plugin version` +**Then** the command reports the running `nsolid-plugin` package version +**And** reports the bundled plugin version from `bundle.json` +**And** `--json` returns a stable object containing `cliVersion` and `bundleVersion` +**And** the command performs no network requests or writes + +#### Scenario: Report versions with the conventional flag + +**Given** the `nsolid-plugin` CLI is runnable +**When** the user runs bare `nsolid-plugin --version` +**Then** the command is an alias for the human-readable `nsolid-plugin version` output +**And** reports both the running CLI package version and bundled plugin version +**And** performs no network requests or writes + +### Requirement: Read-only update checks + +The updater SHALL compare installed and latest versions without invoking any mutating strategy when `--check` is supplied. + +#### Scenario: Check whether the CLI is current + +**Given** the npm registry reports a stable `latest` version for `nsolid-plugin` +**When** the user runs `nsolid-plugin update --check` +**Then** the command compares the running CLI semantic version with the registry version +**And** reports `current`, `update-available`, or `newer-than-registry` +**And** does not invoke a package manager or modify any file +**And** `--json` returns the current version, latest version, status, and target identifier +**And** exits successfully, including when the status is `update-available` + +#### Scenario: Check every detected target + +**Given** multiple N|Solid installations are detectable +**When** the user runs `nsolid-plugin update --all --check` +**Then** every target is inspected without invoking install, update, uninstall, package-manager, tracking, or configuration mutations +**And** targets whose installed version cannot be determined report `unknown` +**And** the command distinguishes `unknown` from `current` + +#### Scenario: Registry lookup fails + +**Given** npm is unreachable, times out, returns invalid data, or returns a non-semantic version +**When** the user checks or performs an update +**Then** the command reports the registry failure without exposing response bodies containing credentials +**And** performs no update +**And** exits non-zero +**And** preserves the current installation + +### Requirement: Safe CLI self-update + +The default `nsolid-plugin update` scope SHALL update only a positively identified global CLI installation and SHALL require approval before mutation. + +#### Scenario: CLI update with a supported global package manager + +**Given** the CLI was installed globally by npm or pnpm +**And** the registry reports a newer stable version +**When** the user runs `nsolid-plugin update` +**Then** the command displays the current version, target version, package manager, and exact planned operation +**And** asks for confirmation in an interactive terminal +**And** after confirmation invokes the detected package manager with a fixed argument array to install `nsolid-plugin@` +**And** verifies the child process succeeded +**And** reports that a new shell or command invocation may be required +**And** prints the exact command for restoring the previous version + +#### Scenario: User declines a CLI update + +**Given** an update is available +**When** the user declines the confirmation +**Then** no package-manager process runs +**And** the result is `skipped` +**And** the command exits successfully + +#### Scenario: Non-interactive CLI update + +**Given** an update is available +**And** standard input is not interactive +**When** the user runs `nsolid-plugin update` without `--yes` +**Then** the command performs no mutation +**And** exits non-zero with guidance to pass `--yes` +**When** the user reruns with `--yes` +**Then** the command performs the displayed fixed update plan without prompting + +#### Scenario: CLI is already current + +**Given** the running CLI version equals the registry `latest` version +**When** the user runs `nsolid-plugin update` +**Then** no package-manager process runs +**And** the command reports `already current` +**And** exits successfully + +#### Scenario: Unsupported CLI installation source + +**Given** the running CLI was launched from a workspace, local path, `npx`, or an installation source that cannot be safely identified +**When** the user runs `nsolid-plugin update` +**Then** the command does not guess a package manager or modify the installation +**And** reports the latest version when it can be resolved +**And** prints safe manual commands for npm, pnpm, and `npx -y nsolid-plugin@latest` + +### Requirement: Harness-owned update strategies + +The updater SHALL preserve native/package ownership and delegate each supported harness update to a deterministic strategy without starting OAuth. + +#### Scenario: Update one installed native harness + +**Given** the requested harness has a detected native N|Solid plugin installation +**When** the user runs `nsolid-plugin update --harness ` +**Then** only that harness target is planned +**And** the command delegates to the harness-owned update strategy +**And** shared NodeSource credentials remain unchanged +**And** no OAuth browser or callback server starts +**And** the result includes versions when discoverable, status, and restart guidance + +#### Scenario: Update Claude native plugin + +**Given** `nsolid-plugin@nodesource` is installed natively in Claude +**And** the `claude` executable is available +**When** the Claude update strategy runs +**Then** it invokes `claude plugin update nsolid-plugin@nodesource` with a fixed executable and argument array +**And** reports `/reload-plugins` or restart guidance +**And** does not run the fallback installer + +#### Scenario: Update Codex native plugin + +**Given** `nsolid-plugin@nodesource` is installed natively in Codex +**And** the `codex` executable is available +**When** the Codex update strategy runs +**Then** it invokes `codex plugin marketplace upgrade nodesource` +**And** verifies the marketplace refresh succeeded +**And** reports that a new Codex session is required +**And** does not remove the installed plugin or configuration + +#### Scenario: Update Pi package-owned skills + +**Given** `npm:nsolid-pi-plugin` is installed in Pi +**And** the `pi` executable is available +**When** the Pi update strategy runs +**Then** it invokes `pi update npm:nsolid-pi-plugin` +**And** does not copy Pi skills into user-level skill directories +**And** reports `/reload` or restart guidance +**And** leaves Pi MCP configuration and NodeSource credentials intact + +#### Scenario: Update OpenCode or another fallback installation + +**Given** the target is OpenCode, which has no native plugin/package update owner, or another target uses the N|Solid fallback/direct installer +**When** its update strategy runs +**Then** it resolves the latest published `nsolid-plugin` CLI bundle +**And** reruns the latest fallback installer only for that harness +**And** reuses existing idempotent skill and MCP merge behavior +**And** preserves non-NodeSource artifacts and valid credentials +**And** creates the normal configuration backup before config mutation + +#### Scenario: Requested harness is not installed + +**Given** neither a native nor fallback N|Solid installation is detected for the requested harness +**When** the user runs `nsolid-plugin update --harness ` +**Then** no install is performed implicitly +**And** the target result is `not-installed` +**And** the command prints appropriate installation guidance + +#### Scenario: Required harness executable is missing + +**Given** a native N|Solid installation is detected +**But** its owning executable is unavailable on `PATH` +**When** its update strategy runs +**Then** no fallback replacement is attempted automatically +**And** the target fails with a missing-executable error +**And** output identifies the missing executable and manual command + +### Requirement: Transactional Antigravity replacement + +The Antigravity strategy SHALL back up and validate the staged NodeSource plugin because AGY has no native plugin-update command. + +#### Scenario: Update Antigravity native plugin + +**Given** the GitHub-root N|Solid plugin is staged by Antigravity +**And** the `agy` executable is available +**When** the Antigravity update strategy runs +**Then** it creates a temporary backup of the existing staged NodeSource plugin +**And** confirms replacement unless `--yes` was supplied +**And** invokes the supported uninstall/install sequence for `https://github.com/NodeSource/nsolid-plugin.git` +**And** removes the backup only after the new staged plugin validates +**And** preserves `~/.agents/.nodesource-auth.json` + +#### Scenario: Antigravity reinstall fails + +**Given** the previous Antigravity plugin was backed up +**When** uninstall succeeds but reinstall or validation fails +**Then** the updater restores the previous staged plugin atomically where supported +**And** reports whether rollback succeeded +**And** exits non-zero +**And** provides a manual reinstall command + +### Requirement: Deterministic multi-target orchestration + +The updater SHALL plan targets before mutation, execute them sequentially in deterministic order, and isolate target failures. + +#### Scenario: Update every detected target + +**Given** one or more N|Solid CLI or harness installations are detected +**When** the user runs `nsolid-plugin update --all` +**Then** the updater displays one ordered plan +**And** updates the CLI target first when supported +**And** updates detected harness targets sequentially in deterministic harness order +**And** records a result for every planned target +**And** prints counts for updated, current, skipped, not-installed, and failed + +#### Scenario: One target fails during update-all + +**Given** multiple update targets were planned +**When** one target fails +**Then** remaining independent targets are attempted +**And** the summary includes the failed target and actionable error +**And** the overall process exits non-zero +**And** no credential value appears in logs or JSON + +#### Scenario: Conflicting update scopes + +**Given** the user supplies both `--all` and `--harness` +**When** argument validation runs +**Then** the command rejects the invocation before network access or mutation +**And** explains that the scopes are mutually exclusive + +### Requirement: Stable and sanitized update output + +Update results SHALL support human-readable and machine-readable output without mixing progress into JSON or exposing secrets. + +#### Scenario: Structured update output + +**Given** the user passes `--json` +**When** an update or check completes +**Then** standard output contains exactly one valid JSON document +**And** progress and diagnostics are written to standard error +**And** each result contains `target`, `ownership`, `status`, optional versions, `changed`, optional restart guidance, and sanitized errors + +### Requirement: Preserve existing installation behavior + +Update operations SHALL retain all existing setup, installation, authentication, backup, merge, tracking, and uninstall safety contracts. + +#### Scenario: Preserve credentials and user-owned configuration + +**Given** the user has valid NodeSource credentials and non-NodeSource skills or MCP servers +**When** any update strategy succeeds, fails, or rolls back +**Then** credentials remain present and unchanged +**And** non-NodeSource skills and MCP entries remain unchanged +**And** update never invokes setup, login, or OAuth +**And** native strategy failure never silently switches to fallback ownership +**And** all external commands run without a shell and with fixed argument arrays diff --git a/openspec/changes/add-update-flow/tasks.md b/openspec/changes/add-update-flow/tasks.md new file mode 100644 index 0000000..91b1e65 --- /dev/null +++ b/openspec/changes/add-update-flow/tasks.md @@ -0,0 +1,107 @@ +# Tasks + +## Task 1: Define update contracts and semantic-version behavior + +- [ ] **Description**: Add the pure update target, ownership, status, plan, result, summary, command, and strategy types defined in the design. Implement strict stable semantic-version parsing/comparison without adding a runtime dependency. +- **Depends on**: None +- **Files**: `packages/core/src/update/types.ts`, `packages/core/src/update/version.ts`, `packages/core/test/unit/update/version.test.ts` +- **Testing**: Cover valid versions, invalid registry values, equal/newer/older comparisons, and deterministic result/count shapes. References: Update Flow “Report running versions” and “Check whether the CLI is current.” + +## Task 2: Add safe command execution and version sources + +- [ ] **Description**: Implement the injected shell-free command runner, bounded/sanitized output, executable lookup, npm registry client, and GitHub-root bundle version source with explicit timeouts and validation. +- **Depends on**: Task 1 +- **Files**: `packages/core/src/update/command-runner.ts`, `packages/core/src/update/version-source.ts`, `packages/core/test/unit/update/command-runner.test.ts`, `packages/core/test/unit/update/version-source.test.ts` +- **Testing**: Mock success, timeout, missing executable, invalid JSON/version, non-zero exit, oversized output, and secret-bearing diagnostics. Verify all subprocess calls use `shell: false` and argument arrays. References: Update Flow “Registry lookup fails,” “Required harness executable is missing,” and “Preserve credentials and user-owned configuration.” + +## Task 3: Detect CLI installation ownership + +- [ ] **Description**: Read the running package/bundle versions and detect npm or pnpm global ownership only from positive evidence. Return unsupported for workspace, local, `npx`, or ambiguous execution. +- **Depends on**: Tasks 1–2 +- **Files**: `packages/core/src/update/package-manager.ts`, `packages/core/src/update/inventory.ts`, `packages/core/test/unit/update/package-manager.test.ts`, `packages/core/test/unit/update/inventory.test.ts` +- **Testing**: Use fixture paths/environments for npm, pnpm, `npx`, workspace, broken symlink, and ambiguous launchers. Verify unsupported sources produce guidance without mutation. References: Update Flow “Unsupported CLI installation source.” + +## Task 4: Implement CLI package update strategy + +- [ ] **Description**: Implement CLI check/update planning, confirmation metadata, npm/pnpm command generation, post-command reporting, and exact previous-version rollback guidance. +- **Depends on**: Tasks 1–3 +- **Files**: `packages/core/src/update/strategies/cli-package.ts`, `packages/core/test/unit/update/cli-package.test.ts` +- **Testing**: Cover current, update available, newer-than-registry, declined, `--yes`, unsupported source, failed package manager, and rollback command. References: all CLI-specific scenarios in Update Flow. + +## Task 5: Extend harness inventory and version evidence + +- [ ] **Description**: Reuse native detection and fallback tracking to classify Claude, Codex, OpenCode, Antigravity, and Pi ownership. Add optional installed version/staged root evidence and backward-compatible `bundleVersion` tracking for fallback installs. +- **Depends on**: Tasks 1–3 +- **Files**: `packages/core/src/update/inventory.ts`, `packages/core/src/harnesses/*.ts`, `packages/core/src/skills/skill-tracker.ts`, related harness/tracker tests +- **Testing**: Cover native, fallback, package-owned, missing, corrupt metadata, legacy tracking without `bundleVersion`, and version-unknown states for every harness. References: Update Flow “Requested harness is not installed,” “Check every detected target,” “Update one installed native harness,” and “Preserve credentials and user-owned configuration.” + +## Task 6: Implement Claude and Codex native strategies + +- [ ] **Blocking verification**: Before implementing `codex.ts`, use a disposable real Codex installation to verify whether `codex plugin marketplace upgrade nodesource` refreshes the version and content of an already-installed plugin rather than only marketplace metadata. Record the tested versions and command/output evidence. If it does not refresh the installed copy, stop and amend the design, Update Flow specification, and this task to use the documented plugin remove/add lifecycle with configuration-preservation coverage. +- [ ] **Description**: Add strategies that generate and execute the fixed Claude plugin update and Codex marketplace upgrade commands, retain native ownership, and return restart/reload guidance. +- **Depends on**: Tasks 2 and 5 +- **Files**: `packages/core/src/update/strategies/claude.ts`, `packages/core/src/update/strategies/codex.ts`, corresponding unit tests +- **Testing**: Mock successful refresh, already-current output, command failure, missing executable, alternate detected plugin IDs/marketplace names where supported, and verify no fallback/auth call. References: Update Flow “Update Claude native plugin” and “Update Codex native plugin.” + +## Task 7: Implement Pi and fallback/OpenCode strategies + +- [ ] **Description**: Add the package-owned Pi update strategy and latest-published-CLI fallback refresh strategy. Reuse existing idempotent installation, backup, merge, and tracking code rather than duplicating it. +- **Depends on**: Tasks 2 and 5 +- **Files**: `packages/core/src/update/strategies/pi.ts`, `packages/core/src/update/strategies/fallback.ts`, corresponding unit/integration tests +- **Testing**: Verify the exact Pi source, package-owned skill boundaries, OpenCode skill refresh, fallback MCP merge, configuration backup, preserved credentials, and no implicit install for an absent target. References: Update Flow “Update Pi package-owned skills,” “Update OpenCode or a fallback installation,” and “Requested harness is not installed.” + +## Task 8: Implement transactional Antigravity update + +- [ ] **Description**: Add known-path staging detection, restrictive temporary backup, confirmed uninstall/install, new-root validation, successful cleanup, and rollback restoration. +- **Depends on**: Tasks 2 and 5 +- **Files**: `packages/core/src/update/antigravity-transaction.ts`, `packages/core/src/update/strategies/antigravity.ts`, related unit/integration tests +- **Testing**: Cover both supported staged roots, successful replacement, declined confirmation, uninstall failure, install failure, validation failure, rollback success/failure, cleanup, and credential preservation. References: Update Flow “Update Antigravity native plugin” and “Antigravity reinstall fails.” + +## Task 9: Build the coordinator and programmatic API + +- [ ] **Description**: Implement scope validation, deterministic target ordering, check-only short circuit, plan confirmation, sequential execution, per-target failure isolation, aggregation, and public `getVersionInfo()`, `checkUpdates()`, and `update()` exports. +- **Depends on**: Tasks 4 and 6–8 +- **Files**: `packages/core/src/update/coordinator.ts`, `packages/core/src/update/index.ts`, `packages/core/src/index.ts`, coordinator/API tests +- **Testing**: Cover CLI-only default, one harness, `--all`, `--all` plus harness rejection, check-only no-execute guarantee, one failure with later success, empty inventory, status counts, and overall success/exit semantics. References: Update Flow “Update every detected target,” “One target fails during update-all,” “Check every detected target,” and “Conflicting update scopes.” + +## Task 10: Add CLI commands and output formatting + +- [ ] **Description**: Add `version` and `update` command parsing, bare `--version`, `--check`/`--all`, confirmation integration, human-readable summaries, JSON-only stdout, stderr progress, help text, and exit-code mapping. +- **Depends on**: Task 9 +- **Files**: `packages/core/src/cli.ts`, `packages/core/src/utils/format.ts` or a new update formatter, CLI help/unit/integration tests +- **Testing**: Spawn the CLI with every supported scope/output combination. Assert exact JSON parseability, no ANSI in JSON, no mutation in check mode, prompts only when appropriate, sanitized errors, bare `--version` parity, and exit zero when a successful check reports `update-available`. References: Update Flow “Structured update output,” “Report versions with the conventional flag,” and “Non-interactive CLI update.” + +## Task 11: Add atomic release preparation + +- [ ] **Description**: Implement `release:prepare` for `patch`, `minor`, `major`, and explicit increasing versions. Snapshot the controlled allowlist, update the three source versions, invoke existing bundle/root generators, validate, restore on failure, and leave the private root version untouched. +- **Depends on**: Task 1 +- **Files**: `scripts/prepare-release.mjs`, `package.json`, generator exports/refactors if needed, `packages/core/test/unit/scripts/prepare-release.test.ts` +- **Testing**: Use isolated fixture roots to verify patch/minor/major/explicit propagation, generated files, invalid/equal/lower rejection with zero writes, mid-stage rollback, unrelated-file preservation, no package skill materialization, and absence of publish/Git/network side effects. References: Release Versioning “Prepare a patch release” through “Atomic release preparation failure.” + +## Task 12: Add release drift and payload checks + +- [ ] **Description**: Implement `release:check`, including source/package equality, generated artifact checks, exact mismatch reporting, and cleanup-state validation. When and only when `--release` is present, compare the specification's explicit payload allowlist with the latest semantic-version tag and validate that payload changes have an update-visible version. +- **Depends on**: Task 11 +- **Files**: `scripts/check-release-version.mjs`, `package.json`, script fixture tests +- **Testing**: Introduce drift independently in every controlled file, stale generated output, unchanged version with changes in each payload allowlist category, malformed/missing tag state, and materialized package skills. Verify normal and `--release` check modes never repair. References: Release Versioning “Check synchronized release versions,” “Release version drift is detected,” and “Skill changes require an update-visible version.” + +## Task 13: Add end-to-end update regression coverage + +- [ ] **Description**: Exercise the public CLI against isolated homes and fake harness executables/registries, including mixed native/fallback ownership and partial failure. +- **Depends on**: Tasks 9–12 +- **Files**: `packages/core/test/integration/update-flow.test.ts`, test fixtures/helpers, `scripts/test-marketplace-install.js` where update assertions fit +- **Testing**: Cover all acceptance tests from the proposal on Linux-compatible fixtures and keep paths/commands portable for macOS and Windows CI. Assert credentials and non-NodeSource configurations are byte-for-byte preserved. + +## Task 14: Document user and maintainer workflows + +- [ ] **Description**: Document CLI self-update, per-harness update ownership, check/JSON/automation modes, AGY replacement behavior, rollback guidance, version propagation, manual publication order, and the first-release bootstrap limitation. +- **Depends on**: Tasks 10–12 +- **Files**: `README.md`, `packages/core/README.md`, `packages/pi-plugin/README.md` +- **Testing**: Validate every documented command against CLI help/tests and ensure no documentation implies that a Git push alone updates version-keyed caches. References: both specifications and Design “Migration Strategy.” + +## Task 15: Run release-quality gates + +- [ ] **Description**: Run version drift checks, source/plugin checks, lint, type checking/build, all unit/integration tests, marketplace install tests, and package dry-run inspection for both publishable packages. +- **Depends on**: Tasks 13–14 +- **Files**: No production files unless a gate exposes a defect +- **Testing**: `pnpm release:check --release`, `pnpm plugin:check`, `pnpm lint`, `pnpm build`, `pnpm test`, `pnpm test:marketplace`, plus dry-run package contents confirming updated skills and same-version Pi/core dependency resolution. From abc061f322011ced520b14cb14ebe85bc4903d02 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Fri, 31 Jul 2026 15:59:23 +0200 Subject: [PATCH 02/12] docs(openspec): harden add-update-flow contracts - Add explicit version, source, installation, and rollback contracts - Handle newer-than-registry and unsupported update outcomes - Preserve native marketplace identities and separate fallback installations - Reject non-canonical Pi sources without mutation - Restore Antigravity staged files and import manifest on rollback - Align proposal, update-flow spec, and implementation tasks --- openspec/changes/add-update-flow/design.md | 104 ++++++++++++++---- openspec/changes/add-update-flow/proposal.md | 27 +++-- .../add-update-flow/specs/update-flow/spec.md | 66 +++++++++-- openspec/changes/add-update-flow/tasks.md | 34 +++--- 4 files changed, 170 insertions(+), 61 deletions(-) diff --git a/openspec/changes/add-update-flow/design.md b/openspec/changes/add-update-flow/design.md index 6119f77..a810c84 100644 --- a/openspec/changes/add-update-flow/design.md +++ b/openspec/changes/add-update-flow/design.md @@ -49,9 +49,10 @@ Release preparation is a separate maintainer-side script. Runtime update code re `packages/core/src/update/inventory.ts` -- Reuses harness adapters and tracking readers to classify each harness as native, fallback, package-owned, or not installed. +- Reuses harness adapters and tracking readers to return one installation record per detected native, fallback, or package-owned installation; native and fallback records for the same harness are not collapsed. - Reads the running CLI/package metadata. -- Adds optional version discovery without changing existing installation detection contracts. +- Carries validated source identity (plugin ID/marketplace, package source, or fallback provenance) into each plan item without changing existing installation detection contracts. +- Treats local, pinned, ambiguous, or otherwise unsupported update sources as `unsupported` instead of substituting a different source. `packages/core/src/update/version-source.ts` @@ -82,9 +83,9 @@ Release preparation is a separate maintainer-side script. Runtime update code re `packages/core/src/update/antigravity-transaction.ts` - Resolves only known NodeSource staged plugin paths. -- Creates a temporary backup before replacement. -- Validates the newly staged root by checking `plugin.json`, `bundle.json`, and canonical skill presence. -- Restores the backup if reinstall or validation fails. +- Creates a temporary backup before replacement containing the staged root and the N|Solid entry in `~/.gemini/config/import_manifest.json`. +- Validates the newly staged root by checking `plugin.json`, `bundle.json`, canonical skill presence, and source registration in the import manifest. +- Restores both the staged root and the saved manifest entry if reinstall or validation fails, preserving unrelated manifest imports. ### Existing modules extended @@ -141,6 +142,8 @@ The private root package version remains `0.0.0`. ## Interfaces and Contracts ```typescript +import type { HarnessType } from '../types.js' + export type UpdateTarget = | 'cli' | 'claude' @@ -155,19 +158,44 @@ export type UpdateOwnership = | 'package-owned' | 'fallback' +export type VersionStatus = + | 'current' + | 'update-available' + | 'newer-than-registry' + | 'unknown' + export type UpdateStatus = | 'current' | 'update-available' + | 'newer-than-registry' | 'updated' | 'skipped' | 'not-installed' + | 'unsupported' | 'unknown' | 'failed' export interface VersionInfo { current?: string latest?: string - status: 'current' | 'update-available' | 'newer-than-registry' | 'unknown' + status: VersionStatus +} + +export type UpdateSource = + | { kind: 'global-package'; packageManager: 'npm' | 'pnpm'; packageName: 'nsolid-plugin' } + | { kind: 'marketplace'; pluginId: string; marketplace: string } + | { kind: 'pi-package'; spec: 'npm:nsolid-pi-plugin' } + | { kind: 'unsupported'; source: string; reason: 'local' | 'git' | 'pinned' | 'ambiguous' } + | { kind: 'antigravity-git'; url: 'https://github.com/NodeSource/nsolid-plugin.git' } + | { kind: 'fallback'; bundleVersion?: string } + +export interface UpdateInstallation { + installationId: string + target: UpdateTarget + ownership: UpdateOwnership + installed: boolean + source: UpdateSource + version: VersionInfo } export interface UpdateOptions { @@ -183,9 +211,11 @@ export interface UpdateOptions { } export interface UpdatePlanItem { + installationId: string target: UpdateTarget ownership: UpdateOwnership installed: boolean + source: UpdateSource version: VersionInfo executable?: string args?: readonly string[] @@ -193,15 +223,29 @@ export interface UpdatePlanItem { restartHint?: string } +export interface UpdateConfirmationContext { + items: readonly UpdatePlanItem[] +} + +export type UpdateConfirmation = ( + context: UpdateConfirmationContext +) => boolean | Promise + export interface UpdateResult { + installationId: string target: UpdateTarget ownership: UpdateOwnership status: UpdateStatus currentVersion?: string + latestVersion?: string resultingVersion?: string changed: boolean restartHint?: string rollbackCommand?: string + rollback?: { + attempted: boolean + succeeded?: boolean + } error?: { code: string message: string @@ -222,13 +266,27 @@ export interface CommandSpec { timeoutMs: number } +export interface CommandResult { + exitCode: number | null + signal?: NodeJS.Signals + stdout: string + stderr: string + timedOut: boolean +} + export interface CommandRunner { run(spec: CommandSpec): Promise } +export interface UpdateContext { + options: Readonly + commandRunner: CommandRunner +} + export interface UpdateStrategy { readonly target: UpdateTarget - plan(context: UpdateContext): Promise + readonly ownership: UpdateOwnership + plan(installation: UpdateInstallation, context: UpdateContext): Promise execute(item: UpdatePlanItem, context: UpdateContext): Promise } ``` @@ -239,8 +297,11 @@ Rules enforced by these contracts: - Command arguments are arrays; a shell command string is not part of the contract. - `error.message` is sanitized and suitable for JSON output. - An absent version is represented as `unknown`, never coerced to `current`. +- A detected installation source that cannot be updated safely is represented as `unsupported`, never replaced with a different source. - Strategies return data; the CLI formatter owns human-readable output. -- A completed check whose result is `update-available` is successful and exits zero; lookup, validation, or execution failures remain non-zero. +- A completed check whose result is `update-available`, `newer-than-registry`, or `unsupported` is informational and exits zero; lookup, validation, or execution failures remain non-zero. +- A mutating update with `newer-than-registry` performs no downgrade and exits zero; a mutating `unsupported` result exits non-zero with manual guidance. +- A declined plan produces `skipped` results and exits zero. ### Fixed harness command plans @@ -248,13 +309,15 @@ Rules enforced by these contracts: |---|---|---| | CLI npm | `npm install -g nsolid-plugin@` | invoke CLI again | | CLI pnpm | `pnpm add -g nsolid-plugin@` | invoke CLI again | -| Claude | `claude plugin update nsolid-plugin@nodesource` | `/reload-plugins` or restart | -| Codex | `codex plugin marketplace upgrade nodesource` | start a new session | +| Claude | `claude plugin update ` | `/reload-plugins` or restart | +| Codex | `codex plugin marketplace upgrade ` | start a new session | | Antigravity | `agy plugin uninstall nsolid-plugin`, then install Git URL | restart AGY | -| Pi | `pi update npm:nsolid-pi-plugin` | `/reload` or restart | +| Pi | `pi update npm:nsolid-pi-plugin` for the canonical npm source only | `/reload` or restart | | Fallback/OpenCode | latest published CLI executes `install --harness ` | restart harness if needed | -No user-derived string is interpolated into an executable shell command. +Marketplace IDs and package sources are passed as separate arguments only after strict validation. A native ID is accepted only when it matches `nsolid-plugin@[A-Za-z0-9][A-Za-z0-9._-]*`; the base name alone, malformed IDs, control characters, whitespace, and ambiguous matches return `unsupported`. The only supported Pi source is the exact `npm:nsolid-pi-plugin`; local, Git, pinned, or ambiguous Pi sources return `unsupported`. No user-derived string is interpolated into an executable shell command. + +The planner emits one item per `UpdateInstallation`. If a harness has both native and fallback artifacts, both items remain visible and are updated independently; a native failure never switches to fallback ownership. The Codex command plan is provisional until Task 6 verifies it against a disposable real installation. Implementing the Codex strategy is blocked on evidence that `marketplace upgrade` refreshes the already-installed plugin, not only marketplace metadata. If it does not, the design and specification must be amended before implementation to use the documented plugin remove/add lifecycle and to cover configuration preservation. @@ -272,8 +335,8 @@ sequenceDiagram User->>CLI: update [scope] --check CLI->>Coordinator: checkUpdates(options) - Coordinator->>Inventory: detect targets and local versions - Inventory-->>Coordinator: installed targets + Coordinator->>Inventory: detect installations, sources, and local versions + Inventory-->>Coordinator: installation records Coordinator->>Registry: resolve latest versions Registry-->>Coordinator: validated versions or unknown/error Coordinator-->>CLI: UpdateSummary(checkOnly=true) @@ -296,7 +359,7 @@ sequenceDiagram Coordinator-->>CLI: ordered plan CLI-->>User: display plan and request confirmation User-->>CLI: confirm or --yes - loop each target, sequentially + loop each installation, sequentially Coordinator->>Strategy: execute(planItem) Strategy->>ExternalCLI: spawn executable + fixed args ExternalCLI-->>Strategy: exit/status/output @@ -317,13 +380,13 @@ sequenceDiagram participant AGY Updater->>FS: locate known staged N|Solid root - Updater->>FS: copy staged root to temporary backup + Updater->>FS: snapshot staged root and N|Solid import entry Updater->>AGY: uninstall nsolid-plugin Updater->>AGY: install GitHub root alt install and validation succeed - Updater->>FS: remove temporary backup + Updater->>FS: remove temporary backup after root + registration validation else install or validation fails - Updater->>FS: restore backup to staged root + Updater->>FS: restore staged root and import registration Updater-->>Updater: return failed + rollback status end ``` @@ -358,9 +421,10 @@ sequenceDiagram - Missing executables use a distinct error code from command failure. - Process output is bounded before being retained in results. - Existing logger redaction is applied to verbose diagnostics. -- `--all` catches errors at the target boundary and continues with independent targets. +- `--all` catches errors at the installation boundary and continues with independent installation records, including native and fallback records for the same harness. - Confirmation is mandatory for mutable non-interactive operations unless `--yes` is present. -- Antigravity backup paths are created with restrictive permissions in an OS temporary directory and always cleaned after success. +- Marketplace IDs are validated before becoming arguments; local, pinned, and ambiguous Pi sources are never silently replaced. +- Antigravity backup paths are created with restrictive permissions in an OS temporary directory and always cleaned after success. Rollback validates both the staged root and the saved import-manifest registration. - Update does not invoke setup, login, or auth modules. - Release scripts snapshot only an explicit allowlist; rollback never performs broad Git or recursive workspace resets. diff --git a/openspec/changes/add-update-flow/proposal.md b/openspec/changes/add-update-flow/proposal.md index e53968a..65a4dfd 100644 --- a/openspec/changes/add-update-flow/proposal.md +++ b/openspec/changes/add-update-flow/proposal.md @@ -19,13 +19,13 @@ Add an explicit, version-aware update workflow for both maintainers and users. For users: - add `nsolid-plugin version` (with bare `nsolid-plugin --version` as an alias) and `nsolid-plugin update`; -- make plain `update` target the npm CLI, `--harness ` target one harness installation, and `--all` target the CLI plus every detected N|Solid installation; +- make plain `update` target the npm CLI, `--harness ` target every detected installation for one harness, and `--all` target the CLI plus every detected N|Solid installation; - add `--check` for a read-only status check and retain `--json`, `--yes`, `--verbose`, and `--no-color` behavior where applicable; - delegate native updates to the owning harness: - - Claude: refresh/update `nsolid-plugin@nodesource`; - - Codex: upgrade the `nodesource` Git marketplace and refresh the installed version; - - Antigravity: safely reinstall the GitHub-root plugin with backup/rollback; - - Pi: update `npm:nsolid-pi-plugin`; + - Claude: refresh/update the detected `nsolid-plugin@` identity; + - Codex: upgrade the detected Git marketplace and refresh the installed version; + - Antigravity: safely reinstall the GitHub-root plugin with staged-root and import-manifest backup/rollback; + - Pi: update the canonical `npm:nsolid-pi-plugin` source, while rejecting local, pinned, Git, or ambiguous sources; - OpenCode, which has no native package owner, and other fallback installations: reinstall from the latest published CLI bundle; - preserve credentials and non-NodeSource configuration throughout updates; - isolate failures during `--all` so one harness failure does not prevent remaining updates, while returning a failing exit status and a per-target summary. @@ -39,7 +39,8 @@ For maintainers: ## Rollback Plan - The CLI update path records the previously installed CLI version and prints the exact package-manager command needed to restore it. -- Native harness updates rely on the harness owner’s cache where available. Antigravity creates a temporary backup of the staged NodeSource plugin and restores it if reinstall fails. +- A CLI newer than the registry is reported and left unchanged; this proposal has no implicit downgrade path. +- Native harness updates rely on the harness owner’s cache where available. Antigravity creates a temporary backup of the staged NodeSource plugin and its import-manifest registration and restores both if reinstall fails. - Fallback installers continue using their existing config backups and idempotent merge behavior. - Update operations never delete shared NodeSource credentials. - The feature can be reverted by removing the new command/service modules and scripts; existing `setup`, `install`, `doctor`, `restore`, and `uninstall` contracts remain unchanged. @@ -51,6 +52,7 @@ For maintainers: - `packages/core/src/index.ts` — public update/version API surface. - `packages/core/src/update/` — update planning, version comparison, command execution, result contracts, and package-manager detection. - `packages/core/src/harnesses/` — harness-owned update strategies and native/fallback installation detection. +- `~/.gemini/config/import_manifest.json` — Antigravity plugin registration included in the transactional backup/rollback contract. - `packages/core/src/index.ts` (the `doctor` function) and formatting utilities — optional update availability in health/status output. - `packages/core/test/unit/update/` — version, planning, detection, safety, and output tests. - `packages/core/test/integration/` — mocked CLI/harness update flows, partial failures, rollback, and exit codes. @@ -64,8 +66,9 @@ For maintainers: - `nsolid-plugin version` and its bare `--version` alias report the running CLI and bundled plugin versions without network access; the command form also supports JSON output. - `nsolid-plugin update --check` performs no writes or subprocess mutations and clearly reports whether the CLI is current. -- Each supported harness has a deterministic update strategy with actionable output when its CLI is unavailable or its installation type is unsupported. +- Each supported harness has a deterministic update strategy with actionable output when its CLI is unavailable, its installation type is unsupported, or its source identity cannot be safely reused. - `nsolid-plugin update --all` updates all detected targets, preserves credentials and user-owned configuration, summarizes every target, and exits non-zero on any failure. +- Native and fallback installations detected for the same harness are represented and updated as separate targets; one ownership never silently replaces the other. - Interactive destructive/replacement steps require confirmation; `--yes` enables non-interactive automation. - Network, registry, missing-binary, permission, corrupt-state, and partial-update failures are covered by tests and never expose credentials. - Release preparation propagates one requested semantic version to every version-bearing source/generated file and never publishes, tags, commits, or pushes. @@ -74,10 +77,10 @@ For maintainers: Acceptance tests: -1. Mock npm reporting a newer CLI version and verify check-only, confirmed update, declined update, and rollback guidance. -2. Mock current and newer Claude/Codex plugin versions and verify the owning marketplace/update commands and restart guidance. -3. Simulate an Antigravity reinstall failure and verify restoration of the prior staged plugin. -4. Mock a Pi package update and an OpenCode fallback refresh from the latest CLI bundle. -5. Run `--all` with one failed target and verify later targets still run, credentials remain untouched, and the final exit code is non-zero. +1. Mock npm reporting a newer, equal, and older-than-registry CLI version and verify check-only, confirmed update, no-downgrade behavior, declined update, and rollback guidance. +2. Mock current and newer Claude/Codex plugin versions, including alternate marketplace IDs, and verify the detected source commands and restart guidance. +3. Simulate an Antigravity reinstall failure and verify restoration of both the prior staged plugin and its import-manifest registration. +4. Mock canonical and non-canonical Pi sources plus an OpenCode fallback refresh from the latest CLI bundle. +5. Run `--all` with coexisting native/fallback installations and one failed target; verify every installation is represented, later targets still run, credentials remain untouched, and the final exit code is non-zero. 6. Prepare a patch version in a fixture repository and verify all version-bearing files become equal while no publish/tag/push command executes. 7. Introduce version drift in each controlled file and verify the release check identifies the exact mismatch. diff --git a/openspec/changes/add-update-flow/specs/update-flow/spec.md b/openspec/changes/add-update-flow/specs/update-flow/spec.md index 3292c98..91b2b0c 100644 --- a/openspec/changes/add-update-flow/specs/update-flow/spec.md +++ b/openspec/changes/add-update-flow/specs/update-flow/spec.md @@ -37,11 +37,21 @@ The updater SHALL compare installed and latest versions without invoking any mut **And** `--json` returns the current version, latest version, status, and target identifier **And** exits successfully, including when the status is `update-available` +#### Scenario: Do not downgrade a CLI newer than the registry + +**Given** the running CLI semantic version is greater than the registry `latest` +**When** the user runs `nsolid-plugin update` +**Then** the command reports `newer-than-registry` +**And** displays the current and registry versions +**And** does not invoke a package manager or modify the installation +**And** exits successfully +**And** does not provide an implicit downgrade path + #### Scenario: Check every detected target **Given** multiple N|Solid installations are detectable **When** the user runs `nsolid-plugin update --all --check` -**Then** every target is inspected without invoking install, update, uninstall, package-manager, tracking, or configuration mutations +**Then** every detected installation is inspected without invoking install, update, uninstall, package-manager, tracking, or configuration mutations **And** targets whose installed version cannot be determined report `unknown` **And** the command distinguishes `unknown` from `current` @@ -103,6 +113,8 @@ The default `nsolid-plugin update` scope SHALL update only a positively identifi **Then** the command does not guess a package manager or modify the installation **And** reports the latest version when it can be resolved **And** prints safe manual commands for npm, pnpm, and `npx -y nsolid-plugin@latest` +**And** the result status is `unsupported` +**And** a mutating update exits non-zero while a read-only check exits successfully ### Requirement: Harness-owned update strategies @@ -118,28 +130,37 @@ The updater SHALL preserve native/package ownership and delegate each supported **And** no OAuth browser or callback server starts **And** the result includes versions when discoverable, status, and restart guidance +#### Scenario: Preserve each detected native source identity + +**Given** Claude or Codex records `nsolid-plugin@` under a marketplace other than `nodesource` +**When** the corresponding native update strategy runs +**Then** Claude uses the detected complete plugin ID +**And** Codex upgrades the detected marketplace +**And** the strategy never substitutes `nodesource` +**And** an unqualified, malformed, or ambiguous ID returns `unsupported` without mutation + #### Scenario: Update Claude native plugin -**Given** `nsolid-plugin@nodesource` is installed natively in Claude +**Given** `nsolid-plugin@` is installed natively in Claude **And** the `claude` executable is available **When** the Claude update strategy runs -**Then** it invokes `claude plugin update nsolid-plugin@nodesource` with a fixed executable and argument array +**Then** it invokes `claude plugin update nsolid-plugin@` with a fixed executable and argument array **And** reports `/reload-plugins` or restart guidance **And** does not run the fallback installer #### Scenario: Update Codex native plugin -**Given** `nsolid-plugin@nodesource` is installed natively in Codex +**Given** `nsolid-plugin@` is installed natively in Codex **And** the `codex` executable is available **When** the Codex update strategy runs -**Then** it invokes `codex plugin marketplace upgrade nodesource` +**Then** it invokes `codex plugin marketplace upgrade ` **And** verifies the marketplace refresh succeeded **And** reports that a new Codex session is required **And** does not remove the installed plugin or configuration #### Scenario: Update Pi package-owned skills -**Given** `npm:nsolid-pi-plugin` is installed in Pi +**Given** the canonical `npm:nsolid-pi-plugin` source is installed in Pi **And** the `pi` executable is available **When** the Pi update strategy runs **Then** it invokes `pi update npm:nsolid-pi-plugin` @@ -147,6 +168,15 @@ The updater SHALL preserve native/package ownership and delegate each supported **And** reports `/reload` or restart guidance **And** leaves Pi MCP configuration and NodeSource credentials intact +#### Scenario: Reject a non-canonical Pi source + +**Given** Pi detects a local, Git, version-pinned, or ambiguous source for `nsolid-pi-plugin` +**When** the user runs a Pi update +**Then** the result status is `unsupported` +**And** no package source is substituted +**And** no Pi package or configuration is mutated +**And** the output provides manual guidance + #### Scenario: Update OpenCode or another fallback installation **Given** the target is OpenCode, which has no native plugin/package update owner, or another target uses the N|Solid fallback/direct installer @@ -157,6 +187,15 @@ The updater SHALL preserve native/package ownership and delegate each supported **And** preserves non-NodeSource artifacts and valid credentials **And** creates the normal configuration backup before config mutation +#### Scenario: Update coexisting native and fallback installations + +**Given** the same harness has both a detected native plugin and tracked fallback artifacts +**When** the user runs `nsolid-plugin update --harness ` or `nsolid-plugin update --all` +**Then** the plan contains one installation item for each ownership +**And** each item has a distinct installation identifier and source evidence +**And** native and fallback updates execute independently +**And** a native failure does not switch ownership or hide the fallback result + #### Scenario: Requested harness is not installed **Given** neither a native nor fallback N|Solid installation is detected for the requested harness @@ -186,7 +225,8 @@ The Antigravity strategy SHALL back up and validate the staged NodeSource plugin **Then** it creates a temporary backup of the existing staged NodeSource plugin **And** confirms replacement unless `--yes` was supplied **And** invokes the supported uninstall/install sequence for `https://github.com/NodeSource/nsolid-plugin.git` -**And** removes the backup only after the new staged plugin validates +**And** validates `plugin.json`, `bundle.json`, canonical skill presence, and the N|Solid entry in `~/.gemini/config/import_manifest.json` +**And** removes the backup only after the new staged plugin and registration validate **And** preserves `~/.agents/.nodesource-auth.json` #### Scenario: Antigravity reinstall fails @@ -194,6 +234,7 @@ The Antigravity strategy SHALL back up and validate the staged NodeSource plugin **Given** the previous Antigravity plugin was backed up **When** uninstall succeeds but reinstall or validation fails **Then** the updater restores the previous staged plugin atomically where supported +**And** restores the previous N|Solid import-manifest entry while preserving unrelated imports **And** reports whether rollback succeeded **And** exits non-zero **And** provides a manual reinstall command @@ -206,11 +247,11 @@ The updater SHALL plan targets before mutation, execute them sequentially in det **Given** one or more N|Solid CLI or harness installations are detected **When** the user runs `nsolid-plugin update --all` -**Then** the updater displays one ordered plan +**Then** the updater displays one ordered plan containing every detected installation **And** updates the CLI target first when supported -**And** updates detected harness targets sequentially in deterministic harness order -**And** records a result for every planned target -**And** prints counts for updated, current, skipped, not-installed, and failed +**And** updates detected installation targets sequentially in deterministic harness and ownership order +**And** records a result for every planned installation +**And** prints counts for every `UpdateStatus`, including `newer-than-registry`, `unsupported`, and `unknown` #### Scenario: One target fails during update-all @@ -238,7 +279,7 @@ Update results SHALL support human-readable and machine-readable output without **When** an update or check completes **Then** standard output contains exactly one valid JSON document **And** progress and diagnostics are written to standard error -**And** each result contains `target`, `ownership`, `status`, optional versions, `changed`, optional restart guidance, and sanitized errors +**And** each result contains `installationId`, `target`, `ownership`, `status`, optional `currentVersion` and `latestVersion`, `changed`, optional restart guidance and rollback status, and sanitized errors ### Requirement: Preserve existing installation behavior @@ -252,4 +293,5 @@ Update operations SHALL retain all existing setup, installation, authentication, **And** non-NodeSource skills and MCP entries remain unchanged **And** update never invokes setup, login, or OAuth **And** native strategy failure never silently switches to fallback ownership +**And** source identity is preserved for every supported native/package-owned update **And** all external commands run without a shell and with fixed argument arrays diff --git a/openspec/changes/add-update-flow/tasks.md b/openspec/changes/add-update-flow/tasks.md index 91b1e65..56a7d48 100644 --- a/openspec/changes/add-update-flow/tasks.md +++ b/openspec/changes/add-update-flow/tasks.md @@ -2,7 +2,7 @@ ## Task 1: Define update contracts and semantic-version behavior -- [ ] **Description**: Add the pure update target, ownership, status, plan, result, summary, command, and strategy types defined in the design. Implement strict stable semantic-version parsing/comparison without adding a runtime dependency. +- [ ] **Description**: Add the pure update target, ownership, source, installation, status, plan, result, summary, command, confirmation, context, and strategy types defined in the design. Include `newer-than-registry`, `unsupported`, `latestVersion`, `installationId`, and structured rollback status. Implement strict stable semantic-version parsing/comparison without adding a runtime dependency. - **Depends on**: None - **Files**: `packages/core/src/update/types.ts`, `packages/core/src/update/version.ts`, `packages/core/test/unit/update/version.test.ts` - **Testing**: Cover valid versions, invalid registry values, equal/newer/older comparisons, and deterministic result/count shapes. References: Update Flow “Report running versions” and “Check whether the CLI is current.” @@ -16,60 +16,60 @@ ## Task 3: Detect CLI installation ownership -- [ ] **Description**: Read the running package/bundle versions and detect npm or pnpm global ownership only from positive evidence. Return unsupported for workspace, local, `npx`, or ambiguous execution. +- [ ] **Description**: Read the running package/bundle versions and detect npm or pnpm global ownership only from positive evidence. Return `unsupported` for workspace, local, `npx`, or ambiguous execution, and preserve the detected source evidence on every installation record. - **Depends on**: Tasks 1–2 - **Files**: `packages/core/src/update/package-manager.ts`, `packages/core/src/update/inventory.ts`, `packages/core/test/unit/update/package-manager.test.ts`, `packages/core/test/unit/update/inventory.test.ts` - **Testing**: Use fixture paths/environments for npm, pnpm, `npx`, workspace, broken symlink, and ambiguous launchers. Verify unsupported sources produce guidance without mutation. References: Update Flow “Unsupported CLI installation source.” ## Task 4: Implement CLI package update strategy -- [ ] **Description**: Implement CLI check/update planning, confirmation metadata, npm/pnpm command generation, post-command reporting, and exact previous-version rollback guidance. +- [ ] **Description**: Implement CLI check/update planning, confirmation metadata, npm/pnpm command generation, post-command reporting, exact previous-version rollback guidance, and a no-mutation `newer-than-registry` outcome without implicit downgrade support. - **Depends on**: Tasks 1–3 - **Files**: `packages/core/src/update/strategies/cli-package.ts`, `packages/core/test/unit/update/cli-package.test.ts` - **Testing**: Cover current, update available, newer-than-registry, declined, `--yes`, unsupported source, failed package manager, and rollback command. References: all CLI-specific scenarios in Update Flow. ## Task 5: Extend harness inventory and version evidence -- [ ] **Description**: Reuse native detection and fallback tracking to classify Claude, Codex, OpenCode, Antigravity, and Pi ownership. Add optional installed version/staged root evidence and backward-compatible `bundleVersion` tracking for fallback installs. +- [ ] **Description**: Reuse native detection and fallback tracking to emit one installation record per native, fallback, or package-owned installation. Carry validated Claude/Codex plugin IDs and marketplaces, canonical Pi source evidence, optional installed version/staged root evidence, and backward-compatible `bundleVersion` tracking for fallback installs. Do not collapse native and fallback records for one harness. - **Depends on**: Tasks 1–3 - **Files**: `packages/core/src/update/inventory.ts`, `packages/core/src/harnesses/*.ts`, `packages/core/src/skills/skill-tracker.ts`, related harness/tracker tests -- **Testing**: Cover native, fallback, package-owned, missing, corrupt metadata, legacy tracking without `bundleVersion`, and version-unknown states for every harness. References: Update Flow “Requested harness is not installed,” “Check every detected target,” “Update one installed native harness,” and “Preserve credentials and user-owned configuration.” +- **Testing**: Cover native, fallback, package-owned, coexisting native+fallback, alternate marketplace IDs, canonical and unsupported Pi sources, missing/corrupt metadata, legacy tracking without `bundleVersion`, and version-unknown states for every harness. References: Update Flow “Requested harness is not installed,” “Check every detected target,” “Update one installed native harness,” “Preserve each detected native source identity,” and “Preserve credentials and user-owned configuration.” ## Task 6: Implement Claude and Codex native strategies - [ ] **Blocking verification**: Before implementing `codex.ts`, use a disposable real Codex installation to verify whether `codex plugin marketplace upgrade nodesource` refreshes the version and content of an already-installed plugin rather than only marketplace metadata. Record the tested versions and command/output evidence. If it does not refresh the installed copy, stop and amend the design, Update Flow specification, and this task to use the documented plugin remove/add lifecycle with configuration-preservation coverage. -- [ ] **Description**: Add strategies that generate and execute the fixed Claude plugin update and Codex marketplace upgrade commands, retain native ownership, and return restart/reload guidance. +- [ ] **Description**: Add strategies that generate and execute commands using the validated detected Claude plugin ID and Codex marketplace, retain native ownership, reject incomplete or ambiguous IDs, and return restart/reload guidance. - **Depends on**: Tasks 2 and 5 - **Files**: `packages/core/src/update/strategies/claude.ts`, `packages/core/src/update/strategies/codex.ts`, corresponding unit tests -- **Testing**: Mock successful refresh, already-current output, command failure, missing executable, alternate detected plugin IDs/marketplace names where supported, and verify no fallback/auth call. References: Update Flow “Update Claude native plugin” and “Update Codex native plugin.” +- **Testing**: Mock successful refresh, already-current and newer-than-registry output, command failure, missing executable, alternate detected plugin IDs/marketplace names, malformed/ambiguous IDs, and verify no fallback/auth call. Keep implementation blocked until the real Codex marketplace-refresh spike has evidence. References: Update Flow “Preserve each detected native source identity,” “Update Claude native plugin,” and “Update Codex native plugin.” ## Task 7: Implement Pi and fallback/OpenCode strategies -- [ ] **Description**: Add the package-owned Pi update strategy and latest-published-CLI fallback refresh strategy. Reuse existing idempotent installation, backup, merge, and tracking code rather than duplicating it. +- [ ] **Description**: Add the package-owned Pi update strategy only for the canonical npm source and return `unsupported` for local, Git, pinned, or ambiguous Pi sources. Add the latest-published-CLI fallback refresh strategy, reusing existing idempotent installation, backup, merge, and tracking code rather than duplicating it. - **Depends on**: Tasks 2 and 5 - **Files**: `packages/core/src/update/strategies/pi.ts`, `packages/core/src/update/strategies/fallback.ts`, corresponding unit/integration tests -- **Testing**: Verify the exact Pi source, package-owned skill boundaries, OpenCode skill refresh, fallback MCP merge, configuration backup, preserved credentials, and no implicit install for an absent target. References: Update Flow “Update Pi package-owned skills,” “Update OpenCode or a fallback installation,” and “Requested harness is not installed.” +- **Testing**: Verify the exact canonical Pi source, rejection of non-canonical sources, package-owned skill boundaries, OpenCode skill refresh, fallback MCP merge, configuration backup, preserved credentials, and no implicit install for an absent target. References: Update Flow “Update Pi package-owned skills,” “Reject a non-canonical Pi source,” “Update OpenCode or a fallback installation,” and “Requested harness is not installed.” ## Task 8: Implement transactional Antigravity update -- [ ] **Description**: Add known-path staging detection, restrictive temporary backup, confirmed uninstall/install, new-root validation, successful cleanup, and rollback restoration. +- [ ] **Description**: Add known-path staging detection, restrictive temporary backup of the staged root and N|Solid import-manifest entry, confirmed uninstall/install, new-root plus registration validation, successful cleanup, and rollback restoration that preserves unrelated imports. - **Depends on**: Tasks 2 and 5 - **Files**: `packages/core/src/update/antigravity-transaction.ts`, `packages/core/src/update/strategies/antigravity.ts`, related unit/integration tests -- **Testing**: Cover both supported staged roots, successful replacement, declined confirmation, uninstall failure, install failure, validation failure, rollback success/failure, cleanup, and credential preservation. References: Update Flow “Update Antigravity native plugin” and “Antigravity reinstall fails.” +- **Testing**: Cover both supported staged roots, successful replacement, declined confirmation, uninstall failure, install failure, root/manifest validation failure, rollback success/failure for both components, cleanup, unrelated import preservation, and credential preservation. References: Update Flow “Update Antigravity native plugin” and “Antigravity reinstall fails.” ## Task 9: Build the coordinator and programmatic API -- [ ] **Description**: Implement scope validation, deterministic target ordering, check-only short circuit, plan confirmation, sequential execution, per-target failure isolation, aggregation, and public `getVersionInfo()`, `checkUpdates()`, and `update()` exports. +- [ ] **Description**: Implement scope validation, one-plan-item-per-installation semantics, deterministic target/ownership ordering, check-only short circuit, plan confirmation, sequential execution, per-installation failure isolation, aggregation, and public `getVersionInfo()`, `checkUpdates()`, and `update()` exports. - **Depends on**: Tasks 4 and 6–8 - **Files**: `packages/core/src/update/coordinator.ts`, `packages/core/src/update/index.ts`, `packages/core/src/index.ts`, coordinator/API tests -- **Testing**: Cover CLI-only default, one harness, `--all`, `--all` plus harness rejection, check-only no-execute guarantee, one failure with later success, empty inventory, status counts, and overall success/exit semantics. References: Update Flow “Update every detected target,” “One target fails during update-all,” “Check every detected target,” and “Conflicting update scopes.” +- **Testing**: Cover CLI-only default, one harness selecting multiple installations, `--all`, `--all` plus harness rejection, check-only no-execute guarantee, newer-than-registry no-downgrade, unsupported check/update exit semantics, one failure with later success, coexisting native/fallback records, empty inventory, all status counts, and overall success/exit semantics. References: Update Flow “Update every detected target,” “Update coexisting native and fallback installations,” “Do not downgrade a CLI newer than the registry,” “One target fails during update-all,” “Check every detected target,” and “Conflicting update scopes.” ## Task 10: Add CLI commands and output formatting -- [ ] **Description**: Add `version` and `update` command parsing, bare `--version`, `--check`/`--all`, confirmation integration, human-readable summaries, JSON-only stdout, stderr progress, help text, and exit-code mapping. +- [ ] **Description**: Add `version` and `update` command parsing, bare `--version`, `--check`/`--all`, confirmation integration, human-readable summaries, JSON-only stdout, stderr progress, help text, installation identifiers/source-safe display, and exit-code mapping. - **Depends on**: Task 9 - **Files**: `packages/core/src/cli.ts`, `packages/core/src/utils/format.ts` or a new update formatter, CLI help/unit/integration tests -- **Testing**: Spawn the CLI with every supported scope/output combination. Assert exact JSON parseability, no ANSI in JSON, no mutation in check mode, prompts only when appropriate, sanitized errors, bare `--version` parity, and exit zero when a successful check reports `update-available`. References: Update Flow “Structured update output,” “Report versions with the conventional flag,” and “Non-interactive CLI update.” +- **Testing**: Spawn the CLI with every supported scope/output combination. Assert exact JSON parseability, `latestVersion` and `installationId` presence, no ANSI in JSON, no mutation in check mode, prompts only when appropriate, sanitized errors, bare `--version` parity, no downgrade for `newer-than-registry`, informational exit zero for successful checks, and non-zero mutable exits for unsupported/failure results. References: Update Flow “Structured update output,” “Report versions with the conventional flag,” “Do not downgrade a CLI newer than the registry,” and “Non-interactive CLI update.” ## Task 11: Add atomic release preparation @@ -87,10 +87,10 @@ ## Task 13: Add end-to-end update regression coverage -- [ ] **Description**: Exercise the public CLI against isolated homes and fake harness executables/registries, including mixed native/fallback ownership and partial failure. +- [ ] **Description**: Exercise the public CLI against isolated homes and fake harness executables/registries, including mixed native/fallback ownership, alternate source identities, unsupported Pi sources, Antigravity manifest rollback, and partial failure. - **Depends on**: Tasks 9–12 - **Files**: `packages/core/test/integration/update-flow.test.ts`, test fixtures/helpers, `scripts/test-marketplace-install.js` where update assertions fit -- **Testing**: Cover all acceptance tests from the proposal on Linux-compatible fixtures and keep paths/commands portable for macOS and Windows CI. Assert credentials and non-NodeSource configurations are byte-for-byte preserved. +- **Testing**: Cover all acceptance tests from the proposal on Linux-compatible fixtures and keep paths/commands portable for macOS and Windows CI. Assert credentials, non-NodeSource configurations, unrelated Antigravity manifest imports, and source identities are preserved byte-for-byte where applicable. ## Task 14: Document user and maintainer workflows From cef2b30c65c2984a4424572383f73f1c25502b0d Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Mon, 3 Aug 2026 14:21:18 +0200 Subject: [PATCH 03/12] docs: finalize update flow specification --- openspec/changes/add-update-flow/design.md | 249 +++++++++++++++--- openspec/changes/add-update-flow/proposal.md | 47 ++-- .../add-update-flow/specs/update-flow/spec.md | 169 +++++++++--- openspec/changes/add-update-flow/tasks.md | 55 ++-- 4 files changed, 401 insertions(+), 119 deletions(-) diff --git a/openspec/changes/add-update-flow/design.md b/openspec/changes/add-update-flow/design.md index a810c84..8315c3f 100644 --- a/openspec/changes/add-update-flow/design.md +++ b/openspec/changes/add-update-flow/design.md @@ -2,7 +2,7 @@ ## Architecture -The update feature is additive to the existing CLI and installer architecture. It does not move installation ownership into the shared CLI: each native harness remains responsible for its own staged plugin, Pi remains package-owned, and OpenCode/fallback installs continue through the existing installer. +The update feature is additive to the existing CLI and installer architecture. It does not move installation ownership into the shared CLI: each native harness remains responsible for its own staged plugin, Pi remains package-owned, and initial OpenCode/fallback installs continue through the existing public installer while update-only reconciliation uses the package-internal refresh entrypoint. The CLI adds an update coordinator that separates four concerns: @@ -42,6 +42,7 @@ Release preparation is a separate maintainer-side script. Runtime update code re `packages/core/src/update/coordinator.ts` - Resolves requested scope (`cli`, one harness, or all detected targets). +- Produces one synthetic `ownership: 'none'` item only when an explicitly requested harness has no detected installation. - Produces the plan before mutation. - Applies confirmation rules. - Executes targets sequentially and isolates per-target failures. @@ -51,21 +52,23 @@ Release preparation is a separate maintainer-side script. Runtime update code re - Reuses harness adapters and tracking readers to return one installation record per detected native, fallback, or package-owned installation; native and fallback records for the same harness are not collapsed. - Reads the running CLI/package metadata. -- Carries validated source identity (plugin ID/marketplace, package source, or fallback provenance) into each plan item without changing existing installation detection contracts. +- Carries validated source identity (Claude plugin ID/marketplace/scope/version source, Codex plugin ID/marketplace/version source, Antigravity layout, effective Pi source/scopes, or fallback provenance/executor) into each plan item without changing existing installation detection contracts. - Treats local, pinned, ambiguous, or otherwise unsupported update sources as `unsupported` instead of substituting a different source. `packages/core/src/update/version-source.ts` - Reads and validates `latest` metadata from npm for `nsolid-plugin` and `nsolid-pi-plugin`. -- Reads the GitHub-root `bundle.json` once for native Git targets. +- Resolves Claude/Codex latest-version evidence only from the exact carried marketplace source: a validated Git repository/ref plus relative manifest path, or the detected local marketplace snapshot. It never substitutes the canonical NodeSource GitHub root for an alternate marketplace. +- Reads the canonical GitHub-root `bundle.json` only for fixed-source native Git targets such as Antigravity. - Applies bounded request timeouts and semantic-version validation. -- Returns `unknown` rather than treating missing version evidence as current. +- Returns `unknown` rather than treating missing, local-stale, ambiguous, or unsupported marketplace version evidence as current; native execution may still use the preserved harness-owned ID when its identity is unambiguous. `packages/core/src/update/package-manager.ts` -- Detects npm or pnpm only from positive installation-path/package-manager evidence. +- Detects npm or pnpm only when the real CLI package/entrypoint is contained by that manager's reported global root and the corresponding executable is available; a shim or package-manager environment variable alone is not sufficient evidence. - Produces a fixed executable plus argument array. -- Returns unsupported for workspaces, `npx`, local checkouts, and ambiguous launchers. +- Pins update and rollback package specs to the exact semantic versions resolved during planning. +- Returns unsupported for workspaces, `npx`, local checkouts, Volta/Yarn/Bun ownership, mismatched global roots, and ambiguous launchers. `packages/core/src/update/command-runner.ts` @@ -80,13 +83,30 @@ Release preparation is a separate maintainer-side script. Runtime update code re - Strategies receive an immutable plan item and execution context. - Strategies cannot broaden scope or switch from native to fallback ownership after failure. +`packages/core/src/update/codex-transaction.ts` + +- Refreshes only the detected Git marketplace snapshot before touching the installed plugin; marketplace refresh is not treated as an installed-plugin update. +- Snapshots the exact `nsolid-plugin@` registration, prior enabled state, user-owned plugin fields, and cached installed payload before removal. +- Runs `codex plugin remove ` followed by `codex plugin add ` with fixed argument arrays. +- Validates that the reinstalled local version/content matches the refreshed marketplace entry, then reapplies the prior enabled state and preserves unrelated Codex configuration. +- Restores the saved registration and cached payload if removal succeeds but add or validation fails. + `packages/core/src/update/antigravity-transaction.ts` -- Resolves only known NodeSource staged plugin paths. -- Creates a temporary backup before replacement containing the staged root and the N|Solid entry in `~/.gemini/config/import_manifest.json`. +- Resolves only the two documented global NodeSource layout pairs: shared Antigravity under `~/.gemini/config/` and AGY CLI under `~/.gemini/antigravity-cli/`. +- Creates a temporary backup before replacement containing the detected staged root and the N|Solid entry in that root's matching `import_manifest.json`. - Validates the newly staged root by checking `plugin.json`, `bundle.json`, canonical skill presence, and source registration in the import manifest. - Restores both the staged root and the saved manifest entry if reinstall or validation fails, preserving unrelated manifest imports. +`packages/core/src/update/fallback-transaction.ts` + +- Resolves one available package executor (`npm exec` preferred, otherwise `pnpm dlx`), pins the child package to the exact validated `nsolid-plugin@` selected during planning, and runs it from a restrictive temporary working directory so workspace binaries/configuration cannot shadow the payload. +- Invokes the exact package's dedicated internal `nsolid-plugin-refresh-owned` binary for only the planned harness; the regular `nsolid-plugin install` command and programmatic `install()` contract are not changed. +- The exact child snapshots the target's tracked NodeSource-owned skill directories, affected MCP configuration, and complete tracking state before mutation, using its own bundled payload for ownership/collision preflight. +- Reconciles the installed asset set against the new bundle: complete skill directories are replaced, previously tracked skills absent from the new bundle are removed, and untracked/user-owned paths and unrelated MCP entries are preserved. +- Updates `bundleVersion` only after the new skills, MCP entries, and tracking data validate; restores the snapshot if execution, reconciliation, or validation fails. +- Treats direct artifacts without sufficient tracking ownership as `unsupported` rather than deleting paths by name or prefix. + ### Existing modules extended `packages/core/src/cli.ts` @@ -96,9 +116,15 @@ Release preparation is a separate maintainer-side script. Runtime update code re - Rejects `--all` with `--harness` before calling the coordinator. - Keeps JSON on stdout and progress/diagnostics on stderr. +`packages/core/src/update/refresh-owned-cli.ts` + +- Implements the package-internal `nsolid-plugin-refresh-owned` binary used by an older updater to execute the exact published bundle's fallback transaction. +- Is not listed as a public user workflow and refuses absent, ambiguous, or untracked ownership; it never authenticates or broadens the requested harness. +- Leaves the existing `nsolid-plugin install` command and public `install()` behavior unchanged. + `packages/core/src/index.ts` -- Exports programmatic `getVersionInfo()`, `checkUpdates()`, and `update()` functions and their public types. +- Exports synchronous, read-only `getVersionInfo(): RunningVersionInfo` plus asynchronous `checkUpdates()` and `update()` functions and their public types. - Existing setup/install/uninstall APIs remain unchanged. `packages/core/src/harnesses/` @@ -108,7 +134,7 @@ Release preparation is a separate maintainer-side script. Runtime update code re `packages/core/src/skills/skill-tracker.ts` -- Fallback tracking may add an optional `bundleVersion` for future checks. +- Fallback tracking adds an optional `bundleVersion` and retains enough per-harness ownership/path evidence to reconcile obsolete assets safely. - Readers must accept existing tracking files that omit it. ### Release modules @@ -157,6 +183,7 @@ export type UpdateOwnership = | 'native-plugin' | 'package-owned' | 'fallback' + | 'none' export type VersionStatus = | 'current' @@ -181,13 +208,77 @@ export interface VersionInfo { status: VersionStatus } +export interface RunningVersionInfo { + cliVersion: string + bundleVersion: string +} + +export type ClaudePluginScope = 'user' | 'project' | 'local' | 'managed' + +export type MarketplaceVersionSource = + | { + kind: 'git' + repository: string + revision?: string + manifestPath: string + } + | { + kind: 'local-snapshot' + root: string + manifestPath: string + freshness: 'verified' | 'stale' | 'unknown' + } + | { + kind: 'unknown' + reason: 'missing-metadata' | 'ambiguous' | 'unsupported' + } + +export type PiPackageLocation = + | { scopes: readonly ['user'] } + | { scopes: readonly ['project']; projectRoot: string } + | { scopes: readonly ['user', 'project']; projectRoot: string } + +export type FallbackPackageExecutor = 'npm-exec' | 'pnpm-dlx' + +export type AntigravityLayout = + | { + kind: 'shared' + pluginRoot: '~/.gemini/config/plugins/nsolid-plugin' + manifestPath: '~/.gemini/config/import_manifest.json' + } + | { + kind: 'agy-cli' + pluginRoot: '~/.gemini/antigravity-cli/plugins/nsolid-plugin' + manifestPath: '~/.gemini/antigravity-cli/import_manifest.json' + } + export type UpdateSource = + | { kind: 'none' } | { kind: 'global-package'; packageManager: 'npm' | 'pnpm'; packageName: 'nsolid-plugin' } - | { kind: 'marketplace'; pluginId: string; marketplace: string } - | { kind: 'pi-package'; spec: 'npm:nsolid-pi-plugin' } - | { kind: 'unsupported'; source: string; reason: 'local' | 'git' | 'pinned' | 'ambiguous' } - | { kind: 'antigravity-git'; url: 'https://github.com/NodeSource/nsolid-plugin.git' } - | { kind: 'fallback'; bundleVersion?: string } + | { + kind: 'claude-marketplace' + pluginId: string + marketplace: string + scope: ClaudePluginScope + versionSource: MarketplaceVersionSource + } + | { + kind: 'codex-marketplace' + pluginId: string + marketplace: string + versionSource: MarketplaceVersionSource + } + | ({ + kind: 'pi-package' + spec: 'npm:nsolid-pi-plugin' + } & PiPackageLocation) + | { + kind: 'unsupported' + source: string + reason: 'local' | 'git' | 'pinned' | 'ambiguous' | 'conflicting' | 'untracked' | 'unsupported-manager' + } + | { kind: 'antigravity-git'; url: 'https://github.com/NodeSource/nsolid-plugin.git'; layout: AntigravityLayout } + | { kind: 'fallback'; bundleVersion?: string; executor?: FallbackPackageExecutor } export interface UpdateInstallation { installationId: string @@ -210,6 +301,29 @@ export interface UpdateOptions { confirm?: UpdateConfirmation } +export type UpdatePlanStep = + | { + kind: 'command' + description: string + command: CommandSpec + } + | { + kind: 'filesystem' + description: string + operation: 'backup' | 'replace' | 'reconcile' | 'restore' | 'cleanup' + paths: readonly string[] + } + | { + kind: 'validation' + description: string + checks: readonly string[] + } + +export interface UpdateError { + code: string + message: string +} + export interface UpdatePlanItem { installationId: string target: UpdateTarget @@ -217,8 +331,9 @@ export interface UpdatePlanItem { installed: boolean source: UpdateSource version: VersionInfo - executable?: string - args?: readonly string[] + steps: readonly UpdatePlanStep[] + rollbackSteps: readonly UpdatePlanStep[] + planningError?: UpdateError requiresConfirmation: boolean restartHint?: string } @@ -246,10 +361,7 @@ export interface UpdateResult { attempted: boolean succeeded?: boolean } - error?: { - code: string - message: string - } + error?: UpdateError } export interface UpdateSummary { @@ -263,6 +375,7 @@ export interface CommandSpec { executable: string args: readonly string[] cwd?: string + env?: Readonly> timeoutMs: number } @@ -294,12 +407,16 @@ export interface UpdateStrategy { Rules enforced by these contracts: - `check` stops after planning/version resolution and never calls `execute`. -- Command arguments are arrays; a shell command string is not part of the contract. +- Every command, filesystem mutation, validation, and rollback action is represented as an ordered plan step before confirmation; strategies cannot introduce an undisclosed external command during execution. +- A lookup or validation failure produces a plan item with sanitized `planningError`, empty execute/rollback steps, and `requiresConfirmation: false`. The coordinator converts it to a `failed` result without calling `execute`, while independent items remain executable. +- Command arguments are arrays; a shell command string is not part of the contract. The formatter redacts sensitive environment values and source credentials when displaying a plan. - `error.message` is sanitized and suitable for JSON output. - An absent version is represented as `unknown`, never coerced to `current`. - A detected installation source that cannot be updated safely is represented as `unsupported`, never replaced with a different source. +- `ownership: 'none'` with `source.kind: 'none'` is reserved for the synthetic, non-mutating plan/result produced when an explicitly requested harness has no detected installation. It has empty execute/rollback steps, requires no confirmation, and is never emitted as a detected target under `--all`. +- Marketplace version resolution uses only the `versionSource` carried by the detected Claude or Codex registration. An unknown or stale local source yields `unknown`; it never falls back to the NodeSource marketplace. - Strategies return data; the CLI formatter owns human-readable output. -- A completed check whose result is `update-available`, `newer-than-registry`, or `unsupported` is informational and exits zero; lookup, validation, or execution failures remain non-zero. +- A completed check whose result is `current`, `update-available`, `newer-than-registry`, `unsupported`, or evidence-only `unknown` is informational and exits zero. A timeout, invalid response, or other operational lookup/validation failure is `failed` and remains non-zero. - A mutating update with `newer-than-registry` performs no downgrade and exits zero; a mutating `unsupported` result exits non-zero with manual guidance. - A declined plan produces `skipped` results and exits zero. @@ -307,19 +424,37 @@ Rules enforced by these contracts: | Target | Native/package action | Success guidance | |---|---|---| -| CLI npm | `npm install -g nsolid-plugin@` | invoke CLI again | -| CLI pnpm | `pnpm add -g nsolid-plugin@` | invoke CLI again | -| Claude | `claude plugin update ` | `/reload-plugins` or restart | -| Codex | `codex plugin marketplace upgrade ` | start a new session | -| Antigravity | `agy plugin uninstall nsolid-plugin`, then install Git URL | restart AGY | -| Pi | `pi update npm:nsolid-pi-plugin` for the canonical npm source only | `/reload` or restart | -| Fallback/OpenCode | latest published CLI executes `install --harness ` | restart harness if needed | - -Marketplace IDs and package sources are passed as separate arguments only after strict validation. A native ID is accepted only when it matches `nsolid-plugin@[A-Za-z0-9][A-Za-z0-9._-]*`; the base name alone, malformed IDs, control characters, whitespace, and ambiguous matches return `unsupported`. The only supported Pi source is the exact `npm:nsolid-pi-plugin`; local, Git, pinned, or ambiguous Pi sources return `unsupported`. No user-derived string is interpolated into an executable shell command. +| CLI npm | `npm install --global nsolid-plugin@` | invoke CLI again | +| CLI pnpm | `pnpm add --global nsolid-plugin@` | invoke CLI again | +| Claude | `claude plugin update --scope ` | `/reload-plugins` or restart | +| Codex | `codex plugin marketplace upgrade `, then `codex plugin remove ` and `codex plugin add ` | start a new session | +| Antigravity | `agy plugin uninstall nsolid-plugin`, then `agy plugin install https://github.com/NodeSource/nsolid-plugin.git` | restart AGY | +| Pi user-only | `pi update npm:nsolid-pi-plugin --no-approve` | `/reload` or restart | +| Pi with detected project scope | `pi update npm:nsolid-pi-plugin --approve` after the project root is disclosed and approved | `/reload` or restart | +| Fallback/OpenCode through npm | `npm exec --yes --package=nsolid-plugin@ -- nsolid-plugin-refresh-owned --harness ` inside a fallback transaction | restart harness if needed | +| Fallback/OpenCode through pnpm | `pnpm --package=nsolid-plugin@ dlx nsolid-plugin-refresh-owned --harness ` inside a fallback transaction | restart harness if needed | + +Marketplace IDs, Claude scopes, package versions, Pi scopes, and package sources are passed as separate arguments only after strict validation. A native ID is accepted only when it matches `nsolid-plugin@[A-Za-z0-9][A-Za-z0-9._-]*`; the base name alone, malformed IDs, control characters, whitespace, ambiguous matches, and a Claude installation whose scope cannot be determined return `unsupported`. Marketplace inventory also carries the exact repository/ref and relative manifest path, or the exact local snapshot path and freshness evidence, used for version resolution. Repository credentials are stripped before data reaches plan or result output; traversal-capable manifest paths and ambiguous source metadata return `unknown` or `unsupported` without canonical-source substitution. The only supported Pi identity is the exact unpinned `npm:nsolid-pi-plugin`. Inventory coalesces canonical user/project entries into one Pi target because one `pi update ` invocation updates every matching identity; the discriminated location requires `projectRoot` whenever project scope is present. Any local, Git, pinned, conflicting, or ambiguous matching entry returns `unsupported` for the whole target rather than producing a misleading partial success. No user-derived string is interpolated into an executable shell command. The planner emits one item per `UpdateInstallation`. If a harness has both native and fallback artifacts, both items remain visible and are updated independently; a native failure never switches to fallback ownership. -The Codex command plan is provisional until Task 6 verifies it against a disposable real installation. Implementing the Codex strategy is blocked on evidence that `marketplace upgrade` refreshes the already-installed plugin, not only marketplace metadata. If it does not, the design and specification must be amended before implementation to use the documented plugin remove/add lifecycle and to cover configuration preservation. +The CLI registry lookup resolves the `latest` dist-tag once, validates it as a stable semantic version, and stores that exact version in the immutable plan. Execution never sends `@latest` back to a package manager. npm uses `install --global`; pnpm uses `add --global`. Success requires both a zero child exit and an on-disk package manifest at the positively identified global root whose name/version equal `nsolid-plugin` and the planned version. A failed or mismatched result returns the exact previous-version command for the same manager. + +Pi source detection reads both user and current-project settings, including object-form entries and filters. User and project entries for the same unpinned npm package become one command target. A user-only target passes `--no-approve` so an unrelated current directory cannot broaden the operation. A detected project target records and displays its project root and passes `--approve` only after the update plan is approved; this is a one-command trust decision and does not rewrite Pi trust/settings files. Pi's own updater preserves source entries and package filters. Because `pi update ` does not accept a target version, the registry version observed during planning is a minimum postcondition rather than an executable argument: the strategy reads and reports the actual package-cache version after Pi completes, accepts a newer valid version published during the run, and fails if any affected cache remains older than the planned version. + +OpenCode supports native skills and a separate npm/local plugin system, but the current N|Solid distribution is not registered as an OpenCode plugin. Its owner is therefore the tracked direct installer at `~/.config/opencode/skills/` plus the merged `mcp` entries in `opencode.json(c)`. The updater must not invoke `opencode plugin`. It invokes the exact published N|Solid CLI package's internal `nsolid-plugin-refresh-owned` binary as the payload provider and transaction executor. The existing public `install` flow keeps its idempotent copy/merge semantics and does not acquire stale-asset removal behavior. + +The Codex marketplace command refreshes only the configured Git marketplace snapshot. It is therefore a discovery prerequisite, not an installed-plugin update. The strategy must use the detected complete plugin ID for both `remove` and `add`, preserve the prior registration/enablement and cached payload transactionally, and validate the resulting local version against the refreshed marketplace entry. + +Command semantics were verified on 2026-08-03 against the official harness documentation: + +- Claude documents `claude plugin update --scope ` and version-keyed installed caches: . +- Codex documents marketplace upgrade as refreshing Git marketplace snapshots and exposes plugin install/remove as separate operations: and . +- Google documents AGY plugin management plus the update sequence as uninstalling the old plugin and installing the new source: and . +- npm and pnpm document exact-version global installation through `npm install --global @` and `pnpm add --global @`: and . +- Pi documents `pi update `, unpinned npm updates, separate user/project caches, project trust flags, and identity-based user/project deduplication: and . +- OpenCode documents global skills under `~/.config/opencode/skills/`; its npm/local plugin system is separate from those direct skill directories: and . +- npm and pnpm document exact package execution through `npm exec --package=@` and `pnpm dlx @`: and . ## Data Flow @@ -352,6 +487,7 @@ sequenceDiagram participant CLI participant Coordinator participant Strategy + participant FS participant ExternalCLI User->>CLI: update [scope] @@ -361,8 +497,14 @@ sequenceDiagram User-->>CLI: confirm or --yes loop each installation, sequentially Coordinator->>Strategy: execute(planItem) - Strategy->>ExternalCLI: spawn executable + fixed args - ExternalCLI-->>Strategy: exit/status/output + loop each approved plan step, in order + alt filesystem or validation step + Strategy->>FS: declared operation and paths/checks + else command step + Strategy->>ExternalCLI: declared executable + fixed args + ExternalCLI-->>Strategy: exit/status/output + end + end Strategy-->>Coordinator: sanitized UpdateResult end Coordinator-->>CLI: aggregate summary @@ -371,6 +513,27 @@ sequenceDiagram CLI self-update is planned first, but the running process does not dynamically import the newly installed package. Remaining already-planned harness strategies execute from the current process. The user must invoke the CLI again to use new CLI code. +### Codex replacement transaction + +```mermaid +sequenceDiagram + participant Updater + participant FS + participant Codex + + Updater->>Codex: marketplace upgrade detected marketplace + Codex-->>Updater: refreshed snapshot or failure + Updater->>FS: snapshot plugin registration, enablement, and cached payload + Updater->>Codex: plugin remove detected plugin ID + Updater->>Codex: plugin add detected plugin ID + alt add and local-version validation succeed + Updater->>FS: restore prior enablement/user-owned fields and remove backup + else remove, add, or validation fails + Updater->>FS: restore prior registration and cached payload + Updater-->>Updater: return failed + rollback status + end +``` + ### Antigravity replacement transaction ```mermaid @@ -379,8 +542,8 @@ sequenceDiagram participant FS participant AGY - Updater->>FS: locate known staged N|Solid root - Updater->>FS: snapshot staged root and N|Solid import entry + Updater->>FS: locate one supported staged-root/manifest pair + Updater->>FS: snapshot detected staged root and matching import entry Updater->>AGY: uninstall nsolid-plugin Updater->>AGY: install GitHub root alt install and validation succeed @@ -421,10 +584,14 @@ sequenceDiagram - Missing executables use a distinct error code from command failure. - Process output is bounded before being retained in results. - Existing logger redaction is applied to verbose diagnostics. -- `--all` catches errors at the installation boundary and continues with independent installation records, including native and fallback records for the same harness. +- `--all` catches version-lookup, planning, and execution errors at the installation boundary and continues with independent installation records, including native and fallback records for the same harness. - Confirmation is mandatory for mutable non-interactive operations unless `--yes` is present. -- Marketplace IDs are validated before becoming arguments; local, pinned, and ambiguous Pi sources are never silently replaced. -- Antigravity backup paths are created with restrictive permissions in an OS temporary directory and always cleaned after success. Rollback validates both the staged root and the saved import-manifest registration. +- Marketplace IDs and Claude scopes are validated before becoming arguments; local, pinned, conflicting, and ambiguous Pi sources are never silently replaced. +- CLI and fallback package execution uses the exact immutable version from the plan; mutable dist-tags are not passed during mutation, and a package-manager success without matching on-disk version evidence is a failure. +- Pi user-only updates pass `--no-approve`; `--approve` is used only when the immutable plan identifies and displays a project-scoped canonical package. Canonical entries across both scopes are updated once, while conflicting/pinned entries block automatic mutation. +- Codex removes the installed plugin only after marketplace refresh and backup succeed. Rollback validates the restored registration and cached payload while preserving unrelated `config.toml` entries. +- Antigravity accepts only one unambiguous documented layout pair. Backup paths are created with restrictive permissions in an OS temporary directory and always cleaned after success. Rollback validates both the staged root and its matching saved import-manifest registration. +- OpenCode/fallback replacement mutates only paths and MCP entries proven to be owned by tracking. The transaction restores overwritten and stale-removed skill directories, config, and tracking together after any failed child execution or validation. - Update does not invoke setup, login, or auth modules. - Release scripts snapshot only an explicit allowlist; rollback never performs broad Git or recursive workspace resets. @@ -436,7 +603,7 @@ This is an additive migration. 2. Add inventory and target strategies behind programmatic APIs. 3. Add CLI parsing/formatting and integration tests. 4. Add release preparation/check scripts and fixture tests. -5. Add optional fallback tracking version while preserving reads of legacy tracking files. +5. Extend fallback tracking with optional bundle version and path-level ownership, preserving reads of legacy tracking files, then add transactional direct-install reconciliation behind the package-internal update entrypoint without changing public install semantics. 6. Update README/package documentation. 7. Ship the feature in a new minor CLI release because it adds public commands; existing `1.0.x` install/setup behavior remains compatible. diff --git a/openspec/changes/add-update-flow/proposal.md b/openspec/changes/add-update-flow/proposal.md index 65a4dfd..01c28cc 100644 --- a/openspec/changes/add-update-flow/proposal.md +++ b/openspec/changes/add-update-flow/proposal.md @@ -21,12 +21,13 @@ For users: - add `nsolid-plugin version` (with bare `nsolid-plugin --version` as an alias) and `nsolid-plugin update`; - make plain `update` target the npm CLI, `--harness ` target every detected installation for one harness, and `--all` target the CLI plus every detected N|Solid installation; - add `--check` for a read-only status check and retain `--json`, `--yes`, `--verbose`, and `--no-color` behavior where applicable; +- update a positively identified npm- or pnpm-owned global CLI with the exact semantic version resolved during planning, then verify the installed package on disk instead of trusting only the package-manager exit code; - delegate native updates to the owning harness: - - Claude: refresh/update the detected `nsolid-plugin@` identity; - - Codex: upgrade the detected Git marketplace and refresh the installed version; - - Antigravity: safely reinstall the GitHub-root plugin with staged-root and import-manifest backup/rollback; - - Pi: update the canonical `npm:nsolid-pi-plugin` source, while rejecting local, pinned, Git, or ambiguous sources; - - OpenCode, which has no native package owner, and other fallback installations: reinstall from the latest published CLI bundle; + - Claude: update the detected `nsolid-plugin@` identity at its detected installation scope and resolve version evidence only from that marketplace's carried source metadata; + - Codex: refresh the detected Git marketplace snapshot, then transactionally remove and add the same detected plugin identity because marketplace refresh does not update the installed copy; version checks never substitute a canonical marketplace for the detected source; + - Antigravity: safely reinstall the GitHub-root plugin with backup/rollback of the detected AGY or shared Antigravity staged-root/import-manifest pair; + - Pi: update the canonical unpinned `npm:nsolid-pi-plugin` identity once across its detected user/project scopes, while rejecting local, pinned, Git, conflicting, or ambiguous sources; + - OpenCode, whose N|Solid installation is direct rather than an OpenCode plugin, and other tracked fallback installations: invoke an internal exact-package refresh binary to transactionally reconcile tracked assets, including removal of obsolete NodeSource-owned assets, without changing public `install` semantics; - preserve credentials and non-NodeSource configuration throughout updates; - isolate failures during `--all` so one harness failure does not prevent remaining updates, while returning a failing exit status and a per-target summary. @@ -38,10 +39,13 @@ For maintainers: ## Rollback Plan -- The CLI update path records the previously installed CLI version and prints the exact package-manager command needed to restore it. +- The CLI update path records the previously installed CLI version, pins both update and rollback commands to exact semantic versions, verifies the resulting global package root, and prints the exact package-manager command needed to restore it. - A CLI newer than the registry is reported and left unchanged; this proposal has no implicit downgrade path. -- Native harness updates rely on the harness owner’s cache where available. Antigravity creates a temporary backup of the staged NodeSource plugin and its import-manifest registration and restores both if reinstall fails. -- Fallback installers continue using their existing config backups and idempotent merge behavior. +- Claude delegates to its native in-place update command while preserving the detected plugin ID and installation scope. +- Codex snapshots the exact plugin registration, enablement, and cached payload before the documented marketplace-refresh plus remove/add sequence, and restores that snapshot if reinstall or validation fails. +- Antigravity creates a temporary backup of the detected staged NodeSource plugin and its matching import-manifest registration and restores both if reinstall fails. +- Pi delegates package replacement to `pi update` without rewriting its settings entries, package filters, MCP configuration, or credentials. +- OpenCode/fallback refresh snapshots every tracked NodeSource-owned skill path, affected MCP entries/config file, and tracking state before replacement; it restores them if exact-version execution, stale-asset reconciliation, or validation fails. - Update operations never delete shared NodeSource credentials. - The feature can be reverted by removing the new command/service modules and scripts; existing `setup`, `install`, `doctor`, `restore`, and `uninstall` contracts remain unchanged. - A bad release can be rolled back by republishing or reinstalling the prior known-good package/plugin version and restoring generated manifests from the corresponding Git tag. @@ -50,13 +54,16 @@ For maintainers: - `packages/core/src/cli.ts` — new commands and update flags. - `packages/core/src/index.ts` — public update/version API surface. -- `packages/core/src/update/` — update planning, version comparison, command execution, result contracts, and package-manager detection. +- `packages/core/src/update/` — update planning, version comparison, command execution, result contracts, package-manager detection, and the package-internal owned-asset refresh entrypoint. - `packages/core/src/harnesses/` — harness-owned update strategies and native/fallback installation detection. -- `~/.gemini/config/import_manifest.json` — Antigravity plugin registration included in the transactional backup/rollback contract. -- `packages/core/src/index.ts` (the `doctor` function) and formatting utilities — optional update availability in health/status output. +- `~/.pi/agent/settings.json`, a detected current-project `.pi/settings.json`, and their corresponding Pi package caches — source/scope evidence for package-owned updates; project access is disclosed/approved and settings remain unchanged. +- `~/.config/opencode/skills/`, `~/.config/opencode/opencode.jsonc`, and fallback tracking/backups — transactional direct-install reconciliation for OpenCode. +- `~/.codex/config.toml` and the detected Codex plugin cache — exact plugin registration/enablement and prior payload included in transactional reinstall rollback. +- `~/.gemini/config/{plugins,import_manifest.json}` and `~/.gemini/antigravity-cli/{plugins,import_manifest.json}` — supported Antigravity staged-root/registration pairs included in transactional backup/rollback. +- Update formatting utilities — plan, progress, summary, and sanitized machine-readable output for the new commands; existing `doctor` behavior remains unchanged. - `packages/core/test/unit/update/` — version, planning, detection, safety, and output tests. - `packages/core/test/integration/` — mocked CLI/harness update flows, partial failures, rollback, and exit codes. -- `bundle.json`, `packages/core/package.json`, `packages/pi-plugin/package.json` — coordinated release version. +- `bundle.json`, `packages/core/package.json`, `packages/pi-plugin/package.json` — coordinated release version and registration of the internal fallback-refresh binary. - `scripts/` and root `package.json` — release preparation and drift checks. - `.claude-plugin/marketplace.json`, `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, and `packages/core/bundle.json` — generated version-bearing outputs. - `README.md` and package READMEs — user and maintainer update instructions. @@ -67,6 +74,7 @@ For maintainers: - `nsolid-plugin version` and its bare `--version` alias report the running CLI and bundled plugin versions without network access; the command form also supports JSON output. - `nsolid-plugin update --check` performs no writes or subprocess mutations and clearly reports whether the CLI is current. - Each supported harness has a deterministic update strategy with actionable output when its CLI is unavailable, its installation type is unsupported, or its source identity cannot be safely reused. +- Claude and Codex version checks use only the source metadata carried by their detected marketplace; missing, stale, or unsupported evidence reports `unknown` instead of reading the NodeSource marketplace. - `nsolid-plugin update --all` updates all detected targets, preserves credentials and user-owned configuration, summarizes every target, and exits non-zero on any failure. - Native and fallback installations detected for the same harness are represented and updated as separate targets; one ownership never silently replaces the other. - Interactive destructive/replacement steps require confirmation; `--yes` enables non-interactive automation. @@ -77,10 +85,11 @@ For maintainers: Acceptance tests: -1. Mock npm reporting a newer, equal, and older-than-registry CLI version and verify check-only, confirmed update, no-downgrade behavior, declined update, and rollback guidance. -2. Mock current and newer Claude/Codex plugin versions, including alternate marketplace IDs, and verify the detected source commands and restart guidance. -3. Simulate an Antigravity reinstall failure and verify restoration of both the prior staged plugin and its import-manifest registration. -4. Mock canonical and non-canonical Pi sources plus an OpenCode fallback refresh from the latest CLI bundle. -5. Run `--all` with coexisting native/fallback installations and one failed target; verify every installation is represented, later targets still run, credentials remain untouched, and the final exit code is non-zero. -6. Prepare a patch version in a fixture repository and verify all version-bearing files become equal while no publish/tag/push command executes. -7. Introduce version drift in each controlled file and verify the release check identifies the exact mismatch. +1. Mock npm reporting a newer, equal, and older-than-registry CLI version and verify check-only, exact-version npm/pnpm commands, on-disk post-install validation, no-downgrade behavior, declined update, unsupported wrappers, and exact rollback guidance. +2. Mock current and newer Claude/Codex plugin versions, including alternate marketplace IDs, source repositories, stale local snapshots, and Claude installation scopes; verify exact-source version resolution, no canonical-source substitution, Claude’s scoped native update, and Codex’s marketplace-refresh plus transactional remove/add flow. +3. Simulate Codex and Antigravity reinstall failures and verify restoration of the prior plugin registration, enablement, cached/staged payload, and matching manifest state. +4. Mock user-only, project-only, and combined canonical Pi scopes plus pinned/conflicting sources; verify one scope-aware native update command and unchanged settings. +5. Refresh a tracked OpenCode installation through the internal exact-version npm and pnpm refresh binary; verify atomic skill replacement, stale tracked-skill removal, MCP merge, tracking update, rollback, and unchanged public `install` behavior without modifying untracked/user-owned artifacts. +6. Run `--all` with coexisting native/fallback installations and one failed target or version lookup; verify every installation is represented, later independent targets still run, credentials remain untouched, and the final exit code is non-zero. +7. Prepare a patch version in a fixture repository and verify all version-bearing files become equal while no publish/tag/push command executes. +8. Introduce version drift in each controlled file and verify the release check identifies the exact mismatch. diff --git a/openspec/changes/add-update-flow/specs/update-flow/spec.md b/openspec/changes/add-update-flow/specs/update-flow/spec.md index 91b2b0c..064013d 100644 --- a/openspec/changes/add-update-flow/specs/update-flow/spec.md +++ b/openspec/changes/add-update-flow/specs/update-flow/spec.md @@ -57,12 +57,14 @@ The updater SHALL compare installed and latest versions without invoking any mut #### Scenario: Registry lookup fails -**Given** npm is unreachable, times out, returns invalid data, or returns a non-semantic version +**Given** the required npm, fixed native-Git, or exact carried marketplace version source for one target is unreachable, times out, returns invalid data, or returns a non-semantic version **When** the user checks or performs an update -**Then** the command reports the registry failure without exposing response bodies containing credentials -**And** performs no update -**And** exits non-zero -**And** preserves the current installation +**Then** the command reports a sanitized lookup failure for the affected target without exposing response bodies, repository credentials, or credential paths +**And** represents the affected installation in the ordered plan with a sanitized planning error and no mutation steps +**And** performs no mutation for the affected target +**And** an `--all` invocation continues planning or executing remaining independent targets and records their results +**And** the overall invocation exits non-zero +**And** preserves every installation whose lookup failed ### Requirement: Safe CLI self-update @@ -75,10 +77,20 @@ The default `nsolid-plugin update` scope SHALL update only a positively identifi **When** the user runs `nsolid-plugin update` **Then** the command displays the current version, target version, package manager, and exact planned operation **And** asks for confirmation in an interactive terminal -**And** after confirmation invokes the detected package manager with a fixed argument array to install `nsolid-plugin@` -**And** verifies the child process succeeded +**And** freezes the resolved semantic version in the plan rather than passing the mutable `latest` tag during execution +**And** after confirmation invokes `npm install --global nsolid-plugin@` or `pnpm add --global nsolid-plugin@` with a fixed argument array +**And** verifies both that the child process succeeded and that the positively identified global package root contains `nsolid-plugin` at the resolved version **And** reports that a new shell or command invocation may be required -**And** prints the exact command for restoring the previous version +**And** prints the same package manager's exact command for restoring `nsolid-plugin@` + +#### Scenario: Package manager exits successfully without installing the planned CLI + +**Given** an exact CLI update was approved +**When** the package-manager process exits successfully but the identified global package root is missing, belongs to a different package, or reports a version other than the planned version +**Then** the update result is `failed` +**And** the command does not report the CLI as updated +**And** prints the exact previous-version restore command +**And** exits non-zero #### Scenario: User declines a CLI update @@ -108,11 +120,11 @@ The default `nsolid-plugin update` scope SHALL update only a positively identifi #### Scenario: Unsupported CLI installation source -**Given** the running CLI was launched from a workspace, local path, `npx`, or an installation source that cannot be safely identified +**Given** the running CLI was launched from a workspace, local path, `npx`, Volta, Yarn, Bun, or an installation source/global root that cannot be safely identified as npm or pnpm owned **When** the user runs `nsolid-plugin update` **Then** the command does not guess a package manager or modify the installation **And** reports the latest version when it can be resolved -**And** prints safe manual commands for npm, pnpm, and `npx -y nsolid-plugin@latest` +**And** prints safe exact-version manual commands for npm, pnpm, ephemeral execution, and the detected wrapper/source when known **And** the result status is `unsupported` **And** a mutating update exits non-zero while a read-only check exits successfully @@ -134,17 +146,21 @@ The updater SHALL preserve native/package ownership and delegate each supported **Given** Claude or Codex records `nsolid-plugin@` under a marketplace other than `nodesource` **When** the corresponding native update strategy runs -**Then** Claude uses the detected complete plugin ID -**And** Codex upgrades the detected marketplace +**Then** Claude uses the detected complete plugin ID and installation scope +**And** Codex refreshes the detected marketplace and reinstalls the detected complete plugin ID +**And** inventory carries that marketplace's exact repository/ref and relative manifest path, or its exact local snapshot path and freshness evidence, for version resolution +**And** latest-version lookup reads only that carried source +**And** missing, stale, ambiguous, traversal-capable, or unsupported version-source evidence reports `unknown` or `unsupported` without querying the NodeSource marketplace **And** the strategy never substitutes `nodesource` -**And** an unqualified, malformed, or ambiguous ID returns `unsupported` without mutation +**And** an unqualified, malformed, or ambiguous ID, or a Claude installation with unknown scope, returns `unsupported` without mutation #### Scenario: Update Claude native plugin -**Given** `nsolid-plugin@` is installed natively in Claude +**Given** `nsolid-plugin@` is installed natively in Claude at a detected `user`, `project`, `local`, or `managed` scope **And** the `claude` executable is available **When** the Claude update strategy runs -**Then** it invokes `claude plugin update nsolid-plugin@` with a fixed executable and argument array +**Then** it invokes `claude plugin update nsolid-plugin@ --scope ` with a fixed executable and argument array +**And** verifies the native update command succeeded **And** reports `/reload-plugins` or restart guidance **And** does not run the fallback installer @@ -155,37 +171,108 @@ The updater SHALL preserve native/package ownership and delegate each supported **When** the Codex update strategy runs **Then** it invokes `codex plugin marketplace upgrade ` **And** verifies the marketplace refresh succeeded +**And** treats that command only as a marketplace snapshot refresh, not as an installed-plugin update +**And** creates a restrictive temporary backup of the exact plugin registration, enabled state, user-owned plugin fields, and cached installed payload +**And** confirms replacement unless `--yes` was supplied +**And** invokes `codex plugin remove nsolid-plugin@` followed by `codex plugin add nsolid-plugin@` with fixed argument arrays +**And** verifies the resulting local version/content matches the refreshed marketplace entry +**And** reapplies the prior enabled state and preserves unrelated Codex configuration **And** reports that a new Codex session is required -**And** does not remove the installed plugin or configuration +**And** does not run the fallback installer + +#### Scenario: Codex reinstall fails + +**Given** the prior Codex plugin registration and cached payload were backed up +**When** removal succeeds but add or installed-version validation fails +**Then** the updater restores the prior plugin registration, enabled state, user-owned fields, and cached payload +**And** preserves unrelated `~/.codex/config.toml` entries +**And** reports whether rollback succeeded +**And** exits non-zero +**And** provides the exact detected plugin remove/add commands for manual recovery #### Scenario: Update Pi package-owned skills -**Given** the canonical `npm:nsolid-pi-plugin` source is installed in Pi +**Given** the exact unpinned `npm:nsolid-pi-plugin` source is installed in Pi user settings, current-project settings, or both **And** the `pi` executable is available **When** the Pi update strategy runs -**Then** it invokes `pi update npm:nsolid-pi-plugin` +**Then** inventory coalesces every canonical matching scope into one Pi update target +**And** the plan displays whether user and/or project package caches will be updated and displays the project root when applicable +**And** a user-only target invokes `pi update npm:nsolid-pi-plugin --no-approve` +**And** a target containing the detected project scope invokes `pi update npm:nsolid-pi-plugin --approve` only after the plan is approved +**And** verifies every affected package cache contains `nsolid-pi-plugin` at a valid version no older than the registry version observed during planning +**And** reports the actual installed version, accepting a newer version published while Pi's native unpinned update was running **And** does not copy Pi skills into user-level skill directories **And** reports `/reload` or restart guidance -**And** leaves Pi MCP configuration and NodeSource credentials intact +**And** leaves Pi source entries, object-form package filters, trust settings, MCP configuration, and NodeSource credentials intact + +#### Scenario: Same canonical Pi identity exists in both scopes + +**Given** exact unpinned `npm:nsolid-pi-plugin` entries exist in both user and current-project settings +**When** the Pi update strategy plans and executes the update +**Then** the plan contains one package-owned Pi target with both scopes +**And** invokes the Pi update command exactly once +**And** does not report one duplicate result per scope #### Scenario: Reject a non-canonical Pi source -**Given** Pi detects a local, Git, version-pinned, or ambiguous source for `nsolid-pi-plugin` +**Given** Pi detects a local, Git, version-pinned, conflicting, or ambiguous source/entry for `nsolid-pi-plugin` in any matching user or current-project scope **When** the user runs a Pi update **Then** the result status is `unsupported` **And** no package source is substituted +**And** a canonical entry in another scope is not partially updated while the conflicting entry remains effective **And** no Pi package or configuration is mutated **And** the output provides manual guidance #### Scenario: Update OpenCode or another fallback installation -**Given** the target is OpenCode, which has no native plugin/package update owner, or another target uses the N|Solid fallback/direct installer +**Given** N|Solid is tracked as a direct OpenCode installation, rather than as an OpenCode npm/local plugin, or another target uses the tracked N|Solid fallback installer **When** its update strategy runs -**Then** it resolves the latest published `nsolid-plugin` CLI bundle -**And** reruns the latest fallback installer only for that harness -**And** reuses existing idempotent skill and MCP merge behavior -**And** preserves non-NodeSource artifacts and valid credentials -**And** creates the normal configuration backup before config mutation +**Then** it resolves and freezes the exact stable `nsolid-plugin` registry version in the plan +**And** requires an available supported package executor +**And** snapshots the target's tracked NodeSource-owned skill directories, affected MCP configuration, and complete tracking state before replacement +**And** invokes either `npm exec --yes --package=nsolid-plugin@ -- nsolid-plugin-refresh-owned --harness ` or `pnpm --package=nsolid-plugin@ dlx nsolid-plugin-refresh-owned --harness ` with fixed argument arrays +**And** runs the package executor from a restrictive temporary working directory where a workspace-local `nsolid-plugin` binary cannot shadow the resolved payload +**And** the internal refresh binary refuses absent, ambiguous, or untracked ownership and does not broaden the planned harness +**And** does not invoke `opencode plugin` +**And** completely replaces tracked skill directories, removes previously tracked skills absent from the new bundle, and merges only the new bundle's NodeSource MCP entries +**And** preserves untracked/user-owned skill paths, unrelated MCP entries, other configuration, and valid credentials +**And** validates installed skills, MCP entries, tracking paths, and `bundleVersion` before deleting the backup + +#### Scenario: No supported exact-package executor is available + +**Given** a tracked direct/fallback installation is updateable but neither `npm exec` nor `pnpm dlx` is available +**When** its update strategy is planned +**Then** the target is `unsupported` +**And** no backup, child process, or filesystem mutation runs +**And** the output provides the exact planned package version and manual commands + +#### Scenario: Direct/fallback refresh cannot prove ownership + +**Given** N|Solid-like skills or MCP entries exist but sufficient per-harness tracking ownership is absent or ambiguous +**When** a direct/fallback update is planned +**Then** the target is `unsupported` +**And** no path is selected from an `ns-` prefix or name-only guess +**And** no package executor or filesystem mutation runs +**And** the output provides repair/reinstall guidance + +#### Scenario: New fallback bundle collides with an untracked destination + +**Given** the exact new bundle contains a skill whose target path already exists but is not owned by the target's fallback tracking +**When** the child installer performs its preflight +**Then** the refresh fails before overwriting that path +**And** the untracked path remains byte-for-byte unchanged +**And** the surrounding transaction restores any earlier mutation from the same refresh +**And** the output identifies the conflicting destination without exposing its contents + +#### Scenario: OpenCode or fallback refresh fails + +**Given** a tracked direct/fallback installation was backed up +**When** exact-package execution, skill reconciliation, MCP merge, tracking update, or post-install validation fails +**Then** the updater restores the prior tracked skill directories, affected configuration, and tracking state +**And** restores stale tracked assets removed during reconciliation +**And** preserves unrelated OpenCode/fallback artifacts +**And** reports whether rollback succeeded +**And** exits non-zero #### Scenario: Update coexisting native and fallback installations @@ -201,6 +288,7 @@ The updater SHALL preserve native/package ownership and delegate each supported **Given** neither a native nor fallback N|Solid installation is detected for the requested harness **When** the user runs `nsolid-plugin update --harness ` **Then** no install is performed implicitly +**And** the coordinator emits one non-mutating item with `ownership: none` and `source.kind: none` for the requested harness **And** the target result is `not-installed` **And** the command prints appropriate installation guidance @@ -219,22 +307,31 @@ The Antigravity strategy SHALL back up and validate the staged NodeSource plugin #### Scenario: Update Antigravity native plugin -**Given** the GitHub-root N|Solid plugin is staged by Antigravity +**Given** the GitHub-root N|Solid plugin is staged in exactly one supported layout **And** the `agy` executable is available **When** the Antigravity update strategy runs -**Then** it creates a temporary backup of the existing staged NodeSource plugin +**Then** the layout is either `~/.gemini/config/plugins/nsolid-plugin` with `~/.gemini/config/import_manifest.json` or `~/.gemini/antigravity-cli/plugins/nsolid-plugin` with `~/.gemini/antigravity-cli/import_manifest.json` +**And** it creates a temporary backup of the detected staged NodeSource plugin and matching N|Solid import-manifest entry **And** confirms replacement unless `--yes` was supplied -**And** invokes the supported uninstall/install sequence for `https://github.com/NodeSource/nsolid-plugin.git` -**And** validates `plugin.json`, `bundle.json`, canonical skill presence, and the N|Solid entry in `~/.gemini/config/import_manifest.json` +**And** invokes `agy plugin uninstall nsolid-plugin` followed by `agy plugin install https://github.com/NodeSource/nsolid-plugin.git` +**And** validates `plugin.json`, `bundle.json`, canonical skill presence, and the N|Solid entry in the detected matching import manifest **And** removes the backup only after the new staged plugin and registration validate **And** preserves `~/.agents/.nodesource-auth.json` +#### Scenario: Antigravity layout is ambiguous or unsupported + +**Given** both supported staged layouts are present, or the detected plugin root has no matching supported manifest location +**When** the Antigravity update strategy plans an update +**Then** the result status is `unsupported` +**And** no AGY command or filesystem mutation runs +**And** the output identifies the conflicting or unsupported paths + #### Scenario: Antigravity reinstall fails **Given** the previous Antigravity plugin was backed up **When** uninstall succeeds but reinstall or validation fails **Then** the updater restores the previous staged plugin atomically where supported -**And** restores the previous N|Solid import-manifest entry while preserving unrelated imports +**And** restores the previous N|Solid entry in the matching detected import manifest while preserving unrelated imports **And** reports whether rollback succeeded **And** exits non-zero **And** provides a manual reinstall command @@ -248,8 +345,10 @@ The updater SHALL plan targets before mutation, execute them sequentially in det **Given** one or more N|Solid CLI or harness installations are detected **When** the user runs `nsolid-plugin update --all` **Then** the updater displays one ordered plan containing every detected installation +**And** each plan item lists every ordered external command, filesystem mutation, validation, and rollback step before confirmation **And** updates the CLI target first when supported **And** updates detected installation targets sequentially in deterministic harness and ownership order +**And** execution introduces no external command absent from the approved plan **And** records a result for every planned installation **And** prints counts for every `UpdateStatus`, including `newer-than-registry`, `unsupported`, and `unknown` @@ -295,3 +394,11 @@ Update operations SHALL retain all existing setup, installation, authentication, **And** native strategy failure never silently switches to fallback ownership **And** source identity is preserved for every supported native/package-owned update **And** all external commands run without a shell and with fixed argument arrays + +#### Scenario: Preserve the public install contract + +**Given** a tracked fallback installation needs transactional stale-asset reconciliation during update +**When** the updater executes the exact published package +**Then** it uses the package-internal `nsolid-plugin-refresh-owned` entrypoint +**And** the public `nsolid-plugin install` command and programmatic `install()` API retain their existing copy, merge, tracking, collision, and repeat-install behavior +**And** invoking `install` outside an update does not remove stale tracked assets under the new update-only reconciliation rules diff --git a/openspec/changes/add-update-flow/tasks.md b/openspec/changes/add-update-flow/tasks.md index 56a7d48..7f2220a 100644 --- a/openspec/changes/add-update-flow/tasks.md +++ b/openspec/changes/add-update-flow/tasks.md @@ -2,74 +2,73 @@ ## Task 1: Define update contracts and semantic-version behavior -- [ ] **Description**: Add the pure update target, ownership, source, installation, status, plan, result, summary, command, confirmation, context, and strategy types defined in the design. Include `newer-than-registry`, `unsupported`, `latestVersion`, `installationId`, and structured rollback status. Implement strict stable semantic-version parsing/comparison without adding a runtime dependency. +- [ ] **Description**: Add the pure running-version, update target, ownership (including the explicit no-installation sentinel), marketplace-version-source, discriminated Pi location, installation, status, ordered execute/rollback plan-step, sanitized planning-error, result, summary, command, confirmation, context, and strategy types defined in the design. Include `cliVersion`, `bundleVersion`, `newer-than-registry`, `unsupported`, `latestVersion`, `installationId`, structured rollback status, and optional controlled command environment additions. Implement strict stable semantic-version parsing/comparison without adding a runtime dependency. - **Depends on**: None - **Files**: `packages/core/src/update/types.ts`, `packages/core/src/update/version.ts`, `packages/core/test/unit/update/version.test.ts` -- **Testing**: Cover valid versions, invalid registry values, equal/newer/older comparisons, and deterministic result/count shapes. References: Update Flow “Report running versions” and “Check whether the CLI is current.” +- **Testing**: Cover valid versions, invalid registry values, equal/newer/older comparisons, type-level Pi project-root requirements, multi-step execute/rollback plans, and deterministic result/count shapes. References: Update Flow “Report running versions,” “Check whether the CLI is current,” and “Update every detected target.” ## Task 2: Add safe command execution and version sources -- [ ] **Description**: Implement the injected shell-free command runner, bounded/sanitized output, executable lookup, npm registry client, and GitHub-root bundle version source with explicit timeouts and validation. +- [ ] **Description**: Implement the injected shell-free command runner, bounded/sanitized output, executable lookup, npm registry client, exact-version npm-exec/pnpm-dlx package executors, exact carried Claude/Codex marketplace version sources, and the fixed canonical GitHub-root Antigravity bundle source with explicit timeouts and validation. Never substitute the NodeSource marketplace for alternate, missing, stale, or unsupported marketplace evidence. - **Depends on**: Task 1 - **Files**: `packages/core/src/update/command-runner.ts`, `packages/core/src/update/version-source.ts`, `packages/core/test/unit/update/command-runner.test.ts`, `packages/core/test/unit/update/version-source.test.ts` -- **Testing**: Mock success, timeout, missing executable, invalid JSON/version, non-zero exit, oversized output, and secret-bearing diagnostics. Verify all subprocess calls use `shell: false` and argument arrays. References: Update Flow “Registry lookup fails,” “Required harness executable is missing,” and “Preserve credentials and user-owned configuration.” +- **Testing**: Mock success, timeout, missing executable, invalid JSON/version, non-zero exit, oversized output, alternate Git repository/ref/manifest sources, fresh and stale local snapshots, traversal-capable paths, and secret-bearing diagnostics. Verify all subprocess calls use `shell: false` and argument arrays, source credentials are redacted, and no canonical-source substitution occurs. References: Update Flow “Registry lookup fails,” “Preserve each detected native source identity,” “Required harness executable is missing,” and “Preserve credentials and user-owned configuration.” ## Task 3: Detect CLI installation ownership -- [ ] **Description**: Read the running package/bundle versions and detect npm or pnpm global ownership only from positive evidence. Return `unsupported` for workspace, local, `npx`, or ambiguous execution, and preserve the detected source evidence on every installation record. +- [ ] **Description**: Read the running package/bundle versions and detect npm or pnpm global ownership only when the real package/entrypoint is contained by that manager's reported global root. Return `unsupported` for workspace, local, `npx`, Volta, Yarn, Bun, mismatched-root, or ambiguous execution, and preserve the detected source evidence on every installation record. - **Depends on**: Tasks 1–2 - **Files**: `packages/core/src/update/package-manager.ts`, `packages/core/src/update/inventory.ts`, `packages/core/test/unit/update/package-manager.test.ts`, `packages/core/test/unit/update/inventory.test.ts` -- **Testing**: Use fixture paths/environments for npm, pnpm, `npx`, workspace, broken symlink, and ambiguous launchers. Verify unsupported sources produce guidance without mutation. References: Update Flow “Unsupported CLI installation source.” +- **Testing**: Use fixture paths/environments for npm, pnpm, `npx`, Volta, Yarn, Bun, workspace, broken symlink, manager-reported root mismatch, and ambiguous launchers. Verify unsupported sources produce exact-version guidance without mutation. References: Update Flow “Unsupported CLI installation source.” ## Task 4: Implement CLI package update strategy -- [ ] **Description**: Implement CLI check/update planning, confirmation metadata, npm/pnpm command generation, post-command reporting, exact previous-version rollback guidance, and a no-mutation `newer-than-registry` outcome without implicit downgrade support. +- [ ] **Description**: Implement CLI check/update planning that freezes the resolved semantic version, confirmation metadata, exact-version npm/pnpm command generation, on-disk global package/version verification, exact previous-version rollback guidance, and a no-mutation `newer-than-registry` outcome without implicit downgrade support. - **Depends on**: Tasks 1–3 - **Files**: `packages/core/src/update/strategies/cli-package.ts`, `packages/core/test/unit/update/cli-package.test.ts` -- **Testing**: Cover current, update available, newer-than-registry, declined, `--yes`, unsupported source, failed package manager, and rollback command. References: all CLI-specific scenarios in Update Flow. +- **Testing**: Cover current, update available, immutable planned version despite a changed dist-tag, newer-than-registry, declined, `--yes`, unsupported source, failed package manager, successful child with missing/wrong on-disk package version, and exact rollback command. References: all CLI-specific scenarios in Update Flow. ## Task 5: Extend harness inventory and version evidence -- [ ] **Description**: Reuse native detection and fallback tracking to emit one installation record per native, fallback, or package-owned installation. Carry validated Claude/Codex plugin IDs and marketplaces, canonical Pi source evidence, optional installed version/staged root evidence, and backward-compatible `bundleVersion` tracking for fallback installs. Do not collapse native and fallback records for one harness. +- [ ] **Description**: Reuse native detection and fallback tracking to emit one installation record per native, fallback, or package-owned installation. Carry validated Claude plugin ID/marketplace/scope and Codex plugin ID/marketplace together with each registration's exact sanitized repository/ref/relative-manifest source or local-snapshot/freshness evidence; carry the effective Pi source in a discriminated user/project location that requires the project root whenever project scope is present; and carry the detected Antigravity staged-root/matching-manifest layout. Require path-level ownership evidence for direct/fallback updates, retain optional installed-version evidence, and add backward-compatible `bundleVersion` tracking. Do not collapse native and fallback records for one harness. - **Depends on**: Tasks 1–3 - **Files**: `packages/core/src/update/inventory.ts`, `packages/core/src/harnesses/*.ts`, `packages/core/src/skills/skill-tracker.ts`, related harness/tracker tests -- **Testing**: Cover native, fallback, package-owned, coexisting native+fallback, alternate marketplace IDs, canonical and unsupported Pi sources, missing/corrupt metadata, legacy tracking without `bundleVersion`, and version-unknown states for every harness. References: Update Flow “Requested harness is not installed,” “Check every detected target,” “Update one installed native harness,” “Preserve each detected native source identity,” and “Preserve credentials and user-owned configuration.” +- **Testing**: Cover native, fallback, package-owned, coexisting native+fallback, alternate marketplace IDs and repositories, every Claude installation scope, unknown scope, missing/ambiguous/stale marketplace evidence without canonical fallback, Pi user-only/project-only/both scopes with required project roots, object-form filters, pinned/conflicting Pi entries, both Antigravity layout pairs, ambiguous layouts, missing/corrupt metadata, direct artifacts without ownership, legacy tracking without `bundleVersion`, and version-unknown states for every harness. References: Update Flow “Requested harness is not installed,” “Check every detected target,” “Update one installed native harness,” “Preserve each detected native source identity,” and “Preserve credentials and user-owned configuration.” ## Task 6: Implement Claude and Codex native strategies -- [ ] **Blocking verification**: Before implementing `codex.ts`, use a disposable real Codex installation to verify whether `codex plugin marketplace upgrade nodesource` refreshes the version and content of an already-installed plugin rather than only marketplace metadata. Record the tested versions and command/output evidence. If it does not refresh the installed copy, stop and amend the design, Update Flow specification, and this task to use the documented plugin remove/add lifecycle with configuration-preservation coverage. -- [ ] **Description**: Add strategies that generate and execute commands using the validated detected Claude plugin ID and Codex marketplace, retain native ownership, reject incomplete or ambiguous IDs, and return restart/reload guidance. +- [ ] **Description**: Add a Claude strategy that invokes the native update command with the validated detected plugin ID and installation scope. Add a transactional Codex strategy that refreshes the detected marketplace snapshot, snapshots the exact plugin registration/enablement/user-owned fields and cached payload, removes and adds the same detected plugin ID, validates local versus refreshed version/content, and restores the snapshot on failure. Retain native ownership, reject incomplete/ambiguous identities, and return restart/reload guidance. - **Depends on**: Tasks 2 and 5 -- **Files**: `packages/core/src/update/strategies/claude.ts`, `packages/core/src/update/strategies/codex.ts`, corresponding unit tests -- **Testing**: Mock successful refresh, already-current and newer-than-registry output, command failure, missing executable, alternate detected plugin IDs/marketplace names, malformed/ambiguous IDs, and verify no fallback/auth call. Keep implementation blocked until the real Codex marketplace-refresh spike has evidence. References: Update Flow “Preserve each detected native source identity,” “Update Claude native plugin,” and “Update Codex native plugin.” +- **Files**: `packages/core/src/update/strategies/claude.ts`, `packages/core/src/update/strategies/codex.ts`, `packages/core/src/update/codex-transaction.ts`, corresponding unit/integration tests +- **Testing**: Cover Claude user/project/local/managed scopes and verify the exact `--scope` argument. For Codex, cover marketplace-refresh failure before mutation, backup failure, remove failure, add failure, local/remote version mismatch, rollback success/failure, prior enabled/disabled state, preserved unrelated config/cache entries, alternate detected plugin IDs/marketplace names, malformed/ambiguous IDs, already-current and newer-than-registry output, missing executables, and no fallback/auth call. References: Update Flow “Preserve each detected native source identity,” “Update Claude native plugin,” “Update Codex native plugin,” and “Codex reinstall fails.” ## Task 7: Implement Pi and fallback/OpenCode strategies -- [ ] **Description**: Add the package-owned Pi update strategy only for the canonical npm source and return `unsupported` for local, Git, pinned, or ambiguous Pi sources. Add the latest-published-CLI fallback refresh strategy, reusing existing idempotent installation, backup, merge, and tracking code rather than duplicating it. +- [ ] **Description**: Add the package-owned Pi strategy for only the exact unpinned canonical npm identity. Coalesce matching user/project scopes into one command, use `--no-approve` for user-only and disclosed/confirmed `--approve` for project scope, preserve settings/filter objects, and reject local, Git, pinned, conflicting, or ambiguous entries without partial scope updates. Add a package-internal `nsolid-plugin-refresh-owned` binary and an exact-version npm-exec/pnpm-dlx fallback transaction that invokes it for one planned harness. The internal entrypoint performs bundle-aware ownership/collision preflight, snapshots tracked skills/config/tracking, fully replaces owned skill directories, removes stale tracked assets, validates `bundleVersion`, rejects untracked destinations, and rolls back all owned state on failure. Do not change the public `nsolid-plugin install` command or programmatic `install()` contract. - **Depends on**: Tasks 2 and 5 -- **Files**: `packages/core/src/update/strategies/pi.ts`, `packages/core/src/update/strategies/fallback.ts`, corresponding unit/integration tests -- **Testing**: Verify the exact canonical Pi source, rejection of non-canonical sources, package-owned skill boundaries, OpenCode skill refresh, fallback MCP merge, configuration backup, preserved credentials, and no implicit install for an absent target. References: Update Flow “Update Pi package-owned skills,” “Reject a non-canonical Pi source,” “Update OpenCode or a fallback installation,” and “Requested harness is not installed.” +- **Files**: `packages/core/src/update/strategies/pi.ts`, `packages/core/src/update/strategies/fallback.ts`, `packages/core/src/update/fallback-transaction.ts`, `packages/core/src/update/refresh-owned-cli.ts`, `packages/core/package.json`, `packages/core/src/skills/skill-copier.ts`, `packages/core/src/skills/skill-tracker.ts`, `packages/core/src/mcp/mcp-config-writer.ts`, corresponding unit/integration tests +- **Testing**: Verify Pi user-only/project-only/both scopes, one invocation for duplicate canonical identity, exact `--approve`/`--no-approve`, unchanged source/filter/trust settings, rejection of non-canonical/conflicting sources, package-owned skill boundaries, an unchanged-too-old cache, and a newer version published during native execution. For OpenCode/fallback, cover deterministic npm-then-pnpm executor selection, exact immutable package version, the exact internal-binary command arrays, isolated temporary cwd with a conflicting local binary, complete replacement, stale tracked-skill removal, an untracked new-bundle destination collision, MCP merge, tracking/version update, missing ownership, missing executor, child/reconciliation/validation failures, rollback of every component, preserved credentials/user artifacts, no implicit install for an absent target, and regression coverage proving repeated public `install` behavior is unchanged. References: Update Flow “Update Pi package-owned skills,” “Same canonical Pi identity exists in both scopes,” “Reject a non-canonical Pi source,” “Update OpenCode or another fallback installation,” “No supported exact-package executor is available,” “Direct/fallback refresh cannot prove ownership,” “New fallback bundle collides with an untracked destination,” “OpenCode or fallback refresh fails,” “Requested harness is not installed,” and “Preserve the public install contract.” ## Task 8: Implement transactional Antigravity update -- [ ] **Description**: Add known-path staging detection, restrictive temporary backup of the staged root and N|Solid import-manifest entry, confirmed uninstall/install, new-root plus registration validation, successful cleanup, and rollback restoration that preserves unrelated imports. +- [ ] **Description**: Detect exactly one supported Antigravity layout pair: shared `~/.gemini/config/{plugins,import_manifest.json}` or AGY CLI `~/.gemini/antigravity-cli/{plugins,import_manifest.json}`. Add restrictive temporary backup of the detected staged root and matching N|Solid manifest entry, confirmed uninstall/install, new-root plus registration validation, successful cleanup, and rollback restoration that preserves unrelated imports. Return `unsupported` without mutation for ambiguous or unmatched layouts. - **Depends on**: Tasks 2 and 5 - **Files**: `packages/core/src/update/antigravity-transaction.ts`, `packages/core/src/update/strategies/antigravity.ts`, related unit/integration tests -- **Testing**: Cover both supported staged roots, successful replacement, declined confirmation, uninstall failure, install failure, root/manifest validation failure, rollback success/failure for both components, cleanup, unrelated import preservation, and credential preservation. References: Update Flow “Update Antigravity native plugin” and “Antigravity reinstall fails.” +- **Testing**: Cover both supported staged-root/manifest pairs, both-present ambiguity, unmatched root/manifest, successful replacement, declined confirmation, uninstall failure, install failure, root/manifest validation failure, rollback success/failure for both components, cleanup, unrelated import preservation, and credential preservation. References: Update Flow “Update Antigravity native plugin,” “Antigravity layout is ambiguous or unsupported,” and “Antigravity reinstall fails.” ## Task 9: Build the coordinator and programmatic API -- [ ] **Description**: Implement scope validation, one-plan-item-per-installation semantics, deterministic target/ownership ordering, check-only short circuit, plan confirmation, sequential execution, per-installation failure isolation, aggregation, and public `getVersionInfo()`, `checkUpdates()`, and `update()` exports. +- [ ] **Description**: Implement scope validation, one-plan-item-per-installation semantics, a synthetic non-mutating `none` item for an explicitly requested absent harness, complete ordered execute/rollback steps before confirmation, deterministic target/ownership ordering, check-only short circuit, plan confirmation, sequential execution, per-installation lookup/execution failure isolation, aggregation, and public `getVersionInfo()`, `checkUpdates()`, and `update()` exports. Represent lookup/validation failures as sanitized non-mutating plan items, convert them to failed results without execution, and reject any strategy execution that attempts an external command absent from its approved immutable plan. - **Depends on**: Tasks 4 and 6–8 - **Files**: `packages/core/src/update/coordinator.ts`, `packages/core/src/update/index.ts`, `packages/core/src/index.ts`, coordinator/API tests -- **Testing**: Cover CLI-only default, one harness selecting multiple installations, `--all`, `--all` plus harness rejection, check-only no-execute guarantee, newer-than-registry no-downgrade, unsupported check/update exit semantics, one failure with later success, coexisting native/fallback records, empty inventory, all status counts, and overall success/exit semantics. References: Update Flow “Update every detected target,” “Update coexisting native and fallback installations,” “Do not downgrade a CLI newer than the registry,” “One target fails during update-all,” “Check every detected target,” and “Conflicting update scopes.” +- **Testing**: Cover CLI-only default, one harness selecting multiple installations, `--all`, `--all` plus harness rejection, check-only no-execute guarantee, multi-command Codex/Antigravity and transactional fallback plans, undisclosed-command rejection, newer-than-registry no-downgrade, unsupported check/update exit semantics, one lookup or execution failure with later success, coexisting native/fallback records, empty inventory, all status counts, and overall success/exit semantics. References: Update Flow “Registry lookup fails,” “Update every detected target,” “Update coexisting native and fallback installations,” “Do not downgrade a CLI newer than the registry,” “One target fails during update-all,” “Check every detected target,” and “Conflicting update scopes.” ## Task 10: Add CLI commands and output formatting -- [ ] **Description**: Add `version` and `update` command parsing, bare `--version`, `--check`/`--all`, confirmation integration, human-readable summaries, JSON-only stdout, stderr progress, help text, installation identifiers/source-safe display, and exit-code mapping. +- [ ] **Description**: Add `version` and `update` command parsing, bare `--version`, `--check`/`--all`, confirmation integration, complete ordered execute/rollback plan display, human-readable summaries, JSON-only stdout, stderr progress, help text, installation identifiers/source-safe display, and exit-code mapping. - **Depends on**: Task 9 - **Files**: `packages/core/src/cli.ts`, `packages/core/src/utils/format.ts` or a new update formatter, CLI help/unit/integration tests -- **Testing**: Spawn the CLI with every supported scope/output combination. Assert exact JSON parseability, `latestVersion` and `installationId` presence, no ANSI in JSON, no mutation in check mode, prompts only when appropriate, sanitized errors, bare `--version` parity, no downgrade for `newer-than-registry`, informational exit zero for successful checks, and non-zero mutable exits for unsupported/failure results. References: Update Flow “Structured update output,” “Report versions with the conventional flag,” “Do not downgrade a CLI newer than the registry,” and “Non-interactive CLI update.” +- **Testing**: Spawn the CLI with every supported scope/output combination. Assert exact JSON parseability, `latestVersion` and `installationId` presence, complete multi-step plans before prompts, no ANSI in JSON, no mutation in check mode, prompts only when appropriate, redacted source/environment values, sanitized errors, bare `--version` parity, no downgrade for `newer-than-registry`, informational exit zero for successful checks, and non-zero mutable exits for unsupported/failure results. References: Update Flow “Structured update output,” “Update every detected target,” “Report versions with the conventional flag,” “Do not downgrade a CLI newer than the registry,” and “Non-interactive CLI update.” ## Task 11: Add atomic release preparation @@ -83,18 +82,18 @@ - [ ] **Description**: Implement `release:check`, including source/package equality, generated artifact checks, exact mismatch reporting, and cleanup-state validation. When and only when `--release` is present, compare the specification's explicit payload allowlist with the latest semantic-version tag and validate that payload changes have an update-visible version. - **Depends on**: Task 11 - **Files**: `scripts/check-release-version.mjs`, `package.json`, script fixture tests -- **Testing**: Introduce drift independently in every controlled file, stale generated output, unchanged version with changes in each payload allowlist category, malformed/missing tag state, and materialized package skills. Verify normal and `--release` check modes never repair. References: Release Versioning “Check synchronized release versions,” “Release version drift is detected,” and “Skill changes require an update-visible version.” +- **Testing**: Introduce drift independently in every controlled file, stale generated output, unchanged version with changes in each payload allowlist category, malformed/missing tag state, and materialized package skills. Verify normal and `--release` check modes never repair. References: Release Versioning “Check synchronized release versions,” “Release version drift is detected,” and “Skill changes retain the previous release version.” ## Task 13: Add end-to-end update regression coverage -- [ ] **Description**: Exercise the public CLI against isolated homes and fake harness executables/registries, including mixed native/fallback ownership, alternate source identities, unsupported Pi sources, Antigravity manifest rollback, and partial failure. +- [ ] **Description**: Exercise the public CLI against isolated homes and fake package managers/executors/harness executables/registries, including exact-version CLI install verification, mixed native/fallback ownership, alternate marketplace identities and version sources, Claude scopes, Codex transactional reinstall/rollback, Pi scope/trust combinations, OpenCode internal-refresh reconciliation/rollback with unchanged public install behavior, unsupported Pi/fallback sources, both Antigravity layout/manifest pairs, and lookup/execution partial failure. - **Depends on**: Tasks 9–12 - **Files**: `packages/core/test/integration/update-flow.test.ts`, test fixtures/helpers, `scripts/test-marketplace-install.js` where update assertions fit -- **Testing**: Cover all acceptance tests from the proposal on Linux-compatible fixtures and keep paths/commands portable for macOS and Windows CI. Assert credentials, non-NodeSource configurations, unrelated Antigravity manifest imports, and source identities are preserved byte-for-byte where applicable. +- **Testing**: Cover all acceptance tests from the proposal on Linux-compatible fixtures and keep paths/commands portable for macOS and Windows CI. Assert credentials, non-NodeSource configurations, unrelated Codex configuration/cache entries, unrelated Antigravity manifest imports, and source identities/scopes are preserved byte-for-byte where applicable. ## Task 14: Document user and maintainer workflows -- [ ] **Description**: Document CLI self-update, per-harness update ownership, check/JSON/automation modes, AGY replacement behavior, rollback guidance, version propagation, manual publication order, and the first-release bootstrap limitation. +- [ ] **Description**: Document exact-version CLI self-update and unsupported wrappers, per-harness update ownership and marketplace-source preservation, check/JSON/automation modes, Pi user/project trust behavior, OpenCode direct-install ownership and update-only transactional replacement while public install behavior remains unchanged, AGY replacement behavior, rollback guidance, version propagation, manual publication order, and the first-release bootstrap limitation. Do not advertise the package-internal refresh binary as a user workflow. - **Depends on**: Tasks 10–12 - **Files**: `README.md`, `packages/core/README.md`, `packages/pi-plugin/README.md` - **Testing**: Validate every documented command against CLI help/tests and ensure no documentation implies that a Git push alone updates version-keyed caches. References: both specifications and Design “Migration Strategy.” @@ -104,4 +103,4 @@ - [ ] **Description**: Run version drift checks, source/plugin checks, lint, type checking/build, all unit/integration tests, marketplace install tests, and package dry-run inspection for both publishable packages. - **Depends on**: Tasks 13–14 - **Files**: No production files unless a gate exposes a defect -- **Testing**: `pnpm release:check --release`, `pnpm plugin:check`, `pnpm lint`, `pnpm build`, `pnpm test`, `pnpm test:marketplace`, plus dry-run package contents confirming updated skills and same-version Pi/core dependency resolution. +- **Testing**: `pnpm release:check --release`, `pnpm plugin:check`, `pnpm lint`, `pnpm build`, `pnpm test`, `pnpm test:marketplace`, plus dry-run package contents confirming updated skills, the packaged internal refresh binary, unchanged public install entrypoints, and same-version Pi/core dependency resolution. From 8d16ac970e14add79b82bfeae33e50854c3ae722 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Mon, 3 Aug 2026 22:49:03 +0200 Subject: [PATCH 04/12] spec: address update flow review gaps --- openspec/changes/add-update-flow/design.md | 105 +++++++++++++---- openspec/changes/add-update-flow/proposal.md | 18 +-- .../specs/release-versioning/spec.md | 32 +++++- .../add-update-flow/specs/update-flow/spec.md | 108 ++++++++++++++---- openspec/changes/add-update-flow/tasks.md | 20 ++-- 5 files changed, 213 insertions(+), 70 deletions(-) diff --git a/openspec/changes/add-update-flow/design.md b/openspec/changes/add-update-flow/design.md index 8315c3f..74c1fc3 100644 --- a/openspec/changes/add-update-flow/design.md +++ b/openspec/changes/add-update-flow/design.md @@ -57,9 +57,9 @@ Release preparation is a separate maintainer-side script. Runtime update code re `packages/core/src/update/version-source.ts` -- Reads and validates `latest` metadata from npm for `nsolid-plugin` and `nsolid-pi-plugin`. -- Resolves Claude/Codex latest-version evidence only from the exact carried marketplace source: a validated Git repository/ref plus relative manifest path, or the detected local marketplace snapshot. It never substitutes the canonical NodeSource GitHub root for an alternate marketplace. -- Reads the canonical GitHub-root `bundle.json` only for fixed-source native Git targets such as Antigravity. +- Reads and validates `latest` metadata from the detected npm registry for `nsolid-plugin` and `nsolid-pi-plugin`, retaining the normalized registry origin, exact tarball URL, version, and registry-provided integrity digest as one immutable artifact identity. +- Resolves every supported Git marketplace ref to a full commit object ID before planning, reads the manifest and content digest from that commit, and carries the repository, commit, relative manifest path, and digest together. A missing revision, mutable ref that cannot be resolved, or source that cannot bind lookup and execution to that commit is `unsupported`. +- Resolves the canonical GitHub-root Antigravity source to a full commit and reads `bundle.json` from that commit; a moving default branch is never the executable identity. - Applies bounded request timeouts and semantic-version validation. - Returns `unknown` rather than treating missing, local-stale, ambiguous, or unsupported marketplace version evidence as current; native execution may still use the preserved harness-owned ID when its identity is unambiguous. @@ -67,7 +67,8 @@ Release preparation is a separate maintainer-side script. Runtime update code re - Detects npm or pnpm only when the real CLI package/entrypoint is contained by that manager's reported global root and the corresponding executable is available; a shim or package-manager environment variable alone is not sufficient evidence. - Produces a fixed executable plus argument array. -- Pins update and rollback package specs to the exact semantic versions resolved during planning. +- Downloads only the planned tarball from the planned registry, verifies its integrity before execution, and gives npm/pnpm the verified local tarball rather than re-resolving `name@version` through ambient registry configuration. +- Verifies post-update package identity against the planned name, version, registry provenance, and integrity/content digest rather than accepting version equality alone. - Returns unsupported for workspaces, `npx`, local checkouts, Volta/Yarn/Bun ownership, mismatched global roots, and ambiguous launchers. `packages/core/src/update/command-runner.ts` @@ -100,11 +101,13 @@ Release preparation is a separate maintainer-side script. Runtime update code re `packages/core/src/update/fallback-transaction.ts` -- Resolves one available package executor (`npm exec` preferred, otherwise `pnpm dlx`), pins the child package to the exact validated `nsolid-plugin@` selected during planning, and runs it from a restrictive temporary working directory so workspace binaries/configuration cannot shadow the payload. -- Invokes the exact package's dedicated internal `nsolid-plugin-refresh-owned` binary for only the planned harness; the regular `nsolid-plugin install` command and programmatic `install()` contract are not changed. -- The exact child snapshots the target's tracked NodeSource-owned skill directories, affected MCP configuration, and complete tracking state before mutation, using its own bundled payload for ownership/collision preflight. +- Resolves one available package executor (`npm exec` preferred, otherwise `pnpm dlx`), verifies the planned `nsolid-plugin` tarball integrity, and runs that verified local artifact from a restrictive temporary working directory so workspace binaries/configuration cannot shadow the payload. +- Before launching the child, the parent creates and fsyncs a restrictive durable journal plus complete snapshot of the selected installation's tracked skill directories, affected MCP fields, links, and tracking state. The journal records `prepared`, `mutating`, and `committed` phases and remains recoverable if the package executor or child times out, crashes, or is killed. +- Invokes the exact package's dedicated internal `nsolid-plugin-refresh-owned` binary with a parent-created transaction manifest. The manifest binds `installationId`, harness, canonical owned paths, tracking-file path and digest, and field-level MCP ownership; the regular `nsolid-plugin install` command and programmatic `install()` contract are not changed. +- The child validates that the live tracking digest, installation identity, canonical paths, and MCP fields still equal the approved manifest before mutation. It refuses stale, sibling, broadened, or ambiguous identity rather than rediscovering a target from `--harness` alone. - Reconciles the installed asset set against the new bundle: complete skill directories are replaced, previously tracked skills absent from the new bundle are removed, and untracked/user-owned paths and unrelated MCP entries are preserved. -- Updates `bundleVersion` only after the new skills, MCP entries, and tracking data validate; restores the snapshot if execution, reconciliation, or validation fails. +- Updates `bundleVersion` only after the new skills, MCP entries, and tracking data validate. The parent marks the journal committed and removes it only after post-update validation; otherwise it restores its snapshot independently of child-process availability. +- On every later update invocation, the parent recovers or reports any non-committed journal before planning new mutation. - Treats direct artifacts without sufficient tracking ownership as `unsupported` rather than deleting paths by name or prefix. ### Existing modules extended @@ -134,8 +137,8 @@ Release preparation is a separate maintainer-side script. Runtime update code re `packages/core/src/skills/skill-tracker.ts` -- Fallback tracking adds an optional `bundleVersion` and retains enough per-harness ownership/path evidence to reconcile obsolete assets safely. -- Readers must accept existing tracking files that omit it. +- Fallback tracking adds an optional `bundleVersion`, canonical per-installation skill/link paths, and MCP ownership evidence per JSON field/value so shared paths and user-modified fields cannot be claimed by name alone. +- Readers accept legacy tracking for reporting, but automatic mutation is `unsupported` until the selected installation has complete per-path and field-level ownership evidence; compatibility never authorizes a name-only write or deletion. ### Release modules @@ -152,7 +155,8 @@ Release preparation is a separate maintainer-side script. Runtime update code re - Compares package and generated versions with the root bundle. - Calls/reuses existing bundle and root-manifest checks. - Activates release mode only when invoked through `pnpm release:check --release`. -- In release mode, compares the explicit plugin payload allowlist from the Release Versioning specification with the latest semantic-version tag and rejects an unchanged version. +- In release mode, compares the explicit published-payload allowlist from the Release Versioning specification with the highest eligible local semantic-version tag whose peeled commit is an ancestor of `HEAD`, and rejects an unchanged version. +- Accepts exactly `X.Y.Z` and `vX.Y.Z` tag names, handles lightweight and annotated tags by peeling to commits, ignores non-semantic and non-ancestor tags, and fails explicitly for missing/malformed-only tags, ambiguous duplicate versions, or shallow history that prevents proving ancestry. Root package scripts: @@ -219,14 +223,17 @@ export type MarketplaceVersionSource = | { kind: 'git' repository: string - revision?: string + revision: string + commit: string manifestPath: string + contentDigest: string } | { kind: 'local-snapshot' root: string manifestPath: string freshness: 'verified' | 'stale' | 'unknown' + contentDigest: string } | { kind: 'unknown' @@ -240,6 +247,45 @@ export type PiPackageLocation = export type FallbackPackageExecutor = 'npm-exec' | 'pnpm-dlx' +export interface NpmArtifactIdentity { + kind: 'npm' + packageName: 'nsolid-plugin' | 'nsolid-pi-plugin' + version: string + registry: string + tarball: string + integrity: string +} + +export interface GitArtifactIdentity { + kind: 'git' + repository: string + commit: string + contentDigest: string +} + +export interface LocalArtifactIdentity { + kind: 'local-snapshot' + root: string + contentDigest: string +} + +export type ResolvedArtifactIdentity = NpmArtifactIdentity | GitArtifactIdentity | LocalArtifactIdentity + +export interface FallbackTransactionIdentity { + installationId: string + harness: HarnessType + trackingPath: string + trackingDigest: string + ownedSkillPaths: readonly string[] + ownedLinkPaths: readonly string[] + ownedMcpFields: readonly { + configPath: string + server: string + field: string + expectedDigest: string + }[] +} + export type AntigravityLayout = | { kind: 'shared' @@ -331,6 +377,8 @@ export interface UpdatePlanItem { installed: boolean source: UpdateSource version: VersionInfo + artifact?: ResolvedArtifactIdentity + fallbackTransaction?: FallbackTransactionIdentity steps: readonly UpdatePlanStep[] rollbackSteps: readonly UpdatePlanStep[] planningError?: UpdateError @@ -369,6 +417,7 @@ export interface UpdateSummary { results: UpdateResult[] counts: Record success: boolean + exitCode: 0 | 1 | 2 } export interface CommandSpec { @@ -415,32 +464,38 @@ Rules enforced by these contracts: - A detected installation source that cannot be updated safely is represented as `unsupported`, never replaced with a different source. - `ownership: 'none'` with `source.kind: 'none'` is reserved for the synthetic, non-mutating plan/result produced when an explicitly requested harness has no detected installation. It has empty execute/rollback steps, requires no confirmation, and is never emitted as a detected target under `--all`. - Marketplace version resolution uses only the `versionSource` carried by the detected Claude or Codex registration. An unknown or stale local source yields `unknown`; it never falls back to the NodeSource marketplace. +- A mutating plan that depends on Git carries a full immutable commit and content digest; a plan that depends on npm carries registry, tarball, version, and integrity; and a verified local snapshot carries its canonical root and content digest. Execution and post-update validation use that same `artifact` identity and never re-resolve a mutable ref, dist-tag, package name/version, ambient registry, or changed snapshot. +- A project-scoped Pi command has `cwd` equal to the canonical captured `projectRoot`. Immediately before execution the strategy revalidates that directory identity, effective `.pi/settings.json` entry, scopes, source, and cache roots still match the approved plan; drift produces a non-mutating failure. +- A fallback child receives and validates the exact `fallbackTransaction` manifest approved by the parent. Harness-only rediscovery is not an executable identity. - Strategies return data; the CLI formatter owns human-readable output. -- A completed check whose result is `current`, `update-available`, `newer-than-registry`, `unsupported`, or evidence-only `unknown` is informational and exits zero. A timeout, invalid response, or other operational lookup/validation failure is `failed` and remains non-zero. -- A mutating update with `newer-than-registry` performs no downgrade and exits zero; a mutating `unsupported` result exits non-zero with manual guidance. +- A completed check whose result is `current`, `update-available`, `newer-than-registry`, `unsupported`, or evidence-only `unknown` is informational and exits `0`. A timeout, invalid response, or other operational lookup/validation failure is `failed` and exits `1`. +- A mutating update with `newer-than-registry` performs no downgrade and exits `0`; a mutating `unsupported` result exits `2` with manual guidance. - A declined plan produces `skipped` results and exits zero. +- Exit code `0` means a completed update/check or an intentional informational no-op. Exit code `1` means an operational lookup, planning, execution, validation, rollback, or recovery failure. Exit code `2` means the requested mutation was unavailable without operational failure because approval was missing or the result was `not-installed`, `unsupported`, or mutation-blocking `unknown`. In aggregate results, code `1` takes precedence over code `2`. +- A read-only check with `not-installed`, `unsupported`, or evidence-only `unknown` exits `0`. A mutating invocation with any such unavailable result exits `2` unless another item failed and requires exit `1`. +- An empty `--all` inventory is an explicit successful no-op: it exits `0`, emits `results: []` and zero counts in JSON, and reports that no targets were detected in human output. ### Fixed harness command plans | Target | Native/package action | Success guidance | |---|---|---| -| CLI npm | `npm install --global nsolid-plugin@` | invoke CLI again | -| CLI pnpm | `pnpm add --global nsolid-plugin@` | invoke CLI again | +| CLI npm | verify the planned tarball integrity, then `npm install --global ` | invoke CLI again | +| CLI pnpm | verify the planned tarball integrity, then `pnpm add --global ` | invoke CLI again | | Claude | `claude plugin update --scope ` | `/reload-plugins` or restart | | Codex | `codex plugin marketplace upgrade `, then `codex plugin remove ` and `codex plugin add ` | start a new session | -| Antigravity | `agy plugin uninstall nsolid-plugin`, then `agy plugin install https://github.com/NodeSource/nsolid-plugin.git` | restart AGY | +| Antigravity | `agy plugin uninstall nsolid-plugin`, then install the canonical repository pinned to the planned full commit | restart AGY | | Pi user-only | `pi update npm:nsolid-pi-plugin --no-approve` | `/reload` or restart | | Pi with detected project scope | `pi update npm:nsolid-pi-plugin --approve` after the project root is disclosed and approved | `/reload` or restart | -| Fallback/OpenCode through npm | `npm exec --yes --package=nsolid-plugin@ -- nsolid-plugin-refresh-owned --harness ` inside a fallback transaction | restart harness if needed | -| Fallback/OpenCode through pnpm | `pnpm --package=nsolid-plugin@ dlx nsolid-plugin-refresh-owned --harness ` inside a fallback transaction | restart harness if needed | +| Fallback/OpenCode through npm | execute `nsolid-plugin-refresh-owned --transaction ` from the integrity-verified local npm tarball | restart harness if needed | +| Fallback/OpenCode through pnpm | execute `nsolid-plugin-refresh-owned --transaction ` from the integrity-verified local pnpm tarball | restart harness if needed | Marketplace IDs, Claude scopes, package versions, Pi scopes, and package sources are passed as separate arguments only after strict validation. A native ID is accepted only when it matches `nsolid-plugin@[A-Za-z0-9][A-Za-z0-9._-]*`; the base name alone, malformed IDs, control characters, whitespace, ambiguous matches, and a Claude installation whose scope cannot be determined return `unsupported`. Marketplace inventory also carries the exact repository/ref and relative manifest path, or the exact local snapshot path and freshness evidence, used for version resolution. Repository credentials are stripped before data reaches plan or result output; traversal-capable manifest paths and ambiguous source metadata return `unknown` or `unsupported` without canonical-source substitution. The only supported Pi identity is the exact unpinned `npm:nsolid-pi-plugin`. Inventory coalesces canonical user/project entries into one Pi target because one `pi update ` invocation updates every matching identity; the discriminated location requires `projectRoot` whenever project scope is present. Any local, Git, pinned, conflicting, or ambiguous matching entry returns `unsupported` for the whole target rather than producing a misleading partial success. No user-derived string is interpolated into an executable shell command. The planner emits one item per `UpdateInstallation`. If a harness has both native and fallback artifacts, both items remain visible and are updated independently; a native failure never switches to fallback ownership. -The CLI registry lookup resolves the `latest` dist-tag once, validates it as a stable semantic version, and stores that exact version in the immutable plan. Execution never sends `@latest` back to a package manager. npm uses `install --global`; pnpm uses `add --global`. Success requires both a zero child exit and an on-disk package manifest at the positively identified global root whose name/version equal `nsolid-plugin` and the planned version. A failed or mismatched result returns the exact previous-version command for the same manager. +The CLI registry lookup resolves the `latest` dist-tag once from the effective registry, validates it as a stable semantic version, and stores registry origin, exact tarball URL, version, and integrity in the immutable plan. Execution never sends `@latest` or `name@version` back to a package manager: it downloads the planned tarball, verifies integrity, and installs that verified local artifact. Success requires both a zero child exit and on-disk package evidence whose name, version, and content digest match the planned artifact. A failed or mismatched result returns the exact previous-artifact guidance for the same manager. -Pi source detection reads both user and current-project settings, including object-form entries and filters. User and project entries for the same unpinned npm package become one command target. A user-only target passes `--no-approve` so an unrelated current directory cannot broaden the operation. A detected project target records and displays its project root and passes `--approve` only after the update plan is approved; this is a one-command trust decision and does not rewrite Pi trust/settings files. Pi's own updater preserves source entries and package filters. Because `pi update ` does not accept a target version, the registry version observed during planning is a minimum postcondition rather than an executable argument: the strategy reads and reports the actual package-cache version after Pi completes, accepts a newer valid version published during the run, and fails if any affected cache remains older than the planned version. +Pi source detection reads both user and current-project settings, including object-form entries and filters. User and project entries for the same unpinned npm package become one command target. A user-only target passes `--no-approve` so an unrelated current directory cannot broaden the operation. A detected project target records the canonical project root and directory identity, displays it, and sets the command `cwd` to that exact root. Immediately before invoking `pi update`, the strategy re-reads the effective user/project entries, scopes, source, and cache roots and refuses mutation if they differ from the approved plan. It passes `--approve` only after this revalidation and plan approval. Because `pi update ` does not accept a target version, the planned registry artifact is a minimum postcondition; every affected cache must retain provenance for that registry and integrity/content evidence for the resulting package, including a newer valid publication observed during execution. OpenCode supports native skills and a separate npm/local plugin system, but the current N|Solid distribution is not registered as an OpenCode plugin. Its owner is therefore the tracked direct installer at `~/.config/opencode/skills/` plus the merged `mcp` entries in `opencode.json(c)`. The updater must not invoke `opencode plugin`. It invokes the exact published N|Solid CLI package's internal `nsolid-plugin-refresh-owned` binary as the payload provider and transaction executor. The existing public `install` flow keeps its idempotent copy/merge semantics and does not acquire stale-asset removal behavior. @@ -587,13 +642,13 @@ sequenceDiagram - `--all` catches version-lookup, planning, and execution errors at the installation boundary and continues with independent installation records, including native and fallback records for the same harness. - Confirmation is mandatory for mutable non-interactive operations unless `--yes` is present. - Marketplace IDs and Claude scopes are validated before becoming arguments; local, pinned, conflicting, and ambiguous Pi sources are never silently replaced. -- CLI and fallback package execution uses the exact immutable version from the plan; mutable dist-tags are not passed during mutation, and a package-manager success without matching on-disk version evidence is a failure. -- Pi user-only updates pass `--no-approve`; `--approve` is used only when the immutable plan identifies and displays a project-scoped canonical package. Canonical entries across both scopes are updated once, while conflicting/pinned entries block automatic mutation. +- CLI and fallback package execution uses the registry, tarball, and integrity identity from the plan; mutable dist-tags and ambient registry resolution are not used during mutation, and package-manager success without matching on-disk content evidence is a failure. +- Pi user-only updates pass `--no-approve`; `--approve` is used only when the immutable plan identifies, displays, executes within, and immediately revalidates a project-scoped canonical package root. Canonical entries across both scopes are updated once, while changed/conflicting/pinned entries block automatic mutation. - Codex removes the installed plugin only after marketplace refresh and backup succeed. Rollback validates the restored registration and cached payload while preserving unrelated `config.toml` entries. - Antigravity accepts only one unambiguous documented layout pair. Backup paths are created with restrictive permissions in an OS temporary directory and always cleaned after success. Rollback validates both the staged root and its matching saved import-manifest registration. -- OpenCode/fallback replacement mutates only paths and MCP entries proven to be owned by tracking. The transaction restores overwritten and stale-removed skill directories, config, and tracking together after any failed child execution or validation. +- OpenCode/fallback replacement mutates only paths and MCP fields bound to the approved installation manifest. The parent-owned durable journal restores overwritten and stale-removed skill directories, config, and tracking after child failure, timeout, signal, or interrupted prior execution. - Update does not invoke setup, login, or auth modules. -- Release scripts snapshot only an explicit allowlist; rollback never performs broad Git or recursive workspace resets. +- Release scripts snapshot only explicit controlled files; rollback never performs broad Git or recursive workspace resets. Release payload checking includes runtime source inputs that are compiled or copied into both published packages. ## Migration Strategy diff --git a/openspec/changes/add-update-flow/proposal.md b/openspec/changes/add-update-flow/proposal.md index 01c28cc..c1853b5 100644 --- a/openspec/changes/add-update-flow/proposal.md +++ b/openspec/changes/add-update-flow/proposal.md @@ -21,13 +21,13 @@ For users: - add `nsolid-plugin version` (with bare `nsolid-plugin --version` as an alias) and `nsolid-plugin update`; - make plain `update` target the npm CLI, `--harness ` target every detected installation for one harness, and `--all` target the CLI plus every detected N|Solid installation; - add `--check` for a read-only status check and retain `--json`, `--yes`, `--verbose`, and `--no-color` behavior where applicable; -- update a positively identified npm- or pnpm-owned global CLI with the exact semantic version resolved during planning, then verify the installed package on disk instead of trusting only the package-manager exit code; +- update a positively identified npm- or pnpm-owned global CLI from the exact registry tarball and integrity identity resolved during planning, then verify the installed content on disk instead of trusting only the package-manager exit code or semantic version; - delegate native updates to the owning harness: - Claude: update the detected `nsolid-plugin@` identity at its detected installation scope and resolve version evidence only from that marketplace's carried source metadata; - Codex: refresh the detected Git marketplace snapshot, then transactionally remove and add the same detected plugin identity because marketplace refresh does not update the installed copy; version checks never substitute a canonical marketplace for the detected source; - - Antigravity: safely reinstall the GitHub-root plugin with backup/rollback of the detected AGY or shared Antigravity staged-root/import-manifest pair; - - Pi: update the canonical unpinned `npm:nsolid-pi-plugin` identity once across its detected user/project scopes, while rejecting local, pinned, Git, conflicting, or ambiguous sources; - - OpenCode, whose N|Solid installation is direct rather than an OpenCode plugin, and other tracked fallback installations: invoke an internal exact-package refresh binary to transactionally reconcile tracked assets, including removal of obsolete NodeSource-owned assets, without changing public `install` semantics; + - Antigravity: safely reinstall the GitHub-root plugin from a planned immutable commit with backup/rollback of the detected AGY or shared Antigravity staged-root/import-manifest pair; + - Pi: update the canonical unpinned `npm:nsolid-pi-plugin` identity once across its detected user/project scopes from the captured/revalidated project root, while rejecting changed, local, pinned, Git, conflicting, or ambiguous sources; + - OpenCode, whose N|Solid installation is direct rather than an OpenCode plugin, and other tracked fallback installations: invoke an internal integrity-verified refresh binary using an exact parent-owned installation manifest and durable recovery journal, including removal of obsolete NodeSource-owned assets without changing public `install` semantics; - preserve credentials and non-NodeSource configuration throughout updates; - isolate failures during `--all` so one harness failure does not prevent remaining updates, while returning a failing exit status and a per-target summary. @@ -39,13 +39,13 @@ For maintainers: ## Rollback Plan -- The CLI update path records the previously installed CLI version, pins both update and rollback commands to exact semantic versions, verifies the resulting global package root, and prints the exact package-manager command needed to restore it. +- The CLI update path records the previously installed CLI artifact, binds update and rollback to registry/tarball/integrity identities, verifies the resulting global package content, and prints exact recovery guidance. - A CLI newer than the registry is reported and left unchanged; this proposal has no implicit downgrade path. - Claude delegates to its native in-place update command while preserving the detected plugin ID and installation scope. - Codex snapshots the exact plugin registration, enablement, and cached payload before the documented marketplace-refresh plus remove/add sequence, and restores that snapshot if reinstall or validation fails. - Antigravity creates a temporary backup of the detected staged NodeSource plugin and its matching import-manifest registration and restores both if reinstall fails. - Pi delegates package replacement to `pi update` without rewriting its settings entries, package filters, MCP configuration, or credentials. -- OpenCode/fallback refresh snapshots every tracked NodeSource-owned skill path, affected MCP entries/config file, and tracking state before replacement; it restores them if exact-version execution, stale-asset reconciliation, or validation fails. +- The fallback parent durably snapshots the exact installation's owned skill/link paths, field-level MCP state, and tracking before launching the child; it restores or recovers them after child failure, timeout, signal, interrupted execution, reconciliation failure, or validation failure. - Update operations never delete shared NodeSource credentials. - The feature can be reverted by removing the new command/service modules and scripts; existing `setup`, `install`, `doctor`, `restore`, and `uninstall` contracts remain unchanged. - A bad release can be rolled back by republishing or reinstalling the prior known-good package/plugin version and restoring generated manifests from the corresponding Git tag. @@ -75,7 +75,7 @@ For maintainers: - `nsolid-plugin update --check` performs no writes or subprocess mutations and clearly reports whether the CLI is current. - Each supported harness has a deterministic update strategy with actionable output when its CLI is unavailable, its installation type is unsupported, or its source identity cannot be safely reused. - Claude and Codex version checks use only the source metadata carried by their detected marketplace; missing, stale, or unsupported evidence reports `unknown` instead of reading the NodeSource marketplace. -- `nsolid-plugin update --all` updates all detected targets, preserves credentials and user-owned configuration, summarizes every target, and exits non-zero on any failure. +- `nsolid-plugin update --all` updates all detected targets, preserves credentials and user-owned configuration, summarizes every target, and uses deterministic exit codes for success/no-op, operational failure, and unavailable mutation, including an explicit successful empty inventory. - Native and fallback installations detected for the same harness are represented and updated as separate targets; one ownership never silently replaces the other. - Interactive destructive/replacement steps require confirmation; `--yes` enables non-interactive automation. - Network, registry, missing-binary, permission, corrupt-state, and partial-update failures are covered by tests and never expose credentials. @@ -85,11 +85,11 @@ For maintainers: Acceptance tests: -1. Mock npm reporting a newer, equal, and older-than-registry CLI version and verify check-only, exact-version npm/pnpm commands, on-disk post-install validation, no-downgrade behavior, declined update, unsupported wrappers, and exact rollback guidance. +1. Mock npm reporting a newer, equal, and older-than-registry CLI artifact and verify registry/tarball/integrity binding, on-disk content validation, no-downgrade behavior, declined update, unsupported wrappers, and exact rollback guidance. 2. Mock current and newer Claude/Codex plugin versions, including alternate marketplace IDs, source repositories, stale local snapshots, and Claude installation scopes; verify exact-source version resolution, no canonical-source substitution, Claude’s scoped native update, and Codex’s marketplace-refresh plus transactional remove/add flow. 3. Simulate Codex and Antigravity reinstall failures and verify restoration of the prior plugin registration, enablement, cached/staged payload, and matching manifest state. 4. Mock user-only, project-only, and combined canonical Pi scopes plus pinned/conflicting sources; verify one scope-aware native update command and unchanged settings. -5. Refresh a tracked OpenCode installation through the internal exact-version npm and pnpm refresh binary; verify atomic skill replacement, stale tracked-skill removal, MCP merge, tracking update, rollback, and unchanged public `install` behavior without modifying untracked/user-owned artifacts. +5. Refresh a tracked OpenCode installation through the internal integrity-verified binary and parent transaction manifest; verify identity revalidation, atomic skill replacement, field-level MCP ownership, parent rollback, next-run recovery, and unchanged public `install` behavior without modifying sibling or user-owned artifacts. 6. Run `--all` with coexisting native/fallback installations and one failed target or version lookup; verify every installation is represented, later independent targets still run, credentials remain untouched, and the final exit code is non-zero. 7. Prepare a patch version in a fixture repository and verify all version-bearing files become equal while no publish/tag/push command executes. 8. Introduce version drift in each controlled file and verify the release check identifies the exact mismatch. diff --git a/openspec/changes/add-update-flow/specs/release-versioning/spec.md b/openspec/changes/add-update-flow/specs/release-versioning/spec.md index 8dcca8a..3505b70 100644 --- a/openspec/changes/add-update-flow/specs/release-versioning/spec.md +++ b/openspec/changes/add-update-flow/specs/release-versioning/spec.md @@ -84,11 +84,13 @@ Release checking SHALL compare every controlled version and generated artifact w ### Requirement: Plugin payload changes require an update-visible version -Release checking SHALL reject payload changes whose explicit bundle version still matches the most recent release tag. +Release checking SHALL reject payload changes whose explicit bundle or package version still matches the highest eligible semantic release tag reachable from `HEAD`. Release mode SHALL be activated only by `pnpm release:check --release`. For this comparison, “plugin payload files” is the following explicit allowlist: - `skills/**` +- `packages/core/src/**` +- `packages/pi-plugin/index.js` - `bundle.json` - `.claude-plugin/marketplace.json` - `.claude-plugin/plugin.json` @@ -102,12 +104,38 @@ Release mode SHALL be activated only by `pnpm release:check --release`. For this #### Scenario: Skill changes retain the previous release version -**Given** committed plugin payload files differ from the most recent release tag +**Given** committed, staged, unstaged, or untracked plugin payload files differ from the selected semantic release tag **And** `bundle.json.version` still equals the version represented by that tag **When** the maintainer runs `pnpm release:check --release` **Then** it fails with guidance to prepare a new semantic version **And** prevents a release that version-keyed harness caches would treat as unchanged +#### Scenario: Published runtime changes retain the previous release version + +**Given** committed, staged, unstaged, or untracked files under `packages/core/src/**` or `packages/pi-plugin/index.js` differ from the selected semantic release tag +**And** the corresponding package version still equals the version represented by that tag +**When** the maintainer runs `pnpm release:check --release` +**Then** it fails with guidance to prepare a new semantic version +**And** it prevents an attempt to publish different runtime bytes under an immutable npm package version + +#### Scenario: Select the semantic release baseline deterministically + +**Given** the local repository contains lightweight or annotated tags whose names exactly match `X.Y.Z` or `vX.Y.Z` +**And** their peeled commits are ancestors of `HEAD` +**When** release mode selects its baseline +**Then** it selects the eligible tag with the highest stable semantic version, independent of tag creation date +**And** treats the optional lowercase `v` as a naming prefix rather than part of the version +**And** ignores non-semantic tags and semantic tags whose commits are not ancestors of `HEAD` +**And** fails as ambiguous if both prefixed and unprefixed eligible tags represent the selected version but peel to different commits + +#### Scenario: Release baseline is unavailable + +**Given** no eligible semantic release tag is available locally, all candidate tags are malformed, or shallow history prevents proving tag ancestry +**When** the maintainer runs `pnpm release:check --release` +**Then** it exits non-zero before evaluating the payload diff +**And** distinguishes missing tags, malformed-only tag state, and incomplete shallow history +**And** reports that remote tags are not considered until they are fetched into the local repository + ### Requirement: Manual publication remains ordered and external Release preparation SHALL leave publication to the maintainer while defining the required package and Git ordering. diff --git a/openspec/changes/add-update-flow/specs/update-flow/spec.md b/openspec/changes/add-update-flow/specs/update-flow/spec.md index 064013d..3da87fc 100644 --- a/openspec/changes/add-update-flow/specs/update-flow/spec.md +++ b/openspec/changes/add-update-flow/specs/update-flow/spec.md @@ -63,12 +63,12 @@ The updater SHALL compare installed and latest versions without invoking any mut **And** represents the affected installation in the ordered plan with a sanitized planning error and no mutation steps **And** performs no mutation for the affected target **And** an `--all` invocation continues planning or executing remaining independent targets and records their results -**And** the overall invocation exits non-zero +**And** the overall invocation exits with code `1` **And** preserves every installation whose lookup failed ### Requirement: Safe CLI self-update -The default `nsolid-plugin update` scope SHALL update only a positively identified global CLI installation and SHALL require approval before mutation. +The default `nsolid-plugin update` scope SHALL update only a positively identified global CLI installation, SHALL require approval before mutation, and SHALL bind registry discovery, package execution, and post-update validation to one integrity-verified artifact. #### Scenario: CLI update with a supported global package manager @@ -77,20 +77,21 @@ The default `nsolid-plugin update` scope SHALL update only a positively identifi **When** the user runs `nsolid-plugin update` **Then** the command displays the current version, target version, package manager, and exact planned operation **And** asks for confirmation in an interactive terminal -**And** freezes the resolved semantic version in the plan rather than passing the mutable `latest` tag during execution -**And** after confirmation invokes `npm install --global nsolid-plugin@` or `pnpm add --global nsolid-plugin@` with a fixed argument array -**And** verifies both that the child process succeeded and that the positively identified global package root contains `nsolid-plugin` at the resolved version +**And** freezes the effective registry origin, exact tarball URL, stable version, and registry-provided integrity digest in the plan rather than passing the mutable `latest` tag during execution +**And** downloads only that tarball and verifies its integrity before confirmation can authorize installation +**And** after confirmation invokes npm or pnpm with the verified local tarball and a fixed argument array, without ambient registry resolution +**And** verifies both that the child process succeeded and that the positively identified global package root contains `nsolid-plugin` with the planned version and content identity **And** reports that a new shell or command invocation may be required **And** prints the same package manager's exact command for restoring `nsolid-plugin@` #### Scenario: Package manager exits successfully without installing the planned CLI **Given** an exact CLI update was approved -**When** the package-manager process exits successfully but the identified global package root is missing, belongs to a different package, or reports a version other than the planned version +**When** the package-manager process exits successfully but the identified global package root is missing, belongs to a different package, reports a version other than the planned version, or cannot prove the planned content identity **Then** the update result is `failed` **And** the command does not report the CLI as updated **And** prints the exact previous-version restore command -**And** exits non-zero +**And** exits with code `1` #### Scenario: User declines a CLI update @@ -106,7 +107,7 @@ The default `nsolid-plugin update` scope SHALL update only a positively identifi **And** standard input is not interactive **When** the user runs `nsolid-plugin update` without `--yes` **Then** the command performs no mutation -**And** exits non-zero with guidance to pass `--yes` +**And** exits with code `2` and guidance to pass `--yes` **When** the user reruns with `--yes` **Then** the command performs the displayed fixed update plan without prompting @@ -126,11 +127,11 @@ The default `nsolid-plugin update` scope SHALL update only a positively identifi **And** reports the latest version when it can be resolved **And** prints safe exact-version manual commands for npm, pnpm, ephemeral execution, and the detected wrapper/source when known **And** the result status is `unsupported` -**And** a mutating update exits non-zero while a read-only check exits successfully +**And** a mutating update exits with code `2` while a read-only check exits with code `0` ### Requirement: Harness-owned update strategies -The updater SHALL preserve native/package ownership and delegate each supported harness update to a deterministic strategy without starting OAuth. +The updater SHALL preserve native/package ownership, bind discovery and execution to the same immutable source identity, and delegate each supported harness update to a deterministic strategy without starting OAuth. #### Scenario: Update one installed native harness @@ -148,9 +149,11 @@ The updater SHALL preserve native/package ownership and delegate each supported **When** the corresponding native update strategy runs **Then** Claude uses the detected complete plugin ID and installation scope **And** Codex refreshes the detected marketplace and reinstalls the detected complete plugin ID -**And** inventory carries that marketplace's exact repository/ref and relative manifest path, or its exact local snapshot path and freshness evidence, for version resolution +**And** inventory carries that marketplace's exact repository/ref and relative manifest path, or its exact local snapshot path, freshness evidence, and content digest, for version resolution +**And** planning resolves every supported Git ref to a full commit object ID and content digest used by both lookup and execution **And** latest-version lookup reads only that carried source -**And** missing, stale, ambiguous, traversal-capable, or unsupported version-source evidence reports `unknown` or `unsupported` without querying the NodeSource marketplace +**And** a local snapshot must retain the planned digest through execution and post-update validation +**And** a missing revision, mutable ref that cannot be resolved and honored by the harness, stale snapshot, ambiguous source, traversal-capable path, or unsupported version-source evidence reports `unknown` or `unsupported` without querying the NodeSource marketplace **And** the strategy never substitutes `nodesource` **And** an unqualified, malformed, or ambiguous ID, or a Claude installation with unknown scope, returns `unsupported` without mutation @@ -159,8 +162,10 @@ The updater SHALL preserve native/package ownership and delegate each supported **Given** `nsolid-plugin@` is installed natively in Claude at a detected `user`, `project`, `local`, or `managed` scope **And** the `claude` executable is available **When** the Claude update strategy runs -**Then** it invokes `claude plugin update nsolid-plugin@ --scope ` with a fixed executable and argument array +**Then** the carried marketplace source resolves to an immutable commit and content digest that Claude can honor for this update +**And** it invokes `claude plugin update nsolid-plugin@ --scope ` with a fixed executable and argument array **And** verifies the native update command succeeded +**And** verifies the installed payload matches the planned commit/content identity rather than version alone **And** reports `/reload-plugins` or restart guidance **And** does not run the fallback installer @@ -175,7 +180,7 @@ The updater SHALL preserve native/package ownership and delegate each supported **And** creates a restrictive temporary backup of the exact plugin registration, enabled state, user-owned plugin fields, and cached installed payload **And** confirms replacement unless `--yes` was supplied **And** invokes `codex plugin remove nsolid-plugin@` followed by `codex plugin add nsolid-plugin@` with fixed argument arrays -**And** verifies the resulting local version/content matches the refreshed marketplace entry +**And** verifies the refreshed marketplace snapshot and resulting local payload match the planned commit and content digest **And** reapplies the prior enabled state and preserves unrelated Codex configuration **And** reports that a new Codex session is required **And** does not run the fallback installer @@ -187,7 +192,7 @@ The updater SHALL preserve native/package ownership and delegate each supported **Then** the updater restores the prior plugin registration, enabled state, user-owned fields, and cached payload **And** preserves unrelated `~/.codex/config.toml` entries **And** reports whether rollback succeeded -**And** exits non-zero +**And** exits with code `1` **And** provides the exact detected plugin remove/add commands for manual recovery #### Scenario: Update Pi package-owned skills @@ -199,7 +204,11 @@ The updater SHALL preserve native/package ownership and delegate each supported **And** the plan displays whether user and/or project package caches will be updated and displays the project root when applicable **And** a user-only target invokes `pi update npm:nsolid-pi-plugin --no-approve` **And** a target containing the detected project scope invokes `pi update npm:nsolid-pi-plugin --approve` only after the plan is approved +**And** that command's `cwd` is the canonical project root captured by inventory +**And** immediately before execution the strategy revalidates the project directory identity, effective settings entries, scopes, canonical package source, and affected cache roots against the approved plan +**And** any drift fails without invoking `pi update` **And** verifies every affected package cache contains `nsolid-pi-plugin` at a valid version no older than the registry version observed during planning +**And** verifies the resulting caches retain the planned registry provenance and integrity/content evidence **And** reports the actual installed version, accepting a newer version published while Pi's native unpinned update was running **And** does not copy Pi skills into user-level skill directories **And** reports `/reload` or restart guidance @@ -227,17 +236,29 @@ The updater SHALL preserve native/package ownership and delegate each supported **Given** N|Solid is tracked as a direct OpenCode installation, rather than as an OpenCode npm/local plugin, or another target uses the tracked N|Solid fallback installer **When** its update strategy runs -**Then** it resolves and freezes the exact stable `nsolid-plugin` registry version in the plan +**Then** it resolves and freezes the exact `nsolid-plugin` registry, tarball, stable version, and integrity in the plan **And** requires an available supported package executor -**And** snapshots the target's tracked NodeSource-owned skill directories, affected MCP configuration, and complete tracking state before replacement -**And** invokes either `npm exec --yes --package=nsolid-plugin@ -- nsolid-plugin-refresh-owned --harness ` or `pnpm --package=nsolid-plugin@ dlx nsolid-plugin-refresh-owned --harness ` with fixed argument arrays +**And** the parent creates and durably records a complete snapshot of the selected installation's owned skill/link paths, owned MCP fields, and tracking state before launching a package executor +**And** invokes `nsolid-plugin-refresh-owned --transaction ` from the integrity-verified local tarball with a fixed argument array **And** runs the package executor from a restrictive temporary working directory where a workspace-local `nsolid-plugin` binary cannot shadow the resolved payload -**And** the internal refresh binary refuses absent, ambiguous, or untracked ownership and does not broaden the planned harness +**And** the parent manifest binds the exact `installationId`, harness, canonical paths, tracking path and digest, and field-level MCP ownership approved in the plan +**And** the internal refresh binary revalidates that identity and refuses absent, stale, sibling, broadened, ambiguous, or untracked ownership **And** does not invoke `opencode plugin` **And** completely replaces tracked skill directories, removes previously tracked skills absent from the new bundle, and merges only the new bundle's NodeSource MCP entries **And** preserves untracked/user-owned skill paths, unrelated MCP entries, other configuration, and valid credentials **And** validates installed skills, MCP entries, tracking paths, and `bundleVersion` before deleting the backup +Fallback mutation SHALL be authorized by an exact parent-owned installation manifest and SHALL remain recoverable without cooperation from the package-executor child. + +#### Scenario: Fallback tracking or ownership changes after planning + +**Given** a fallback plan and parent transaction manifest were approved +**And** the tracking file, an owned path, a shared-path membership, or an owned MCP field changes before the child starts mutation +**When** the internal refresh validates the manifest +**Then** it fails without mutating any installation +**And** it does not rediscover another installation from the harness name +**And** a user-modified MCP field or sibling installation remains unchanged + #### Scenario: No supported exact-package executor is available **Given** a tracked direct/fallback installation is updateable but neither `npm exec` nor `pnpm dlx` is available @@ -272,7 +293,24 @@ The updater SHALL preserve native/package ownership and delegate each supported **And** restores stale tracked assets removed during reconciliation **And** preserves unrelated OpenCode/fallback artifacts **And** reports whether rollback succeeded -**And** exits non-zero +**And** exits with code `1` + +#### Scenario: Fallback child terminates after mutation + +**Given** the parent durably recorded a complete snapshot and marked the fallback journal `mutating` +**When** npm, pnpm, or the internal refresh process times out, crashes, receives a signal, or exits without a structured rollback result after mutation began +**Then** the parent restores the selected installation from its own snapshot +**And** records whether parent-owned recovery succeeded +**And** retains an incomplete journal when automatic restoration cannot be proven complete +**And** exits with code `1` + +#### Scenario: Recover an interrupted fallback transaction on the next run + +**Given** a prior invocation left a non-committed durable fallback journal +**When** any later update invocation starts +**Then** recovery runs before new inventory or mutation +**And** restores and validates the exact recorded installation or reports a recovery failure +**And** no new update plan executes while unresolved recovery state remains #### Scenario: Update coexisting native and fallback installations @@ -291,6 +329,7 @@ The updater SHALL preserve native/package ownership and delegate each supported **And** the coordinator emits one non-mutating item with `ownership: none` and `source.kind: none` for the requested harness **And** the target result is `not-installed` **And** the command prints appropriate installation guidance +**And** a mutating invocation exits with code `2`, while a read-only check exits with code `0` #### Scenario: Required harness executable is missing @@ -303,7 +342,7 @@ The updater SHALL preserve native/package ownership and delegate each supported ### Requirement: Transactional Antigravity replacement -The Antigravity strategy SHALL back up and validate the staged NodeSource plugin because AGY has no native plugin-update command. +The Antigravity strategy SHALL install a commit-pinned source and back up and validate the staged NodeSource plugin because AGY has no native plugin-update command. #### Scenario: Update Antigravity native plugin @@ -313,8 +352,9 @@ The Antigravity strategy SHALL back up and validate the staged NodeSource plugin **Then** the layout is either `~/.gemini/config/plugins/nsolid-plugin` with `~/.gemini/config/import_manifest.json` or `~/.gemini/antigravity-cli/plugins/nsolid-plugin` with `~/.gemini/antigravity-cli/import_manifest.json` **And** it creates a temporary backup of the detected staged NodeSource plugin and matching N|Solid import-manifest entry **And** confirms replacement unless `--yes` was supplied -**And** invokes `agy plugin uninstall nsolid-plugin` followed by `agy plugin install https://github.com/NodeSource/nsolid-plugin.git` +**And** resolves the canonical repository to a full commit and invokes `agy plugin uninstall nsolid-plugin` followed by installation of that commit-pinned Git source **And** validates `plugin.json`, `bundle.json`, canonical skill presence, and the N|Solid entry in the detected matching import manifest +**And** verifies the staged payload matches the planned commit/content digest **And** removes the backup only after the new staged plugin and registration validate **And** preserves `~/.agents/.nodesource-auth.json` @@ -333,7 +373,7 @@ The Antigravity strategy SHALL back up and validate the staged NodeSource plugin **Then** the updater restores the previous staged plugin atomically where supported **And** restores the previous N|Solid entry in the matching detected import manifest while preserving unrelated imports **And** reports whether rollback succeeded -**And** exits non-zero +**And** exits with code `1` **And** provides a manual reinstall command ### Requirement: Deterministic multi-target orchestration @@ -358,9 +398,18 @@ The updater SHALL plan targets before mutation, execute them sequentially in det **When** one target fails **Then** remaining independent targets are attempted **And** the summary includes the failed target and actionable error -**And** the overall process exits non-zero +**And** the overall process exits with code `1` **And** no credential value appears in logs or JSON +#### Scenario: Update-all detects no targets + +**Given** no CLI or harness installation is detected +**When** the user runs `nsolid-plugin update --all` or `nsolid-plugin update --all --check` +**Then** no confirmation, child process, or filesystem mutation occurs +**And** the result is an explicit successful no-op with `results: []` and zero counts for every status +**And** human output reports that no targets were detected +**And** the process exits with code `0` + #### Scenario: Conflicting update scopes **Given** the user supplies both `--all` and `--harness` @@ -378,8 +427,19 @@ Update results SHALL support human-readable and machine-readable output without **When** an update or check completes **Then** standard output contains exactly one valid JSON document **And** progress and diagnostics are written to standard error +**And** the summary contains `exitCode` with the exact process code selected from `0`, `1`, or `2` **And** each result contains `installationId`, `target`, `ownership`, `status`, optional `currentVersion` and `latestVersion`, `changed`, optional restart guidance and rollback status, and sanitized errors +#### Scenario: Exit codes distinguish unavailable mutation from failure + +**Given** an update or check has completed +**When** the CLI maps its summary to a process exit code +**Then** code `0` represents completed work or an intentional informational no-op, including checks, `current`, `newer-than-registry`, `skipped`, and an empty `--all` +**And** code `1` represents an operational lookup, planning, execution, validation, rollback, or recovery failure +**And** code `2` represents a requested mutation that was unavailable without operational failure because approval was missing or its result was `not-installed`, `unsupported`, or mutation-blocking `unknown` +**And** code `1` takes precedence over code `2` for aggregate results +**And** JSON status data remains present so automation can distinguish individual results sharing an exit category + ### Requirement: Preserve existing installation behavior Update operations SHALL retain all existing setup, installation, authentication, backup, merge, tracking, and uninstall safety contracts. diff --git a/openspec/changes/add-update-flow/tasks.md b/openspec/changes/add-update-flow/tasks.md index 7f2220a..b79071f 100644 --- a/openspec/changes/add-update-flow/tasks.md +++ b/openspec/changes/add-update-flow/tasks.md @@ -9,10 +9,10 @@ ## Task 2: Add safe command execution and version sources -- [ ] **Description**: Implement the injected shell-free command runner, bounded/sanitized output, executable lookup, npm registry client, exact-version npm-exec/pnpm-dlx package executors, exact carried Claude/Codex marketplace version sources, and the fixed canonical GitHub-root Antigravity bundle source with explicit timeouts and validation. Never substitute the NodeSource marketplace for alternate, missing, stale, or unsupported marketplace evidence. +- [ ] **Description**: Implement the injected shell-free command runner, bounded/sanitized output, executable lookup, npm registry client, integrity-verified tarball execution, exact carried Claude/Codex marketplace sources resolved to immutable commits/content digests, and the canonical GitHub-root Antigravity source resolved to a full commit with explicit timeouts and validation. Bind lookup, execution, and post-update verification to the same npm registry/tarball/integrity or Git commit/content identity. Never substitute the NodeSource marketplace for alternate, missing, stale, or unsupported marketplace evidence. - **Depends on**: Task 1 - **Files**: `packages/core/src/update/command-runner.ts`, `packages/core/src/update/version-source.ts`, `packages/core/test/unit/update/command-runner.test.ts`, `packages/core/test/unit/update/version-source.test.ts` -- **Testing**: Mock success, timeout, missing executable, invalid JSON/version, non-zero exit, oversized output, alternate Git repository/ref/manifest sources, fresh and stale local snapshots, traversal-capable paths, and secret-bearing diagnostics. Verify all subprocess calls use `shell: false` and argument arrays, source credentials are redacted, and no canonical-source substitution occurs. References: Update Flow “Registry lookup fails,” “Preserve each detected native source identity,” “Required harness executable is missing,” and “Preserve credentials and user-owned configuration.” +- **Testing**: Mock success, timeout, missing executable, invalid JSON/version, non-zero exit, oversized output, alternate Git repository/ref/manifest sources, missing or moving refs, commit/content mismatch, alternate registries serving the same version with different bytes, tarball/integrity mismatch, fresh and stale local snapshots, traversal-capable paths, and secret-bearing diagnostics. Verify all subprocess calls use `shell: false` and argument arrays, source credentials are redacted, and no canonical-source substitution or ambient registry re-resolution occurs. References: Update Flow “Registry lookup fails,” “Preserve each detected native source identity,” “Required harness executable is missing,” and “Preserve credentials and user-owned configuration.” ## Task 3: Detect CLI installation ownership @@ -30,7 +30,7 @@ ## Task 5: Extend harness inventory and version evidence -- [ ] **Description**: Reuse native detection and fallback tracking to emit one installation record per native, fallback, or package-owned installation. Carry validated Claude plugin ID/marketplace/scope and Codex plugin ID/marketplace together with each registration's exact sanitized repository/ref/relative-manifest source or local-snapshot/freshness evidence; carry the effective Pi source in a discriminated user/project location that requires the project root whenever project scope is present; and carry the detected Antigravity staged-root/matching-manifest layout. Require path-level ownership evidence for direct/fallback updates, retain optional installed-version evidence, and add backward-compatible `bundleVersion` tracking. Do not collapse native and fallback records for one harness. +- [ ] **Description**: Reuse native detection and fallback tracking to emit one installation record per native, fallback, or package-owned installation. Carry validated Claude plugin ID/marketplace/scope and Codex plugin ID/marketplace together with each registration's exact sanitized repository/ref/relative-manifest source or local-snapshot/freshness evidence; carry the effective Pi source in a discriminated user/project location that requires the canonical project root and directory identity whenever project scope is present; and carry the detected Antigravity staged-root/matching-manifest layout. Require per-installation canonical skill/link paths and field-level MCP ownership evidence for direct/fallback updates, retain optional installed-version evidence, and add backward-compatible `bundleVersion` tracking. Do not collapse native and fallback records for one harness. - **Depends on**: Tasks 1–3 - **Files**: `packages/core/src/update/inventory.ts`, `packages/core/src/harnesses/*.ts`, `packages/core/src/skills/skill-tracker.ts`, related harness/tracker tests - **Testing**: Cover native, fallback, package-owned, coexisting native+fallback, alternate marketplace IDs and repositories, every Claude installation scope, unknown scope, missing/ambiguous/stale marketplace evidence without canonical fallback, Pi user-only/project-only/both scopes with required project roots, object-form filters, pinned/conflicting Pi entries, both Antigravity layout pairs, ambiguous layouts, missing/corrupt metadata, direct artifacts without ownership, legacy tracking without `bundleVersion`, and version-unknown states for every harness. References: Update Flow “Requested harness is not installed,” “Check every detected target,” “Update one installed native harness,” “Preserve each detected native source identity,” and “Preserve credentials and user-owned configuration.” @@ -44,14 +44,14 @@ ## Task 7: Implement Pi and fallback/OpenCode strategies -- [ ] **Description**: Add the package-owned Pi strategy for only the exact unpinned canonical npm identity. Coalesce matching user/project scopes into one command, use `--no-approve` for user-only and disclosed/confirmed `--approve` for project scope, preserve settings/filter objects, and reject local, Git, pinned, conflicting, or ambiguous entries without partial scope updates. Add a package-internal `nsolid-plugin-refresh-owned` binary and an exact-version npm-exec/pnpm-dlx fallback transaction that invokes it for one planned harness. The internal entrypoint performs bundle-aware ownership/collision preflight, snapshots tracked skills/config/tracking, fully replaces owned skill directories, removes stale tracked assets, validates `bundleVersion`, rejects untracked destinations, and rolls back all owned state on failure. Do not change the public `nsolid-plugin install` command or programmatic `install()` contract. +- [ ] **Description**: Add the package-owned Pi strategy for only the exact unpinned canonical npm identity. Coalesce matching user/project scopes into one command, use `--no-approve` for user-only and disclosed/confirmed `--approve` for project scope, set project commands to the captured canonical root, and revalidate directory identity/settings/source/cache roots immediately before execution. Add a package-internal `nsolid-plugin-refresh-owned` binary executed from an integrity-verified tarball. Before launching it, the parent creates a durable snapshot/journal and passes a transaction manifest binding installation ID, canonical paths, tracking digest, and field-level MCP ownership. The child refuses stale or broadened identity, and the parent restores or recovers interrupted mutation independently of child availability. Do not change the public `nsolid-plugin install` command or programmatic `install()` contract. - **Depends on**: Tasks 2 and 5 - **Files**: `packages/core/src/update/strategies/pi.ts`, `packages/core/src/update/strategies/fallback.ts`, `packages/core/src/update/fallback-transaction.ts`, `packages/core/src/update/refresh-owned-cli.ts`, `packages/core/package.json`, `packages/core/src/skills/skill-copier.ts`, `packages/core/src/skills/skill-tracker.ts`, `packages/core/src/mcp/mcp-config-writer.ts`, corresponding unit/integration tests -- **Testing**: Verify Pi user-only/project-only/both scopes, one invocation for duplicate canonical identity, exact `--approve`/`--no-approve`, unchanged source/filter/trust settings, rejection of non-canonical/conflicting sources, package-owned skill boundaries, an unchanged-too-old cache, and a newer version published during native execution. For OpenCode/fallback, cover deterministic npm-then-pnpm executor selection, exact immutable package version, the exact internal-binary command arrays, isolated temporary cwd with a conflicting local binary, complete replacement, stale tracked-skill removal, an untracked new-bundle destination collision, MCP merge, tracking/version update, missing ownership, missing executor, child/reconciliation/validation failures, rollback of every component, preserved credentials/user artifacts, no implicit install for an absent target, and regression coverage proving repeated public `install` behavior is unchanged. References: Update Flow “Update Pi package-owned skills,” “Same canonical Pi identity exists in both scopes,” “Reject a non-canonical Pi source,” “Update OpenCode or another fallback installation,” “No supported exact-package executor is available,” “Direct/fallback refresh cannot prove ownership,” “New fallback bundle collides with an untracked destination,” “OpenCode or fallback refresh fails,” “Requested harness is not installed,” and “Preserve the public install contract.” +- **Testing**: Verify Pi user-only/project-only/both scopes, exact planned `cwd`, root/settings replacement between plan and execution, one invocation for duplicate canonical identity, exact `--approve`/`--no-approve`, unchanged source/filter/trust settings, rejection of non-canonical/conflicting sources, package-owned skill boundaries, and registry/content postconditions. For OpenCode/fallback, cover integrity-verified execution, exact transaction-manifest command arrays, isolated temporary cwd, shared paths, user-modified MCP fields, tracking digest/path/installation changes after approval, child timeout/crash/signal after each mutation boundary, parent rollback, next-run journal recovery, incomplete recovery, complete replacement, stale removal, collisions, missing ownership/executor, preserved user artifacts, no implicit install, and unchanged public `install` behavior. References: the Pi and fallback scenarios in Update Flow. ## Task 8: Implement transactional Antigravity update -- [ ] **Description**: Detect exactly one supported Antigravity layout pair: shared `~/.gemini/config/{plugins,import_manifest.json}` or AGY CLI `~/.gemini/antigravity-cli/{plugins,import_manifest.json}`. Add restrictive temporary backup of the detected staged root and matching N|Solid manifest entry, confirmed uninstall/install, new-root plus registration validation, successful cleanup, and rollback restoration that preserves unrelated imports. Return `unsupported` without mutation for ambiguous or unmatched layouts. +- [ ] **Description**: Detect exactly one supported Antigravity layout pair and resolve the canonical repository to a full immutable commit/content identity used for both lookup and installation. Add restrictive temporary backup of the detected staged root and matching N|Solid manifest entry, confirmed commit-pinned uninstall/install, content plus registration validation, successful cleanup, and rollback restoration that preserves unrelated imports. Return `unsupported` without mutation when layout or immutable source binding is unavailable. - **Depends on**: Tasks 2 and 5 - **Files**: `packages/core/src/update/antigravity-transaction.ts`, `packages/core/src/update/strategies/antigravity.ts`, related unit/integration tests - **Testing**: Cover both supported staged-root/manifest pairs, both-present ambiguity, unmatched root/manifest, successful replacement, declined confirmation, uninstall failure, install failure, root/manifest validation failure, rollback success/failure for both components, cleanup, unrelated import preservation, and credential preservation. References: Update Flow “Update Antigravity native plugin,” “Antigravity layout is ambiguous or unsupported,” and “Antigravity reinstall fails.” @@ -61,14 +61,14 @@ - [ ] **Description**: Implement scope validation, one-plan-item-per-installation semantics, a synthetic non-mutating `none` item for an explicitly requested absent harness, complete ordered execute/rollback steps before confirmation, deterministic target/ownership ordering, check-only short circuit, plan confirmation, sequential execution, per-installation lookup/execution failure isolation, aggregation, and public `getVersionInfo()`, `checkUpdates()`, and `update()` exports. Represent lookup/validation failures as sanitized non-mutating plan items, convert them to failed results without execution, and reject any strategy execution that attempts an external command absent from its approved immutable plan. - **Depends on**: Tasks 4 and 6–8 - **Files**: `packages/core/src/update/coordinator.ts`, `packages/core/src/update/index.ts`, `packages/core/src/index.ts`, coordinator/API tests -- **Testing**: Cover CLI-only default, one harness selecting multiple installations, `--all`, `--all` plus harness rejection, check-only no-execute guarantee, multi-command Codex/Antigravity and transactional fallback plans, undisclosed-command rejection, newer-than-registry no-downgrade, unsupported check/update exit semantics, one lookup or execution failure with later success, coexisting native/fallback records, empty inventory, all status counts, and overall success/exit semantics. References: Update Flow “Registry lookup fails,” “Update every detected target,” “Update coexisting native and fallback installations,” “Do not downgrade a CLI newer than the registry,” “One target fails during update-all,” “Check every detected target,” and “Conflicting update scopes.” +- **Testing**: Cover CLI-only default, one harness selecting multiple installations, `--all`, `--all` plus harness rejection, check-only no-execute guarantee, multi-command Codex/Antigravity and parent-journaled fallback plans, undisclosed-command rejection, newer-than-registry no-downgrade, exact exit codes `0`/`1`/`2`, precedence in mixed results, not-installed, mutation-blocking unknown, one lookup or execution failure with later success, coexisting native/fallback records, explicit empty-inventory success, all status counts, and overall success/exit semantics. References: the orchestration and output scenarios in Update Flow. ## Task 10: Add CLI commands and output formatting - [ ] **Description**: Add `version` and `update` command parsing, bare `--version`, `--check`/`--all`, confirmation integration, complete ordered execute/rollback plan display, human-readable summaries, JSON-only stdout, stderr progress, help text, installation identifiers/source-safe display, and exit-code mapping. - **Depends on**: Task 9 - **Files**: `packages/core/src/cli.ts`, `packages/core/src/utils/format.ts` or a new update formatter, CLI help/unit/integration tests -- **Testing**: Spawn the CLI with every supported scope/output combination. Assert exact JSON parseability, `latestVersion` and `installationId` presence, complete multi-step plans before prompts, no ANSI in JSON, no mutation in check mode, prompts only when appropriate, redacted source/environment values, sanitized errors, bare `--version` parity, no downgrade for `newer-than-registry`, informational exit zero for successful checks, and non-zero mutable exits for unsupported/failure results. References: Update Flow “Structured update output,” “Update every detected target,” “Report versions with the conventional flag,” “Do not downgrade a CLI newer than the registry,” and “Non-interactive CLI update.” +- **Testing**: Spawn the CLI with every supported scope/output combination. Assert exact JSON parseability including `exitCode`, complete plans before prompts, no ANSI in JSON, no mutation in check mode, prompts only when appropriate, redaction, bare `--version` parity, and exact process/summary codes: `0` for completed or informational no-op, `1` for operational failure, and `2` for unavailable mutation or missing approval, including precedence and empty `--all`. References: Update Flow “Structured update output,” “Update every detected target,” “Report versions with the conventional flag,” “Do not downgrade a CLI newer than the registry,” and “Non-interactive CLI update.” ## Task 11: Add atomic release preparation @@ -79,10 +79,10 @@ ## Task 12: Add release drift and payload checks -- [ ] **Description**: Implement `release:check`, including source/package equality, generated artifact checks, exact mismatch reporting, and cleanup-state validation. When and only when `--release` is present, compare the specification's explicit payload allowlist with the latest semantic-version tag and validate that payload changes have an update-visible version. +- [ ] **Description**: Implement `release:check`, including source/package equality, generated artifact checks, exact mismatch reporting, and cleanup-state validation. When and only when `--release` is present, select the specification's highest eligible local semantic-version tag by name, peeled commit, and `HEAD` ancestry; then compare the complete published-payload allowlist and validate that payload changes have an update-visible version. - **Depends on**: Task 11 - **Files**: `scripts/check-release-version.mjs`, `package.json`, script fixture tests -- **Testing**: Introduce drift independently in every controlled file, stale generated output, unchanged version with changes in each payload allowlist category, malformed/missing tag state, and materialized package skills. Verify normal and `--release` check modes never repair. References: Release Versioning “Check synchronized release versions,” “Release version drift is detected,” and “Skill changes retain the previous release version.” +- **Testing**: Introduce drift independently in every controlled file, stale generated output, unchanged version with changes in each payload allowlist category including `packages/core/src/**` and `packages/pi-plugin/index.js`, committed/staged/unstaged/untracked payload changes, and materialized package skills. Cover `X.Y.Z`/`vX.Y.Z`, lightweight/annotated tags, non-semantic tags, non-ancestor tags, duplicate-version ambiguity, missing/malformed-only local tags, remote-only tags, and shallow history. Verify deterministic highest-eligible selection and that normal and `--release` modes never repair. References: all Release Versioning baseline and payload scenarios. ## Task 13: Add end-to-end update regression coverage From 922b71681da31597d2ce22560d53bebf4a598280 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Tue, 25 Aug 2026 18:00:40 +0200 Subject: [PATCH 05/12] feat(update): implement approved update flow --- README.md | 24 + openspec/changes/add-update-flow/design.md | 44 +- .../changes/add-update-flow/implementation.md | 173 +++++ openspec/changes/add-update-flow/proposal.md | 1 + .../add-update-flow/specs/update-flow/spec.md | 80 ++- openspec/changes/add-update-flow/tasks.md | 20 +- package.json | 4 +- packages/core/README.md | 11 + packages/core/package.json | 3 +- packages/core/src/cli.ts | 145 +++- .../core/src/harnesses/pi-plugin-detector.ts | 2 +- packages/core/src/index.ts | 35 + packages/core/src/mcp/mcp-tracker.ts | 32 + packages/core/src/skills/skill-linker.ts | 7 +- packages/core/src/skills/skill-tracker.ts | 47 +- .../src/update/antigravity-transaction.ts | 213 ++++++ packages/core/src/update/claude-record.ts | 15 + packages/core/src/update/codex-config.ts | 186 +++++ packages/core/src/update/codex-transaction.ts | 460 ++++++++++++ packages/core/src/update/command-runner.ts | 503 +++++++++++++ packages/core/src/update/coordinator.ts | 376 ++++++++++ packages/core/src/update/fallback-journal.ts | 262 +++++++ .../core/src/update/fallback-ownership.ts | 37 + .../core/src/update/fallback-transaction.ts | 399 +++++++++++ packages/core/src/update/fs-transaction.ts | 64 ++ packages/core/src/update/index.ts | 45 ++ packages/core/src/update/integrity.ts | 21 + packages/core/src/update/inventory.ts | 669 ++++++++++++++++++ packages/core/src/update/native-evidence.ts | 65 ++ packages/core/src/update/package-content.ts | 138 ++++ packages/core/src/update/package-manager.ts | 221 ++++++ packages/core/src/update/redaction.ts | 16 + packages/core/src/update/refresh-owned-cli.ts | 51 ++ .../core/src/update/strategies/antigravity.ts | 52 ++ packages/core/src/update/strategies/claude.ts | 116 +++ .../core/src/update/strategies/cli-package.ts | 106 +++ packages/core/src/update/strategies/codex.ts | 109 +++ packages/core/src/update/strategies/common.ts | 88 +++ .../core/src/update/strategies/fallback.ts | 254 +++++++ packages/core/src/update/strategies/pi.ts | 258 +++++++ .../core/src/update/transaction-commands.ts | 20 + packages/core/src/update/types.ts | 367 ++++++++++ packages/core/src/update/version-source.ts | 415 +++++++++++ packages/core/src/update/version.ts | 119 ++++ .../core/test/integration/update-flow.test.ts | 122 ++++ .../update/antigravity-transaction.test.ts | 70 ++ .../test/unit/update/claude-record.test.ts | 11 + .../unit/update/cli-package-strategy.test.ts | 25 + .../unit/update/codex-transaction.test.ts | 297 ++++++++ .../test/unit/update/command-runner.test.ts | 307 ++++++++ .../core/test/unit/update/coordinator.test.ts | 241 +++++++ .../test/unit/update/fallback-journal.test.ts | 151 ++++ .../unit/update/fallback-strategy.test.ts | 93 +++ .../unit/update/fallback-transaction.test.ts | 297 ++++++++ .../core/test/unit/update/integrity.test.ts | 25 + .../core/test/unit/update/inventory.test.ts | 389 ++++++++++ .../test/unit/update/package-manager.test.ts | 285 ++++++++ .../test/unit/update/pi-provenance.test.ts | 227 ++++++ .../core/test/unit/update/strategies.test.ts | 436 ++++++++++++ .../test/unit/update/version-source.test.ts | 203 ++++++ .../core/test/unit/update/version.test.ts | 40 ++ packages/pi-plugin/README.md | 9 + scripts/check-release-version.mjs | 195 +++++ scripts/prepare-release.mjs | 147 ++++ 64 files changed, 9808 insertions(+), 35 deletions(-) create mode 100644 openspec/changes/add-update-flow/implementation.md create mode 100644 packages/core/src/update/antigravity-transaction.ts create mode 100644 packages/core/src/update/claude-record.ts create mode 100644 packages/core/src/update/codex-config.ts create mode 100644 packages/core/src/update/codex-transaction.ts create mode 100644 packages/core/src/update/command-runner.ts create mode 100644 packages/core/src/update/coordinator.ts create mode 100644 packages/core/src/update/fallback-journal.ts create mode 100644 packages/core/src/update/fallback-ownership.ts create mode 100644 packages/core/src/update/fallback-transaction.ts create mode 100644 packages/core/src/update/fs-transaction.ts create mode 100644 packages/core/src/update/index.ts create mode 100644 packages/core/src/update/integrity.ts create mode 100644 packages/core/src/update/inventory.ts create mode 100644 packages/core/src/update/native-evidence.ts create mode 100644 packages/core/src/update/package-content.ts create mode 100644 packages/core/src/update/package-manager.ts create mode 100644 packages/core/src/update/redaction.ts create mode 100644 packages/core/src/update/refresh-owned-cli.ts create mode 100644 packages/core/src/update/strategies/antigravity.ts create mode 100644 packages/core/src/update/strategies/claude.ts create mode 100644 packages/core/src/update/strategies/cli-package.ts create mode 100644 packages/core/src/update/strategies/codex.ts create mode 100644 packages/core/src/update/strategies/common.ts create mode 100644 packages/core/src/update/strategies/fallback.ts create mode 100644 packages/core/src/update/strategies/pi.ts create mode 100644 packages/core/src/update/transaction-commands.ts create mode 100644 packages/core/src/update/types.ts create mode 100644 packages/core/src/update/version-source.ts create mode 100644 packages/core/src/update/version.ts create mode 100644 packages/core/test/integration/update-flow.test.ts create mode 100644 packages/core/test/unit/update/antigravity-transaction.test.ts create mode 100644 packages/core/test/unit/update/claude-record.test.ts create mode 100644 packages/core/test/unit/update/cli-package-strategy.test.ts create mode 100644 packages/core/test/unit/update/codex-transaction.test.ts create mode 100644 packages/core/test/unit/update/command-runner.test.ts create mode 100644 packages/core/test/unit/update/coordinator.test.ts create mode 100644 packages/core/test/unit/update/fallback-journal.test.ts create mode 100644 packages/core/test/unit/update/fallback-strategy.test.ts create mode 100644 packages/core/test/unit/update/fallback-transaction.test.ts create mode 100644 packages/core/test/unit/update/integrity.test.ts create mode 100644 packages/core/test/unit/update/inventory.test.ts create mode 100644 packages/core/test/unit/update/package-manager.test.ts create mode 100644 packages/core/test/unit/update/pi-provenance.test.ts create mode 100644 packages/core/test/unit/update/strategies.test.ts create mode 100644 packages/core/test/unit/update/version-source.test.ts create mode 100644 packages/core/test/unit/update/version.test.ts create mode 100644 scripts/check-release-version.mjs create mode 100644 scripts/prepare-release.mjs diff --git a/README.md b/README.md index 8bb3d41..6cd855d 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,23 @@ nsolid-plugin setup --harness nsolid-plugin install --harness ``` +### Check and update + +The updater plans changes before executing them and never starts OAuth. Version reporting is read-only: + +```bash +nsolid-plugin version +nsolid-plugin --version +nsolid-plugin update --check +nsolid-plugin update --all --check --json +``` + +The default `update` scope is the globally installed CLI. Use `--harness claude|codex|opencode|antigravity|pi` for one harness or `--all` for the CLI plus every detected installation. Mutating updates require an interactive confirmation or `--yes`; non-interactive automation should use `--yes --json`. + +Ownership remains with each tool. Claude uses its detected plugin ID and scope, Codex refreshes the detected marketplace and transactionally removes/adds that same ID, and Antigravity backs up its staged root and matching import manifest before reinstalling the fixed GitHub source. Pi runs its native unpinned package update once for the detected user/project scopes. OpenCode and tracked fallback installs use the exact published package's internal refresh entrypoint; they do not invoke `opencode plugin`. + +The updater preserves credentials, user-owned configuration, unrelated MCP entries, and native marketplace identities. It refuses ambiguous or unsupported sources, never silently downgrades a CLI newer than the registry, and rolls back owned fallback/Antigravity/Codex state when validation fails. The public `nsolid-plugin install` command and programmatic `install()` behavior remain unchanged. + Without a global install, use `npx -y nsolid-plugin setup --harness ` and `npx -y nsolid-plugin install --harness `. Use direct CLI install as the primary install path for OpenCode. For Claude Code, Codex CLI, and Antigravity CLI, prefer the native plugin commands below and keep `nsolid-plugin install` for fallback or repair. For Pi Agent, skills come from `nsolid-pi-plugin`; the CLI writes Pi MCP config only. @@ -215,10 +232,17 @@ pnpm plugin:sync # Regenerate manifests/conf pnpm plugin:materialize # Copy root skills into the Pi package for pack/release pnpm plugin:root # Refresh root marketplace/plugin manifests from bundle.json pnpm plugin:root:check # Fail if committed root manifests drift from bundle.json +pnpm release:prepare -- patch # Prepare the next release version atomically +pnpm release:check # Check synchronized versions and generated payload +pnpm release:check --release # Also require payload changes to bump the release version ``` Run `pnpm plugin:check` in CI and before release. The source tree keeps one canonical skill copy under root `skills/`; package-local `skills/` directories are materialized only for npm package release and cleaned afterward by package sync scripts. +### Release order + +`release:prepare` only updates and validates local files; it never publishes or creates Git state. After review, run `pnpm release:check --release`, publish `nsolid-plugin@` first, publish the same-version `nsolid-pi-plugin` second, then commit and push the generated root manifests and the matching semantic Git tag. Run `pnpm plugin:clean` after any interrupted package materialization. The first release that introduces this updater must be bootstrapped manually because an older CLI cannot update itself before the new package and native marketplace payload are published. + ## Troubleshooting ### Run the doctor command diff --git a/openspec/changes/add-update-flow/design.md b/openspec/changes/add-update-flow/design.md index 74c1fc3..65eebfe 100644 --- a/openspec/changes/add-update-flow/design.md +++ b/openspec/changes/add-update-flow/design.md @@ -66,6 +66,7 @@ Release preparation is a separate maintainer-side script. Runtime update code re `packages/core/src/update/package-manager.ts` - Detects npm or pnpm only when the real CLI package/entrypoint is contained by that manager's reported global root and the corresponding executable is available; a shim or package-manager environment variable alone is not sufficient evidence. +- Resolves the manager executable into exactly one supported `ExecutableIdentity` (below): on a non-Windows host a shell-free native/JS entrypoint; on Windows a validated native `.exe`/`.com` or a derived immutable JS entrypoint executed with `process.execPath` and `shell: false`. A bare `npm`/`pnpm` launcher name is never passed to `spawn` with `shell: false`. - Produces a fixed executable plus argument array. - Downloads only the planned tarball from the planned registry, verifies its integrity before execution, and gives npm/pnpm the verified local tarball rather than re-resolving `name@version` through ambient registry configuration. - Verifies post-update package identity against the planned name, version, registry provenance, and integrity/content digest rather than accepting version equality alone. @@ -73,10 +74,27 @@ Release preparation is a separate maintainer-side script. Runtime update code re `packages/core/src/update/command-runner.ts` -- Wraps `spawn`/`spawnSync` with `shell: false`. +- Wraps `spawn`/`spawnSync` with `shell: false` and an immutable `ExecutableIdentity`. `shell: true` and launching through `cmd.exe` are never used. +- `ExecutableIdentity` is one of: a validated absolute native executable (`.exe`/`.com` on Windows), `process.execPath` plus an absolute, existence/content-verified JS entrypoint, or a Windows shim that is validated to be npm-generated and from which an immutable absolute JS entrypoint is derived and then executed with `process.execPath` under `shell: false`. A `.cmd`/`.bat` shim whose format or target cannot be verified, and any `.ps1`-only launcher, resolve to `unsupported` with manual guidance. +- Resolves executable/entrypoint paths over `PATH`/`Path` case-insensitively (respecting the Windows `Path` casing), honours `PATHEXT`, ignores empty and cwd-relative path segments, returns an absolute path plus identity evidence, and revalidates that identity immediately before `spawn`. - Accepts executable and argument arrays, controlled environment additions, timeout, and output mode. +- Confirms the termination of the whole descendant process tree before any rollback runs. On Windows it uses controlled tree termination; when termination cannot be confirmed it leaves the journal recoverable (or defers rollback) rather than restoring concurrently. - Redacts tokens, authorization headers, and credential paths from captured diagnostics. -- Is injected in tests so no real package manager or harness command runs. +- Is injected in unit tests so no real package manager or harness command runs there; targeted platform integration tests exercise the real resolver/spawn boundary with controlled fixture executables and shims. + +`packages/core/src/update/path-normalize.ts` + +- Provides the single shared path normalization used consistently by planning, tracking digests, plan display, manifests, execution, and rollback: `path.resolve`, platform separators, and Windows drive/root and case-insensitive semantics. +- Never applies a universal `toLowerCase` to paths that may live on case-sensitive directories; equivalence for Windows compares drive/root and raw segments case-insensitively only where case-insensitive semantics are proven. +- Rejects UNC or remote paths up front when their equivalence to local owned paths cannot be guaranteed. + +`packages/core/src/update/fs-transaction.ts` + +- Records each owned path kind via `lstat` as `junction`, `copy`, or `directory` before acting and, on Windows, never dereferences a junction to determine ownership or to delete it. +- Creates staging and backup targets as siblings on the same volume as each target so `rename`-into-place stays on one volume. +- Requires the destination to be absent (or already owned-and-backed-up) before `rename`-into-place. +- Applies bounded retries for `EPERM`, `EBUSY`, and `ENOTEMPTY` with revalidation between attempts and a non-mutating `unsupported`/failure path if the path identity drifts. +- Edits config and manifest files byte-preserving: CRLF line endings, comments, and unrelated entries are retained; mutation is verified by reloading and comparing only the owned slice. `packages/core/src/update/strategies/*.ts` @@ -95,17 +113,19 @@ Release preparation is a separate maintainer-side script. Runtime update code re `packages/core/src/update/antigravity-transaction.ts` - Resolves only the two documented global NodeSource layout pairs: shared Antigravity under `~/.gemini/config/` and AGY CLI under `~/.gemini/antigravity-cli/`. -- Creates a temporary backup before replacement containing the detected staged root and the N|Solid entry in that root's matching `import_manifest.json`. +- Creates a temporary backup before replacement at a sibling path selected by the shared `fs-transaction` rules on the same volume as the target, containing the detected staged root and the N|Solid entry in that root's matching `import_manifest.json`. - Validates the newly staged root by checking `plugin.json`, `bundle.json`, canonical skill presence, and source registration in the import manifest. -- Restores both the staged root and the saved manifest entry if reinstall or validation fails, preserving unrelated manifest imports. +- Restores both the staged root and the saved manifest entry if reinstall or validation fails, preserving unrelated manifest imports and retrying bounded `EPERM`/`EBUSY`/`ENOTEMPTY` with revalidation before any restore is declared failed. +- Edits `import_manifest.json` byte-preserving (CRLF, comments, unrelated imports retained) and verifies the owned slice after mutation. `packages/core/src/update/fallback-transaction.ts` - Resolves one available package executor (`npm exec` preferred, otherwise `pnpm dlx`), verifies the planned `nsolid-plugin` tarball integrity, and runs that verified local artifact from a restrictive temporary working directory so workspace binaries/configuration cannot shadow the payload. -- Before launching the child, the parent creates and fsyncs a restrictive durable journal plus complete snapshot of the selected installation's tracked skill directories, affected MCP fields, links, and tracking state. The journal records `prepared`, `mutating`, and `committed` phases and remains recoverable if the package executor or child times out, crashes, or is killed. +- Before launching the child, the parent creates and fsyncs a durable journal plus complete snapshot of the selected installation's tracked skill directories, affected MCP fields, links, and tracking state. The journal records `prepared`, `mutating`, and `committed` phases and remains recoverable if the package executor or child times out, crashes, or is killed. Durability (fsync) is stated separately from confidentiality: the journal lives under a private user-owned root and staging sits beside the target; no `chmod 0600` ACL guarantee is promised where Windows ACLs are not controlled. - Invokes the exact package's dedicated internal `nsolid-plugin-refresh-owned` binary with a parent-created transaction manifest. The manifest binds `installationId`, harness, canonical owned paths, tracking-file path and digest, and field-level MCP ownership; the regular `nsolid-plugin install` command and programmatic `install()` contract are not changed. - The child validates that the live tracking digest, installation identity, canonical paths, and MCP fields still equal the approved manifest before mutation. It refuses stale, sibling, broadened, or ambiguous identity rather than rediscovering a target from `--harness` alone. -- Reconciles the installed asset set against the new bundle: complete skill directories are replaced, previously tracked skills absent from the new bundle are removed, and untracked/user-owned paths and unrelated MCP entries are preserved. +- Reconciles the installed asset set against the new bundle: complete skill directories are replaced, previously tracked skills absent from the new bundle are removed, and untracked/user-owned paths and unrelated MCP entries are preserved. Junctions are recorded via `lstat` and never dereferenced for ownership or deletion. +- On failure the parent first confirms the descendant process tree is terminated, then restores its snapshot; it never restores concurrently with a possibly-live child. When tree termination cannot be confirmed, the parent leaves the journal recoverable/deferred rather than restoring partially. - Updates `bundleVersion` only after the new skills, MCP entries, and tracking data validate. The parent marks the journal committed and removes it only after post-update validation; otherwise it restores its snapshot independently of child-process availability. - On every later update invocation, the parent recovers or reports any non-committed journal before planning new mutation. - Treats direct artifacts without sufficient tracking ownership as `unsupported` rather than deleting paths by name or prefix. @@ -139,6 +159,7 @@ Release preparation is a separate maintainer-side script. Runtime update code re - Fallback tracking adds an optional `bundleVersion`, canonical per-installation skill/link paths, and MCP ownership evidence per JSON field/value so shared paths and user-modified fields cannot be claimed by name alone. - Readers accept legacy tracking for reporting, but automatic mutation is `unsupported` until the selected installation has complete per-path and field-level ownership evidence; compatibility never authorizes a name-only write or deletion. +- Tracking paths and digests use the shared `path-normalize` so plan, manifest, execution, and rollback compare identical normalized identities. ### Release modules @@ -479,8 +500,7 @@ Rules enforced by these contracts: | Target | Native/package action | Success guidance | |---|---|---| -| CLI npm | verify the planned tarball integrity, then `npm install --global ` | invoke CLI again | -| CLI pnpm | verify the planned tarball integrity, then `pnpm add --global ` | invoke CLI again | +| CLI npm/pnpm | verify the planned tarball integrity, then run the manager through its resolved `ExecutableIdentity` (native `.exe`/`.com`, or `process.execPath` + verified JS entrypoint; a bare `npm`/`pnpm` name or unverified `.cmd`/`.bat`/`.ps1` shim is never spawned) with `--global` on the verified local tarball | invoke CLI again | | Claude | `claude plugin update --scope ` | `/reload-plugins` or restart | | Codex | `codex plugin marketplace upgrade `, then `codex plugin remove ` and `codex plugin add ` | start a new session | | Antigravity | `agy plugin uninstall nsolid-plugin`, then install the canonical repository pinned to the planned full commit | restart AGY | @@ -636,7 +656,8 @@ sequenceDiagram ## Error Handling and Safety - Network lookups have explicit timeouts and schema validation. -- Missing executables use a distinct error code from command failure. +- Missing executables use a distinct error code from command failure. Executables are resolved to an absolute, identity-verified target (see `command-runner`); resolution never trusts a bare name, cwd-relative, or unvalidated `.cmd`/`.bat`/`.ps1` shim. +- Timeouts confirm descendant-tree termination before rollback; on Windows, controlled tree termination is used and, when termination cannot be confirmed, rollback is deferred/left recoverable rather than restoring concurrently. - Process output is bounded before being retained in results. - Existing logger redaction is applied to verbose diagnostics. - `--all` catches version-lookup, planning, and execution errors at the installation boundary and continues with independent installation records, including native and fallback records for the same harness. @@ -645,10 +666,11 @@ sequenceDiagram - CLI and fallback package execution uses the registry, tarball, and integrity identity from the plan; mutable dist-tags and ambient registry resolution are not used during mutation, and package-manager success without matching on-disk content evidence is a failure. - Pi user-only updates pass `--no-approve`; `--approve` is used only when the immutable plan identifies, displays, executes within, and immediately revalidates a project-scoped canonical package root. Canonical entries across both scopes are updated once, while changed/conflicting/pinned entries block automatic mutation. - Codex removes the installed plugin only after marketplace refresh and backup succeed. Rollback validates the restored registration and cached payload while preserving unrelated `config.toml` entries. -- Antigravity accepts only one unambiguous documented layout pair. Backup paths are created with restrictive permissions in an OS temporary directory and always cleaned after success. Rollback validates both the staged root and its matching saved import-manifest registration. -- OpenCode/fallback replacement mutates only paths and MCP fields bound to the approved installation manifest. The parent-owned durable journal restores overwritten and stale-removed skill directories, config, and tracking after child failure, timeout, signal, or interrupted prior execution. +- Antigravity accepts only one unambiguous documented layout pair. Backups are staged beside the target on the same volume; `chmod`-based confidentiality is not promised on Windows (ACLs govern there). Rollback validates both the staged root and its matching saved import-manifest registration and retries bounded `EPERM`/`EBUSY`/`ENOTEMPTY` with revalidation. +- OpenCode/fallback replacement mutates only paths and MCP fields bound to the approved installation manifest. The parent-owned durable journal restores overwritten and stale-removed skill directories, config, and tracking after child failure, timeout, signal, or interrupted prior execution, but only after confirming the child's process tree has terminated. - Update does not invoke setup, login, or auth modules. - Release scripts snapshot only explicit controlled files; rollback never performs broad Git or recursive workspace resets. Release payload checking includes runtime source inputs that are compiled or copied into both published packages. +- A read-only `--check` performs no subprocess mutation: only read-only inventory, version resolution, planning, and formatting run. ## Migration Strategy diff --git a/openspec/changes/add-update-flow/implementation.md b/openspec/changes/add-update-flow/implementation.md new file mode 100644 index 0000000..1a89102 --- /dev/null +++ b/openspec/changes/add-update-flow/implementation.md @@ -0,0 +1,173 @@ +# Implementation record + +This branch implements the approved `add-update-flow` change from +`cesar/update-flow-spec@5812b0f`. The proposal, design, tasks, and both +normative specs are unchanged. The implementation is recorded here against +the complete branch diff, not only the last corrective pass. + +## Scope and invariants + +- The updater remains a minor, additive feature. Existing installation APIs + and the public `nsolid-plugin install` workflow are preserved. +- Every mutating plan carries an immutable artifact identity: npm registry, + exact version, tarball and integrity, or Git repository, full commit and + content digest. A mutable ref, an ambient registry re-resolution, or an + unsupported harness source produces a non-mutating result. +- Native and fallback installations remain separate plan items. Ownership is + tracked per installation, skill/link path, tracking field, and MCP field; + unrelated user-owned state is never included in a mutation or rollback. +- Fallback recovery is parent-owned and durable. A child process cannot be + the only source of rollback truth. +- `--check` is read-only, JSON stdout contains one valid document, and the + summary exposes the approved exit-code contract (`0`, `1`, `2`). + +## Implemented areas + +### Update domain, sources, and command execution + +- Added `packages/core/src/update/types.ts` with the approved contracts for + versions, targets, ownership, installations, artifact identities, plans, + execute/rollback steps, sanitized errors, results, summaries, commands, + confirmations, context, and strategies. +- Added strict stable-semver parsing/comparison in + `packages/core/src/update/version.ts`. +- Added shell-free, argument-array command execution with bounded output, + executable lookup, timeouts, and sanitized diagnostics in + `packages/core/src/update/command-runner.ts`. +- Added registry, npm tarball/integrity, Git commit/content, and local-source + resolution in `packages/core/src/update/version-source.ts`. Resolution, + execution, and post-update verification use the same frozen identity. +- Added package-manager detection and positive realpath ownership checks in + `packages/core/src/update/package-manager.ts`; unsupported workspace, + `npx`, Volta, Yarn, Bun, mismatched-root, and ambiguous launches do not + mutate. + +### Inventory and existing ownership state + +- Added complete installation discovery, source evidence, deterministic + ordering, target/scope filters, and empty-inventory handling in + `packages/core/src/update/inventory.ts`. +- Extended `packages/core/src/skills/skill-tracker.ts` and + `packages/core/src/skills/skill-linker.ts` with per-installation paths, + ownership, bundle-version compatibility evidence, and safe reconciliation. +- Extended `packages/core/src/mcp/mcp-tracker.ts` with field-level ownership + and digest evidence. +- Updated `packages/core/src/harnesses/pi-plugin-detector.ts` so Pi project + roots, effective settings, source identity, scope, and cache roots can be + captured and revalidated. +- Kept native and fallback records distinct and preserved legacy tracking + without requiring a new public installer contract. + +### Strategies and transactional mutations + +- CLI package updates: `strategies/cli-package.ts` plans exact-version npm or + pnpm operations, uses the positively identified package-manager executable, + verifies the installed package/version, and reports exact rollback guidance. +- Claude native updates: `strategies/claude.ts` uses the detected plugin ID + and installation scope only. +- Codex native updates: `strategies/codex.ts` and + `codex-transaction.ts` refresh the exact detected marketplace/plugin, + snapshot registration, enablement, user fields, and cache, then validate or + restore the transaction without touching neighboring plugins. +- Pi package-owned updates: `strategies/pi.ts` coalesces only matching user + and project scopes, chooses the approved approval mode, sets the captured + project root, and revalidates settings, source, directory identity, and + caches immediately before mutation. +- Antigravity native updates: `strategies/antigravity.ts` and + `antigravity-transaction.ts` operate on one supported staged-root/manifest + pair, use the pinned Git identity, validate both content and registration, + and restore unrelated imports on failure. +- OpenCode/fallback updates: `strategies/fallback.ts`, + `fallback-transaction.ts`, `fallback-journal.ts`, and + `refresh-owned-cli.ts` implement exact-package execution from a verified + tarball, a restrictive transaction manifest, atomic durable `prepared`, + `mutating`, and `committed` journal phases, field-level MCP checks, parent + rollback/recovery, timeout/crash handling, and stale-journal recovery on the + next mutable invocation. +- The private refresh binary is invoked only with `--transaction `; + executable harness-only ownership rediscovery was not introduced. + +### Coordinator, API, and CLI + +- Added `packages/core/src/update/coordinator.ts` for deterministic inventory, + scope validation, one plan item per installation, explicit absent-harness + `none` items, complete execute/rollback plans before confirmation, + check-only short-circuiting, sequential execution, independent failure + isolation, aggregation, and the approved exit-code precedence. +- Added `packages/core/src/update/index.ts` and exports in + `packages/core/src/index.ts` for `getVersionInfo()`, `checkUpdates()`, + `planUpdates()`, and `update()`. +- Extended `packages/core/src/cli.ts` with `version`, bare `--version`, + `update`, `--check`, `--all`, target/ownership validation, confirmation and + non-interactive approval, human-readable output, JSON-only stdout, sanitized + errors, recovery reporting, and manual remove/add guidance. + +### Release, packaging, and documentation + +- Added atomic `scripts/prepare-release.mjs` and read-only + `scripts/check-release-version.mjs`. +- Added the `release:prepare` and `release:check` package scripts in the root, + and registered the private `nsolid-plugin-refresh-owned` binary in + `packages/core/package.json`, while leaving the private root package version + untouched. +- Release checks cover generated artifacts, source/package equality, the full + published payload (including `packages/core/src/**` and + `packages/pi-plugin/index.js`), committed/staged/unstaged/untracked + changes, semantic `X.Y.Z`/`vX.Y.Z` tags, peeled annotated tags, ancestry, + duplicate versions, missing tags, and shallow history. +- Updated `README.md`, `packages/core/README.md`, and + `packages/pi-plugin/README.md` with user, maintainer, automation, rollback, + unsupported-wrapper, scope/trust, publication, and first-release guidance. + +### Branch file inventory + +The branch changes are distributed across the following implementation +surfaces: + +- Update runtime: `packages/core/src/update/{types,version,command-runner, + version-source,package-manager,inventory,coordinator,index, + refresh-owned-cli,fallback-journal,fallback-transaction, + codex-transaction,antigravity-transaction}.ts` and + `packages/core/src/update/strategies/{common,cli-package,claude,codex,pi, + antigravity,fallback}.ts`. +- Existing integration points: `packages/core/src/cli.ts`, + `packages/core/src/index.ts`, `packages/core/src/harnesses/pi-plugin-detector.ts`, + `packages/core/src/mcp/mcp-tracker.ts`, + `packages/core/src/skills/skill-linker.ts`, and + `packages/core/src/skills/skill-tracker.ts`. +- Regression coverage: `packages/core/test/integration/update-flow.test.ts` + and the update unit suites for the command runner, version/source logic, + package-manager ownership, inventory, CLI strategy, Codex, Antigravity, + fallback, and semver behavior. +- Release and package surfaces: `scripts/prepare-release.mjs`, + `scripts/check-release-version.mjs`, root `package.json`, and + `packages/core/package.json`. +- User/maintainer documentation: `README.md`, `packages/core/README.md`, + and `packages/pi-plugin/README.md`. + +## Tests and verification + +The implementation branch was verified with the following successful gates: + +- `openspec validate add-update-flow --strict` +- `pnpm lint` +- `pnpm build` +- `pnpm test:unit` (41/41) +- `pnpm test:integration` (125 tests, 26 suites) +- `pnpm test:marketplace` (all checks) +- `pnpm test` (539 tests, 104 suites) +- `pnpm plugin:check` +- `pnpm release:check` +- package dry-runs for both publishable packages +- `pnpm plugin:sync` cleanup and `git diff --check` + +The resulting commit is `3f7efff` (`feat(update): implement approved update +flow`). The worktree is clean and the approved OpenSpec documents remain +unchanged. + +`pnpm release:check --release` correctly reports that the current payload has +changed since `v1.0.1` without an update-visible version. This is the expected +release gate until `release:prepare` is run for a future release; it is not an +implementation failure. A live upgrade against a newly published candidate is +also intentionally not claimed here because no candidate is currently +available. diff --git a/openspec/changes/add-update-flow/proposal.md b/openspec/changes/add-update-flow/proposal.md index c1853b5..4e73daf 100644 --- a/openspec/changes/add-update-flow/proposal.md +++ b/openspec/changes/add-update-flow/proposal.md @@ -79,6 +79,7 @@ For maintainers: - Native and fallback installations detected for the same harness are represented and updated as separate targets; one ownership never silently replaces the other. - Interactive destructive/replacement steps require confirmation; `--yes` enables non-interactive automation. - Network, registry, missing-binary, permission, corrupt-state, and partial-update failures are covered by tests and never expose credentials. +- Windows updates run without `shell: true`/`cmd.exe`: CLI/fallback package managers execute through a resolved executable identity (native `.exe`/`.com` or `process.execPath` + verified JS entrypoint; unverified `.cmd`/`.bat`/`.ps1` shims are `unsupported`), executable lookup is case-insensitive over `PATH`/`Path` with `PATHEXT`, timeouts confirm descendant-tree termination before rollback, junctions are never dereferenced for ownership or deletion, staging/backup stay on one volume, and config/manifest edits preserve CRLF and unrelated entries — with real `windows-latest` CI coverage extending the existing matrix. - Release preparation propagates one requested semantic version to every version-bearing source/generated file and never publishes, tags, commits, or pushes. - Release checking fails when package, bundle, or generated manifest versions drift. - Existing installation, authentication, uninstall, restore, doctor, lint, build, and test behavior remains green. diff --git a/openspec/changes/add-update-flow/specs/update-flow/spec.md b/openspec/changes/add-update-flow/specs/update-flow/spec.md index 3da87fc..72d67be 100644 --- a/openspec/changes/add-update-flow/specs/update-flow/spec.md +++ b/openspec/changes/add-update-flow/specs/update-flow/spec.md @@ -33,7 +33,7 @@ The updater SHALL compare installed and latest versions without invoking any mut **When** the user runs `nsolid-plugin update --check` **Then** the command compares the running CLI semantic version with the registry version **And** reports `current`, `update-available`, or `newer-than-registry` -**And** does not invoke a package manager or modify any file +**And** does not invoke a mutating package-manager or harness command or modify any file **And** `--json` returns the current version, latest version, status, and target identifier **And** exits successfully, including when the status is `update-available` @@ -129,6 +129,70 @@ The default `nsolid-plugin update` scope SHALL update only a positively identifi **And** the result status is `unsupported` **And** a mutating update exits with code `2` while a read-only check exits with code `0` +### Requirement: Cross-platform-safe command execution + +The updater SHALL run every external command without a shell and with a resolved, immutable executable identity, and SHALL behave deterministically on Windows shims, PATH/PATHEXT lookup, and timeout tree termination. + +#### Scenario: Execute a native or Node-executed manager on Windows + +**Given** the CLI self-update manager on Windows resolves to a native `.exe`/`.com` or to `process.execPath` plus a verified immutable JS entrypoint +**When** the command runner executes the plan step +**Then** it spawns directly with `shell: false` and `shell: true`/`cmd.exe` are never used +**And** an unvalidated `.cmd`/`.bat` shim whose format or target cannot be proven npm-generated, or any `.ps1`-only launcher, is `unsupported` instead of executed +**And** the resolved absolute path and identity evidence are revalidated immediately before `spawn` + +#### Scenario: Resolve executables through case-insensitive PATH and PATHEXT + +**Given** executable lookup runs on a platform where path/drive casing is case-insensitive (Windows) +**When** the runner resolves a manager or harness executable +**Then** it searches `PATH`/`Path` case-insensitively, honours `PATHEXT`, ignores empty and cwd-relative segments, and returns an absolute path with identity evidence +**And** a bare name, cwd-relative path, or ambiguous match is never used to isolate mutation + +#### Scenario: Timeout terminates the whole process tree before rollback + +**Given** a package executor or child process exceeds the timeout during a mutating transaction +**When** timeout handling completes +**Then** the runner confirms the entire descendant process tree is terminated before any rollback or restore runs +**And** on Windows it uses controlled tree termination; if termination cannot be confirmed it leaves the journal recoverable or defers rollback, never restoring concurrently +**And** the result reports the timed-out status and the rollback/recovery plan chosen + +### Requirement: Cross-platform-safe filesystem transactions + +The updater SHALL treat junctions, cross-volume moves, and Windows file-locking semantics explicitly so rollback and replacement are safe and byte-preserving. + +#### Scenario: Junction is never dereferenced for ownership or deletion + +**Given** an owned skill or link path on Windows is a junction (reparse point) +**When** the transaction inventories or replaces that path +**Then** it records the path kind via `lstat` and treats it as the owned link itself, never dereferencing it to read, relabel, or delete the linked destination + +#### Scenario: Staging and backup stay on the same volume + +**Given** a replacement or rollback needs a staging or backup directory +**When** the transaction creates it +**Then** it creates the staging/backup as a sibling on the same volume as the target so `rename`-into-place is not a cross-volume move +**And** the destination is absent (or already owned-and-backed-up) before any `rename`-into-place + +#### Scenario: Windows file locks fail with bounded revalidation + +**Given** a mutation or rollback hits `EPERM`, `EBUSY`, or `ENOTEMPTY` (for example an actively used npm global or a locked junction) +**When** the transaction retries +**Then** it retries a bounded number of times with path revalidation between attempts +**And** if the lock persists it reports a non-mutating failure or leaves a recoverable journal rather than restoring concurrently with a live process + +#### Scenario: Config and manifest edits preserve bytes and unrelated entries + +**Given** `import_manifest.json`, TOML/JSONC config, or tracking state uses CRLF, comments where supported, or contains unrelated entries +**When** the transaction mutates its approved owned slice +**Then** it preserves CRLF line endings, comments, and all unrelated entries and verifies the owned slice after mutation +**And** a concurrent change after planning is identity drift and blocks mutation or restore instead of being merged implicitly + +#### Scenario: Durability and confidentiality are separated + +**Given** the durable journal is created +**When** durability is stated +**Then** the specification distinguishes fsync durability from ACL confidentiality: journals live under a private user-owned root and staging beside the target, and no `chmod 0600` ACL guarantee is promised where Windows ACLs are not controlled + ### Requirement: Harness-owned update strategies The updater SHALL preserve native/package ownership, bind discovery and execution to the same immutable source identity, and delegate each supported harness update to a deterministic strategy without starting OAuth. @@ -299,11 +363,21 @@ Fallback mutation SHALL be authorized by an exact parent-owned installation mani **Given** the parent durably recorded a complete snapshot and marked the fallback journal `mutating` **When** npm, pnpm, or the internal refresh process times out, crashes, receives a signal, or exits without a structured rollback result after mutation began -**Then** the parent restores the selected installation from its own snapshot +**Then** the parent first confirms the descendant process tree has terminated (controlled tree termination on Windows) +**And** only then restores the selected installation from its own snapshot +**And** when termination cannot be confirmed it leaves the journal recoverable or defers rollback instead of restoring concurrently **And** records whether parent-owned recovery succeeded **And** retains an incomplete journal when automatic restoration cannot be proven complete **And** exits with code `1` +#### Scenario: Plan and rollback share normalized path identity + +**Given** a fallback plan binds canonical owned paths and a tracking digest +**When** the parent writes the manifest and the child validates identity +**Then** both use the same shared path normalization (resolve, separators, Windows drive/root and case-insensitive semantics) across planning, manifest, execution, and rollback +**And** path equivalence never applies a universal lowercase transform where case-sensitive directories exist +**And** UNC or remote paths are `unsupported` when their equivalence to local owned paths cannot be guaranteed + #### Scenario: Recover an interrupted fallback transaction on the next run **Given** a prior invocation left a non-committed durable fallback journal @@ -453,7 +527,7 @@ Update operations SHALL retain all existing setup, installation, authentication, **And** update never invokes setup, login, or OAuth **And** native strategy failure never silently switches to fallback ownership **And** source identity is preserved for every supported native/package-owned update -**And** all external commands run without a shell and with fixed argument arrays +**And** all external commands run through the resolved executable identity, without a shell and with fixed argument arrays (native executable, `process.execPath` plus verified JS entrypoint, or a verified npm-generated shim derived to a JS entrypoint); an unverified `.cmd`/`.bat`/`.ps1` shim is never executed #### Scenario: Preserve the public install contract diff --git a/openspec/changes/add-update-flow/tasks.md b/openspec/changes/add-update-flow/tasks.md index b79071f..9de6507 100644 --- a/openspec/changes/add-update-flow/tasks.md +++ b/openspec/changes/add-update-flow/tasks.md @@ -9,17 +9,17 @@ ## Task 2: Add safe command execution and version sources -- [ ] **Description**: Implement the injected shell-free command runner, bounded/sanitized output, executable lookup, npm registry client, integrity-verified tarball execution, exact carried Claude/Codex marketplace sources resolved to immutable commits/content digests, and the canonical GitHub-root Antigravity source resolved to a full commit with explicit timeouts and validation. Bind lookup, execution, and post-update verification to the same npm registry/tarball/integrity or Git commit/content identity. Never substitute the NodeSource marketplace for alternate, missing, stale, or unsupported marketplace evidence. +- [ ] **Description**: Implement the injected command runner with immutable `ExecutableIdentity` (native absolute executable; `process.execPath` plus a verified absolute JS entrypoint; or a Windows npm-generated shim validated and derived to a JS entrypoint — never `shell: true`, never `cmd.exe`, never an unverified `.cmd`/`.bat`/`.ps1`), bounded/sanitized output, case-insensitive `PATH`/`Path` + `PATHEXT` executable lookup with absolute identity evidence and revalidation immediately before `spawn`, timeout with descendant-tree termination confirmation before any rollback (controlled tree termination on Windows), the shared `path-normalize` helper, npm registry client, integrity-verified tarball execution, exact carried Claude/Codex marketplace sources resolved to immutable commits/content digests, and the canonical GitHub-root Antigravity source resolved to a full commit with explicit timeouts and validation. Bind lookup, execution, and post-update verification to the same npm registry/tarball/integrity or Git commit/content identity. Never substitute the NodeSource marketplace for alternate, missing, stale, or unsupported marketplace evidence. - **Depends on**: Task 1 -- **Files**: `packages/core/src/update/command-runner.ts`, `packages/core/src/update/version-source.ts`, `packages/core/test/unit/update/command-runner.test.ts`, `packages/core/test/unit/update/version-source.test.ts` -- **Testing**: Mock success, timeout, missing executable, invalid JSON/version, non-zero exit, oversized output, alternate Git repository/ref/manifest sources, missing or moving refs, commit/content mismatch, alternate registries serving the same version with different bytes, tarball/integrity mismatch, fresh and stale local snapshots, traversal-capable paths, and secret-bearing diagnostics. Verify all subprocess calls use `shell: false` and argument arrays, source credentials are redacted, and no canonical-source substitution or ambient registry re-resolution occurs. References: Update Flow “Registry lookup fails,” “Preserve each detected native source identity,” “Required harness executable is missing,” and “Preserve credentials and user-owned configuration.” +- **Files**: `packages/core/src/update/command-runner.ts`, `packages/core/src/update/path-normalize.ts`, `packages/core/src/update/version-source.ts`, `packages/core/test/unit/update/command-runner.test.ts`, `packages/core/test/unit/update/path-normalize.test.ts`, `packages/core/test/unit/update/version-source.test.ts` +- **Testing**: Mock success, timeout, missing executable, invalid JSON/version, non-zero exit, oversized output, alternate Git repository/ref/manifest sources, missing or moving refs, commit/content mismatch, alternate registries serving the same version with different bytes, tarball/integrity mismatch, fresh and stale local snapshots, traversal-capable paths, and secret-bearing diagnostics. Cover executable identity discrimination (npm-generated `.cmd` shim derivation, derived JS entrypoint on Windows, unverified shim and `.ps1`-only `unsupported`, bare-name rejection), case-insensitive `PATH`/`Path`/`PATHEXT` lookup, empty/cwd-relative segment rejection, and timeout that kills the whole process tree before rollback (defer/leave recoverable journal when termination cannot be confirmed). Verify all subprocess calls use `shell: false` and argument arrays, source credentials are redacted, and no canonical-source substitution or ambient registry re-resolution occurs. Run this unit suite on `windows-latest` plus a real integration job for shim derivation and PATH/PATHEXT (CI matrix already includes Windows). References: Update Flow “Cross-platform-safe command execution,” “Registry lookup fails,” “Preserve each detected native source identity,” “Required harness executable is missing,” and “Preserve credentials and user-owned configuration.” ## Task 3: Detect CLI installation ownership -- [ ] **Description**: Read the running package/bundle versions and detect npm or pnpm global ownership only when the real package/entrypoint is contained by that manager's reported global root. Return `unsupported` for workspace, local, `npx`, Volta, Yarn, Bun, mismatched-root, or ambiguous execution, and preserve the detected source evidence on every installation record. +- [ ] **Description**: Read the running package/bundle versions and detect npm or pnpm global ownership only when the real package/entrypoint is contained by that manager's reported global root. On Windows accept ownership only when the manager resolves to a supported `ExecutableIdentity` (native `.exe`/`.com` or verified JS entrypoint); an unverified `.cmd`/`.bat` shim and `.ps1`-only launchers are `unsupported`. Return `unsupported` for workspace, local, `npx`, Volta, Yarn, Bun, mismatched-root, or ambiguous execution, and preserve the detected source evidence on every installation record. - **Depends on**: Tasks 1–2 - **Files**: `packages/core/src/update/package-manager.ts`, `packages/core/src/update/inventory.ts`, `packages/core/test/unit/update/package-manager.test.ts`, `packages/core/test/unit/update/inventory.test.ts` -- **Testing**: Use fixture paths/environments for npm, pnpm, `npx`, Volta, Yarn, Bun, workspace, broken symlink, manager-reported root mismatch, and ambiguous launchers. Verify unsupported sources produce exact-version guidance without mutation. References: Update Flow “Unsupported CLI installation source.” +- **Testing**: Use fixture paths/environments for npm, pnpm, `npx`, Volta, Yarn, Bun, workspace, broken symlink, manager-reported root mismatch, ambiguous launchers, and Windows `npm.cmd`/`pnpm.cmd` shim targets. Verify unsupported sources produce exact-version guidance without mutation. References: Update Flow “Unsupported CLI installation source.” ## Task 4: Implement CLI package update strategy @@ -44,14 +44,14 @@ ## Task 7: Implement Pi and fallback/OpenCode strategies -- [ ] **Description**: Add the package-owned Pi strategy for only the exact unpinned canonical npm identity. Coalesce matching user/project scopes into one command, use `--no-approve` for user-only and disclosed/confirmed `--approve` for project scope, set project commands to the captured canonical root, and revalidate directory identity/settings/source/cache roots immediately before execution. Add a package-internal `nsolid-plugin-refresh-owned` binary executed from an integrity-verified tarball. Before launching it, the parent creates a durable snapshot/journal and passes a transaction manifest binding installation ID, canonical paths, tracking digest, and field-level MCP ownership. The child refuses stale or broadened identity, and the parent restores or recovers interrupted mutation independently of child availability. Do not change the public `nsolid-plugin install` command or programmatic `install()` contract. +- [ ] **Description**: Add a shared `fs-transaction` helper used by mutable strategies: record owned-path kind via `lstat` (junction/copy/directory), never dereference junctions for ownership or deletion, stage backups as siblings on the same volume, require absent destination before rename-into-place, apply bounded `EPERM`/`EBUSY`/`ENOTEMPTY` retries with revalidation, and edit config/manifests byte-preserving (CRLF, comments, unrelated entries) with owned-slice verification after mutation. Add the package-owned Pi strategy for only the exact unpinned canonical npm identity. Coalesce matching user/project scopes into one command, use `--no-approve` for user-only and disclosed/confirmed `--approve` for project scope, set project commands to the captured canonical root, and revalidate directory identity/settings/source/cache roots immediately before execution. Add a package-internal `nsolid-plugin-refresh-owned` binary executed from an integrity-verified tarball. Before launching it, the parent creates a durable snapshot/journal and passes a transaction manifest binding installation ID, canonical paths, tracking digest, and field-level MCP ownership. The child refuses stale or broadened identity, and the parent restores or recovers interrupted mutation independently of child availability, after confirming the descendant process tree has terminated. Durability (fsync) is stated separately from confidentiality; no `chmod 0600` ACL guarantee is promised where Windows ACLs cannot be controlled. Do not change the public `nsolid-plugin install` command or programmatic `install()` contract. - **Depends on**: Tasks 2 and 5 -- **Files**: `packages/core/src/update/strategies/pi.ts`, `packages/core/src/update/strategies/fallback.ts`, `packages/core/src/update/fallback-transaction.ts`, `packages/core/src/update/refresh-owned-cli.ts`, `packages/core/package.json`, `packages/core/src/skills/skill-copier.ts`, `packages/core/src/skills/skill-tracker.ts`, `packages/core/src/mcp/mcp-config-writer.ts`, corresponding unit/integration tests -- **Testing**: Verify Pi user-only/project-only/both scopes, exact planned `cwd`, root/settings replacement between plan and execution, one invocation for duplicate canonical identity, exact `--approve`/`--no-approve`, unchanged source/filter/trust settings, rejection of non-canonical/conflicting sources, package-owned skill boundaries, and registry/content postconditions. For OpenCode/fallback, cover integrity-verified execution, exact transaction-manifest command arrays, isolated temporary cwd, shared paths, user-modified MCP fields, tracking digest/path/installation changes after approval, child timeout/crash/signal after each mutation boundary, parent rollback, next-run journal recovery, incomplete recovery, complete replacement, stale removal, collisions, missing ownership/executor, preserved user artifacts, no implicit install, and unchanged public `install` behavior. References: the Pi and fallback scenarios in Update Flow. +- **Files**: `packages/core/src/update/fs-transaction.ts`, `packages/core/src/update/strategies/pi.ts`, `packages/core/src/update/strategies/fallback.ts`, `packages/core/src/update/fallback-transaction.ts`, `packages/core/src/update/refresh-owned-cli.ts`, `packages/core/package.json`, `packages/core/src/skills/skill-copier.ts`, `packages/core/src/skills/skill-tracker.ts`, `packages/core/src/mcp/mcp-config-writer.ts`, corresponding unit/integration tests +- **Testing**: Verify Pi user-only/project-only/both scopes, exact planned `cwd`, root/settings replacement between plan and execution, one invocation for duplicate canonical identity, exact `--approve`/`--no-approve`, unchanged source/filter/trust settings, rejection of non-canonical/conflicting sources, package-owned skill boundaries, and registry/content postconditions. For OpenCode/fallback, cover integrity-verified execution, exact transaction-manifest command arrays, isolated temporary cwd, shared paths, user-modified MCP fields, tracking digest/path/installation changes after approval, child timeout/crash/signal after each mutation boundary, parent rollback that first confirms tree termination, defer/recoverable journal when termination cannot be confirmed, next-run journal recovery, incomplete recovery, complete replacement, stale removal, collisions, missing ownership/executor, preserved user artifacts, no implicit install, and unchanged public `install` behavior. For `fs-transaction`, cover junction reparse points (never dereferenced), same-volume staging/backup, rename-into-place with absent destination, bounded lock retries, and byte-preserving CRLF/comment-keeping manifest edits. References: the Pi, fallback, and cross-platform filesystem scenarios in Update Flow. ## Task 8: Implement transactional Antigravity update -- [ ] **Description**: Detect exactly one supported Antigravity layout pair and resolve the canonical repository to a full immutable commit/content identity used for both lookup and installation. Add restrictive temporary backup of the detected staged root and matching N|Solid manifest entry, confirmed commit-pinned uninstall/install, content plus registration validation, successful cleanup, and rollback restoration that preserves unrelated imports. Return `unsupported` without mutation when layout or immutable source binding is unavailable. +- [ ] **Description**: Detect exactly one supported Antigravity layout pair and resolve the canonical repository to a full immutable commit/content identity used for both lookup and installation. Add byte-preserving backup of the detected staged root and matching N|Solid manifest entry (preserving CRLF, comments, and unrelated imports), confirmed commit-pinned uninstall/install, content plus registration validation, successful cleanup, and rollback restoration that retries bounded `EPERM`/`EBUSY`/`ENOTEMPTY` with revalidation and preserves unrelated imports. Return `unsupported` without mutation when layout or immutable source binding is unavailable. - **Depends on**: Tasks 2 and 5 - **Files**: `packages/core/src/update/antigravity-transaction.ts`, `packages/core/src/update/strategies/antigravity.ts`, related unit/integration tests - **Testing**: Cover both supported staged-root/manifest pairs, both-present ambiguity, unmatched root/manifest, successful replacement, declined confirmation, uninstall failure, install failure, root/manifest validation failure, rollback success/failure for both components, cleanup, unrelated import preservation, and credential preservation. References: Update Flow “Update Antigravity native plugin,” “Antigravity layout is ambiguous or unsupported,” and “Antigravity reinstall fails.” @@ -89,7 +89,7 @@ - [ ] **Description**: Exercise the public CLI against isolated homes and fake package managers/executors/harness executables/registries, including exact-version CLI install verification, mixed native/fallback ownership, alternate marketplace identities and version sources, Claude scopes, Codex transactional reinstall/rollback, Pi scope/trust combinations, OpenCode internal-refresh reconciliation/rollback with unchanged public install behavior, unsupported Pi/fallback sources, both Antigravity layout/manifest pairs, and lookup/execution partial failure. - **Depends on**: Tasks 9–12 - **Files**: `packages/core/test/integration/update-flow.test.ts`, test fixtures/helpers, `scripts/test-marketplace-install.js` where update assertions fit -- **Testing**: Cover all acceptance tests from the proposal on Linux-compatible fixtures and keep paths/commands portable for macOS and Windows CI. Assert credentials, non-NodeSource configurations, unrelated Codex configuration/cache entries, unrelated Antigravity manifest imports, and source identities/scopes are preserved byte-for-byte where applicable. +- **Testing**: Cover all acceptance tests from the proposal on Linux-compatible fixtures and keep paths/commands portable for macOS and Windows CI. Add dedicated integration coverage on `windows-latest` (the CI matrix in `.github/workflows/test.yml` already runs `windows-latest`; keep it and extend it, do not claim it is missing): npm `.cmd` shim derivation to a JS entrypoint executed with `process.execPath`, `PATH`/`Path`/`PATHEXT` resolution, arguments and paths containing spaces/metacharacters/Unicode, junction ownership (never dereferenced), same-volume staging/backup, cross-volume source/temporary-root fixtures that prove staging is relocated beside the target or rejected before mutation, locked files and descendant process-tree termination, and long paths. Assert credentials, non-NodeSource configurations, unrelated Codex configuration/cache entries, unrelated Antigravity manifest imports, and source identities/scopes are preserved byte-for-byte where applicable, including CRLF file endings. ## Task 14: Document user and maintainer workflows diff --git a/package.json b/package.json index 913bdaa..2d65506 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "plugin:materialize": "node scripts/sync-plugin-assets.mjs --materialize-skills", "plugin:clean": "node scripts/sync-plugin-assets.mjs", "plugin:root": "node scripts/materialize-github-marketplace.mjs", - "plugin:root:check": "node scripts/materialize-github-marketplace.mjs --check" + "plugin:root:check": "node scripts/materialize-github-marketplace.mjs --check", + "release:prepare": "node scripts/prepare-release.mjs", + "release:check": "node scripts/check-release-version.mjs" }, "engines": { "node": ">=22.3.0" diff --git a/packages/core/README.md b/packages/core/README.md index f8ab479..24e109b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -136,6 +136,17 @@ nsolid-plugin restore --harness claude --list nsolid-plugin restore --harness claude --backup ~/.agents/.config-backup/claude/1234567890.json ``` +Update and version commands are additive to the installer API: + +```bash +nsolid-plugin version +nsolid-plugin update --check +nsolid-plugin update --harness opencode --yes +nsolid-plugin update --all --check --json +``` + +`getVersionInfo()` is synchronous and read-only. `checkUpdates()` performs discovery only; `update()` plans first, asks for confirmation unless `yes: true`, and executes each owned target sequentially. Native harnesses keep their own ownership and source identity. Direct fallback updates use the package-internal refresh binary and path-level tracking; the public `install()` function keeps its existing idempotent behavior. + Use `--verbose` (or `NSOLID_PLUGIN_VERBOSE=1`) for detailed, timestamped logs written to stderr. Verbose mode redacts tokens and auth headers. For Claude Code, Codex, and Antigravity, prefer native GitHub plugin install from the repository root; `install --harness` is a fallback direct installer only. For Pi, install `nsolid-pi-plugin` for package-owned skills; CLI install/setup only writes MCP config. OpenCode is CLI-only and uses `setup --harness opencode` for auth + bridge + skills + MCP config in one step; `install --harness opencode` is the fallback asset path. Credentials are a single shared file (`~/.agents/.nodesource-auth.json`), not per-harness, so a member of more than one NodeSource org can only be authenticated against one org at a time. `switch-org` forces a fresh OAuth round-trip — even if current credentials are still valid — so NodeSource's sign-in flow can show its org picker again; the new org then applies to every installed harness, not just the one named. Native-plugin-installed harnesses (claude/codex/antigravity) pick it up on their next MCP reconnect; fallback-installed harnesses and CLI-direct harnesses (opencode/pi) need `install --harness ` re-run afterward (see `switch-org`'s own output for harness-specific guidance). diff --git a/packages/core/package.json b/packages/core/package.json index 81fdfc4..39cbe49 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,7 +5,8 @@ "main": "dist/src/index.js", "types": "dist/src/index.d.ts", "bin": { - "nsolid-plugin": "./dist/src/cli.js" + "nsolid-plugin": "./dist/src/cli.js", + "nsolid-plugin-refresh-owned": "./dist/src/update/refresh-owned-cli.js" }, "exports": { ".": { diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 47f832b..7747872 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -5,21 +5,23 @@ import { createInterface } from 'node:readline/promises' import path from 'node:path' import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { installWithRuntime, setup, uninstall, logout, doctor, restore, loadCredentials } from './index.js' +import { installWithRuntime, setup, uninstall, logout, doctor, restore, loadCredentials, executeUpdatePlan, getVersionInfo, planUpdates } from './index.js' import type { AuthConfirmation, HarnessType } from './types.js' import { HARNESS_VALUES, PLUGIN_OWNED_HARNESSES } from './types.js' +import type { UpdateConfirmationContext, UpdatePlan, UpdatePlanItem, UpdateSummary } from './update/types.js' import { formatPluginError } from './errors.js' import { listConfigBackups } from './utils/backup.js' import { C, supportsColor } from './utils/format.js' import { createConsoleProgress, silentProgress } from './utils/progress.js' +import { resolvePackageRoot } from './update/version.js' const PACKAGE_OWNED_SKILL_HARNESSES = new Set(['pi']) const HARNESS_SPECIFIC_SKILL_HARNESSES = new Set(['opencode']) const __dirname = path.dirname(fileURLToPath(import.meta.url)) -// At runtime the bin is dist/src/cli.js, so __dirname is /dist/src. -// bundle.json and skills/ ship at the package root (per package.json "files"), -// not under dist/ — resolve up two levels to reach the package root. -const CORE_PKG_ROOT = path.resolve(__dirname, '..', '..') +// At runtime the bin is dist/src/cli.js and source execution is src/cli.ts. +// Resolve the nearest directory containing the package and bundle manifests so +// both layouts use the same package root. +const CORE_PKG_ROOT = resolvePackageRoot(__dirname) const REPO_ROOT = path.resolve(CORE_PKG_ROOT, '..', '..') const DEFAULT_SOURCE_ROOT = existsSync(path.join(REPO_ROOT, 'bundle.json')) && existsSync(path.join(REPO_ROOT, 'skills')) ? REPO_ROOT @@ -61,6 +63,8 @@ Commands: switch-org Force re-authentication to switch NodeSource organizations (opens a browser; affects all harnesses) doctor Check installation health for a harness restore Restore a harness MCP config from the latest backup + version Report the CLI and bundled plugin versions + update Check or update the CLI and detected harness installations Options: --harness Target harness (required in non-interactive mode): ${HARNESS_VALUES.join(', ')} @@ -74,6 +78,9 @@ Options: --no-color Disable colored output --quiet Suppress step-by-step progress output (setup/install/switch-org) --yes Skip interactive confirmation prompts + --check Report update status without mutating anything + --all Include every detected installation (cannot combine with --harness) + --version Print the CLI and bundled plugin versions (alias for version) --accounts-url Explicit origin-only accounts URL override for setup/switch-org --help Show this help message @@ -86,6 +93,91 @@ Distribution notes: Auth: only setup/switch-org may open a browser.`) } +function printVersion (json: boolean): void { + const info = getVersionInfo(CORE_PKG_ROOT) + if (json) { + console.log(JSON.stringify(info)) + return + } + console.log(`nsolid-plugin CLI ${info.cliVersion}`) + console.log(`bundled plugin ${info.bundleVersion}`) +} + +async function confirmUpdatePlan (_context: UpdateConfirmationContext, _color: boolean): Promise { + const rl = createPrompt() + try { + const answer = (await rl.question('Apply this update plan? [y/N]: ')).trim().toLowerCase() + return answer === 'y' || answer === 'yes' + } finally { + rl.close() + } +} + +function printUpdatePlan (plan: UpdatePlan, color: boolean): void { + if (plan.items.length === 0) { + process.stderr.write('No installations detected.\n') + return + } + process.stderr.write(plan.checkOnly ? 'Update check:\n' : 'Update plan:\n') + for (const item of plan.items) printUpdatePlanItem(item, color, process.stderr) +} + +function printUpdatePlanItem (item: UpdatePlanItem, color: boolean, output: NodeJS.WritableStream): void { + const paint = (value: string) => color ? C.dim(value) : value + output.write(` ${item.installationId} — ${item.ownership} — ${item.version.status}`) + if (item.version.current && item.version.latest) output.write(` (${item.version.current} → ${item.version.latest})`) + else if (item.version.current) output.write(` (${item.version.current})`) + else if (item.version.latest) output.write(` (latest: ${item.version.latest})`) + output.write(`\n source: ${sourceLabel(item)}\n`) + for (const command of item.manualCommands ?? []) output.write(` ${paint('manual:')} ${command}\n`) + if (item.planningError) { + output.write(` error: ${item.planningError.message}\n`) + return + } + for (const step of item.steps) { + if (step.kind === 'command') output.write(` ${paint('run:')} ${formatCommand(step.command.executable, step.command.args)}\n`) + if (step.kind === 'filesystem') output.write(` ${paint(`${step.operation}:`)} ${step.paths.join(', ')}\n`) + if (step.kind === 'validation') output.write(` ${paint('check:')} ${step.checks.join('; ')}\n`) + } + if (item.rollbackSteps.length > 0) { + output.write(` ${paint('rollback:')}\n`) + for (const step of item.rollbackSteps) { + if (step.kind === 'command') output.write(` ${paint('run:')} ${formatCommand(step.command.executable, step.command.args)}\n`) + if (step.kind === 'filesystem') output.write(` ${paint(`${step.operation}:`)} ${step.paths.join(', ')}\n`) + if (step.kind === 'validation') output.write(` ${paint('check:')} ${step.checks.join('; ')}\n`) + } + } +} + +function printUpdateSummary (summary: UpdateSummary, color: boolean): void { + for (const result of summary.results) { + const version = result.resultingVersion ?? result.latestVersion ?? result.currentVersion + const suffix = version ? ` (${version})` : '' + const error = result.error ? ` — ${result.error.message}` : '' + console.log(`${result.installationId}: ${result.status}${suffix}${error}`) + if (result.rollbackCommand && result.status === 'failed') console.log(` restore: ${result.rollbackCommand}`) + if (result.restartHint && result.status === 'updated') console.log(` ${result.restartHint}`) + } + const counts = Object.entries(summary.counts).filter(([, count]) => count > 0).map(([status, count]) => `${status}=${count}`).join(', ') + console.log(`${color ? C.dim('Summary:') : 'Summary:'} ${counts || 'none'}`) +} + +function sourceLabel (item: UpdatePlanItem): string { + const source = item.source + if (source.kind === 'none') return 'none' + if (source.kind === 'unsupported') return `${source.reason} (${source.source})` + if (source.kind === 'global-package') return `${source.packageManager}: ${source.packageName}` + if (source.kind === 'claude-marketplace') return `${source.pluginId} @ ${source.marketplace} (${source.scope})` + if (source.kind === 'codex-marketplace') return `${source.pluginId} @ ${source.marketplace}` + if (source.kind === 'pi-package') return `${source.spec} (${source.scopes.join(',')})` + if (source.kind === 'antigravity-git') return `${source.url} (${source.layout.kind})` + return `fallback${source.executor ? ` (${source.executor})` : ''}` +} + +function formatCommand (executable: string, args: readonly string[]): string { + return [executable, ...args].map((value) => /[\s"']/.test(value) ? JSON.stringify(value) : value).join(' ') +} + function isInteractive (): boolean { return process.stdin.isTTY === true && process.stderr.isTTY === true } @@ -243,10 +335,18 @@ async function main (): Promise { 'keep-credentials': { type: 'boolean' }, quiet: { type: 'boolean' }, yes: { type: 'boolean' }, + check: { type: 'boolean' }, + all: { type: 'boolean' }, + version: { type: 'boolean', short: 'v' }, help: { type: 'boolean', short: 'H' }, }, }) + if (values.version === true) { + printVersion(values.json === true) + return + } + if (values.help || positionals.length === 0) { printUsage() process.exit(values.help ? 0 : 1) @@ -255,6 +355,11 @@ async function main (): Promise { const command = positionals[0] const harness = values.harness as HarnessType | undefined + if (values.all === true && harness) { + console.error('Error: --all cannot be combined with --harness') + process.exit(1) + } + const resolveHarnesses = async (multiple: boolean): Promise => { if (harness && HARNESS_VALUES.includes(harness)) return [harness] if (!harness && isInteractive() && values.yes !== true) return promptForHarnesses(command, multiple) @@ -283,6 +388,36 @@ async function main (): Promise { } switch (command) { + case 'version': { + printVersion(values.json === true) + break + } + case 'update': { + if (harness && !HARNESS_VALUES.includes(harness)) { + console.error(`Error: --harness must be one of: ${HARNESS_VALUES.join(', ')}`) + process.exit(1) + } + const updateOptions = { + harness, + all: values.all === true, + check: values.check === true, + yes: values.yes === true, + json: values.json === true, + verbose: values.verbose === true, + noColor: values['no-color'] === true, + packageRoot: CORE_PKG_ROOT, + confirm: isInteractive() && values.yes !== true + ? (context: UpdateConfirmationContext) => confirmUpdatePlan(context, values['no-color'] !== true && supportsColor(process.stderr)) + : undefined, + } + const plan = await planUpdates(updateOptions) + printUpdatePlan(plan, color) + const summary = await executeUpdatePlan(plan, updateOptions) + if (values.json === true) console.log(JSON.stringify(summary)) + else printUpdateSummary(summary, color) + process.exitCode = summary.exitCode + break + } case 'setup': { if (values['accounts-url']) { process.env.NSOLID_ACCOUNTS_URL = values['accounts-url'] diff --git a/packages/core/src/harnesses/pi-plugin-detector.ts b/packages/core/src/harnesses/pi-plugin-detector.ts index 11d3429..45960ea 100644 --- a/packages/core/src/harnesses/pi-plugin-detector.ts +++ b/packages/core/src/harnesses/pi-plugin-detector.ts @@ -31,7 +31,7 @@ function readPiPackageSourceEntries (settingsPath: string): string[] { .filter((source): source is string => typeof source === 'string' && source.length > 0) } -function packageNameFromNpmSource (source: string): string | null { +export function packageNameFromNpmSource (source: string): string | null { if (!source.startsWith('npm:')) return null const spec = source.slice('npm:'.length) if (spec.startsWith('@')) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2e7f3e2..a6c9162 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,6 +27,7 @@ import { readTrackingFile, addTrackedSkills, removeTrackedSkills, + setTrackingBundleVersion, } from './skills/skill-tracker.js' import { writeMcpConfig, @@ -383,6 +384,17 @@ export async function install (options: InstallOptions): Promise } result.success = result.errors.length === 0 + if (result.success) { + try { + // Version evidence is meaningful only after every owned install step has + // succeeded. A partial install must remain repairable on the next run. + await setTrackingBundleVersion(bundle.version, logger, options.harness) + } catch (err) { + const pluginErr = toPluginError(err, 'TRACKING_UPDATE_FAILED', { harness: options.harness }) + result.errors.push(`Tracking version update failed: ${pluginErr.message}`) + result.success = false + } + } if (result.success) { if (options.packageOwnedSkills === true) { const mcpCount = result.mcpServersConfigured.length @@ -826,3 +838,26 @@ export type { HarnessType, InstallOptions, InstallResult, SetupOptions, SetupRes export type { LinkResult, LinkStatus } from './skills/skill-linker.js' export type { SkillTrackingEntry, McpTrackingEntry, TrackingData } from './skills/skill-tracker.js' export type { BackupEntry } from './utils/backup.js' +export { checkUpdates, executeUpdatePlan, planUpdates, update } from './update/coordinator.js' +export { readRunningVersionInfo as getVersionInfo } from './update/version.js' +export type { + CommandResult, + CommandRunner, + CommandSpec, + RunningVersionInfo, + UpdateConfirmation, + UpdateError, + UpdateInstallation, + UpdateOptions, + UpdatePlan, + UpdatePlanItem, + UpdateResult, + UpdateSource, + UpdateStatus, + UpdateSummary, + NpmArtifactIdentity, + GitArtifactIdentity, + LocalArtifactIdentity, + ResolvedArtifactIdentity, + FallbackTransactionIdentity, +} from './update/types.js' diff --git a/packages/core/src/mcp/mcp-tracker.ts b/packages/core/src/mcp/mcp-tracker.ts index 5c30067..d29e7e3 100644 --- a/packages/core/src/mcp/mcp-tracker.ts +++ b/packages/core/src/mcp/mcp-tracker.ts @@ -1,9 +1,11 @@ import path from 'node:path' import { existsSync, unlinkSync } from 'node:fs' +import { createHash } from 'node:crypto' import type { HarnessType, Logger } from '../types.js' import type { McpTrackingEntry, TrackingData } from '../skills/skill-tracker.js' import { readTrackingFile, writeTrackingFile } from '../skills/skill-tracker.js' import { getTrackingFilePath, resolveHome } from '../utils/path.js' +import { readJsonFile, readJsoncFile, readTomlFile } from '../utils/config.js' export type { McpTrackingEntry } from '../skills/skill-tracker.js' @@ -33,12 +35,14 @@ export async function addTrackedMcps ( if (existing) { existing.configPath = path.resolve(resolveHome(entry.configPath)) existing.configuredAt = now + existing.fields = readOwnedFieldDigests(existing.configPath, existing.name) } else { tracking.mcpServers.push({ name: entry.name, configPath: path.resolve(resolveHome(entry.configPath)), harness, configuredAt: now, + fields: readOwnedFieldDigests(path.resolve(resolveHome(entry.configPath)), entry.name), }) } } @@ -46,6 +50,34 @@ export async function addTrackedMcps ( await writeTrackingFile(tracking, logger) } +function readOwnedFieldDigests (configPath: string, name: string): Record | undefined { + try { + const raw = configPath.endsWith('.toml') + ? readTomlFile>(configPath) + : configPath.endsWith('.jsonc') + ? readJsoncFile>(configPath) + : readJsonFile>(configPath) + if (!raw) return undefined + const servers = (raw.mcpServers ?? raw.mcp_servers ?? raw.mcp) as unknown + if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return undefined + const server = (servers as Record)[name] + if (!server || typeof server !== 'object' || Array.isArray(server)) return undefined + return Object.fromEntries(Object.entries(server as Record).map(([field, value]) => [field, digest(value)])) + } catch { return undefined } +} + +function digest (value: unknown): string { + return createHash('sha256').update(JSON.stringify(stableValue(value)) ?? 'undefined').digest('hex') +} + +function stableValue (value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue) + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, stableValue(child)])) + } + return value +} + export async function removeTrackedMcps ( serverNames: string[], harness?: HarnessType, diff --git a/packages/core/src/skills/skill-linker.ts b/packages/core/src/skills/skill-linker.ts index 18715eb..d0ca274 100644 --- a/packages/core/src/skills/skill-linker.ts +++ b/packages/core/src/skills/skill-linker.ts @@ -1,4 +1,4 @@ -import { symlink, readlink, lstat, rm, rename, cp, access } from 'node:fs/promises' +import { symlink, readlink, lstat, rm, rename, cp } from 'node:fs/promises' import path from 'node:path' import type { HarnessType, Logger, SkillRef } from '../types.js' import { getSkillsDir } from '../utils/path.js' @@ -60,7 +60,10 @@ export async function unlinkSkillsFromHarness ( const safeName = assertSafeSkillName(skill.name) const target = path.join(harnessDir, safeName) try { - await access(target) + // lstat also finds dangling symlinks. access() follows the link and + // treated a removed shared skill as missing, leaving stale harness links + // behind after a fallback refresh. + await lstat(target) logger?.debug('skills.unlink', { harness, skill: skill.name, target }) await rm(target, { recursive: true, force: true }) } catch (err) { diff --git a/packages/core/src/skills/skill-tracker.ts b/packages/core/src/skills/skill-tracker.ts index d111f8c..38230db 100644 --- a/packages/core/src/skills/skill-tracker.ts +++ b/packages/core/src/skills/skill-tracker.ts @@ -19,19 +19,30 @@ export interface McpTrackingEntry { configPath: string; harness: string; configuredAt: string; + /** SHA-256 evidence for each NodeSource-owned field in the server object. */ + fields?: Record; } export interface TrackingData { version: string; installedAt: string; harness: string; + /** Version of the bundle used by the last owned refresh, when known. */ + bundleVersion?: string; + /** Version evidence keyed by the fallback harness that was refreshed. */ + bundleVersions?: Partial>; skills: SkillTrackingEntry[]; mcpServers: McpTrackingEntry[]; } export async function readTrackingFile (logger?: Logger): Promise { try { - return readJsonFile(getTrackingFilePath()) + const value = readJsonFile(getTrackingFilePath()) + if (!isValidTrackingData(value)) { + logger?.warn('tracking.read.invalid', { path: getTrackingFilePath() }) + return null + } + return value } catch (err) { logger?.warn('tracking.read.failed', { error: (err as Error).message }) return null @@ -50,6 +61,25 @@ export async function writeTrackingFile (data: TrackingData, logger?: Logger): P } } +export async function setTrackingBundleVersion (bundleVersion: string, logger?: Logger, harness?: HarnessType): Promise { + const tracking = await readTrackingFile(logger) + if (!tracking) return + tracking.bundleVersion = bundleVersion + if (harness) tracking.bundleVersions = { ...(tracking.bundleVersions ?? {}), [harness]: bundleVersion } + await writeTrackingFile(tracking, logger) +} + +export function isValidTrackingData (value: unknown): value is TrackingData { + if (!isRecord(value) || typeof value.version !== 'string' || typeof value.installedAt !== 'string' || typeof value.harness !== 'string') return false + if (!Array.isArray(value.skills) || !Array.isArray(value.mcpServers)) return false + if (value.bundleVersion !== undefined && typeof value.bundleVersion !== 'string') return false + if (value.bundleVersions !== undefined) { + if (!isRecord(value.bundleVersions) || Object.values(value.bundleVersions).some((version) => typeof version !== 'string')) return false + } + if (value.skills.some((entry) => !isValidSkillTrackingEntry(entry))) return false + return !value.mcpServers.some((entry) => !isValidMcpTrackingEntry(entry)) +} + export async function addTrackedSkills ( skills: SkillRef[], harness: HarnessType, @@ -145,3 +175,18 @@ function createEmptyTracking (harness: HarnessType): TrackingData { mcpServers: [], } } + +function isValidSkillTrackingEntry (value: unknown): value is SkillTrackingEntry { + if (!isRecord(value) || typeof value.name !== 'string' || typeof value.path !== 'string' || typeof value.installedAt !== 'string' || !Array.isArray(value.harnesses)) return false + if (value.harnesses.some((harness) => typeof harness !== 'string')) return false + if (value.paths !== undefined && (!isRecord(value.paths) || Object.values(value.paths).some((entry) => typeof entry !== 'string'))) return false + return true +} + +function isValidMcpTrackingEntry (value: unknown): value is McpTrackingEntry { + return isRecord(value) && typeof value.name === 'string' && typeof value.configPath === 'string' && typeof value.harness === 'string' && typeof value.configuredAt === 'string' && (value.fields === undefined || (isRecord(value.fields) && Object.values(value.fields).every((field) => typeof field === 'string'))) +} + +function isRecord (value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} diff --git a/packages/core/src/update/antigravity-transaction.ts b/packages/core/src/update/antigravity-transaction.ts new file mode 100644 index 0000000..5fed1e6 --- /dev/null +++ b/packages/core/src/update/antigravity-transaction.ts @@ -0,0 +1,213 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { resolveHome } from '../utils/path.js' +import type { CommandRunner, UpdateError, UpdatePlanItem } from './types.js' +import { isStableVersion } from './version.js' +import { createHash } from 'node:crypto' +import { copyOwnedPath, createSiblingBackupPath, ownedPathKind, removeOwnedPath } from './fs-transaction.js' +import type { SiblingBackupPath } from './fs-transaction.js' +import { runTransactionCommands } from './transaction-commands.js' + +export interface AntigravityTransactionResult { + success: boolean + rollbackAttempted: boolean + rollbackSucceeded?: boolean + error?: UpdateError +} + +interface AntigravityBackupSnapshot { + root: { target: string; backup: string; existed: boolean; complete: boolean } + manifest: { target: string; backup: string; existed: boolean; complete: boolean } +} + +export async function executeAntigravityTransaction ( + item: UpdatePlanItem, + commandRunner: CommandRunner +): Promise { + if (item.source.kind !== 'antigravity-git') { + return { success: false, rollbackAttempted: false, error: { code: 'INVALID_ANTIGRAVITY_SOURCE', message: 'Antigravity source is not the fixed GitHub root' } } + } + const pluginRoot = resolveHome(item.source.layout.pluginRoot) + const manifestPath = resolveHome(item.source.layout.manifestPath) + const rootKind = await ownedPathKind(pluginRoot) + const manifestKind = await ownedPathKind(manifestPath) + if (manifestKind !== 'missing' && manifestKind !== 'file') { + return { success: false, rollbackAttempted: false, error: { code: 'ANTIGRAVITY_MANIFEST_KIND_UNSUPPORTED', message: 'Antigravity import manifest must be a regular file for transactional replacement' } } + } + // Allocate backup storage before any mutation. A missing parent directory + // (e.g. `~/.gemini/config/plugins` absent while a manifest is present) makes + // mkdtemp reject with ENOENT; that must surface as a structured failure, + // never as an escaped rejected promise. Any partially allocated backup + // directory is removed. + let rootBackupStorage: SiblingBackupPath | undefined + let manifestBackupStorage: SiblingBackupPath | undefined + try { + rootBackupStorage = await createSiblingBackupPath(pluginRoot, 'plugin-backup') + manifestBackupStorage = await createSiblingBackupPath(manifestPath, 'manifest-backup') + } catch { + await Promise.all([ + rootBackupStorage ? removeOwnedPath(rootBackupStorage.directory).catch(() => {}) : Promise.resolve(), + manifestBackupStorage ? removeOwnedPath(manifestBackupStorage.directory).catch(() => {}) : Promise.resolve(), + ]) + return { + success: false, + rollbackAttempted: false, + error: { code: 'ANTIGRAVITY_BACKUP_FAILED', message: 'Antigravity plugin or import manifest backup could not be completed' }, + } + } + const rootBackup = rootBackupStorage.path + const manifestBackup = manifestBackupStorage.path + const rootExisted = rootKind !== 'missing' + const manifestExisted = manifestKind !== 'missing' + let rootBackupComplete = !rootExisted + let manifestBackupComplete = !manifestExisted + let backupsComplete = false + let mutationStarted = false + let rollbackAttempted = false + let preserveBackup = false + const backupSnapshot = (): AntigravityBackupSnapshot => ({ + root: { target: pluginRoot, backup: rootBackup, existed: rootExisted, complete: rootBackupComplete }, + manifest: { target: manifestPath, backup: manifestBackup, existed: manifestExisted, complete: manifestBackupComplete }, + }) + + try { + // Do not enter rollback handling until every original asset has a complete + // backup. A failed recursive copy may leave rootBackup present but + // incomplete; treating mere existence as proof would destroy the live AGY + // plugin while restoring a partial tree. + try { + if (rootExisted) { + await copyOwnedPath(pluginRoot, rootBackup) + rootBackupComplete = true + } + if (manifestExisted) { + await writeFile(manifestBackup, await readFile(manifestPath), { mode: 0o600 }) + manifestBackupComplete = true + } + backupsComplete = rootBackupComplete && manifestBackupComplete + } catch { + return { + success: false, + rollbackAttempted: false, + error: { code: 'ANTIGRAVITY_BACKUP_FAILED', message: 'Antigravity plugin or import manifest backup could not be completed' }, + } + } + + mutationStarted = true + const commandResult = await runTransactionCommands(item.steps, commandRunner) + if (!commandResult.success) { + const { result } = commandResult + if (result.timedOut && result.treeTerminated !== true) { + preserveBackup = true + return { + success: false, + rollbackAttempted: false, + error: { + code: 'ANTIGRAVITY_TREE_TERMINATION_UNCONFIRMED', + message: `Antigravity timed out and descendant termination could not be confirmed; backups were preserved at ${rootBackupStorage.directory} and ${manifestBackupStorage.directory}`, + }, + } + } + rollbackAttempted = true + const rollbackSucceeded = await restore(backupSnapshot()) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: result.spawnErrorCode === 'ENOENT' + ? { code: 'MISSING_EXECUTABLE', message: 'agy executable was not found on PATH' } + : { code: result.timedOut ? 'ANTIGRAVITY_COMMAND_TIMEOUT' : 'ANTIGRAVITY_COMMAND_FAILED', message: 'Antigravity plugin replacement command failed' }, + } + } + + if (!validateStagedPlugin(pluginRoot, manifestPath, item.version.latest, item.artifact?.kind === 'git' ? item.artifact.contentDigest : undefined)) { + rollbackAttempted = true + const rollbackSucceeded = await restore(backupSnapshot()) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'ANTIGRAVITY_VALIDATION_FAILED', message: 'Antigravity staged plugin or import manifest did not validate' }, + } + } + return { success: true, rollbackAttempted: false } + } catch { + rollbackAttempted = backupsComplete && mutationStarted + const rollbackSucceeded = rollbackAttempted + ? await restore(backupSnapshot()) + : undefined + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { + code: rollbackAttempted ? 'ANTIGRAVITY_TRANSACTION_FAILED' : 'ANTIGRAVITY_BACKUP_FAILED', + message: rollbackAttempted ? 'Antigravity replacement transaction failed' : 'Antigravity backup phase did not complete', + }, + } + } finally { + if (!preserveBackup) { + await Promise.all([ + removeOwnedPath(rootBackupStorage.directory).catch(() => {}), + removeOwnedPath(manifestBackupStorage.directory).catch(() => {}), + ]) + } + } +} + +export function validateStagedPlugin (pluginRoot: string, manifestPath: string, expectedVersion?: string, expectedDigest?: string): boolean { + if (!existsSync(path.join(pluginRoot, 'plugin.json'))) return false + if (!existsSync(path.join(pluginRoot, 'bundle.json'))) return false + if (!existsSync(path.join(pluginRoot, 'skills'))) return false + try { + const plugin = JSON.parse(readFileSync(path.join(pluginRoot, 'plugin.json'), 'utf8')) as unknown + if (!plugin || typeof plugin !== 'object') return false + const bundle = JSON.parse(readFileSync(path.join(pluginRoot, 'bundle.json'), 'utf8')) as { version?: unknown; skills?: Array<{ name?: unknown; path?: unknown }> } + if (expectedVersion !== undefined && (!isStableVersion(bundle.version) || bundle.version !== expectedVersion)) return false + if (expectedDigest && createHash('sha256').update(readFileSync(path.join(pluginRoot, 'bundle.json'))).digest('hex') !== expectedDigest) return false + if (!Array.isArray(bundle.skills) || bundle.skills.length === 0) return false + for (const skill of bundle.skills) { + if (typeof skill.name !== 'string' || typeof skill.path !== 'string') return false + if (path.isAbsolute(skill.path) || skill.path.split(/[\\/]+/).includes('..')) return false + if (!existsSync(path.join(pluginRoot, skill.path, 'SKILL.md'))) return false + } + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { imports?: unknown } + if (Array.isArray(manifest.imports)) return manifest.imports.some((entry) => isPluginImport(entry)) + if (manifest.imports && typeof manifest.imports === 'object') { + return Object.entries(manifest.imports as Record).some(([key, value]) => + key.includes('nsolid-plugin') || isPluginImport(value)) + } + return false + } catch { + return false + } +} + +function isPluginImport (entry: unknown): boolean { + if (!entry || typeof entry !== 'object') return false + const value = entry as { name?: unknown; plugin?: unknown } + return value.name === 'nsolid-plugin' || value.plugin === 'nsolid-plugin' +} + +async function restore ( + snapshot: AntigravityBackupSnapshot +): Promise { + try { + if (!snapshot.root.complete || !snapshot.manifest.complete) return false + if (snapshot.root.existed) { + await removeOwnedPath(snapshot.root.target) + await copyOwnedPath(snapshot.root.backup, snapshot.root.target) + } else { + await removeOwnedPath(snapshot.root.target) + } + if (snapshot.manifest.existed) await writeFile(snapshot.manifest.target, await readFile(snapshot.manifest.backup), { mode: 0o600 }) + else await removeOwnedPath(snapshot.manifest.target) + const rootRestored = snapshot.root.existed ? existsSync(snapshot.root.target) : !existsSync(snapshot.root.target) + const manifestRestored = snapshot.manifest.existed ? existsSync(snapshot.manifest.target) : !existsSync(snapshot.manifest.target) + if (!rootRestored || !manifestRestored) return false + return snapshot.root.existed && snapshot.manifest.existed ? validateStagedPlugin(snapshot.root.target, snapshot.manifest.target) : true + } catch { + return false + } +} diff --git a/packages/core/src/update/claude-record.ts b/packages/core/src/update/claude-record.ts new file mode 100644 index 0000000..33dd98c --- /dev/null +++ b/packages/core/src/update/claude-record.ts @@ -0,0 +1,15 @@ +import type { ClaudePluginScope } from './types.js' + +const CLAUDE_SCOPES = new Set(['user', 'project', 'local', 'managed']) + +export function readClaudePluginScope (record: Record): ClaudePluginScope | undefined { + const metadata = record.metadata + const metadataScope = metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record).scope + : undefined + const values = [record.scope, record.installationScope, metadataScope] + .filter((candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0) + if (new Set(values).size > 1) return undefined + const value = values[0] + return value && CLAUDE_SCOPES.has(value as ClaudePluginScope) ? value as ClaudePluginScope : undefined +} diff --git a/packages/core/src/update/codex-config.ts b/packages/core/src/update/codex-config.ts new file mode 100644 index 0000000..39a47bc --- /dev/null +++ b/packages/core/src/update/codex-config.ts @@ -0,0 +1,186 @@ +import { readFileSync } from 'node:fs' +import { parse as parseToml } from 'smol-toml' +import { atomicWriteSync } from '../utils/fs.js' +import { readTomlFile } from '../utils/config.js' + +const CODEX_ENGINE_OWNED_FIELDS = ['version', 'path', 'installPath', 'cachePath'] as const + +export function readCodexPlugin (configPath: string, pluginId: string): Record | undefined { + try { + const data = readTomlFile>(configPath) + const plugins = data?.plugins + const plugin = plugins && typeof plugins === 'object' && !Array.isArray(plugins) + ? (plugins as Record)[pluginId] + : undefined + return isRecord(plugin) ? { ...plugin } : undefined + } catch { + return undefined + } +} + +export function restoreCodexUserOwnedFields ( + configPath: string, + pluginId: string, + original: Record, + originalText: string | undefined +): boolean { + try { + if (originalText === undefined) return false + const currentText = readFileSync(configPath, 'utf8') + const currentData = readTomlFile>(configPath) + if (!currentData) return false + const plugins = currentData.plugins + if (!isRecord(plugins)) return false + const current = plugins[pluginId] + if (!isRecord(current)) return false + const patched = patchCodexPluginTable(originalText, pluginId, current) + if (!patched) return false + const parsed = parseToml(patched) as Record + const parsedPlugins = parsed.plugins + const preserved = isRecord(parsedPlugins) ? parsedPlugins[pluginId] : undefined + if (!isRecord(preserved) || !codexUserOwnedFieldsMatch(preserved, original)) return false + for (const key of CODEX_ENGINE_OWNED_FIELDS) { + if (Object.prototype.hasOwnProperty.call(current, key) !== Object.prototype.hasOwnProperty.call(preserved, key)) return false + if (Object.prototype.hasOwnProperty.call(current, key) && !sameValue(current[key], preserved[key])) return false + } + if (patched !== currentText) atomicWriteSync(configPath, patched) + return true + } catch { + return false + } +} + +export function codexUserOwnedFieldsMatch (current: Record, original: Record): boolean { + for (const [key, value] of Object.entries(original)) { + if (CODEX_ENGINE_OWNED_FIELDS.includes(key as typeof CODEX_ENGINE_OWNED_FIELDS[number])) continue + if (!sameValue(current[key], value)) return false + } + return true +} + +function patchCodexPluginTable (source: string, pluginId: string, current: Record): string | undefined { + const lines = splitTomlLines(source) + const header = `[plugins.${JSON.stringify(pluginId)}]` + const matchingHeaders = lines.filter((line) => line.text.trim().split('#', 1)[0].trim() === header) + if (matchingHeaders.length !== 1) return undefined + const headerLine = matchingHeaders[0]! + const headerIndex = lines.indexOf(headerLine) + const tableEndIndex = lines.findIndex((line, index) => index > headerIndex && /^\s*\[{1,2}[^\]]+\]/.test(line.text)) + const endIndex = tableEndIndex === -1 ? lines.length : tableEndIndex + const engineLines = new Map() + const replacements: Array<{ start: number; end: number; value: string }> = [] + + for (let index = headerIndex + 1; index < endIndex; index++) { + const line = lines[index]! + const assignment = line.text.match(/^(\s*)([A-Za-z0-9_-]+)(\s*=\s*)(.*)$/) + if (!assignment || !CODEX_ENGINE_OWNED_FIELDS.includes(assignment[2] as typeof CODEX_ENGINE_OWNED_FIELDS[number])) continue + const key = assignment[2]! + if (engineLines.has(key) || !isSimpleTomlValue(assignment[4]!)) return undefined + engineLines.set(key, line) + } + + const missing: string[] = [] + for (const key of CODEX_ENGINE_OWNED_FIELDS) { + const hasCurrent = Object.prototype.hasOwnProperty.call(current, key) + const line = engineLines.get(key) + if (line) { + if (!hasCurrent) replacements.push({ start: line.start, end: line.end, value: '' }) + else { + const formatted = formatTomlValue(current[key]) + if (formatted === undefined) return undefined + replacements.push({ start: line.start, end: line.end, value: replaceTomlValue(line.text, formatted) + line.newline }) + } + } else if (hasCurrent) { + const formatted = formatTomlValue(current[key]) + if (formatted === undefined) return undefined + missing.push(`${key} = ${formatted}`) + } + } + + if (missing.length > 0) { + const endOffset = tableEndIndex === -1 ? source.length : lines[tableEndIndex]!.start + const before = source.slice(0, endOffset) + const hasNewline = before.endsWith('\n') || before.endsWith('\r') + const followedByTable = tableEndIndex !== -1 + const newline = source.includes('\r\n') ? '\r\n' : '\n' + const value = `${hasNewline ? '' : newline}${missing.join(newline)}${hasNewline || followedByTable ? newline : ''}` + replacements.push({ start: endOffset, end: endOffset, value }) + } + + return replacements + .sort((left, right) => right.start - left.start) + .reduce((value, replacement) => value.slice(0, replacement.start) + replacement.value + value.slice(replacement.end), source) +} + +interface TomlLine { start: number; end: number; text: string; newline: string } + +function splitTomlLines (source: string): TomlLine[] { + const lines: TomlLine[] = [] + let start = 0 + while (start < source.length) { + const newlineIndex = source.indexOf('\n', start) + if (newlineIndex === -1) { + lines.push({ start, end: source.length, text: source.slice(start), newline: '' }) + break + } + const newline = newlineIndex > start && source[newlineIndex - 1] === '\r' ? '\r\n' : '\n' + const contentEnd = newlineIndex - (newline === '\r\n' ? 1 : 0) + lines.push({ start, end: newlineIndex + 1, text: source.slice(start, contentEnd), newline }) + start = newlineIndex + 1 + } + return lines +} + +function isSimpleTomlValue (value: string): boolean { + const trimmed = value.trim() + return trimmed.length > 0 && !trimmed.startsWith('[') && !trimmed.startsWith('{') && !trimmed.includes('"""') && !trimmed.includes("'''") +} + +function formatTomlValue (value: unknown): string | undefined { + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'boolean') return String(value) + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + return undefined +} + +function replaceTomlValue (line: string, formatted: string): string { + const equals = line.indexOf('=') + let valueStart = equals + 1 + while (/\s/.test(line[valueStart] ?? '')) valueStart++ + const comment = findTomlComment(line, valueStart) + const valueEnd = comment === -1 ? line.length : comment + const whitespace = line.slice(valueStart, valueEnd).match(/\s*$/)?.[0] ?? '' + return line.slice(0, valueStart) + formatted + whitespace + (comment === -1 ? '' : line.slice(comment)) +} + +function findTomlComment (line: string, start: number): number { + let quote: '"' | "'" | undefined + let escaped = false + for (let index = start; index < line.length; index++) { + const character = line[index] + if (quote === '"' && character === '\\' && !escaped) { + escaped = true + continue + } + if (quote && character === quote && !escaped) quote = undefined + else if (!quote && (character === '"' || character === "'")) quote = character + else if (!quote && character === '#') return index + escaped = false + } + return -1 +} + +function sameValue (left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true + if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => sameValue(value, right[index])) + if (isRecord(left) && isRecord(right)) { + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && sameValue(left[key], right[key])) + } + return false +} + +function isRecord (value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} diff --git a/packages/core/src/update/codex-transaction.ts b/packages/core/src/update/codex-transaction.ts new file mode 100644 index 0000000..aafa1bb --- /dev/null +++ b/packages/core/src/update/codex-transaction.ts @@ -0,0 +1,460 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import type { CommandRunner, UpdateError, UpdatePlanItem } from './types.js' +import { resolveHome } from '../utils/path.js' +import { compareVersions, isStableVersion } from './version.js' +import { copyOwnedPath, createSiblingBackupPath, ownedPathKind, removeOwnedPath } from './fs-transaction.js' +import type { SiblingBackupPath } from './fs-transaction.js' +import { nativePayloadDigest } from './native-evidence.js' +import { runTransactionCommands } from './transaction-commands.js' +import { codexUserOwnedFieldsMatch, readCodexPlugin, restoreCodexUserOwnedFields } from './codex-config.js' + +export interface CodexTransactionResult { + success: boolean + rollbackAttempted: boolean + rollbackSucceeded?: boolean + error?: UpdateError +} + +interface CodexBackupSnapshot { + config: { target: string; backup: string; existed: boolean; complete: boolean } + cache: { target: string; backup: string; existed: boolean; complete: boolean } +} + +export async function executeCodexTransaction ( + item: UpdatePlanItem, + commandRunner: CommandRunner +): Promise { + const configPath = path.resolve(item.metadata?.configPath ?? item.metadata?.trackedMcpConfigPath ?? resolveHome('~/.codex/config.toml')) + const pluginId = item.source.kind === 'codex-marketplace' ? item.source.pluginId : undefined + const cachePath = pluginId + ? resolveCodexPluginCachePath( + configPath, + pluginId, + item.source.kind === 'codex-marketplace' ? item.source.marketplace : undefined, + item.metadata?.packageRoot + ) + : item.metadata?.packageRoot + if (!cachePath) { + return { + success: false, + rollbackAttempted: false, + error: { code: 'CODEX_CACHE_NOT_FOUND', message: 'The exact Codex plugin cache directory could not be identified safely' }, + } + } + + const configKind = await ownedPathKind(configPath) + const cacheKind = await ownedPathKind(cachePath) + if (configKind !== 'missing' && configKind !== 'file') { + return { + success: false, + rollbackAttempted: false, + error: { code: 'CODEX_CONFIG_KIND_UNSUPPORTED', message: 'Codex configuration must be a regular file for transactional replacement' }, + } + } + + // Allocate backup storage before any mutation. A missing parent directory + // (e.g. `~/.codex` absent while the config is missing) makes mkdtemp reject + // with ENOENT; that must surface as a structured failure, never as an escaped + // rejected promise. Any partially allocated backup directory is removed. + let configBackupStorage: SiblingBackupPath | undefined + let cacheBackupStorage: SiblingBackupPath | undefined + try { + configBackupStorage = await createSiblingBackupPath(configPath, 'config-backup') + cacheBackupStorage = await createSiblingBackupPath(cachePath, 'cache-backup') + } catch { + await Promise.all([ + configBackupStorage ? removeOwnedPath(configBackupStorage.directory).catch(() => {}) : Promise.resolve(), + cacheBackupStorage ? removeOwnedPath(cacheBackupStorage.directory).catch(() => {}) : Promise.resolve(), + ]) + return { + success: false, + rollbackAttempted: false, + error: { code: 'CODEX_BACKUP_FAILED', message: 'Codex configuration or plugin cache backup could not be completed' }, + } + } + const backupPath = configBackupStorage.path + const cacheBackup = cacheBackupStorage.path + const originalPlugin = pluginId ? readCodexPlugin(configPath, pluginId) : undefined + const configExisted = configKind !== 'missing' + const cacheExisted = cacheKind !== 'missing' + let configBackupComplete = !configExisted + let cacheBackupComplete = !cacheExisted + let backupsComplete = false + let originalConfigText: string | undefined + let mutationStarted = false + let rollbackAttempted = false + let preserveBackup = false + const backupSnapshot = (): CodexBackupSnapshot => ({ + config: { target: configPath, backup: backupPath, existed: configExisted, complete: configBackupComplete }, + cache: { target: cachePath, backup: cacheBackup, existed: cacheExisted, complete: cacheBackupComplete }, + }) + + try { + // Backup is a separate phase. If a recursive copy fails after creating a + // partial tree, that tree is not a valid rollback source and must never be + // used to replace the untouched live cache. + try { + if (configExisted) { + const original = await readFile(configPath) + originalConfigText = original.toString('utf8') + await writeFile(backupPath, original, { mode: 0o600 }) + configBackupComplete = true + } + if (cacheExisted) { + await copyOwnedPath(cachePath, cacheBackup) + cacheBackupComplete = true + } + backupsComplete = configBackupComplete && cacheBackupComplete + } catch { + return { + success: false, + rollbackAttempted: false, + error: { code: 'CODEX_BACKUP_FAILED', message: 'Codex configuration or plugin cache backup could not be completed' }, + } + } + + mutationStarted = true + const commandResult = await runTransactionCommands(item.steps, commandRunner) + if (!commandResult.success) { + const { command, result } = commandResult + if (result.timedOut && result.treeTerminated !== true) { + preserveBackup = true + return { + success: false, + rollbackAttempted: false, + error: { code: 'CODEX_TREE_TERMINATION_UNCONFIRMED', message: 'Codex timed out and descendant termination could not be confirmed; the backup was preserved' }, + } + } + rollbackAttempted = commandResult.completed.some((completed) => completed.args.includes('remove')) || command.args.includes('remove') + const rollbackSucceeded = rollbackAttempted + ? await restoreFiles(backupSnapshot()) + : undefined + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { + code: result.spawnErrorCode === 'ENOENT' ? 'MISSING_EXECUTABLE' : result.timedOut ? 'CODEX_COMMAND_TIMEOUT' : 'CODEX_COMMAND_FAILED', + message: result.spawnErrorCode === 'ENOENT' ? 'codex executable was not found on PATH' : `Codex command ${command.args[0] ?? 'operation'} failed`, + }, + } + } + + const refreshedPlugin = pluginId ? readCodexPlugin(configPath, pluginId) : undefined + if (pluginId && !refreshedPlugin) { + rollbackAttempted = true + const rollbackSucceeded = await restoreFiles(backupSnapshot()) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_REGISTRATION_MISSING', message: 'Codex did not recreate the selected plugin registration' }, + } + } + + if (pluginId && item.version.latest && refreshedPlugin) { + // Codex's normal registration contains enablement/source fields, not a + // version. Validate the payload selected by the recreated registration, + // rather than accepting the expected version elsewhere in the cache. + const registeredPayload = resolveRegisteredPayloadPath(configPath, refreshedPlugin) + const selectedPayload = registeredPayload ?? ( + hasRegisteredPayloadField(refreshedPlugin) + ? undefined + : resolveVersionedPayloadPath(cachePath, pluginId, item.version.latest) + ) + const cachedVersion = selectedPayload + ? readDirectCodexPayloadVersion(selectedPayload, pluginId) + : readCodexPayloadVersion(cachePath, pluginId) + if (cachedVersion !== item.version.latest) { + rollbackAttempted = true + const rollbackSucceeded = await restoreFiles(backupSnapshot()) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_VERSION_MISMATCH', message: 'Reinstalled Codex cached payload did not match the refreshed marketplace version' }, + } + } + if (item.artifact && (item.artifact.kind === 'git' || item.artifact.kind === 'local-snapshot')) { + const versionSource = item.source.kind === 'codex-marketplace' ? item.source.versionSource : undefined + const manifestPath = versionSource && versionSource.kind !== 'unknown' ? versionSource.manifestPath : undefined + const digest = selectedPayload ? nativePayloadDigest(selectedPayload, manifestPath) : undefined + if (!selectedPayload || !digest || digest !== item.artifact.contentDigest) { + rollbackAttempted = true + const rollbackSucceeded = await restoreFiles(backupSnapshot()) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_CONTENT_MISMATCH', message: 'Reinstalled Codex payload did not match the planned content identity' }, + } + } + } + } + + if (pluginId) { + const restoredUserFields = originalPlugin + ? restoreCodexUserOwnedFields(configPath, pluginId, originalPlugin, originalConfigText) + : true + const restoredPlugin = readCodexPlugin(configPath, pluginId) + if (!restoredPlugin || (originalPlugin !== undefined && !codexUserOwnedFieldsMatch(restoredPlugin, originalPlugin))) { + rollbackAttempted = true + const rollbackSucceeded = await restoreFiles(backupSnapshot()) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_REGISTRATION_MISSING', message: 'Codex did not recreate the selected plugin registration and its preserved fields' }, + } + } + if (!restoredUserFields) { + rollbackAttempted = true + const rollbackSucceeded = await restoreFiles(backupSnapshot()) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_REGISTRATION_MISSING', message: 'Codex plugin registration could not preserve its user-owned fields' }, + } + } + } + + const validation = item.steps.find((step) => step.kind === 'validation') + if (validation && (!existsSync(configPath) || (pluginId !== undefined && !readCodexPlugin(configPath, pluginId)))) { + rollbackAttempted = true + const rollbackSucceeded = await restoreFiles(backupSnapshot()) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_VALIDATION_FAILED', message: 'Codex configuration was not present after reinstall' }, + } + } + return { success: true, rollbackAttempted: false } + } catch { + rollbackAttempted = mutationStarted && backupsComplete + const rollbackSucceeded = rollbackAttempted + ? await restoreFiles(backupSnapshot()) + : undefined + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { + code: rollbackAttempted ? 'CODEX_TRANSACTION_FAILED' : 'CODEX_BACKUP_FAILED', + message: rollbackAttempted ? 'Codex replacement transaction failed' : 'Codex backup phase did not complete', + }, + } + } finally { + if (!preserveBackup) { + await Promise.all([ + removeOwnedPath(configBackupStorage.directory).catch(() => {}), + removeOwnedPath(cacheBackupStorage.directory).catch(() => {}), + ]) + } + } +} + +/** Read version evidence from the refreshed Codex cache, never from config.toml. */ +export function readCodexPayloadVersion (cachePath: string, pluginId: string): string | undefined { + return readCodexPayloadVersions(cachePath, pluginId).sort(compareVersions).at(-1) +} + +export function readCodexPayloadVersions (cachePath: string, pluginId: string): string[] { + const pluginName = pluginId.split('@', 1)[0] + const candidates: string[] = [] + collectPayloadManifests(cachePath, 0, candidates) + return versionsFromManifests(candidates, pluginName) +} + +function readDirectCodexPayloadVersion (cachePath: string, pluginId: string): string | undefined { + return versionsFromManifests(directPayloadManifests(cachePath), pluginId.split('@', 1)[0]).sort(compareVersions).at(-1) +} + +function versionsFromManifests (candidates: string[], pluginName: string): string[] { + const versions: string[] = [] + + for (const filePath of candidates) { + let value: unknown + try { value = JSON.parse(readFileSync(filePath, 'utf8')) as unknown } catch { continue } + if (!isPayloadForPlugin(value, pluginName)) continue + const object = value as Record + const metadata = object.metadata + const nestedPlugin = object.plugin + const version = [ + object.version, + object.pluginVersion, + object.bundleVersion, + isRecord(metadata) ? metadata.version : undefined, + isRecord(nestedPlugin) ? nestedPlugin.version : undefined, + ].find(isStableVersion) + if (version && !versions.includes(version)) versions.push(version) + } + return versions +} + +function collectPayloadManifests (root: string, depth: number, output: string[]): void { + if (depth > 4 || output.length >= 256) return + let entries + try { entries = readdirSync(root, { withFileTypes: true }) } catch { return } + for (const entry of entries) { + if (output.length >= 256) return + const filePath = path.join(root, entry.name) + if (entry.isDirectory()) collectPayloadManifests(filePath, depth + 1, output) + else if (entry.isFile() && ['bundle.json', 'plugin.json', 'package.json', 'manifest.json'].includes(entry.name)) output.push(filePath) + } +} + +function directPayloadManifests (root: string): string[] { + if (existsSync(root) && !isDirectory(root)) return [root] + return ['bundle.json', 'plugin.json', 'package.json', 'manifest.json'] + .map((name) => path.join(root, name)) + .filter(existsSync) +} + +function isPayloadForPlugin (value: unknown, pluginName: string): boolean { + if (!isRecord(value)) return false + const identityValues = [value.name, value.id, value.pluginId, value.packageName] + const nested = value.plugin + if (isRecord(nested)) identityValues.push(nested.name, nested.id) + return identityValues.some((identity) => typeof identity === 'string' && (identity === pluginName || identity.startsWith(`${pluginName}@`))) +} + +function isRecord (value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function resolveRegisteredPayloadPath ( + configPath: string, + plugin: Record +): string | undefined { + const cacheBase = path.resolve(path.dirname(configPath), 'plugins', 'cache') + for (const key of ['path', 'installPath', 'cachePath']) { + const configured = plugin[key] + if (typeof configured !== 'string' || configured.length === 0) continue + const candidates = path.isAbsolute(configured) + ? [path.resolve(configured)] + : [path.resolve(cacheBase, configured), path.resolve(path.dirname(configPath), configured)] + const selected = candidates.find((candidate) => isSameOrContained(candidate, cacheBase) && existsSync(candidate)) + if (selected) return selected + } + return undefined +} + +function hasRegisteredPayloadField (plugin: Record): boolean { + return ['path', 'installPath', 'cachePath'].some((key) => typeof plugin[key] === 'string' && plugin[key].length > 0) +} + +function resolveVersionedPayloadPath ( + cachePath: string, + pluginId: string, + expectedVersion: string +): string | undefined { + const exactVersionRoot = path.resolve(cachePath, expectedVersion) + if ( + isSameOrContained(exactVersionRoot, cachePath) && + existsSync(exactVersionRoot) && + readDirectCodexPayloadVersion(exactVersionRoot, pluginId) === expectedVersion + ) return exactVersionRoot + return readDirectCodexPayloadVersion(cachePath, pluginId) === expectedVersion ? cachePath : undefined +} + +function isDirectory (filePath: string): boolean { + try { return readdirSync(filePath).length >= 0 } catch { return false } +} + +async function restoreFiles ( + snapshot: CodexBackupSnapshot +): Promise { + try { + if (snapshot.config.existed && !snapshot.config.complete) return false + if (snapshot.cache.existed && !snapshot.cache.complete) return false + if (snapshot.config.complete && snapshot.config.existed) await writeFile(snapshot.config.target, await readFile(snapshot.config.backup), { mode: 0o600 }) + else if (!snapshot.config.existed) await removeOwnedPath(snapshot.config.target) + if (snapshot.cache.complete && snapshot.cache.existed) { + await removeOwnedPath(snapshot.cache.target) + await copyOwnedPath(snapshot.cache.backup, snapshot.cache.target) + } else if (!snapshot.cache.existed) { + await removeOwnedPath(snapshot.cache.target) + } + const configRestored = snapshot.config.existed ? snapshot.config.complete && existsSync(snapshot.config.backup) && existsSync(snapshot.config.target) : !existsSync(snapshot.config.target) + const cacheRestored = snapshot.cache.existed ? snapshot.cache.complete && existsSync(snapshot.cache.backup) && existsSync(snapshot.cache.target) : !existsSync(snapshot.cache.target) + return configRestored && cacheRestored + } catch { + return false + } +} + +export function resolveCodexPluginCachePath ( + configPath: string, + pluginId: string, + marketplace: string | undefined, + hintedPath: string | undefined +): string | undefined { + const cacheBase = path.resolve(path.dirname(configPath), 'plugins', 'cache') + const pluginName = pluginId.split('@', 1)[0].toLowerCase() + if (hintedPath) { + const candidate = path.resolve(hintedPath) + if (isSameOrContained(candidate, cacheBase) && candidate !== cacheBase && !isBroadCachePath(candidate, cacheBase) && isPluginCacheCandidate(candidate, pluginName)) { + return candidate + } + } + + const directories: string[] = [] + collectDirectories(cacheBase, 0, directories) + const marketplaceKeys = new Set([ + ...(marketplace ? marketplace.split('/').map((part) => part.replace(/\.git$/, '').toLowerCase()) : []), + pluginId.split('@')[1]?.toLowerCase(), + ].filter((value): value is string => typeof value === 'string' && value.length > 0)) + const candidates = directories + .filter((candidate) => candidate !== cacheBase && !isBroadCachePath(candidate, cacheBase)) + .filter((candidate) => isPluginCacheCandidate(candidate, pluginName)) + .map((candidate) => ({ candidate, score: pluginCacheScore(candidate, pluginName, marketplaceKeys) })) + .sort((left, right) => right.score - left.score || pathDepth(left.candidate) - pathDepth(right.candidate)) + const best = candidates[0] + const tied = candidates[1] && candidates[1].score === best?.score && pathDepth(candidates[1].candidate) === pathDepth(best.candidate) + return best && !tied ? best.candidate : undefined +} + +function collectDirectories (root: string, depth: number, output: string[]): void { + if (depth > 5) return + let entries + try { entries = readdirSync(root, { withFileTypes: true }) } catch { return } + output.push(root) + for (const entry of entries) { + if (entry.isDirectory()) collectDirectories(path.join(root, entry.name), depth + 1, output) + } +} + +function isPluginCacheCandidate (candidate: string, pluginName: string): boolean { + const segments = candidate.toLowerCase().split(path.sep) + const basename = path.basename(candidate).toLowerCase() + return basename === pluginName || basename.startsWith(`${pluginName}@`) || segments.includes(pluginName) || readCodexPayloadVersions(candidate, pluginName).length > 0 +} + +function pluginCacheScore (candidate: string, pluginName: string, marketplaceKeys: ReadonlySet): number { + const basename = path.basename(candidate).toLowerCase() + const segments = candidate.toLowerCase().split(path.sep) + const parent = path.basename(path.dirname(candidate)).toLowerCase() + const marketplaceMatched = marketplaceKeys.has(parent) + if (basename === pluginName) return marketplaceMatched ? 220 : 100 + if (basename.startsWith(`${pluginName}@`)) return marketplaceMatched ? 215 : 95 + if (segments.includes(pluginName)) return marketplaceMatched ? 180 : 90 + return 50 +} + +function isBroadCachePath (candidate: string, cacheBase: string): boolean { + const basename = path.basename(candidate).toLowerCase() + return candidate === cacheBase || basename === 'cache' || basename === 'plugins' +} + +function pathDepth (filePath: string): number { + return filePath.split(path.sep).length +} + +function isSameOrContained (candidate: string, parent: string): boolean { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} diff --git a/packages/core/src/update/command-runner.ts b/packages/core/src/update/command-runner.ts new file mode 100644 index 0000000..af7393a --- /dev/null +++ b/packages/core/src/update/command-runner.ts @@ -0,0 +1,503 @@ +import { spawn } from 'node:child_process' +import { accessSync, constants, existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import type { CommandResult, CommandRunner, CommandSpec, ResolvedExecutable } from './types.js' +import { redactSecrets } from './redaction.js' + +export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000 +export const MAX_COMMAND_OUTPUT = 64 * 1024 + +export function sanitizeOutput (value: string): string { + return redactSecrets(value).slice(0, MAX_COMMAND_OUTPUT) +} + +export function createCommandRunner (): CommandRunner { + return { run: runCommand } +} + +/** + * Resolves the effective spawn command for an executable step: + * - a validated absolute native executable (`.exe`/`.com` on Windows or an + * executable bit on POSIX) is spawned directly with `shell: false`; + * - a Windows `.cmd`/`.bat` shim produced by npm is NOT spawned through a + * reconstructed command line (that reintroduces cmd.exe interpretation). + * Instead its immutable JS entrypoint is derived and run with + * `process.execPath` under `shell: false`; + * - an unverifiable `.cmd`/`.bat` shim, a `.ps1`-only launcher, or a bare + * name that cannot be resolved to an absolute path resolves to + * `unsupported`; `shell: true` / `cmd.exe` are never used. + */ +export async function runCommand (spec: CommandSpec): Promise { + const timeoutMs = Number.isFinite(spec.timeoutMs) && spec.timeoutMs > 0 + ? spec.timeoutMs + : DEFAULT_COMMAND_TIMEOUT_MS + + // Plans must freeze an absolute executable identity. PATH lookup belongs to + // planning/detection; execution never re-resolves a bare name against a + // potentially changed environment. + if (!path.isAbsolute(spec.executable)) { + return { + exitCode: null, + spawnErrorCode: 'ENOENT', + stdout: '', + stderr: 'executable not found', + timedOut: false, + treeTerminated: true, + } + } + + const env = mergeCommandEnvironment(spec.env) + // Resolve the identity once; it is the single authoritative spawn target. + const identity = resolveExecutableIdentity(spec.executable, env) + if (identity.kind === 'unsupported') { + return { + exitCode: null, + spawnErrorCode: identity.reason === 'not-found' ? 'ENOENT' : 'UNSAFE_LAUNCHER', + stdout: '', + stderr: identity.reason === 'not-found' ? 'executable not found' : `${spec.executable} launcher cannot be executed safely`, + timedOut: false, + treeTerminated: true, + } + } + + // Revalidate the freshly resolved identity against the planned evidence + // immediately before spawn: a previously resolved shim/entrypoint may have + // been replaced or removed between planning and execution. + if (!revalidatePlannedIdentity(spec, identity)) { + return { + exitCode: null, + spawnErrorCode: 'EXECUTABLE_IDENTITY_DRIFT', + stdout: '', + stderr: 'planned executable identity changed before execution', + timedOut: false, + treeTerminated: true, + } + } + + const spawnArgs = identity.kind === 'node' + ? [identity.entrypoint, ...spec.args] + : [...spec.args] + const executable = identity.kind === 'node' ? process.execPath : identity.executable + + return await new Promise((resolve) => { + let stdout = '' + let stderr = '' + let timedOut = false + let treeTerminated = true + let settled = false + let timeoutTermination: Promise | undefined + + const child = spawn(executable, spawnArgs, { + cwd: spec.cwd, + env, + shell: false, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }) + + const append = (target: 'stdout' | 'stderr', chunk: Buffer | string) => { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8') + if (target === 'stdout') stdout += text + else stderr += text + if (stdout.length > MAX_COMMAND_OUTPUT) stdout = stdout.slice(0, MAX_COMMAND_OUTPUT) + if (stderr.length > MAX_COMMAND_OUTPUT) stderr = stderr.slice(0, MAX_COMMAND_OUTPUT) + } + + child.stdout?.on('data', (chunk: Buffer | string) => append('stdout', chunk)) + child.stderr?.on('data', (chunk: Buffer | string) => append('stderr', chunk)) + + const timer = setTimeout(() => { + timedOut = true + treeTerminated = false + // Terminate the whole descendant tree, then confirm termination before + // any caller proceeds to rollback. + const pid = child.pid + if (pid !== undefined) { + timeoutTermination = terminateTree(pid) + timeoutTermination.then((terminated) => { + treeTerminated = terminated + finish(null, undefined, terminated ? undefined : 'TREE_TERMINATION_UNCONFIRMED') + }).catch(() => finish(null, undefined, 'TREE_TERMINATION_UNCONFIRMED')) + } else { + child.kill('SIGTERM') + finish(null, undefined, 'TREE_TERMINATION_UNCONFIRMED') + } + }, timeoutMs) + + function finish (exitCode: number | null, signal?: NodeJS.Signals, spawnErrorCode?: string, errorText?: string) { + if (settled) return + settled = true + clearTimeout(timer) + if (spawnErrorCode === 'ENOENT') stderr += 'executable not found' + if (errorText) stderr += errorText + resolve({ + exitCode, + signal, + spawnErrorCode, + stdout: sanitizeOutput(stdout), + stderr: sanitizeOutput(stderr), + timedOut, + treeTerminated, + }) + } + + child.once('error', (error: NodeJS.ErrnoException) => { + finish(null, undefined, error.code) + }) + child.once('exit', (code, signal) => { + if (timedOut && timeoutTermination) return + finish(code, signal ?? undefined) + }) + }) +} + +export function isCommandSuccessful (result: CommandResult): boolean { + return !result.timedOut && result.exitCode === 0 +} + +/** + * Resolves an executable/entrypoint name to an absolute, identity-verified + * spawn target. Never trusts a bare name, a cwd-relative path, an empty path + * segment, an unvalidated `.cmd`/`.bat` shim, or a `.ps1`-only launcher. + */ +export function resolveExecutableIdentity (executable: string, env: Readonly> = process.env, platform: NodeJS.Platform = process.platform): ResolvedExecutable { + const isWindows = platform === 'win32' + + if (!executable) return { kind: 'unsupported', reason: 'not-found' } + + const resolved = findExecutable(executable, env, platform) + if (!resolved) return { kind: 'unsupported', reason: 'not-found' } + + // On a POSIX host forced to win32 semantics (the parser's cross-platform + // tests), the win32 resolver returns backslash-separated paths that must be + // mapped to native separators before touching the real filesystem. On the + // host Windows platform this is a no-op. + const resolvedPath = platform === 'win32' && process.platform !== 'win32' + ? resolved.split('\\').join(path.sep) + : resolved + + if (isWindows) { + const ext = path.posix.extname(resolvedPath).toLowerCase() || path.win32.extname(resolvedPath).toLowerCase() + if (ext === '.exe' || ext === '.com') return { kind: 'native', executable: resolvedPath } + if (ext === '.ps1') return { kind: 'unsupported', reason: 'powershell-only' } + if (ext === '.cmd' || ext === '.bat') { + const entrypoint = deriveShimEntrypoint(resolvedPath, platform) + if (!entrypoint) return { kind: 'unsupported', reason: 'unverifiable-shim' } + return { kind: 'node', executable: process.execPath, entrypoint } + } + // Extensionless or unknown extension: only trust it as a native target if + // it is a real file; otherwise refuse rather than guessing a shim. + if (existsSync(resolvedPath)) return { kind: 'native', executable: resolvedPath } + return { kind: 'unsupported', reason: 'not-found' } + } + + return { kind: 'native', executable: resolvedPath } +} + +/** + * Locate an executable by name. On Windows the lookup is case-insensitive over + * `PATH`/`Path` and honours `PATHEXT` (preferring `.exe`/`.com` over + * `.cmd`/`.bat` shims); empty and cwd-relative path segments are ignored. On + * POSIX the returned path is checked for the executable bit. A name containing + * a path separator is returned only if it passes the same checks, so a caller + * can never launch an arbitrary relative path via PATH lookup. + */ +export function findExecutable ( + executable: string, + env: Readonly> = process.env, + platform: NodeJS.Platform = process.platform +): string | undefined { + if (!executable) return undefined + + const isWindows = platform === 'win32' + const pathApi = isWindows ? path.win32 : path.posix + const pathValue = environmentValue(env, 'PATH') ?? '' + const candidates = pathValue.split(pathApi.delimiter).filter((segment) => pathApi.isAbsolute(segment)) + const requestedExtension = pathApi.extname(executable) + const extensions = isWindows && !requestedExtension + ? preferredWindowsExtensions(environmentValue(env, 'PATHEXT') ?? '.EXE;.COM;.CMD;.BAT') + : [''] + + if (!/[\\/]/.test(executable)) { + for (const directory of candidates) { + for (const extension of extensions) { + const candidate = pathApi.join(directory, executable + extension) + if (isExecutable(candidate, platform)) return pathApi.resolve(candidate) + } + } + } + + // A name carrying a path separator was not found in PATH. Accept a direct + // absolute path only after strict identity checks (Windows extensions and + // verify it is a file; POSIX requires the executable bit). + if (/[\\/]/.test(executable)) { + if (!pathApi.isAbsolute(executable)) return undefined + if (isWindows) { + const directExtensions = requestedExtension ? [''] : ['.exe', '.com', '.cmd', '.bat', '.ps1', ''] + for (const extension of directExtensions) { + const candidate = executable.toLowerCase().endsWith(extension) ? executable : executable + extension + if (isExecutable(candidate, platform)) { + if (extension === '' && !/\.(exe|com|cmd|bat|ps1)$/i.test(candidate)) { + // Extensionless-with-separator-path: only trust real files. + return existsSync(candidate) ? pathApi.resolve(candidate) : undefined + } + return pathApi.resolve(candidate) + } + } + return undefined + } + return isExecutable(executable, platform) ? pathApi.resolve(executable) : undefined + } + + return undefined +} + +const SHIM_INVOCATION_RE = /(?:^|[&;])\s*@?(?:"(?:%_prog%|%dp0%\\node\.exe|node(?:\.exe)?)"|node(?:\.exe)?)\s+/i +const SHIM_ENTRYPOINT_RE = /node_modules[\\/]([^"\r\n']+?\.(?:js|cjs))/i + +/** + * Parse an npm-generated Windows `.cmd`/`.bat` shim and return the absolute + * path of the immutable JS entrypoint it invokes + * (`...\node_modules\\bin\*.js|cjs`). Returns undefined when the shim + * cannot be verified as an npm shim, so the launcher is treated as unsafe + * rather than executed through cmd.exe. + * + * The invocation line is recognised by a quoted program token (`"%_prog%"`, + * `"%dp0%\node.exe"`, `"node"`/`"node.exe"`, or a legacy bare `node`) near the + * line start or after a `&`/`;`, together with a `%*` suffix. This covers the + * modern cmd-shim template, which emits the program token mid-line + * (`... || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\npm\bin\npm-cli.js" %*`), + * as well as the legacy `@node "%~dp0\..." %*` forms. + * + * Ownership is proven from the package manifest, not from a directory-name + * match: the package that contains the entrypoint must declare a `bin` whose + * name equals the shim basename without extension and whose value points to + * the captured entrypoint within the package. When several lines have + * invocation form, the first one whose ownership verifies wins; earlier decoy + * lines pointing elsewhere are skipped, and if none verifies the shim is + * unverifiable (fail-closed). Traversal (`..`) and existence of the entrypoint + * are still enforced. + * + * `platform` only influences path resolution and case sensitivity so the + * parser can run for `win32` semantics on a POSIX host during tests; it + * defaults to the host platform. + */ +export function deriveShimEntrypoint (shimPath: string, platform: NodeJS.Platform = process.platform): string | undefined { + let content: string + try { + content = readFileSync(shimPath, 'utf8') + } catch { + return undefined + } + const isWindows = platform === 'win32' + const shimName = path.win32.basename(shimPath, path.win32.extname(shimPath)) + const namesEqual = (left: string, right: string): boolean => + isWindows ? left.toLowerCase() === right.toLowerCase() : left === right + const invocationLines = content.split(/\r?\n/).filter((line) => SHIM_INVOCATION_RE.test(line) && /%\*/.test(line)) + for (const invocation of invocationLines) { + if (!/node_modules[\\/]/.test(invocation) || !/\.(?:js|cjs)["\s]/i.test(invocation)) continue + // Match a `node_modules\<...>...\*.js|cjs` target (npm's cmd-shim emits the + // entrypoint relative to the shim directory via %dp0%). Since every + // character inside the entrypoint path is constrained to a Windows path we + // strip quotes and whitespace around it. + const match = SHIM_ENTRYPOINT_RE.exec(invocation) + if (!match) continue + const relative = match[1] + if (relative.length === 0 || /\.\./.test(relative)) continue + const packageEntry = verifyPackageBinOwnership(shimPath, shimName, relative, namesEqual, isWindows) + if (!packageEntry) continue + const entrypoint = path.win32 + .resolve(path.win32.dirname(shimPath), 'node_modules', relative) + .split('\\') + .join(path.sep) + if (!existsSync(entrypoint)) continue + return entrypoint + } + return undefined +} + +/** + * Prove that the package containing `relative` (a path under + * `node_modules\...` captured from a shim invocation line) declares a `bin` + * named after the shim and pointing at that exact entrypoint. Returns the bin + * entry name on success, undefined on any failure (fail-closed). + */ +function verifyPackageBinOwnership ( + shimPath: string, + shimName: string, + relative: string, + namesEqual: (left: string, right: string) => boolean, + isWindows: boolean +): string | undefined { + const segments = relative.split(/[\\/]+/) + if (segments.length === 0 || segments[0].length === 0) return undefined + // The package directory is one segment, or two for a scoped package. + const packageSegments = segments[0].startsWith('@') ? 2 : 1 + if (segments.length <= packageSegments) return undefined + const packageDir = segments.slice(0, packageSegments).join('\\') + const inPackageRelative = segments.slice(packageSegments).join('\\') + + const manifestPath = path.win32 + .resolve(path.win32.dirname(shimPath), 'node_modules', packageDir, 'package.json') + .split('\\') + .join(path.sep) + let manifest: unknown + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + } catch { + return undefined + } + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return undefined + const record = manifest as Record + const bin = record.bin + + // A `bin` object: some key must equal the shim name. + if (bin && typeof bin === 'object' && !Array.isArray(bin)) { + const binObject = bin as Record + const matchingKey = Object.keys(binObject).find((key) => namesEqual(key, shimName)) + if (matchingKey === undefined) return undefined + const value = binObject[matchingKey] + return typeof value === 'string' && binValueMatches(value, inPackageRelative, namesEqual) ? matchingKey : undefined + } + // A `bin` string: the bin name is the package name (last segment when scoped). + if (typeof bin === 'string') { + const packageName = typeof record.name === 'string' ? record.name : '' + const binName = packageName.includes('/') ? packageName.split('/').at(-1) ?? '' : packageName + if (!namesEqual(binName, shimName)) return undefined + return binValueMatches(bin, inPackageRelative, namesEqual) ? shimName : undefined + } + return undefined +} + +/** Compare a `bin` value against the in-package entrypoint path, normalising + * separators and a leading `./`, case-sensitively on POSIX. */ +function binValueMatches (binValue: string, inPackageRelative: string, namesEqual: (left: string, right: string) => boolean): boolean { + const normalize = (value: string): string => value.replace(/\\/g, '/').replace(/^\.\//, '') + return namesEqual(normalize(binValue), normalize(inPackageRelative)) +} + +/** + * Terminate a process and its entire descendant tree, returning whether + * termination was issued (and can be assumed) rather than silent/reconstructed + * single-process kill. On Windows uses `taskkill`'s `/T` to include children; + * on POSIX signals the process group. Callers must only proceed to rollback + * after this reports the tree was terminated. + */ +async function terminateTree (pid: number): Promise { + if (process.platform === 'win32') { + // `taskkill /T` includes descendants. Do not report success until taskkill + // itself exits successfully and the original pid is no longer observable. + const exitCode = await new Promise((resolve) => { + const killer = spawn('taskkill.exe', ['/pid', String(pid), '/T', '/F'], { + shell: false, + windowsHide: true, + stdio: 'ignore', + }) + let settled = false + const finish = (code: number | null) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(code) + } + const timer = setTimeout(() => { + killer.kill() + finish(null) + }, 5_000) + killer.once('error', () => finish(null)) + killer.once('exit', (code) => finish(code)) + }) + return exitCode === 0 && await waitForProcessExit(pid) + } + // POSIX children are spawned detached, making their pid the process-group + // id. Signal and confirm the group, escalating once to SIGKILL if needed. + try { + process.kill(-pid, 'SIGTERM') + } catch { /* group may not exist */ } + if (await waitForProcessGroupExit(pid, 500)) return true + try { process.kill(-pid, 'SIGKILL') } catch { /* group may already be gone */ } + return await waitForProcessGroupExit(pid, 500) +} + +async function waitForProcessExit (pid: number, timeoutMs = 1_000): Promise { + return await waitUntilGone(() => process.kill(pid, 0), timeoutMs) +} + +async function waitForProcessGroupExit (pid: number, timeoutMs: number): Promise { + return await waitUntilGone(() => process.kill(-pid, 0), timeoutMs) +} + +async function waitUntilGone (probe: () => void, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { probe() } catch { return true } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + try { probe(); return false } catch { return true } +} + +function isExecutable (filePath: string, platform: NodeJS.Platform = process.platform): boolean { + // When a POSIX host is forced to win32 semantics (cross-platform tests) the + // win32 resolver emits backslash-separated paths; map them to native before + // touching the real filesystem. On the host platform this is a no-op. + const nativePath = process.platform === 'win32' ? filePath : filePath.split('\\').join(path.sep) + try { + if (platform === 'win32') { + accessSync(nativePath, constants.F_OK) + return true + } + accessSync(nativePath, constants.X_OK) + return true + } catch { + return false + } +} + +function environmentValue (env: Readonly>, key: string): string | undefined { + const exact = env[key] + if (exact !== undefined) return exact + const conventional = key === 'PATH' ? env.Path : key === 'PATHEXT' ? env.PathExt : undefined + if (conventional !== undefined) return conventional + const match = Object.entries(env).find(([name, value]) => name.toLowerCase() === key.toLowerCase() && value !== undefined) + return match?.[1] +} + +function mergeCommandEnvironment (overrides?: Readonly>): NodeJS.ProcessEnv { + const merged: NodeJS.ProcessEnv = { ...process.env } + for (const [name, value] of Object.entries(overrides ?? {})) { + if (process.platform === 'win32') { + for (const existing of Object.keys(merged)) { + if (existing.toLowerCase() === name.toLowerCase()) delete merged[existing] + } + } + merged[name] = value + } + return merged +} + +function revalidatePlannedIdentity (spec: CommandSpec, fresh: ResolvedExecutable): boolean { + const planned = spec.executableIdentity + if (!planned) return true + if (planned.kind === 'unsupported') return false + if (fresh.kind === 'unsupported') return false + // The freshly resolved spawn target must match the planned executable. For a + // `node` identity `spec.executable` is `process.execPath`, which resolves + // fresh to a `native` executable whose path must equal the planned one. + if (fresh.executable !== planned.executable) return false + if (planned.kind === 'native') return fresh.kind === 'native' && path.isAbsolute(planned.executable) && existsSync(planned.executable) + // For a `node` identity the derived entrypoint is spawned as spec.args[0]; it + // must match the planned entrypoint and still exist. + return path.isAbsolute(planned.entrypoint) && spec.args[0] === planned.entrypoint && existsSync(planned.entrypoint) +} + +function preferredWindowsExtensions (value: string): string[] { + const extensions = value.split(';').map((extension) => extension.trim()).filter(Boolean) + return extensions.sort((left, right) => windowsExtensionPriority(left) - windowsExtensionPriority(right)) +} + +function windowsExtensionPriority (extension: string): number { + const normalized = extension.toLowerCase() + if (normalized === '.exe' || normalized === '.com') return 0 + if (normalized === '.cmd' || normalized === '.bat') return 1 + return 2 +} diff --git a/packages/core/src/update/coordinator.ts b/packages/core/src/update/coordinator.ts new file mode 100644 index 0000000..329a73c --- /dev/null +++ b/packages/core/src/update/coordinator.ts @@ -0,0 +1,376 @@ +import type { HarnessType } from '../types.js' +import { rm } from 'node:fs/promises' +import path from 'node:path' +import { createCommandRunner } from './command-runner.js' +import { detectCliInstallation, detectInstallations } from './inventory.js' +import { cleanupNpmArtifact, downloadNpmArtifact, resolveFixedGitBundleVersion, resolveMarketplaceVersion, resolveRegistryVersion } from './version-source.js' +import { classifyVersionSet, classifyVersions } from './version.js' +import type { + UpdateContext, + UpdateInstallation, + UpdateOptions, + UpdatePlan, + UpdatePlanItem, + UpdateResult, + UpdateStatus, + UpdateStrategy, + UpdateSummary, + VersionLookupResult, +} from './types.js' +import { planItem, resultFromPlan } from './strategies/common.js' +import { cliPackageStrategy } from './strategies/cli-package.js' +import { claudeStrategy } from './strategies/claude.js' +import { codexStrategy } from './strategies/codex.js' +import { antigravityStrategy } from './strategies/antigravity.js' +import { piStrategy } from './strategies/pi.js' +import { fallbackStrategy } from './strategies/fallback.js' +import { recoverFallbackJournal } from './fallback-journal.js' +import { getTrackingFilePath } from '../utils/path.js' + +const STATUSES: readonly UpdateStatus[] = [ + 'current', + 'update-available', + 'newer-than-registry', + 'updated', + 'skipped', + 'not-installed', + 'unsupported', + 'unknown', + 'failed', +] + +export async function planUpdates (options: UpdateOptions = {}): Promise { + validateScope(options) + const pendingRecovery = await recoverFallbackJournal(getTrackingFilePath(), options.check !== true) + if (pendingRecovery.pending && !pendingRecovery.recovered) { + return { + checkOnly: options.check === true, + items: [recoveryPlanItem(options.check === true)], + } + } + const commandRunner = options.commandRunner ?? createCommandRunner() + const context: UpdateContext = { options, commandRunner } + const detected = await detectInstallations({ + commandRunner, + cwd: options.cwd, + packageRoot: options.packageRoot, + includeCli: options.harness === undefined, + readOnly: options.check === true, + deferCliOwnership: options.check !== true, + }) + const selected = selectInstallations(detected, options) + const withSynthetic = options.harness && selected.length === 0 + ? [syntheticInstallation(options.harness)] + : selected + const items: UpdatePlanItem[] = [] + + for (let installation of withSynthetic) { + if (installation.inventoryError) { + items.push(planItem(installation, [], [], undefined, installation.inventoryError)) + continue + } + if (installation.source.kind === 'none') { + items.push({ ...planItem(installation), manualCommands: installationGuidance(installation.target) }) + continue + } + + const lookup = await resolveLatestVersion(installation, options) + const plannedStatus = lookup.version + ? classifyVersions(installation.version.current, lookup.version).status + : installation.version.status + if (options.check !== true && lookup.artifact?.kind === 'npm' && (plannedStatus === 'update-available' || plannedStatus === 'unknown') && !lookup.artifact.tarballPath) { + try { + lookup.artifact = await downloadNpmArtifact(lookup.artifact, { fetchImpl: options.fetchImpl }) + } catch { + items.push(planItem(installation, [], [], undefined, { code: 'ARTIFACT_INTEGRITY_FAILED', message: 'Planned registry artifact could not be downloaded or verified' })) + continue + } + } + if ( + installation.target === 'cli' && + options.check !== true && + installation.source.kind === 'unsupported' && + (plannedStatus === 'update-available' || (plannedStatus === 'unknown' && installation.version.status === 'unknown')) + ) { + // Ownership probing is deferred until a mutation is actually possible. + // This keeps current/newer-than-registry paths free of package-manager + // subprocesses while still requiring positive ownership evidence before + // an update command is planned. + installation = await detectCliInstallation({ + commandRunner, + cwd: options.cwd, + packageRoot: options.packageRoot, + includeCli: true, + readOnly: false, + }) + } + const resolved = lookup.version + ? { ...installation, version: classifyInstallationVersion(installation, lookup.version), artifact: lookup.artifact ?? installation.artifact } + : installation + if (lookup.error) { + if (options.check !== true && isMutationUnavailableLookup(lookup.error.code)) { + const unsupported = { + ...resolved, + source: { + kind: 'unsupported' as const, + source: `${installation.target}:immutable-source`, + reason: 'git' as const, + }, + } + items.push({ ...planItem(unsupported), manualCommands: installationGuidance(installation.target) }) + continue + } + items.push(planItem(resolved, [], [], undefined, lookup.error)) + continue + } + // Checks are inventory/version reports. They must not ask a mutation + // strategy to discover an executor, construct commands, or inspect + // writable transaction state. + if (options.check === true) { + items.push(planItem(resolved)) + continue + } + const strategy = strategyFor(resolved) + try { + items.push(await strategy.plan(resolved, context)) + } catch { + items.push(planItem(resolved, [], [], undefined, { code: 'UPDATE_PLAN_FAILED', message: 'Target update plan could not be created' })) + } + } + + return { checkOnly: options.check === true, items } +} + +export async function checkUpdates (options: UpdateOptions = {}): Promise { + const plan = await planUpdates({ ...options, check: true }) + return summarizePlan(plan) +} + +export async function update (options: UpdateOptions = {}): Promise { + const plan = await planUpdates({ ...options, check: false }) + return executeUpdatePlan(plan, options) +} + +export async function executeUpdatePlan (plan: UpdatePlan, options: UpdateOptions = {}): Promise { + if (plan.checkOnly || options.check === true) return summarizePlan(plan) + const commandRunner = options.commandRunner ?? createCommandRunner() + + const recoveryItem = plan.items.find((item) => item.planningError?.code === 'FALLBACK_RECOVERY_PENDING' || item.planningError?.code === 'FALLBACK_RECOVERY_FAILED') + if (recoveryItem?.planningError) { + const results = plan.items.map((item) => item.planningError + ? resultFromPlan(item, 'failed', { error: item.planningError }) + : item.requiresConfirmation + ? resultFromPlan(item, 'failed', { error: recoveryItem.planningError }) + : resultFromPlan(item, statusForPlan(item, false))) + await Promise.all(plan.items.map(async (item, index) => { + if (!mustPreservePlanState(results[index]!)) await cleanupPlanState(item) + })) + return summarizeResults(false, results) + } + + const planningResults = plan.items.map((item) => item.planningError ? resultFromPlan(item, 'failed', { error: item.planningError }) : undefined) + const mutableItems = plan.items.filter((item) => item.requiresConfirmation) + let approved = options.yes === true + + if (mutableItems.length > 0 && !approved) { + if (!options.confirm) { + const results = plan.items.map((item, index) => planningResults[index] ?? ( + item.requiresConfirmation + ? resultFromPlan(item, 'skipped', { error: { code: 'CONFIRMATION_REQUIRED', message: 'Pass --yes in non-interactive mode to approve this update' } }) + : resultFromPlan(item, statusForPlan(item, false)) + )) + await Promise.all(plan.items.map(async (item, index) => { + if (!mustPreservePlanState(results[index]!)) await cleanupPlanState(item) + })) + return summarizeResults(false, results) + } + approved = await options.confirm({ items: mutableItems }) + } + + const results: UpdateResult[] = [] + for (const item of plan.items) { + let result: UpdateResult + if (item.planningError) { + result = resultFromPlan(item, 'failed', { error: item.planningError }) + } else if (item.requiresConfirmation && !approved) { + result = resultFromPlan(item, 'skipped', { error: { code: 'CONFIRMATION_REQUIRED', message: 'Update was not approved' } }) + } else if (!item.requiresConfirmation) { + result = resultFromPlan(item, statusForPlan(item, false)) + } else { + try { + result = await strategyForPlan(item).execute(item, { options, commandRunner }) + } catch { + await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + result = resultFromPlan(item, 'failed', { error: { code: 'UPDATE_EXECUTION_FAILED', message: 'Update strategy failed' } }) + } + } + if (!mustPreservePlanState(result)) await cleanupPlanState(item) + results.push(result) + } + return summarizeResults(false, results) +} + +function recoveryPlanItem (checkOnly: boolean): UpdatePlanItem { + const installation = { + installationId: 'fallback:recovery' as const, + target: 'opencode' as const, + ownership: 'fallback' as const, + installed: true, + source: { kind: 'fallback' as const }, + version: { status: 'unknown' as const }, + } + return { + ...planItem(installation), + planningError: { + code: checkOnly ? 'FALLBACK_RECOVERY_PENDING' : 'FALLBACK_RECOVERY_FAILED', + message: checkOnly + ? 'A pending fallback transaction requires recovery before the next mutable update' + : 'A pending fallback transaction could not be recovered', + }, + } +} + +async function cleanupPlanState (item: UpdatePlanItem): Promise { + await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + const command = item.steps.find((step) => step.kind === 'command') + const transactionIndex = command?.kind === 'command' ? command.command.args.indexOf('--transaction') : -1 + const manifestPath = transactionIndex >= 0 && command?.kind === 'command' ? command.command.args[transactionIndex + 1] : undefined + if (manifestPath) await rm(path.dirname(manifestPath), { recursive: true, force: true }).catch(() => {}) +} + +function mustPreservePlanState (result: UpdateResult): boolean { + return result.error?.code === 'FALLBACK_TREE_TERMINATION_UNCONFIRMED' || result.error?.code === 'CLI_TREE_TERMINATION_UNCONFIRMED' +} + +export function summarizePlan (plan: UpdatePlan): UpdateSummary { + return summarizeResults(plan.checkOnly, plan.items.map((item) => item.planningError + ? resultFromPlan(item, 'failed', { error: item.planningError }) + : resultFromPlan(item, statusForPlan(item, plan.checkOnly)))) +} + +export function summarizeResults (checkOnly: boolean, results: UpdateResult[]): UpdateSummary { + const counts = Object.fromEntries(STATUSES.map((status) => [status, 0])) as Record + for (const result of results) counts[result.status]++ + return { + checkOnly, + results, + counts, + exitCode: checkOnly + ? counts.failed > 0 ? 1 : 0 + : counts.failed > 0 ? 1 : (counts.unsupported > 0 || counts['not-installed'] > 0 || counts.unknown > 0 || results.some((result) => result.error?.code === 'CONFIRMATION_REQUIRED')) ? 2 : 0, + success: (checkOnly ? counts.failed === 0 : counts.failed === 0 && counts.unsupported === 0 && counts['not-installed'] === 0 && counts.unknown === 0 && !results.some((result) => result.error?.code === 'CONFIRMATION_REQUIRED')), + } +} + +function statusForPlan (item: UpdatePlanItem, checkOnly: boolean): UpdateStatus { + if (checkOnly) { + if (!item.installed || item.source.kind === 'none') return 'not-installed' + if (item.source.kind === 'unsupported' && item.target !== 'cli') return 'unsupported' + switch (item.version.status) { + case 'current': return 'current' + case 'update-available': return 'update-available' + case 'newer-than-registry': return 'newer-than-registry' + default: return 'unknown' + } + } + if (item.source.kind === 'unsupported') { + // A CLI that is already current, or newer than the registry, has no + // mutation to authorize and therefore does not need ownership probing. + if (item.target === 'cli' && item.version.status === 'current') return 'current' + if (item.target === 'cli' && item.version.status === 'newer-than-registry') return 'newer-than-registry' + return 'unsupported' + } + if (!item.installed || item.ownership === 'none' || item.source.kind === 'none') return 'not-installed' + switch (item.version.status) { + case 'current': return 'current' + case 'newer-than-registry': return 'newer-than-registry' + case 'update-available': return 'update-available' + default: return 'unknown' + } +} + +function classifyInstallationVersion (installation: UpdateInstallation, latest: string) { + const currents = installation.version.currentVersions + return currents + ? classifyVersionSet(currents, latest) + : classifyVersions(installation.version.current, latest) +} + +function strategyForPlan (item: UpdatePlanItem): UpdateStrategy { + return strategyFor({ target: item.target, ownership: item.ownership, source: item.source }) +} + +function strategyFor (installation: Pick): UpdateStrategy { + if (installation.target === 'cli') return cliPackageStrategy + if (installation.ownership === 'fallback') return fallbackStrategy + switch (installation.target) { + case 'claude': return claudeStrategy + case 'codex': return codexStrategy + case 'antigravity': return antigravityStrategy + case 'pi': return piStrategy + default: return fallbackStrategy + } +} + +async function resolveLatestVersion (installation: UpdateInstallation, options: UpdateOptions): Promise { + const sourceOptions = { + fetchImpl: options.fetchImpl, + registry: options.registry, + downloadArtifact: false, + requireImmutable: options.check !== true, + } + switch (installation.source.kind) { + case 'global-package': + return resolveRegistryVersion('nsolid-plugin', sourceOptions) + case 'pi-package': + return resolveRegistryVersion('nsolid-pi-plugin', sourceOptions) + case 'fallback': + return resolveRegistryVersion('nsolid-plugin', sourceOptions) + case 'claude-marketplace': + case 'codex-marketplace': + return resolveMarketplaceVersion(installation.source.versionSource, sourceOptions) + case 'antigravity-git': + return resolveFixedGitBundleVersion(sourceOptions) + case 'unsupported': + if (installation.target === 'cli') return resolveRegistryVersion('nsolid-plugin', sourceOptions) + return {} + case 'none': + return {} + } +} + +function selectInstallations (detected: UpdateInstallation[], options: UpdateOptions): UpdateInstallation[] { + if (options.harness) return detected.filter((installation) => installation.target === options.harness) + if (options.all) return detected + return detected.filter((installation) => installation.target === 'cli') +} + +function syntheticInstallation (harness: HarnessType): UpdateInstallation { + return { + installationId: `${harness}:none`, + target: harness, + ownership: 'none', + installed: false, + source: { kind: 'none' }, + version: { status: 'unknown' }, + } +} + +function installationGuidance (target: UpdateInstallation['target']): readonly string[] { + switch (target) { + case 'claude': return ['claude plugin marketplace add NodeSource/nsolid-plugin', 'claude plugin install nsolid-plugin@nodesource'] + case 'codex': return ['codex plugin marketplace add NodeSource/nsolid-plugin', 'codex plugin add nsolid-plugin@nodesource'] + case 'antigravity': return ['agy plugin install https://github.com/NodeSource/nsolid-plugin.git'] + case 'opencode': return ['nsolid-plugin setup --harness opencode', 'nsolid-plugin install --harness opencode'] + case 'pi': return ['pi install npm:nsolid-pi-plugin', 'nsolid-plugin setup --harness pi'] + case 'cli': return ['npm install --global nsolid-plugin'] + } +} + +function validateScope (options: UpdateOptions): void { + if (options.all && options.harness) throw new Error('Cannot combine --all with --harness') +} + +function isMutationUnavailableLookup (code: string): boolean { + return code === 'IMMUTABLE_SOURCE_UNAVAILABLE' || code === 'SOURCE_CONTENT_MISMATCH' || code === 'INVALID_MARKETPLACE_SOURCE' +} diff --git a/packages/core/src/update/fallback-journal.ts b/packages/core/src/update/fallback-journal.ts new file mode 100644 index 0000000..37fd848 --- /dev/null +++ b/packages/core/src/update/fallback-journal.ts @@ -0,0 +1,262 @@ +import { createHash } from 'node:crypto' +import { cp, lstat, mkdtemp, open, readFile, readlink, readdir, rename, rm, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import type { FallbackTransactionIdentity } from './types.js' +import { isValidTrackingData, type TrackingData } from '../skills/skill-tracker.js' +import { isCanonicalPath, isSameOrContained, matchesTrackedOwnership } from './fallback-ownership.js' + +export type FallbackJournalPhase = 'prepared' | 'mutating' | 'committed' + +export interface FallbackJournal { + version: 1 + phase: FallbackJournalPhase + manifest: FallbackTransactionIdentity + journalPath: string + snapshotDirectory: string + entries: readonly FallbackJournalEntry[] +} + +interface FallbackJournalEntry { + path: string + backup: string + existed: boolean + digest?: string + /** Exact live state the parent is authorized to replace during rollback. */ + expectedCurrentDigest?: string | null +} + +export interface FallbackJournalResult { + journal: FallbackJournal + rollbackSucceeded?: boolean +} + +export function trackingDigest (trackingPath: string): string | undefined { + try { return createHash('sha256').update(readFileSync(trackingPath)).digest('hex') } catch { return undefined } +} + +export function valueDigest (value: unknown): string { + return createHash('sha256').update(JSON.stringify(stableValue(value)) ?? 'undefined').digest('hex') +} + +export function fallbackJournalPath (trackingPath: string): string { + return `${path.resolve(trackingPath)}.update-journal.json` +} + +export async function beginFallbackJournal (manifest: FallbackTransactionIdentity): Promise { + const trackingPath = path.resolve(manifest.trackingPath) + const currentTrackingDigest = trackingDigest(trackingPath) + if (!currentTrackingDigest || currentTrackingDigest !== manifest.trackingDigest || !await manifestMatchesTrackingFile(manifest)) { + throw new Error('FALLBACK_TRACKING_DRIFT') + } + const journalPath = fallbackJournalPath(trackingPath) + const snapshotDirectory = await mkdtemp(path.join(path.dirname(trackingPath), '.nsolid-plugin-update-')) + const paths = [...new Set([ + trackingPath, + ...manifest.ownedSkillPaths, + ...manifest.ownedLinkPaths, + ...manifest.ownedMcpFields.map((field) => field.configPath), + ].map((value) => path.resolve(value)))] + const entries: FallbackJournalEntry[] = [] + try { + for (const [index, target] of paths.entries()) { + const existed = existsSync(target) + const backup = path.join(snapshotDirectory, String(index)) + const digest = existed ? await pathDigest(target) : undefined + if (existed && !digest) throw new Error(`cannot digest ${target}`) + if (existed) await cp(target, backup, { recursive: true, force: true }) + entries.push({ path: target, backup, existed, digest, expectedCurrentDigest: digest ?? null }) + } + const journal: FallbackJournal = { version: 1, phase: 'prepared', manifest, journalPath, snapshotDirectory, entries } + await writeDurable(journalPath, journal) + return { journal } + } catch (error) { + await rm(snapshotDirectory, { recursive: true, force: true }).catch(() => {}) + throw new Error('FALLBACK_BACKUP_FAILED', { cause: error }) + } +} + +export async function markFallbackJournalMutating (journal: FallbackJournal): Promise { + const updated = { ...journal, phase: 'mutating' as const } + await writeDurable(journal.journalPath, updated) + return updated +} + +export async function captureFallbackJournalState (journal: FallbackJournal): Promise { + if (!isSafeJournal(journal) || !await journalOwnershipIsValid(journal)) throw new Error('Invalid fallback journal') + const entries: FallbackJournalEntry[] = [] + for (const entry of journal.entries) { + const expectedCurrentDigest = existsSync(entry.path) ? await pathDigest(entry.path) : null + if (expectedCurrentDigest === undefined) throw new Error('Fallback state cannot be identified') + entries.push({ ...entry, expectedCurrentDigest }) + } + const updated = { ...journal, entries } + await writeDurable(journal.journalPath, updated) + return updated +} + +export async function commitFallbackJournal (journal: FallbackJournal): Promise { + if (!isSafeJournal(journal) || !await journalOwnershipIsValid(journal)) throw new Error('Invalid fallback journal') + await writeDurable(journal.journalPath, { ...journal, phase: 'committed' }) + await rm(journal.journalPath, { force: true }) + await rm(journal.snapshotDirectory, { recursive: true, force: true }) +} + +export async function restoreFallbackJournal (journal: FallbackJournal): Promise { + if (!isSafeJournal(journal) || !await journalOwnershipIsValid(journal)) return false + try { + if (!await journalStateMatchesExpected(journal)) return false + for (const entry of journal.entries) { + if (!await entryStateMatchesExpected(entry)) return false + if (entry.existed) { + await rm(entry.path, { recursive: true, force: true }) + await cp(entry.backup, entry.path, { recursive: true, force: true }) + } else { + await rm(entry.path, { recursive: true, force: true }) + } + } + const valid = await Promise.all(journal.entries.map(async (entry) => { + if (!entry.existed) return !existsSync(entry.path) + if (!existsSync(entry.path) || !entry.digest) return false + return await pathDigest(entry.path) === entry.digest + })).then((values) => values.every(Boolean)) + if (valid) { + await rm(journal.journalPath, { force: true }) + await rm(journal.snapshotDirectory, { recursive: true, force: true }) + } + return valid + } catch { + return false + } +} + +export async function recoverFallbackJournal (trackingPath: string, mutate: boolean): Promise<{ pending: boolean; recovered: boolean }> { + const journalPath = fallbackJournalPath(trackingPath) + if (!existsSync(journalPath)) return { pending: false, recovered: true } + let journal: FallbackJournal + try { journal = JSON.parse(await readFile(journalPath, 'utf8')) as FallbackJournal } catch { return { pending: true, recovered: false } } + if (!isSafeJournal(journal) || journal.journalPath !== journalPath || !await journalOwnershipIsValid(journal)) return { pending: true, recovered: false } + if (!mutate) return { pending: true, recovered: false } + if (journal.phase === 'committed') { + await rm(journal.journalPath, { force: true }).catch(() => {}) + await rm(journal.snapshotDirectory, { recursive: true, force: true }).catch(() => {}) + return { pending: false, recovered: true } + } + const recovered = await restoreFallbackJournal(journal) + return { pending: true, recovered } +} + +async function writeDurable (filePath: string, value: unknown): Promise { + const temporary = `${filePath}.${process.pid}.tmp` + await writeFile(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }) + const handle = await open(temporary, 'r+') + try { await handle.sync() } finally { await handle.close() } + await rename(temporary, filePath) + try { + const directory = await open(path.dirname(filePath), 'r') + await directory.sync() + await directory.close() + } catch { /* directory fsync is unavailable on some platforms */ } +} + +async function pathDigest (target: string): Promise { + try { + const stat = await lstat(target) + const hash = createHash('sha256') + if (stat.isSymbolicLink()) { + hash.update('symlink\0').update(await readlink(target)) + return hash.digest('hex') + } + if (stat.isFile()) { + hash.update('file\0').update(await readFile(target)) + return hash.digest('hex') + } + if (stat.isDirectory()) { + hash.update('directory\0') + const entries = await readdir(target, { withFileTypes: true }) + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const child = path.join(target, entry.name) + hash.update(entry.name).update('\0') + const childDigest = await pathDigest(child) + if (!childDigest) return undefined + hash.update(childDigest) + } + return hash.digest('hex') + } + return undefined + } catch { + return undefined + } +} + +function isSafeJournal (journal: FallbackJournal): boolean { + if (!journal || journal.version !== 1 || !['prepared', 'mutating', 'committed'].includes(journal.phase) || !journal.manifest || !Array.isArray(journal.entries)) return false + if (!Array.isArray(journal.manifest.ownedSkillPaths) || !Array.isArray(journal.manifest.ownedLinkPaths) || !Array.isArray(journal.manifest.ownedMcpFields)) return false + if (journal.manifest.ownedMcpFields.some((field) => !field || typeof field.configPath !== 'string' || typeof field.server !== 'string' || typeof field.field !== 'string' || typeof field.expectedDigest !== 'string')) return false + if (journal.manifest.ownedSkillPaths.some((value) => typeof value !== 'string') || journal.manifest.ownedLinkPaths.some((value) => typeof value !== 'string')) return false + if (typeof journal.manifest.trackingPath !== 'string' || typeof journal.manifest.trackingDigest !== 'string' || typeof journal.manifest.harness !== 'string' || typeof journal.manifest.installationId !== 'string') return false + if (typeof journal.journalPath !== 'string' || typeof journal.snapshotDirectory !== 'string') return false + const trackingPath = path.resolve(journal.manifest.trackingPath) + if (journal.journalPath !== fallbackJournalPath(trackingPath)) return false + if (!isSameOrContained(path.resolve(journal.snapshotDirectory), path.dirname(trackingPath))) return false + if (!journal.manifest.installationId || journal.manifest.installationId !== `${journal.manifest.harness}:fallback`) return false + const expectedPaths = new Set([ + trackingPath, + ...journal.manifest.ownedSkillPaths, + ...journal.manifest.ownedLinkPaths, + ...journal.manifest.ownedMcpFields.map((field) => field.configPath), + ].map((value) => path.resolve(value))) + if ([...expectedPaths].some((value) => !isCanonicalPath(value))) return false + const entries = new Set() + for (const entry of journal.entries) { + if (!entry || typeof entry.path !== 'string' || typeof entry.backup !== 'string' || typeof entry.existed !== 'boolean') return false + const target = path.resolve(entry.path) + if (!isCanonicalPath(target) || !expectedPaths.has(target) || entries.has(target)) return false + if (!isSameOrContained(path.resolve(entry.backup), path.resolve(journal.snapshotDirectory))) return false + if (entry.expectedCurrentDigest !== undefined && entry.expectedCurrentDigest !== null && typeof entry.expectedCurrentDigest !== 'string') return false + entries.add(target) + } + return entries.size === expectedPaths.size && [...expectedPaths].every((target) => entries.has(target)) +} + +async function journalStateMatchesExpected (journal: FallbackJournal): Promise { + const matches = await Promise.all(journal.entries.map(entryStateMatchesExpected)) + return matches.every(Boolean) +} + +async function entryStateMatchesExpected (entry: FallbackJournalEntry): Promise { + const expected = entry.expectedCurrentDigest !== undefined ? entry.expectedCurrentDigest : entry.digest ?? null + const current = existsSync(entry.path) ? await pathDigest(entry.path) : null + return current !== undefined && current === expected +} + +async function journalOwnershipIsValid (journal: FallbackJournal): Promise { + const trackingPath = path.resolve(journal.manifest.trackingPath) + const trackingEntry = journal.entries.find((entry) => path.resolve(entry.path) === trackingPath) + if (!trackingEntry?.existed || !trackingEntry.digest) return false + if (await pathDigest(trackingEntry.backup) !== trackingEntry.digest) return false + if (trackingDigest(trackingEntry.backup) !== journal.manifest.trackingDigest) return false + try { + const tracking = JSON.parse(await readFile(trackingEntry.backup, 'utf8')) as unknown + return isValidTrackingData(tracking) && matchesTrackedOwnership(tracking as TrackingData, journal.manifest) + } catch { + return false + } +} + +async function manifestMatchesTrackingFile (manifest: FallbackTransactionIdentity): Promise { + try { + const tracking = JSON.parse(await readFile(manifest.trackingPath, 'utf8')) as unknown + return isValidTrackingData(tracking) && matchesTrackedOwnership(tracking as TrackingData, manifest) + } catch { + return false + } +} + +function stableValue (value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue) + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, stableValue(child)])) + } + return value +} diff --git a/packages/core/src/update/fallback-ownership.ts b/packages/core/src/update/fallback-ownership.ts new file mode 100644 index 0000000..1792739 --- /dev/null +++ b/packages/core/src/update/fallback-ownership.ts @@ -0,0 +1,37 @@ +import path from 'node:path' +import { getHarnessSkillsPath } from '../skills/skill-linker.js' +import type { TrackingData } from '../skills/skill-tracker.js' +import type { FallbackTransactionIdentity } from './types.js' + +export function matchesTrackedOwnership (tracking: TrackingData, identity: FallbackTransactionIdentity): boolean { + const linkRoot = path.resolve(getHarnessSkillsPath(identity.harness)) + if (identity.ownedLinkPaths.some((value) => !isSameOrContained(path.resolve(value), linkRoot))) return false + const scopedSkills = tracking.skills.filter((entry) => entry.harnesses.includes(identity.harness)) + const trackedPaths = new Set(scopedSkills + .map((entry) => entry.paths?.[identity.harness] ?? entry.path) + .filter((value): value is string => typeof value === 'string') + .map((value) => path.resolve(value))) + const ownedSkillPaths = new Set(identity.ownedSkillPaths.map((value) => path.resolve(value))) + if (ownedSkillPaths.size !== trackedPaths.size || ![...ownedSkillPaths].every((value) => trackedPaths.has(value))) return false + const expectedLinkPaths = new Set(scopedSkills.map((entry) => path.resolve(linkRoot, entry.name))) + const ownedLinkPaths = new Set(identity.ownedLinkPaths.map((value) => path.resolve(value))) + if (ownedLinkPaths.size !== expectedLinkPaths.size || ![...ownedLinkPaths].every((value) => expectedLinkPaths.has(value))) return false + const expectedMcpFields = tracking.mcpServers + .filter((entry) => entry.harness === identity.harness && entry.fields) + .flatMap((entry) => Object.entries(entry.fields ?? {}).map(([field, expectedDigest]) => `${path.resolve(entry.configPath)}\0${entry.name}\0${field}\0${expectedDigest}`)) + const ownedMcpFields = new Set(identity.ownedMcpFields.map((field) => `${path.resolve(field.configPath)}\0${field.server}\0${field.field}\0${field.expectedDigest}`)) + if (expectedMcpFields.length > 0 && (ownedMcpFields.size !== expectedMcpFields.length || !expectedMcpFields.every((value) => ownedMcpFields.has(value)))) return false + return identity.ownedMcpFields.every((field) => tracking.mcpServers.some((entry) => { + if (entry.harness !== identity.harness || entry.name !== field.server || path.resolve(entry.configPath) !== path.resolve(field.configPath)) return false + return entry.fields?.[field.field] === field.expectedDigest + })) +} + +export function isCanonicalPath (value: string): boolean { + return path.isAbsolute(value) && !value.split(path.sep).includes('..') && path.resolve(value) === value +} + +export function isSameOrContained (candidate: string, parent: string): boolean { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} diff --git a/packages/core/src/update/fallback-transaction.ts b/packages/core/src/update/fallback-transaction.ts new file mode 100644 index 0000000..c2ef692 --- /dev/null +++ b/packages/core/src/update/fallback-transaction.ts @@ -0,0 +1,399 @@ +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync, lstatSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { BundleDescriptor, Credentials, HarnessType } from '../types.js' +import { validateBundle } from '../validate.js' +import { readJsonFile, readJsoncFile, readTomlFile } from '../utils/config.js' +import { resolveHome, getSkillsDir, getAuthFilePath } from '../utils/path.js' +import { deriveMcpUrlFromConsoleUrl } from '../auth/mcp-url.js' +import { removeMcpConfig, writeMcpConfig } from '../mcp/mcp-config-writer.js' +import { readTrackingFile, writeTrackingFile, type SkillTrackingEntry, type TrackingData } from '../skills/skill-tracker.js' +import { installSkillsToDirectory } from '../skills/skill-copier.js' +import { getHarnessSkillsPath, linkSkillsToHarness, unlinkSkillsFromHarness } from '../skills/skill-linker.js' +import { assertSafeSkillName } from '../utils/skill-name.js' +import { getAdapter } from '../harnesses/index.js' +import type { FallbackTransactionIdentity, UpdateError } from './types.js' +import { trackingDigest, valueDigest } from './fallback-journal.js' +import { readPackageVersion } from './package-manager.js' +import { isStableVersion } from './version.js' +import { isCanonicalPath, matchesTrackedOwnership } from './fallback-ownership.js' + +export interface FallbackRefreshOptions { + harness: HarnessType + bundlePath: string + skillsSource: string + transaction?: FallbackTransactionIdentity +} + +export interface FallbackRefreshResult { + success: boolean + rollbackAttempted?: boolean + rollbackSucceeded?: boolean + error?: UpdateError +} + +export async function refreshOwnedInstallation (options: FallbackRefreshOptions): Promise { + if (options.transaction) { + const validation = validateTransactionIdentity(options.transaction) + if (validation) return failure(validation.code, validation.message) + } + const tracking = await readTrackingFile() + if (!tracking) return failure('UNTRACKED_INSTALLATION', 'No NodeSource tracking record exists') + if (options.transaction && !matchesTrackedOwnership(tracking, options.transaction)) { + return failure('FALLBACK_OWNERSHIP_DRIFT', 'Fallback ownership no longer matches the approved transaction manifest') + } + const previousSkills = tracking.skills.filter((entry) => entry.harnesses.includes(options.harness)) + const previousMcps = tracking.mcpServers.filter((entry) => entry.harness === options.harness) + if (previousSkills.length === 0 && previousMcps.length === 0) return failure('UNTRACKED_INSTALLATION', 'The requested harness has no tracked NodeSource ownership') + + let bundle: BundleDescriptor + try { + const raw = readJsonFile(options.bundlePath) + if (!raw) return failure('BUNDLE_NOT_FOUND', 'Update bundle is not available') + bundle = validateBundle(raw) + } catch { + return failure('BUNDLE_INVALID', 'Update bundle is invalid') + } + const packageVersion = readPackageVersion(options.skillsSource) + const hasPackageManifest = existsSync(path.join(options.skillsSource, 'package.json')) + if (!isStableVersion(bundle.version) || (hasPackageManifest && packageVersion !== bundle.version)) { + return failure('FALLBACK_BUNDLE_VERSION_MISMATCH', 'Update bundle version does not match the executing package version') + } + + const destination = options.harness === 'opencode' + ? path.resolve(process.env.NSOLID_OPENCODE_SKILLS_DIR ?? resolveHome('~/.config/opencode/skills')) + : getSkillsDir() + const linkSkills = options.harness !== 'opencode' + const linkDir = linkSkills ? getHarnessSkillsPath(options.harness) : undefined + const oldPaths = previousSkills.map((entry) => entry.paths?.[options.harness] ?? entry.path) + if (oldPaths.some((value) => typeof value !== 'string' || !path.isAbsolute(value))) { + return failure('UNTRACKED_INSTALLATION', 'Tracked skill ownership does not contain safe absolute paths') + } + try { + for (const skill of bundle.skills) assertSafeSkillName(skill.name) + } catch { + return failure('BUNDLE_INVALID', 'Update bundle contains an unsafe skill destination') + } + const oldPathSet = new Set(oldPaths.map((value) => path.resolve(value))) + const newPaths = bundle.skills.map((skill) => path.join(destination, skill.name)) + const trackedPathSet = new Set( + tracking.skills.flatMap((entry) => [entry.path, ...Object.values(entry.paths ?? {})] + .filter((value): value is string => typeof value === 'string')) + .map((value) => path.resolve(value)) + ) + + for (const target of newPaths) { + if (pathExists(target) && !oldPathSet.has(path.resolve(target)) && !trackedPathSet.has(path.resolve(target))) { + return failure('UNTRACKED_DESTINATION', `Owned refresh would overwrite an untracked destination: ${path.basename(target)}`) + } + } + + const backupDir = await mkdtemp(path.join(os.tmpdir(), 'nsolid-plugin-fallback-')) + const trackingBackup = path.join(backupDir, 'tracking.json') + const configPath = previousMcps[0]?.configPath ?? getAdapter(options.harness).getMcpConfigPath() + const configExisted = configPath ? existsSync(configPath) : false + const configBackup = configPath ? path.join(backupDir, 'mcp-config') : undefined + const skillsBackup = path.join(backupDir, 'skills') + const linkPaths = linkDir ? [...new Set([...previousSkills, ...bundle.skills].map((skill) => path.join(linkDir, skill.name)))] : [] + const linksBackup = path.join(backupDir, 'links') + const sharedNewPaths = newPaths.filter((value) => existsSync(value) && trackedPathSet.has(path.resolve(value))) + const backupPaths = [...new Set([...oldPaths, ...sharedNewPaths])] + const previousSkillNames = new Set(previousSkills.map((entry) => entry.name)) + + // linkSkillsToHarness historically renamed any regular destination to a + // timestamped .bak before linking. A new bundle skill has no such ownership + // evidence, so reject that collision before the transaction can rename a + // user's directory or file. + if (linkDir) { + for (const skill of bundle.skills) { + const linkPath = path.join(linkDir, skill.name) + if (!previousSkillNames.has(skill.name) && pathExists(linkPath) && !trackedPathSet.has(path.resolve(linkPath))) { + await rm(backupDir, { recursive: true, force: true }).catch(() => {}) + return failure('UNTRACKED_DESTINATION', `Owned refresh would overwrite an untracked harness link destination: ${skill.name}`) + } + } + } + + let backupsComplete = false + let mutationStarted = false + try { + // Keep backup creation outside the mutation catch. A partial backup is + // never safe input to rollback: deleting the live paths and restoring the + // partial tree can destroy the only intact copy of a user's installation. + try { + await writeFile(trackingBackup, JSON.stringify(tracking, null, 2) + '\n', { mode: 0o600 }) + await mkdir(skillsBackup, { recursive: true, mode: 0o700 }) + for (const oldPath of backupPaths) { + if (pathExists(oldPath)) { + const target = path.join(skillsBackup, encodeURIComponent(oldPath)) + await cp(oldPath, target, { recursive: true, force: true }) + } + } + if (linkPaths.length > 0) { + await mkdir(linksBackup, { recursive: true, mode: 0o700 }) + for (const linkPath of linkPaths) { + if (pathExists(linkPath)) await cp(linkPath, path.join(linksBackup, encodeURIComponent(linkPath)), { recursive: true, force: true }) + } + } + if (configPath && configBackup && existsSync(configPath)) await writeFile(configBackup, await readFile(configPath), { mode: 0o600 }) + backupsComplete = true + } catch { + return { + success: false, + rollbackAttempted: false, + error: { code: 'FALLBACK_BACKUP_FAILED', message: 'Owned fallback backup could not be completed' }, + } + } + + try { + mutationStarted = true + const newNames = new Set(bundle.skills.map((skill) => skill.name)) + const pathsToReplace = previousSkills + .filter((entry) => newNames.has(entry.name)) + .map((entry) => entry.paths?.[options.harness] ?? entry.path) + const pathsToRemove = previousSkills + .filter((entry) => !newNames.has(entry.name) && canRemoveOwnedPath(entry, options.harness)) + .map((entry) => entry.paths?.[options.harness] ?? entry.path) + for (const ownedPath of [...pathsToReplace, ...pathsToRemove, ...sharedNewPaths]) { + await rm(ownedPath, { recursive: true, force: true }) + } + + await installSkillsToDirectory(bundle.skills, options.skillsSource, destination) + for (const oldEntry of previousSkills) { + if (!newNames.has(oldEntry.name)) { + if (linkSkills) await unlinkSkillsFromHarness(options.harness, [{ name: oldEntry.name, path: oldEntry.name, description: '' }]) + } + } + if (linkSkills) await linkSkillsToHarness(options.harness, bundle.skills) + + const credentials = readValidCredentials() + const canReconcileMcp = credentials !== null + const previousMcpNames = previousMcps.map((entry) => entry.name) + const desiredMcpNames = bundle.mcpServers.map((server) => server.name) + if (!canReconcileMcp && !sameNameSet(previousMcpNames, desiredMcpNames)) { + throw new FallbackTransactionError('MCP_RECONCILIATION_REQUIRED', 'Fallback MCP state changed but valid credentials are unavailable') + } + const newMcpNames = canReconcileMcp + ? new Set(desiredMcpNames) + : new Set(previousMcpNames) + const staleMcpNames = previousMcps + .filter((entry) => !newMcpNames.has(entry.name)) + .filter((entry) => !tracking.mcpServers.some((other) => other !== entry && other.name === entry.name && path.resolve(other.configPath) === path.resolve(configPath))) + .map((entry) => entry.name) + if (configPath && staleMcpNames.length > 0) { + await removeMcpConfig(options.harness, [...new Set(staleMcpNames)], { configPath }) + } + const configuredMcpServers = canReconcileMcp ? bundle.mcpServers : [] + if (credentials && bundle.mcpServers.length > 0) { + const variables = await mcpVariables(credentials) + await writeMcpConfig(options.harness, bundle.mcpServers, variables, { configPath }) + } + + const updated = reconcileTracking(tracking, options.harness, destination, bundle.skills, configPath, configuredMcpServers, staleMcpNames) + updated.bundleVersion = bundle.version + updated.bundleVersions = { ...(updated.bundleVersions ?? {}), [options.harness]: bundle.version } + await writeTrackingFile(updated) + return { success: true } + } catch (error) { + const rollback = backupsComplete && mutationStarted + ? await rollbackFallback({ trackingBackup, configBackup, configPath, configExisted, skillsBackup, backupPaths, newPaths, linksBackup, linkPaths }) + : false + if (error instanceof FallbackTransactionError && rollback) { + return failure(error.code, error.message, { attempted: true, succeeded: true }) + } + return rollback + ? failure('FALLBACK_REFRESH_FAILED', 'Owned fallback refresh failed and was rolled back', { attempted: true, succeeded: true }) + : failure('FALLBACK_ROLLBACK_FAILED', 'Owned fallback refresh failed and rollback was incomplete', { attempted: true, succeeded: false }) + } + } finally { + await rm(backupDir, { recursive: true, force: true }).catch(() => {}) + } +} + +function validateTransactionIdentity (identity: FallbackTransactionIdentity): UpdateError | undefined { + if (!identity.installationId || identity.installationId !== `${identity.harness}:fallback`) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction manifest has an invalid installation identity' } + if (!path.isAbsolute(identity.trackingPath) || !trackingDigest(identity.trackingPath)) return { code: 'FALLBACK_TRACKING_DRIFT', message: 'Fallback tracking file is absent or cannot be hashed' } + if (trackingDigest(identity.trackingPath) !== identity.trackingDigest) return { code: 'FALLBACK_TRACKING_DRIFT', message: 'Fallback tracking file changed after planning' } + if (identity.ownedSkillPaths.some((value) => !isCanonicalPath(value))) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains an unsafe skill path' } + if (identity.ownedLinkPaths.some((value) => !isCanonicalPath(value))) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains an unsafe link path' } + for (const field of identity.ownedMcpFields) { + if (!isCanonicalPath(field.configPath) || !existsSync(field.configPath)) return { code: 'FALLBACK_MCP_DRIFT', message: 'Owned MCP configuration changed after planning' } + const current = readMcpField(field.configPath, field.server, field.field) + if (field.expectedDigest && valueDigest(current) !== field.expectedDigest) return { code: 'FALLBACK_MCP_DRIFT', message: 'Owned MCP field changed after planning' } + } + return undefined +} + +function readMcpField (configPath: string, server: string, field: string): unknown { + try { + const parsed = readMcpConfig(configPath) + const servers = parsed?.mcpServers ?? parsed?.mcp_servers ?? parsed?.mcp + const record = servers && typeof servers === 'object' ? (servers as Record)[server] : undefined + return record && typeof record === 'object' && !Array.isArray(record) ? (record as Record)[field] : undefined + } catch { return undefined } +} + +function readMcpConfig (configPath: string): Record | null { + if (configPath.endsWith('.toml')) return readTomlFile>(configPath) + if (configPath.endsWith('.jsonc')) return readJsoncFile>(configPath) + return readJsonFile>(configPath) +} + +function readValidCredentials (): Credentials | null { + try { + const credentials = readJsonFile(getAuthFilePath()) + if (!credentials || typeof credentials.expiresAt !== 'string') return null + return Date.parse(credentials.expiresAt) > Date.now() ? credentials : null + } catch { return null } +} + +async function mcpVariables (credentials: Credentials): Promise> { + const mcpUrl = credentials.mcpUrl || deriveMcpUrlFromConsoleUrl(credentials.consoleUrl, credentials.organizationId) + if (!mcpUrl) throw new Error('MCP URL could not be derived') + return { AUTH_TOKEN: credentials.serviceToken, AUTH_ORG_ID: credentials.organizationId, MCP_URL: mcpUrl } +} + +async function rollbackFallback (options: { + trackingBackup: string + configBackup?: string + configPath?: string + configExisted: boolean + skillsBackup: string + backupPaths: string[] + newPaths: string[] + linksBackup: string + linkPaths: string[] +}): Promise { + try { + for (const newPath of options.newPaths) await rm(newPath, { recursive: true, force: true }) + for (const oldPath of options.backupPaths) { + const backup = path.join(options.skillsBackup, encodeURIComponent(oldPath)) + if (existsSync(backup)) await cp(backup, oldPath, { recursive: true, force: true }) + } + for (const linkPath of options.linkPaths) await rm(linkPath, { recursive: true, force: true }) + for (const linkPath of options.linkPaths) { + const backup = path.join(options.linksBackup, encodeURIComponent(linkPath)) + if (existsSync(backup)) await cp(backup, linkPath, { recursive: true, force: true }) + } + if (options.configPath && options.configBackup && existsSync(options.configBackup)) { + await writeFile(options.configPath, await readFile(options.configBackup), { mode: 0o600 }) + } else if (options.configPath && !options.configExisted) { + await rm(options.configPath, { force: true }) + } + const tracking = JSON.parse(await readFile(options.trackingBackup, 'utf8')) as TrackingData + await writeTrackingFile(tracking) + return true + } catch { + return false + } +} + +function canRemoveOwnedPath (entry: SkillTrackingEntry, harness: HarnessType): boolean { + const ownedPath = entry.paths?.[harness] ?? entry.path + const remainingHarnesses = entry.harnesses.filter((value) => value !== harness) + if (remainingHarnesses.length === 0) return true + const remainingPaths = remainingHarnesses.map((value) => entry.paths?.[value]).filter((value): value is string => typeof value === 'string') + // Legacy entries may not have per-harness paths. Keep the physical path when + // another owner remains and the old record cannot prove it is unshared. + if (remainingPaths.length === 0) return false + return !remainingPaths.some((value) => path.resolve(value) === path.resolve(ownedPath)) +} + +function reconcileTracking ( + original: TrackingData, + harness: HarnessType, + destination: string, + skills: BundleDescriptor['skills'], + configPath: string | undefined, + mcpServers: BundleDescriptor['mcpServers'], + staleMcpNames: string[] +): TrackingData { + const tracking = JSON.parse(JSON.stringify(original)) as TrackingData + const newNames = new Set(skills.map((skill) => skill.name)) + + for (const entry of tracking.skills) { + if (!entry.harnesses.includes(harness)) continue + if (newNames.has(entry.name)) { + entry.paths = { ...(entry.paths ?? {}), [harness]: path.resolve(destination, entry.name) } + continue + } + entry.harnesses = entry.harnesses.filter((value) => value !== harness) + if (entry.paths) delete entry.paths[harness] + if (entry.harnesses.length > 0) { + const remainingPath = entry.paths?.[entry.harnesses[0]] + if (remainingPath) entry.path = remainingPath + } + } + + tracking.skills = tracking.skills.filter((entry) => entry.harnesses.length > 0) + for (const skill of skills) { + const normalizedPath = path.resolve(destination, skill.name) + const existing = tracking.skills.find((entry) => entry.name === skill.name) + if (existing) { + if (!existing.harnesses.includes(harness)) existing.harnesses.push(harness) + existing.paths = { ...(existing.paths ?? {}), [harness]: normalizedPath } + if (existing.harnesses.length === 1) existing.path = normalizedPath + } else { + tracking.skills.push({ + name: skill.name, + path: normalizedPath, + paths: { [harness]: normalizedPath }, + installedAt: new Date().toISOString(), + harnesses: [harness], + }) + } + } + + const stale = new Set(staleMcpNames) + tracking.mcpServers = tracking.mcpServers.filter((entry) => !(entry.harness === harness && stale.has(entry.name))) + if (configPath) { + const now = new Date().toISOString() + for (const server of mcpServers) { + const existing = tracking.mcpServers.find((entry) => entry.harness === harness && entry.name === server.name) + if (existing) { + existing.configPath = path.resolve(configPath) + existing.configuredAt = now + existing.fields = readMcpRecord(configPath, server.name) + } else { + tracking.mcpServers.push({ name: server.name, configPath: path.resolve(configPath), harness, configuredAt: now, fields: readMcpRecord(configPath, server.name) }) + } + } + } + return tracking +} + +function readMcpRecord (configPath: string, name: string): Record | undefined { + try { + const parsed = readMcpConfig(configPath) + const servers = parsed?.mcpServers ?? parsed?.mcp_servers ?? parsed?.mcp + const record = servers && typeof servers === 'object' ? (servers as Record)[name] : undefined + if (!record || typeof record !== 'object' || Array.isArray(record)) return undefined + return Object.fromEntries(Object.entries(record as Record).map(([field, value]) => [field, valueDigest(value)])) + } catch { return undefined } +} + +function failure (code: string, message: string, rollback?: { attempted: boolean; succeeded: boolean }): FallbackRefreshResult { + return { success: false, rollbackAttempted: rollback?.attempted, rollbackSucceeded: rollback?.succeeded, error: { code, message } } +} + +class FallbackTransactionError extends Error { + constructor (public readonly code: string, message: string) { + super(message) + } +} + +function sameNameSet (left: readonly string[], right: readonly string[]): boolean { + const leftSet = new Set(left) + const rightSet = new Set(right) + return leftSet.size === rightSet.size && [...leftSet].every((name) => rightSet.has(name)) +} + +function pathExists (filePath: string): boolean { + try { + lstatSync(filePath) + return true + } catch { + return false + } +} diff --git a/packages/core/src/update/fs-transaction.ts b/packages/core/src/update/fs-transaction.ts new file mode 100644 index 0000000..fc24048 --- /dev/null +++ b/packages/core/src/update/fs-transaction.ts @@ -0,0 +1,64 @@ +import { cp, lstat, mkdtemp, rm } from 'node:fs/promises' +import path from 'node:path' + +export type OwnedPathKind = 'missing' | 'file' | 'directory' | 'junction-or-symlink' | 'other' + +export interface SiblingBackupPath { + directory: string + path: string +} + +const RETRYABLE_FS_ERRORS = new Set(['EPERM', 'EBUSY', 'ENOTEMPTY']) + +/** Allocate backup storage beside the target so it is necessarily on the same volume. */ +export async function createSiblingBackupPath (targetPath: string, label: string): Promise { + const absolute = path.resolve(targetPath) + const directory = await mkdtemp(path.join(path.dirname(absolute), `.${path.basename(absolute)}.nsolid-${label}-`)) + return { directory, path: path.join(directory, path.basename(absolute)) } +} + +/** Classify the owned path itself; never dereference a junction/symlink. */ +export async function ownedPathKind (targetPath: string): Promise { + try { + const stats = await lstat(targetPath) + if (stats.isSymbolicLink()) return 'junction-or-symlink' + if (stats.isDirectory()) return 'directory' + if (stats.isFile()) return 'file' + return 'other' + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing' + throw error + } +} + +/** Copy without following reparse points and verify the copied path kind. */ +export async function copyOwnedPath (source: string, destination: string): Promise { + const sourceKind = await ownedPathKind(source) + if (sourceKind === 'missing') throw new Error('Owned backup source is missing') + await cp(source, destination, { + recursive: sourceKind === 'directory', + force: false, + errorOnExist: true, + dereference: false, + verbatimSymlinks: true, + }) + if (await ownedPathKind(destination) !== sourceKind) throw new Error('Owned backup path kind changed during copy') + return sourceKind +} + +/** Remove the owned path itself with bounded Windows lock retries and kind revalidation. */ +export async function removeOwnedPath (targetPath: string, expectedKind?: OwnedPathKind): Promise { + for (let attempt = 0; attempt < 5; attempt++) { + const currentKind = await ownedPathKind(targetPath) + if (currentKind === 'missing') return + if (expectedKind && currentKind !== expectedKind) throw new Error('Owned path kind changed before removal') + try { + await rm(targetPath, { recursive: currentKind === 'directory', force: true }) + return + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (!code || !RETRYABLE_FS_ERRORS.has(code) || attempt === 4) throw error + await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1))) + } + } +} diff --git a/packages/core/src/update/index.ts b/packages/core/src/update/index.ts new file mode 100644 index 0000000..7985103 --- /dev/null +++ b/packages/core/src/update/index.ts @@ -0,0 +1,45 @@ +export { checkUpdates, executeUpdatePlan, planUpdates, summarizePlan, summarizeResults, update } from './coordinator.js' +export { createCommandRunner, findExecutable, isCommandSuccessful, runCommand, sanitizeOutput } from './command-runner.js' +export { detectAntigravityLayout, detectInstallations, detectCliInstallation } from './inventory.js' +export { commitFallbackJournal, fallbackJournalPath, recoverFallbackJournal, trackingDigest, valueDigest } from './fallback-journal.js' +export { refreshOwnedInstallation } from './fallback-transaction.js' +export type { FallbackJournal, FallbackJournalPhase } from './fallback-journal.js' +export type { FallbackRefreshOptions, FallbackRefreshResult } from './fallback-transaction.js' +export { compareVersions, classifyVersionSet, classifyVersions, isStableVersion, parseStableVersion, readPackageVersion, readRunningVersionInfo, resolvePackageRoot } from './version.js' +export { cleanupNpmArtifact, downloadNpmArtifact, resolveFixedGitBundleVersion, resolveMarketplaceVersion, resolveRegistryArtifactVersion, resolveRegistryVersion, sanitizeRepository } from './version-source.js' +export type { + AntigravityLayout, + ClaudePluginScope, + CommandResult, + CommandRunner, + CommandSpec, + FallbackPackageExecutor, + MarketplaceVersionSource, + NpmArtifactIdentity, + GitArtifactIdentity, + LocalArtifactIdentity, + ResolvedArtifactIdentity, + FallbackTransactionIdentity, + PiPackageLocation, + RunningVersionInfo, + UpdateConfirmation, + UpdateConfirmationContext, + UpdateContext, + UpdateError, + UpdateInstallation, + UpdateInstallationMetadata, + UpdateOptions, + UpdateOwnership, + UpdatePlan, + UpdatePlanItem, + UpdatePlanStep, + UpdateResult, + UpdateSource, + UpdateStatus, + UpdateStrategy, + UpdateSummary, + UpdateTarget, + VersionInfo, + VersionLookupResult, + VersionStatus, +} from './types.js' diff --git a/packages/core/src/update/integrity.ts b/packages/core/src/update/integrity.ts new file mode 100644 index 0000000..0ae4b2c --- /dev/null +++ b/packages/core/src/update/integrity.ts @@ -0,0 +1,21 @@ +import { createHash } from 'node:crypto' + +export interface ParsedIntegrity { + algorithm: 'sha256' | 'sha384' | 'sha512' + digest: string +} + +/** Parse npm SRI, including unpadded base64url digests, into canonical base64. */ +export function parseIntegrity (value: string): ParsedIntegrity | undefined { + const match = value.match(/^sha(256|384|512)-([A-Za-z0-9+/_-]+={0,2})$/i) + if (!match) return undefined + const unpadded = match[2].replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '') + if (unpadded.length % 4 === 1) return undefined + const padding = '='.repeat((4 - (unpadded.length % 4)) % 4) + return { algorithm: `sha${match[1]}` as ParsedIntegrity['algorithm'], digest: unpadded + padding } +} + +export function bytesMatchIntegrity (bytes: Uint8Array, integrity: string): boolean { + const parsed = parseIntegrity(integrity) + return parsed !== undefined && createHash(parsed.algorithm).update(bytes).digest('base64') === parsed.digest +} diff --git a/packages/core/src/update/inventory.ts b/packages/core/src/update/inventory.ts new file mode 100644 index 0000000..f887239 --- /dev/null +++ b/packages/core/src/update/inventory.ts @@ -0,0 +1,669 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { readJsonFile, readTomlFile } from '../utils/config.js' +import { getTrackingFilePath, resolveHome } from '../utils/path.js' +import { isValidTrackingData } from '../skills/skill-tracker.js' +import { packageNameFromNpmSource, PI_PLUGIN_PACKAGE_NAME } from '../harnesses/pi-plugin-detector.js' +import { isNsolidPluginId } from '../harnesses/plugin-name.js' +import type { HarnessType } from '../types.js' +import type { + AntigravityLayout, + MarketplaceVersionSource, + UpdateInstallation, + UpdateInstallationMetadata, + UpdateSource, +} from './types.js' +import type { CommandRunner } from './types.js' +import { readClaudePluginScope } from './claude-record.js' +import { detectGlobalPackageOwnership, readPackageVersion as readNamedPackageVersion } from './package-manager.js' +import { readCodexPayloadVersion, resolveCodexPluginCachePath } from './codex-transaction.js' +import { classifyVersionSet, classifyVersions, isStableVersion, readRunningVersionInfo, resolvePackageRoot } from './version.js' + +export interface InventoryOptions { + commandRunner: CommandRunner + cwd?: string + packageRoot?: string + includeCli?: boolean + readOnly?: boolean + deferCliOwnership?: boolean +} + +const HARNESS_ORDER: HarnessType[] = ['claude', 'codex', 'antigravity', 'opencode', 'pi'] +const PLUGIN_ID = /^nsolid-plugin@([A-Za-z0-9][A-Za-z0-9._-]*)$/ + +export async function detectInstallations (options: InventoryOptions): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()) + const installations: UpdateInstallation[] = [] + const includeCli = options.includeCli !== false + + if (includeCli) installations.push(await detectCliInstallation(options, options.deferCliOwnership !== true)) + + const fallback = await detectFallbackInstallations() + for (const harness of HARNESS_ORDER) { + const native = harness === 'claude' + ? detectClaudeInstallations() + : harness === 'codex' + ? detectCodexInstallations() + : harness === 'antigravity' + ? detectAntigravityInstallations() + : harness === 'pi' + ? detectPiInstallations(cwd) + : [] + installations.push(...native) + const fallbackInstallation = fallback.find((item) => item.target === harness) + if (fallbackInstallation) installations.push(fallbackInstallation) + } + + return installations.sort(compareInstallations) +} + +export async function detectCliInstallation (options: InventoryOptions, probeOwnership = true): Promise { + const packageRoot = path.resolve(options.packageRoot ?? defaultPackageRoot()) + const running = safeRunningVersion(packageRoot) + const ownership = probeOwnership + ? await detectGlobalPackageOwnership({ + commandRunner: options.commandRunner, + packageRoot, + executablePath: process.argv[1], + readOnly: options.readOnly, + }) + : undefined + + const source: UpdateSource = ownership?.ownership + ? { + kind: 'global-package', + packageManager: ownership.ownership.manager, + packageName: 'nsolid-plugin', + } + : { + kind: 'unsupported', + source: process.argv[1] || 'unknown', + reason: 'unsupported-manager', + } + + const metadata: UpdateInstallationMetadata = ownership?.ownership + ? { + packageRoot: ownership.ownership.packageRoot, + packagePath: ownership.ownership.packagePath, + previousVersion: running?.cliVersion, + rollbackCommand: ownership.ownership.rollbackCommand, + packageManagerExecutable: ownership.ownership.executable, + } + : { packageRoot } + + return { + installationId: 'cli:global', + target: 'cli', + ownership: ownership?.ownership ? 'global-package' : 'none', + installed: true, + source, + version: classifyVersions(running?.cliVersion, undefined), + metadata, + } +} + +function detectClaudeInstallations (): UpdateInstallation[] { + const installedPath = resolveHome('~/.claude/plugins/installed_plugins.json') + const data = safeReadJson(installedPath) + const knownMarketplaces = safeReadJson(resolveHome('~/.claude/plugins/known_marketplaces.json')) ?? {} + const records = extractPluginRecords(data) + const output: UpdateInstallation[] = [] + + for (const { id, record } of records) { + if (!isNsolidPluginId(id)) continue + const parsed = PLUGIN_ID.exec(id) + const scope = readClaudePluginScope(record) + const metadata = { ...(recordMetadata(record) ?? {}), configPath: installedPath } + const marketplaceRecord = parsed && isRecord(knownMarketplaces[parsed[1]]) ? knownMarketplaces[parsed[1]] as Record : {} + const enrichedRecord = { ...marketplaceRecord, ...record } + const source = parsed && scope + ? makeClaudeSource(id, parsed[1], enrichedRecord, metadata) + : makeUnsupportedSource(id, !parsed ? 'ambiguous' : 'ambiguous') + const version = readRecordVersion(enrichedRecord, metadata?.packageRoot) + output.push({ + installationId: `claude:native:${id}:${scope ?? 'unknown'}`, + target: 'claude', + ownership: 'native-plugin', + installed: true, + source, + version: classifyVersions(version, undefined), + metadata, + }) + } + return output +} + +function detectCodexInstallations (): UpdateInstallation[] { + const configPath = path.resolve(process.env.CODEX_CONFIG_PATH ?? resolveHome('~/.codex/config.toml')) + const config = readTomlResult(configPath) + if (config.kind === 'missing') return [] + if (config.kind === 'parse-error') { + return [{ + installationId: 'codex:native:config', + target: 'codex', + ownership: 'native-plugin', + installed: true, + source: makeUnsupportedSource('codex:config', 'untracked'), + version: { status: 'unknown' }, + inventoryError: { code: 'CODEX_CONFIG_PARSE_FAILED', message: 'Codex configuration could not be parsed' }, + metadata: { configPath }, + }] + } + const data = config.value + const plugins = data?.plugins + if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return [] + const output: UpdateInstallation[] = [] + + for (const [id, value] of Object.entries(plugins as Record)) { + if (!isNsolidPluginId(id)) continue + const parsed = PLUGIN_ID.exec(id) + const marketplaceRecord = isRecord(data.marketplaces) && isRecord((data.marketplaces as Record)[parsed?.[1] ?? '']) + ? (data.marketplaces as Record)[parsed![1]] as Record + : {} + const record = { ...marketplaceRecord, ...(isRecord(value) ? value : {}) } + const source = parsed + ? makeCodexSource(id, parsed[1], record) + : makeUnsupportedSource(id, 'ambiguous') + const recordedMetadata = recordMetadata(record) + const cacheRoot = parsed + ? resolveCodexPluginCachePath(configPath, id, parsed[1], recordedMetadata?.packageRoot) + : recordedMetadata?.packageRoot + const metadata = { ...(recordedMetadata ?? {}), ...(cacheRoot ? { packageRoot: cacheRoot } : {}), configPath } + const version = readRecordVersion(record, cacheRoot) ?? (cacheRoot ? readCodexPayloadVersion(cacheRoot, id) : undefined) + output.push({ + installationId: `codex:native:${id}`, + target: 'codex', + ownership: 'native-plugin', + installed: true, + source, + version: classifyVersions(version, undefined), + metadata, + }) + } + return output +} + +function detectAntigravityInstallations (): UpdateInstallation[] { + const detected = detectAntigravityLayout() + if (!detected.layout) { + if (!detected.reason) return [] + return [{ + installationId: 'antigravity:native:unsupported-layout', + target: 'antigravity', + ownership: 'native-plugin', + installed: true, + source: makeUnsupportedSource(detected.reason, 'ambiguous'), + version: { status: 'unknown' }, + metadata: { pluginRoot: detected.pluginRoot, manifestPath: detected.manifestPath }, + }] + } + + const pluginRoot = resolveHome(detected.layout.pluginRoot) + const bundleVersion = safeReadVersion(path.join(pluginRoot, 'bundle.json')) + return [{ + installationId: `antigravity:native:${detected.layout.kind}`, + target: 'antigravity', + ownership: 'native-plugin', + installed: true, + source: { + kind: 'antigravity-git', + url: 'https://github.com/NodeSource/nsolid-plugin.git', + layout: detected.layout, + }, + version: classifyVersions(bundleVersion, undefined), + metadata: { pluginRoot, manifestPath: resolveHome(detected.layout.manifestPath) }, + }] +} + +function detectPiInstallations (cwd: string): UpdateInstallation[] { + const userSettings = resolveHome('~/.pi/agent/settings.json') + const projectSettings = path.join(cwd, '.pi', 'settings.json') + const userEntries = readPiEntries(userSettings, 'user') + const projectEntries = existsSync(projectSettings) ? readPiEntries(projectSettings, 'project') : [] + const allEntries = [...userEntries, ...projectEntries] + const matching = allEntries.filter((entry) => isPiPluginName(entry.source)) + const packageRoots: string[] = [] + const invalidScopes = new Set(matching.filter((entry) => !entry.canonical).map((entry) => entry.scope)) + const hasUserCanonical = !invalidScopes.has('user') && matching.some((entry) => entry.scope === 'user' && entry.canonical) + const hasProjectCanonical = !invalidScopes.has('project') && matching.some((entry) => entry.scope === 'project' && entry.canonical) + // A cache directory is not source evidence. Only an explicit canonical + // settings entry makes a Pi package installation updateable. + if (!hasUserCanonical && !hasProjectCanonical) { + const invalid = matching.find((entry) => !entry.canonical) + if (!invalid) return [] + return [{ + installationId: 'pi:package:unsupported', + target: 'pi', + ownership: 'package-owned', + installed: true, + source: makeUnsupportedSource(invalid.source, invalid.reason), + version: { status: 'unknown' }, + }] + } + + const scopes: Array<'user' | 'project'> = [] + if (hasUserCanonical) scopes.push('user') + if (hasProjectCanonical) scopes.push('project') + if (scopes.includes('user')) packageRoots.push(resolveHome(`~/.pi/agent/npm/node_modules/${PI_PLUGIN_PACKAGE_NAME}`)) + if (scopes.includes('project')) { + packageRoots.push(path.join(cwd, '.pi', 'npm', 'node_modules', PI_PLUGIN_PACKAGE_NAME)) + } + const uniqueRoots = [...new Set(packageRoots)] + const packageEvidencePaths = uniqueRoots.map(findPiPackageEvidencePath) + const version = classifyVersionSet(uniqueRoots.map(safePackageVersion), undefined) + const settingsPaths = [ + ...(scopes.includes('user') ? [userSettings] : []), + ...(scopes.includes('project') ? [projectSettings] : []), + ] + const location = scopes.length === 2 + ? { scopes: ['user', 'project'] as const, projectRoot: cwd } + : scopes[0] === 'project' + ? { scopes: ['project'] as const, projectRoot: cwd } + : { scopes: ['user'] as const } + + return [{ + installationId: `pi:package:${scopes.join('+')}`, + target: 'pi', + ownership: 'package-owned', + installed: true, + source: { kind: 'pi-package', spec: 'npm:nsolid-pi-plugin', ...location }, + version, + metadata: { + packageRoots: uniqueRoots, + packageRootIdentities: uniqueRoots.map(safeRealpath), + projectRoot: scopes.includes('project') ? cwd : undefined, + projectRootIdentity: scopes.includes('project') ? safeRealpath(cwd) : undefined, + settingsPaths, + settingsDigests: settingsPaths.map(fileDigest), + sourceEntries: matching.filter((entry) => scopes.includes(entry.scope)).map((entry) => entry.source), + cacheDigests: uniqueRoots.map((root) => fileDigest(path.join(root, 'package.json'))), + packageEvidencePaths, + packageEvidenceDigests: packageEvidencePaths.map(fileDigest), + }, + }] +} + +async function detectFallbackInstallations (): Promise { + const trackingPath = getTrackingFilePath() + if (!existsSync(trackingPath)) return [] + let rawTracking: unknown + try { + rawTracking = readJsonFile(trackingPath) + } catch { + return [unsupportedFallbackInstallation('tracking file could not be read')] + } + if (!isValidTrackingData(rawTracking)) return [unsupportedFallbackInstallation('tracking file has an invalid shape', trackingHarness(rawTracking))] + const tracking = rawTracking + const output: UpdateInstallation[] = [] + for (const harness of HARNESS_ORDER) { + const rawTrackedSkills = tracking.skills + .filter((entry) => entry.harnesses.includes(harness)) + .map((entry) => ({ + name: entry.name, + path: entry.paths?.[harness] ?? entry.path, + })) + const trackedSkills = rawTrackedSkills.filter((entry): entry is { name: string; path: string } => typeof entry.path === 'string') + const trackedMcps = tracking.mcpServers.filter((entry) => entry.harness === harness) + // Pi owns its skills through nsolid-pi-plugin. Its normal setup may still + // leave MCP entries in the shared tracking file, but those entries do not + // constitute a fallback installation and must not create a duplicate + // unsupported target beside the package-owned Pi target. + if (harness === 'pi' && trackedSkills.length === 0) continue + if (trackedSkills.length === 0 && trackedMcps.length === 0) continue + const scopedVersion = tracking.bundleVersions?.[harness] + const legacyVersion = tracking.bundleVersions === undefined && tracking.harness === harness ? tracking.bundleVersion : undefined + const bundleVersion = isStableVersion(scopedVersion) + ? scopedVersion + : isStableVersion(legacyVersion) ? legacyVersion : undefined + const ownershipProven = rawTrackedSkills.length === trackedSkills.length && trackedSkills.every((entry) => path.isAbsolute(entry.path)) + const source: UpdateSource = ownershipProven && trackedSkills.length > 0 + ? { kind: 'fallback', bundleVersion } + : { kind: 'unsupported', source: `${harness}:tracking`, reason: 'untracked' } + output.push({ + installationId: `${harness}:fallback`, + target: harness, + ownership: 'fallback', + installed: true, + source, + version: classifyVersions(bundleVersion, undefined), + metadata: { + trackedSkills, + trackedMcpConfigPath: trackedMcps[0]?.configPath, + trackedMcpNames: trackedMcps.map((entry) => entry.name), + trackedMcpFields: trackedMcps.flatMap((entry) => Object.entries(entry.fields ?? {}).map(([field, expectedDigest]) => ({ + configPath: path.resolve(entry.configPath), + server: entry.name, + field, + expectedDigest, + }))), + trackedMcpOwnershipComplete: trackedMcps.every((entry) => entry.fields !== undefined), + }, + }) + } + return output +} + +function unsupportedFallbackInstallation (reason: string, target: HarnessType = 'opencode'): UpdateInstallation { + return { + installationId: `${target}:fallback`, + target, + ownership: 'fallback', + installed: true, + source: makeUnsupportedSource(`${target}:tracking (${reason})`, 'untracked'), + version: { status: 'unknown' }, + metadata: { trackedSkills: [] }, + } +} + +function trackingHarness (value: unknown): HarnessType { + if (isRecord(value) && typeof value.harness === 'string' && HARNESS_ORDER.includes(value.harness as HarnessType)) return value.harness as HarnessType + return 'opencode' +} + +function makeClaudeSource ( + id: string, + marketplace: string, + record: Record, + metadata?: UpdateInstallationMetadata +): UpdateSource { + const scope = readClaudePluginScope(record) + if (!scope) return makeUnsupportedSource(id, 'ambiguous') + const versionSource = sourceFromRecord(record, metadata) + if (versionSource.kind === 'unknown') { + return makeUnsupportedSource(id, versionSource.reason === 'ambiguous' ? 'ambiguous' : 'untracked') + } + return { + kind: 'claude-marketplace', + pluginId: id, + marketplace, + scope, + versionSource, + } +} + +function makeCodexSource (id: string, marketplace: string, record: Record): UpdateSource { + const versionSource = sourceFromRecord(record, recordMetadata(record)) + if (versionSource.kind === 'unknown') { + return makeUnsupportedSource(id, versionSource.reason === 'ambiguous' ? 'ambiguous' : 'untracked') + } + return { + kind: 'codex-marketplace', + pluginId: id, + marketplace, + versionSource, + } +} + +function sourceFromRecord (record: Record, metadata?: UpdateInstallationMetadata): MarketplaceVersionSource { + const source = isRecord(record.source) ? record.source : record + const repositoryCandidate = firstString(source.repository, source.repo, source.url, record.repository, record.repo) + const sourceValue = typeof source.source === 'string' ? source.source : undefined + const repository = repositoryCandidate ?? (sourceValue && (sourceValue.includes('/') || /^https?:\/\//.test(sourceValue)) ? sourceValue : undefined) + const manifestPath = firstString( + source.manifestPath, + source.relativeManifestPath, + source.relativePath, + source.manifest, + source.manifestFile, + record.manifestPath, + record.relativeManifestPath, + record.relativePath, + record.manifest + ) + const revision = firstString(source.revision, source.ref, source.commit, record.revision, record.ref) + const effectiveManifestPath = manifestPath ?? (repository ? 'bundle.json' : undefined) + if (repository && effectiveManifestPath && isSafeManifestPath(effectiveManifestPath)) { + const safeRepository = sanitizeRepository(repository) + if (!safeRepository) return { kind: 'unknown', reason: 'unsupported' } + const commit = firstString(source.commit, record.commit) + const contentDigest = firstString(source.contentDigest, record.contentDigest) + return { kind: 'git', repository: safeRepository, revision, commit, contentDigest, manifestPath: effectiveManifestPath } + } + + const root = metadata?.packageRoot ?? firstString( + record.installPath, + record.installLocation, + record.pluginRoot, + record.path, + source.path + ) + if (root && isSafeSnapshotRoot(root)) { + const inferredManifest = effectiveManifestPath ?? ( + existsSync(path.join(root, 'plugin.json')) + ? 'plugin.json' + : existsSync(path.join(root, 'bundle.json')) ? 'bundle.json' : undefined + ) + if (inferredManifest && isSafeManifestPath(inferredManifest)) { + const freshnessValue = firstString(record.freshness, source.freshness) + const freshness = freshnessValue === 'verified' || freshnessValue === 'stale' || freshnessValue === 'unknown' + ? freshnessValue + : 'unknown' + return { + kind: 'local-snapshot', + root, + manifestPath: inferredManifest, + freshness, + contentDigest: contentDigestForSnapshot(root, inferredManifest), + } + } + } + return { kind: 'unknown', reason: repository || root ? 'unsupported' : 'missing-metadata' } +} + +function recordMetadata (record: Record): UpdateInstallationMetadata | undefined { + const packageRoot = firstString(record.installPath, record.installLocation, record.pluginRoot, record.path) + return packageRoot ? { packageRoot } : undefined +} + +function extractPluginRecords (data: unknown): Array<{ id: string; record: Record }> { + if (!data || typeof data !== 'object') return [] + const output: Array<{ id: string; record: Record }> = [] + const object = data as Record + const plugins = object.plugins + if (plugins && typeof plugins === 'object' && !Array.isArray(plugins)) { + for (const [id, value] of Object.entries(plugins as Record)) { + if (Array.isArray(value)) { + for (const record of value) output.push({ id, record: isRecord(record) ? record : {} }) + } else output.push({ id, record: isRecord(value) ? value : {} }) + } + } else if (Array.isArray(plugins)) { + for (const value of plugins) { + if (typeof value === 'string') output.push({ id: value, record: {} }) + else if (isRecord(value) && typeof value.id === 'string') output.push({ id: value.id, record: value }) + } + } + return output +} + +function readPiEntries (settingsPath: string, scope: 'user' | 'project'): Array<{ source: string; scope: 'user' | 'project'; canonical: boolean; reason: 'local' | 'git' | 'pinned' | 'conflicting' | 'ambiguous' }> { + const settings = safeReadJson(settingsPath) + const packages = settings?.packages + if (!Array.isArray(packages)) return [] + return packages + .map((entry) => typeof entry === 'string' ? entry : isRecord(entry) ? entry.source : undefined) + .filter((source): source is string => typeof source === 'string') + .filter((source) => source.includes('nsolid-pi-plugin')) + .map((source) => { + if (source === 'npm:nsolid-pi-plugin') return { source, scope, canonical: true, reason: 'ambiguous' as const } + if (source.startsWith('npm:')) return { source, scope, canonical: false, reason: 'pinned' as const } + if (/^(git:|https?:|ssh:)/.test(source)) return { source, scope, canonical: false, reason: 'git' as const } + return { source, scope, canonical: false, reason: 'local' as const } + }) +} + +function readRecordVersion (record: Record, root?: string): string | undefined { + const candidate = firstString(record.version, record.pluginVersion, record.bundleVersion) + if (isStableVersion(candidate)) return candidate + return root ? safeReadVersion(path.join(root, 'bundle.json')) ?? safeReadVersion(path.join(root, 'plugin.json')) : undefined +} + +function safeRunningVersion (packageRoot: string) { + try { return readRunningVersionInfo(packageRoot) } catch { return undefined } +} + +function safeReadJson (filePath: string): Record | null { + try { + const value = readJsonFile(filePath) + return isRecord(value) ? value : null + } catch { return null } +} + +function readTomlResult (filePath: string): + | { kind: 'missing' } + | { kind: 'parsed'; value: Record } + | { kind: 'parse-error' } { + if (!existsSync(filePath)) return { kind: 'missing' } + try { + const value = readTomlFile(filePath) + return isRecord(value) ? { kind: 'parsed', value } : { kind: 'parse-error' } + } catch { + return { kind: 'parse-error' } + } +} + +function safeReadVersion (filePath: string): string | undefined { + const data = safeReadJson(filePath) + const version = data?.version + return isStableVersion(version) ? version : undefined +} + +function safePackageVersion (root: string): string | undefined { + return readNamedPackageVersion(root, PI_PLUGIN_PACKAGE_NAME) +} + +function fileDigest (filePath: string): string { + try { return createHash('sha256').update(readFileSync(filePath)).digest('hex') } catch { return '' } +} + +function findPiPackageEvidencePath (packageRoot: string): string { + const candidates = [ + path.resolve(packageRoot, '..', '..', 'package-lock.json'), + path.resolve(packageRoot, '..', '..', 'npm-shrinkwrap.json'), + path.resolve(packageRoot, '..', '.package-lock.json'), + path.join(packageRoot, 'package.json'), + ] + return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]! +} + +function safeRealpath (filePath: string): string { + try { return realpathSync(filePath) } catch { return path.resolve(filePath) } +} + +function contentDigestForSnapshot (root: string, manifestPath: string): string | undefined { + try { return createHash('sha256').update(readFileSync(path.resolve(root, manifestPath))).digest('hex') } catch { return undefined } +} + +function compareInstallations (a: UpdateInstallation, b: UpdateInstallation): number { + const targetOrder = (target: string) => target === 'cli' ? -1 : HARNESS_ORDER.indexOf(target as HarnessType) + const targetDifference = targetOrder(a.target) - targetOrder(b.target) + if (targetDifference !== 0) return targetDifference + const ownershipOrder: Record = { 'global-package': 0, 'native-plugin': 1, 'package-owned': 1, fallback: 2, none: 3 } + return (ownershipOrder[a.ownership] ?? 9) - (ownershipOrder[b.ownership] ?? 9) || a.installationId.localeCompare(b.installationId) +} + +function makeUnsupportedSource (source: string, reason: 'local' | 'git' | 'pinned' | 'ambiguous' | 'conflicting' | 'untracked' | 'unsupported-manager'): UpdateSource { + return { kind: 'unsupported', source: sanitizeUnsupportedSource(source), reason } +} + +function sanitizeUnsupportedSource (source: string): string { + const safeControls = [...source.trim()].map((character) => { + const code = character.charCodeAt(0) + return code < 0x20 || code === 0x7f ? '?' : character + }).join('') + const redacted = safeControls.replace(/((?:https?|ssh):\/\/)[^/\s@]+@/gi, '$1[REDACTED]@') + try { + const url = new URL(redacted) + url.username = '' + url.password = '' + url.search = '' + url.hash = '' + return url.toString().replace(/\/$/, '').slice(0, 120) + } catch { + return redacted.slice(0, 120) + } +} + +function firstString (...values: unknown[]): string | undefined { + return values.find((value): value is string => typeof value === 'string' && value.length > 0) +} + +function isPiPluginName (source: string): boolean { + if (source.startsWith('npm:')) return packageNameFromNpmSource(source) === PI_PLUGIN_PACKAGE_NAME + + const withoutFragment = source.trim().split(/[\s?#]/, 1)[0].replace(/[\\/]+$/, '').replace(/\.git$/, '') + const basename = withoutFragment.split(/[\\/:]/).at(-1) + return basename === PI_PLUGIN_PACKAGE_NAME +} + +function isRecord (value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function isSafeManifestPath (value: string): boolean { + return value.length > 0 && !path.isAbsolute(value) && !value.split(/[\\/]+/).includes('..') && !value.includes('\\') +} + +function isSafeSnapshotRoot (value: string): boolean { + return path.isAbsolute(value) && !value.split(path.sep).includes('..') +} + +function sanitizeRepository (value: string): string | undefined { + const githubShorthand = value.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\.git)?$/) + if (githubShorthand) return `https://github.com/${githubShorthand[1]}/${githubShorthand[2]}.git` + try { + const url = new URL(value) + url.username = '' + url.password = '' + url.search = '' + url.hash = '' + return url.toString().replace(/\/$/, '') + } catch { return undefined } +} + +export function detectAntigravityLayout (): { + layout?: AntigravityLayout + reason?: string + pluginRoot?: string + manifestPath?: string +} { + const candidates: Array<{ layout: AntigravityLayout; pluginRoot: string; manifestPath: string }> = [ + { + layout: { kind: 'shared', pluginRoot: '~/.gemini/config/plugins/nsolid-plugin', manifestPath: '~/.gemini/config/import_manifest.json' }, + pluginRoot: resolveHome('~/.gemini/config/plugins/nsolid-plugin'), + manifestPath: resolveHome('~/.gemini/config/import_manifest.json'), + }, + { + layout: { kind: 'agy-cli', pluginRoot: '~/.gemini/antigravity-cli/plugins/nsolid-plugin', manifestPath: '~/.gemini/antigravity-cli/import_manifest.json' }, + pluginRoot: resolveHome('~/.gemini/antigravity-cli/plugins/nsolid-plugin'), + manifestPath: resolveHome('~/.gemini/antigravity-cli/import_manifest.json'), + }, + ] + const valid = candidates.filter((candidate) => + existsSync(candidate.pluginRoot) && existsSync(candidate.manifestPath) && manifestContainsPlugin(candidate.manifestPath)) + // A generic import manifest belongs to the layout only when it contains + // NodeSource evidence. Merely having both product manifests on disk is + // common and must not be reported as an ambiguous N|Solid installation. + const present = candidates.filter((candidate) => + existsSync(candidate.pluginRoot) || (existsSync(candidate.manifestPath) && manifestContainsPlugin(candidate.manifestPath))) + if (present.length > 1) return { reason: 'multiple Antigravity plugin layouts are present' } + if (valid.length === 1) return valid[0] + const touched = present[0] + if (touched) return { reason: 'Antigravity plugin root and matching import manifest are incomplete', pluginRoot: touched.pluginRoot, manifestPath: touched.manifestPath } + return {} +} + +function manifestContainsPlugin (manifestPath: string): boolean { + const data = safeReadJson(manifestPath) + const imports = data?.imports + if (Array.isArray(imports)) return imports.some((entry) => isRecord(entry) && (entry.name === 'nsolid-plugin' || entry.plugin === 'nsolid-plugin')) + if (isRecord(imports)) { + return Object.entries(imports).some(([key, value]) => key === 'nsolid-plugin' || key.includes('nsolid-plugin') || (isRecord(value) && (value.name === 'nsolid-plugin' || value.plugin === 'nsolid-plugin'))) + } + return false +} + +function defaultPackageRoot (): string { + return resolvePackageRoot(path.dirname(fileURLToPath(new URL('.', import.meta.url)))) +} diff --git a/packages/core/src/update/native-evidence.ts b/packages/core/src/update/native-evidence.ts new file mode 100644 index 0000000..39e3bcb --- /dev/null +++ b/packages/core/src/update/native-evidence.ts @@ -0,0 +1,65 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import type { ResolvedArtifactIdentity, UpdateSource } from './types.js' + +const FULL_COMMIT = /^[0-9a-f]{40}$/i + +export function nativeSourceHonorsArtifact ( + source: UpdateSource, + artifact: ResolvedArtifactIdentity | undefined +): boolean { + if (!artifact || (artifact.kind !== 'git' && artifact.kind !== 'local-snapshot')) return true + if (source.kind !== 'claude-marketplace' && source.kind !== 'codex-marketplace') return false + const versionSource = source.versionSource + + if (artifact.kind === 'git') { + if (versionSource.kind !== 'git') return false + const pinnedRevision = FULL_COMMIT.test(versionSource.revision ?? '') ? versionSource.revision : undefined + const observedCommitMatches = versionSource.commit === undefined || versionSource.commit.toLowerCase() === artifact.commit.toLowerCase() + return pinnedRevision?.toLowerCase() === artifact.commit.toLowerCase() && observedCommitMatches && + normalizeRepository(versionSource.repository) === normalizeRepository(artifact.repository) + } + + return versionSource.kind === 'local-snapshot' && + versionSource.freshness === 'verified' && + path.resolve(versionSource.root) === path.resolve(artifact.root) && + versionSource.contentDigest === artifact.contentDigest +} + +export function nativePayloadDigest (root: string, manifestPath?: string): string | undefined { + try { + if (manifestPath) { + const base = path.resolve(root) + const manifest = path.resolve(base, manifestPath) + if (manifest.startsWith(`${base}${path.sep}`) && existsSync(manifest)) { + return createHash('sha256').update(readFileSync(manifest)).digest('hex') + } + } + const directBundle = path.join(root, 'bundle.json') + if (existsSync(directBundle)) return createHash('sha256').update(readFileSync(directBundle)).digest('hex') + const files: string[] = [] + collectDigestFiles(root, 0, files) + if (files.length === 0) return undefined + const hash = createHash('sha256') + for (const file of files.sort()) hash.update(file).update(readFileSync(file)) + return hash.digest('hex') + } catch { return undefined } +} + +function collectDigestFiles (root: string, depth: number, output: string[]): void { + if (depth > 4 || output.length > 256) return + try { + for (const entry of readdirSync(root, { withFileTypes: true })) { + const file = path.join(root, entry.name) + if (entry.isDirectory()) collectDigestFiles(file, depth + 1, output) + else if (entry.isFile() && ['bundle.json', 'plugin.json', 'package.json', 'manifest.json'].includes(entry.name)) output.push(file) + } + } catch { + if (existsSync(root)) output.push(root) + } +} + +function normalizeRepository (repository: string): string { + return repository.trim().replace(/\.git\/?$/i, '').replace(/\/$/, '').toLowerCase() +} diff --git a/packages/core/src/update/package-content.ts b/packages/core/src/update/package-content.ts new file mode 100644 index 0000000..8d8819b --- /dev/null +++ b/packages/core/src/update/package-content.ts @@ -0,0 +1,138 @@ +import { lstatSync, readFileSync, readlinkSync, readdirSync, realpathSync } from 'node:fs' +import path from 'node:path' +import { gunzipSync } from 'node:zlib' + +type TarEntry = + | { kind: 'file'; content: Buffer } + | { kind: 'symlink'; target: string } + +export function installedPackageMatchesTarball (packageRoot: string, tarballPath: string): boolean { + try { + const expected = readNpmTarball(tarballPath) + const resolvedRoot = realpathSync(packageRoot) + if (!expected.has('package.json')) return false + for (const [relative, entry] of expected) { + const target = path.resolve(resolvedRoot, relative) + if (!isContained(target, resolvedRoot) || !hasDirectoryParents(resolvedRoot, relative)) return false + const stat = lstatSync(target) + if (entry.kind === 'file') { + if (!stat.isFile() || !readFileSync(target).equals(entry.content)) return false + } else if (!stat.isSymbolicLink() || readlinkSync(target) !== entry.target) return false + } + return installedPayloadFiles(resolvedRoot).every((relative) => expected.has(relative)) + } catch { + return false + } +} + +function readNpmTarball (tarballPath: string): Map { + const archive = gunzipSync(readFileSync(tarballPath)) + const entries = new Map() + let offset = 0 + let longPath: string | undefined + let paxPath: string | undefined + while (offset + 512 <= archive.length) { + const header = archive.subarray(offset, offset + 512) + if (header.every((value) => value === 0)) break + const size = tarNumber(header.subarray(124, 136)) + const bodyStart = offset + 512 + const bodyEnd = bodyStart + size + if (!Number.isSafeInteger(size) || size < 0 || bodyEnd > archive.length) throw new Error('invalid tar size') + const body = archive.subarray(bodyStart, bodyEnd) + const type = String.fromCharCode(header[156] || 48) + const headerPath = [tarString(header.subarray(345, 500)), tarString(header.subarray(0, 100))].filter(Boolean).join('/') + + if (type === 'L') longPath = tarString(body) + else if (type === 'x') paxPath = parsePaxPath(body) + else if (type !== 'g') { + const relative = packageRelativePath(paxPath ?? longPath ?? headerPath) + paxPath = undefined + longPath = undefined + if (relative) { + if (entries.has(relative)) throw new Error('duplicate tar entry') + if (type === '0') entries.set(relative, { kind: 'file', content: Buffer.from(body) }) + else if (type === '2') entries.set(relative, { kind: 'symlink', target: tarString(header.subarray(157, 257)) }) + else if (type !== '5') throw new Error('unsupported tar entry') + } + } + offset = bodyStart + Math.ceil(size / 512) * 512 + } + return entries +} + +function installedPayloadFiles (root: string): string[] { + const output: string[] = [] + const walk = (directory: string, relativeRoot: string) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (!relativeRoot && (entry.name === 'node_modules' || entry.name === '.package-lock.json')) continue + const relative = relativeRoot ? `${relativeRoot}/${entry.name}` : entry.name + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) walk(absolute, relative) + else if (entry.isFile() || entry.isSymbolicLink()) output.push(relative) + else throw new Error('unsupported installed entry') + } + } + walk(path.resolve(root), '') + return output +} + +function packageRelativePath (entryPath: string): string | undefined { + const normalized = entryPath.replace(/\\/g, '/').replace(/^\.\//, '') + if (!normalized.startsWith('package/')) return undefined + const relative = normalized.slice('package/'.length).replace(/\/$/, '') + if (!relative || path.posix.isAbsolute(relative) || relative.split('/').some((segment) => !segment || segment === '.' || segment === '..')) { + if (!relative) return undefined + throw new Error('unsafe tar path') + } + return relative +} + +function parsePaxPath (body: Buffer): string | undefined { + let offset = 0 + let result: string | undefined + while (offset < body.length) { + const space = body.indexOf(0x20, offset) + if (space < 0) throw new Error('invalid pax record') + const length = Number(body.subarray(offset, space).toString('ascii')) + if (!Number.isSafeInteger(length) || length <= 0 || offset + length > body.length) throw new Error('invalid pax length') + const record = body.subarray(space + 1, offset + length - 1).toString('utf8') + const equals = record.indexOf('=') + if (equals > 0 && record.slice(0, equals) === 'path') result = record.slice(equals + 1) + offset += length + } + return result +} + +function tarNumber (value: Buffer): number { + if ((value[0] ?? 0) & 0x80) { + let result = BigInt((value[0] ?? 0) & 0x7f) + for (const byte of value.subarray(1)) result = (result << 8n) | BigInt(byte) + const number = Number(result) + if (!Number.isSafeInteger(number)) throw new Error('tar number overflow') + return number + } + const parsed = Number.parseInt(tarString(value).trim() || '0', 8) + if (!Number.isSafeInteger(parsed)) throw new Error('invalid tar number') + return parsed +} + +function tarString (value: Buffer): string { + const end = value.indexOf(0) + return value.subarray(0, end < 0 ? value.length : end).toString('utf8').replace(/\n$/, '') +} + +function isContained (candidate: string, root: string): boolean { + const relative = path.relative(path.resolve(root), path.resolve(candidate)) + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative) +} + +function hasDirectoryParents (root: string, relative: string): boolean { + const segments = relative.split('/').slice(0, -1) + let current = root + for (const segment of segments) { + current = path.join(current, segment) + const stat = lstatSync(current) + if (!stat.isDirectory() || stat.isSymbolicLink()) return false + } + return true +} diff --git a/packages/core/src/update/package-manager.ts b/packages/core/src/update/package-manager.ts new file mode 100644 index 0000000..a2aa9e3 --- /dev/null +++ b/packages/core/src/update/package-manager.ts @@ -0,0 +1,221 @@ +import path from 'node:path' +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import type { CommandRunner, CommandSpec, ExecutableIdentity, NpmArtifactIdentity, UpdateError } from './types.js' +import { DEFAULT_COMMAND_TIMEOUT_MS, isCommandSuccessful, resolveExecutableIdentity } from './command-runner.js' +import { isStableVersion } from './version.js' +import { bytesMatchIntegrity } from './integrity.js' +import { installedPackageMatchesTarball } from './package-content.js' + +export interface GlobalPackageOwnership { + manager: 'npm' | 'pnpm' + packageRoot: string + packagePath: string + /** Resolved safe spawn identity for the manager launcher. */ + executable: ExecutableIdentity + rollbackCommand: string +} + +export interface PackageManagerDetectionOptions { + commandRunner: CommandRunner + packageRoot: string + executablePath?: string + env?: NodeJS.ProcessEnv + /** Do not invoke npm/pnpm when collecting a read-only update report. */ + readOnly?: boolean +} + +export interface PackageManagerDetection { + ownership?: GlobalPackageOwnership + unsupported?: UpdateError +} + +export async function detectGlobalPackageOwnership ( + options: PackageManagerDetectionOptions +): Promise { + const env = options.env ?? process.env + const executablePath = options.executablePath ?? process.argv[1] ?? '' + const source = `${executablePath} ${env.npm_execpath ?? ''} ${env.npm_config_user_agent ?? ''}`.toLowerCase() + + // Installation roots and the resolved executable are stronger evidence than + // ambient variables. Volta/Bun/Yarn commonly export their home variables in + // ordinary npm/pnpm shells, so those variables alone must not reject a + // positively identified global package. + const wrapperEnvironment = env.npm_command === 'exec' + const npxCache = /[\\/]\.npm[\\/]_npx[\\/]/.test(executablePath) + if (wrapperEnvironment || npxCache || /(^|[\\/])(?:npx|volta|yarn|bun)(?:\.exe)?(?:\s|$)/.test(source) || source.includes('node_modules/.bin')) { + return { unsupported: unsupported('unsupported-manager', 'CLI was launched through an unsupported wrapper') } + } + + // A check must not even query a package manager. It can still report the + // running version; ownership is intentionally left unsupported until a + // mutating plan is requested and the manager can be positively verified. + if (options.readOnly) { + return { unsupported: unsupported('unsupported-manager', 'CLI ownership was not probed during a read-only check') } + } + + const candidates: Array<'npm' | 'pnpm'> = [] + if (source.includes('pnpm')) candidates.push('pnpm') + if (source.includes('npm')) candidates.push('npm') + for (const manager of ['npm', 'pnpm'] as const) { + if (!candidates.includes(manager)) candidates.push(manager) + } + + const matches: GlobalPackageOwnership[] = [] + for (const manager of candidates) { + // Resolve the manager to a safe spawn identity (native `.exe`/`.com`, or a + // validated npm `.cmd`/`.bat` shim derived to `process.execPath`+JS). On + // Windows a bare npm/pnpm name never reaches `spawn` with `shell: false`, + // and an unverifiable shim/`.ps1`-only launcher is rejected as unsupported. + const identity = resolveExecutableIdentity(manager, env) + if (identity.kind === 'unsupported') continue + let rootResult + try { + // Materialize the CommandSpec from the resolved identity so the + // commandRunner receives a spawn-safe executable (native path, or + // process.execPath + derived entrypoint) rather than a bare manager name. + const spawn = managerArgsForIdentity(identity, ['root', '--global']) + rootResult = await options.commandRunner.run({ + executable: spawn.executable, + executableIdentity: identity, + args: spawn.args, + timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS, + }) + } catch { + continue + } + if (!isCommandSuccessful(rootResult)) continue + const globalRoot = rootResult.stdout.trim().split(/\r?\n/).find((line) => path.isAbsolute(line) && existsSync(line)) + if (!globalRoot) continue + + // Keep the manager-reported link as the package identity used for reads + // after an update. pnpm repoints this link to a new store directory; a + // realpath captured before `pnpm add --global` would keep verification + // pinned to the old versioned store entry. + const packagePath = path.resolve(globalRoot, 'nsolid-plugin') + const resolvedPackagePath = realpathOrAbsolute(packagePath) + const resolvedPackageRoot = realpathOrAbsolute(options.packageRoot) + if (!isSameOrContained(resolvedPackageRoot, resolvedPackagePath)) continue + const packageVersion = readPackageVersion(packagePath) + if (!packageVersion) continue + if (!isSameOrContained(executablePath, resolvedPackagePath) && executablePath) { + // The entrypoint may be a symlink. Resolve it when possible, but reject a + // launcher that is unrelated to the positively identified package root. + const resolvedEntry = safeRealpath(executablePath) + if (!resolvedEntry || !isSameOrContained(resolvedEntry, resolvedPackagePath)) continue + } + + matches.push({ + manager, + packageRoot: path.resolve(globalRoot), + packagePath, + executable: identity, + rollbackCommand: formatRollbackCommand(manager, packageVersion), + }) + } + + if (matches.length === 1) return { ownership: matches[0] } + if (matches.length > 1) return { unsupported: unsupported('unsupported-manager', 'CLI ownership is ambiguous between multiple package managers') } + + return { unsupported: unsupported('unsupported-manager', 'CLI installation is not proven npm or pnpm global-owned') } +} + +export function buildGlobalUpdateCommand (ownership: GlobalPackageOwnership, version: string, artifact?: NpmArtifactIdentity): CommandSpec { + const packageSpec = artifact?.tarballPath ?? `nsolid-plugin@${version}` + const managerArgs = ownership.manager === 'npm' + ? ['install', '--global', packageSpec] + : ['add', '--global', packageSpec] + const args = managerArgsForIdentity(ownership.executable, managerArgs) + return { + executable: args.executable, + executableIdentity: ownership.executable, + args: args.args, + timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS, + } +} + +/** + * Translate a resolved `ExecutableIdentity` into a spawn-safe `CommandSpec` + * executable/args. For a `node` identity the manager name is replaced by + * `process.execPath` with the derived entrypoint prepended, so the npm/pnpm + * `.cmd` shim never runs through a reconstructed cmd.exe command line. + */ +export function managerArgsForIdentity ( + identity: ExecutableIdentity, + args: readonly string[] +): { executable: string; args: string[] } { + if (identity.kind === 'node') return { executable: identity.executable, args: [identity.entrypoint, ...args] } + if (identity.kind === 'native') return { executable: identity.executable, args: [...args] } + // Unsupported identity should not normally reach this point; fall back to a + // safe empty command so callers fail closed rather than spawning a name. + return { executable: 'nsolid-plugin-unreachable', args: [...args] } +} + +export function formatRollbackCommand (manager: 'npm' | 'pnpm', version?: string): string { + if (!version) return `${manager} ${manager === 'npm' ? 'install' : 'add'} --global nsolid-plugin@` + return manager === 'npm' + ? `npm install --global nsolid-plugin@${version}` + : `pnpm add --global nsolid-plugin@${version}` +} + +export function readPackageVersion (packagePath: string, expectedName = 'nsolid-plugin'): string | undefined { + try { + const parsed = JSON.parse(readFileSync(path.join(packagePath, 'package.json'), 'utf8')) as { name?: unknown; version?: unknown } + return parsed.name === expectedName && isStableVersion(parsed.version) ? parsed.version : undefined + } catch { + return undefined + } +} + +export function verifyGlobalPackage (ownership: GlobalPackageOwnership, expectedVersion: string, artifact?: NpmArtifactIdentity): boolean { + if (readPackageVersion(ownership.packagePath) !== expectedVersion) return false + if (!artifact) return true + try { + const packageJson = JSON.parse(readFileSync(path.join(ownership.packagePath, 'package.json'), 'utf8')) as Record + const resolved = packageJson._resolved ?? packageJson.resolved ?? packageJson.tarball + const integrity = packageJson._integrity ?? packageJson.integrity + if (typeof resolved === 'string' && resolved !== artifact.tarball) return false + if (typeof integrity === 'string' && integrity !== artifact.integrity) return false + const contentDigest = packageJson.contentDigest ?? packageJson._contentDigest + if (typeof contentDigest === 'string' && artifact.contentDigest && contentDigest !== artifact.contentDigest) return false + if (packageJson.name !== artifact.packageName || packageJson.version !== artifact.version) return false + if (typeof integrity === 'string' && integrity === artifact.integrity) return true + if (typeof contentDigest === 'string' && artifact.contentDigest && contentDigest === artifact.contentDigest) return true + return Boolean( + artifact.tarballPath && + bytesMatchIntegrity(readFileSync(artifact.tarballPath), artifact.integrity) && + installedPackageMatchesTarball(ownership.packagePath, artifact.tarballPath) + ) + } catch { + return false + } +} + +export function verifyLocalArtifact (artifact: NpmArtifactIdentity): boolean { + if (!artifact.tarballPath) return false + try { + return bytesMatchIntegrity(readFileSync(artifact.tarballPath), artifact.integrity) + } catch { return false } +} + +function unsupported (reason: 'unsupported-manager', message: string): UpdateError { + return { code: 'UNSUPPORTED_CLI_SOURCE', message: `${message}. Use an exact-version npm or pnpm command manually.` } +} + +function isSameOrContained (candidate: string, parent: string): boolean { + if (!candidate || !parent) return false + const relative = path.relative(path.resolve(parent), path.resolve(candidate)) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + +function safeRealpath (filePath: string): string | undefined { + try { + const resolved = path.resolve(filePath) + return existsSync(resolved) ? realpathSync(resolved) : undefined + } catch { + return undefined + } +} + +function realpathOrAbsolute (filePath: string): string { + return safeRealpath(filePath) ?? path.resolve(filePath) +} diff --git a/packages/core/src/update/redaction.ts b/packages/core/src/update/redaction.ts new file mode 100644 index 0000000..a180f09 --- /dev/null +++ b/packages/core/src/update/redaction.ts @@ -0,0 +1,16 @@ +const SECRET_PATTERNS = [ + /Bearer\s+[A-Za-z0-9._~+/=-]+/gi, + /(["']?(?:authorization|token|password|secret|api[-_]?key)["']?\s*[:=]\s*)[^\s,;"']+/gi, + /https?:\/\/[^\s/@]+:[^\s/@]+@/gi, + /((?:access[_-]?token|refresh[_-]?token|client[_-]?secret|api[_-]?key)\s*[=:]\s*)["']?[^\s,"']+/gi, + /(?:[A-Za-z]:[\\/]|\/)[^\s"']*(?:\.nodesource-auth|credentials?|\.npmrc|token)[^\s"']*/gi, +] + +/** Redact credentials and credential-bearing paths from untrusted text. */ +export function redactSecrets (value: string): string { + let result = value.replace(/((["']?(?:authorization|token|password|secret|api[-_]?key)["']?)\s*[:=]\s*)(["'])[^"']*\3/gi, '$1$3[REDACTED]$3') + for (const pattern of SECRET_PATTERNS) { + result = result.replace(pattern, (_match, prefix?: unknown) => typeof prefix === 'string' ? `${prefix}[REDACTED]` : '[REDACTED]') + } + return result +} diff --git a/packages/core/src/update/refresh-owned-cli.ts b/packages/core/src/update/refresh-owned-cli.ts new file mode 100644 index 0000000..8ccc3b7 --- /dev/null +++ b/packages/core/src/update/refresh-owned-cli.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +import path from 'node:path' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { refreshOwnedInstallation } from './fallback-transaction.js' +import { resolvePackageRoot } from './version.js' +import type { FallbackTransactionIdentity } from './types.js' +import { HARNESS_VALUES } from '../types.js' + +const args = process.argv.slice(2) +const transactionIndex = args.indexOf('--transaction') +const transactionPath = transactionIndex >= 0 ? args[transactionIndex + 1] : undefined +if (!transactionPath) { + console.error('nsolid-plugin-refresh-owned requires --transaction ') + process.exit(2) +} + +let transaction: FallbackTransactionIdentity +try { + transaction = JSON.parse(await readFile(transactionPath, 'utf8')) as FallbackTransactionIdentity +} catch { + console.error('Fallback transaction manifest could not be read') + process.exit(2) +} +const harness = transaction.harness +if (!HARNESS_VALUES.includes(harness)) { + console.error('Fallback transaction manifest has an unsupported harness') + process.exit(2) +} + +let result +try { + const sourceRoot = resolvePackageRoot(path.dirname(fileURLToPath(import.meta.url))) + result = await refreshOwnedInstallation({ + harness, + bundlePath: path.join(sourceRoot, 'bundle.json'), + skillsSource: sourceRoot, + transaction, + }) +} catch { + console.error('Owned refresh failed before mutation') + console.error('rollback: not-attempted') + process.exit(1) +} +if (!result.success) { + console.error(result.error?.message ?? 'Owned refresh failed') + if (result.rollbackAttempted) console.error(`rollback: ${result.rollbackSucceeded ? 'succeeded' : 'failed'}`) + else console.error('rollback: not-attempted') + process.exit(result.rollbackAttempted && result.rollbackSucceeded === false ? 2 : 1) +} diff --git a/packages/core/src/update/strategies/antigravity.ts b/packages/core/src/update/strategies/antigravity.ts new file mode 100644 index 0000000..be93db0 --- /dev/null +++ b/packages/core/src/update/strategies/antigravity.ts @@ -0,0 +1,52 @@ +import path from 'node:path' +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import { DEFAULT_COMMAND_TIMEOUT_MS, resolveExecutableIdentity } from '../command-runner.js' +import { managerArgsForIdentity } from '../package-manager.js' +import { executeAntigravityTransaction } from '../antigravity-transaction.js' +import { failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' + +export const antigravityStrategy: UpdateStrategy = { + target: 'antigravity', + ownership: 'native-plugin', + + async plan (installation: UpdateInstallation): Promise { + const source = installation.source + if (source.kind !== 'antigravity-git' || !isMutableVersion(installation)) return planItem(installation) + const paths = [path.resolve(installation.metadata?.pluginRoot ?? source.layout.pluginRoot), path.resolve(installation.metadata?.manifestPath ?? source.layout.manifestPath)] + const pinnedSource = installation.artifact?.kind === 'git' && installation.artifact.commit + ? `${source.url}#${installation.artifact.commit}` + : source.url + const identity = resolveExecutableIdentity('agy') + if (identity.kind === 'unsupported') { + return { + ...planItem(installation, [], [], undefined, { code: 'UNSAFE_HARNESS_LAUNCHER', message: 'Antigravity launcher cannot be verified as a safe executable identity' }), + manualCommands: ['agy plugin uninstall nsolid-plugin', `agy plugin install ${pinnedSource}`], + } + } + const uninstall = managerArgsForIdentity(identity, ['plugin', 'uninstall', 'nsolid-plugin']) + const install = managerArgsForIdentity(identity, ['plugin', 'install', pinnedSource]) + return { + ...planItem( + installation, + [ + { kind: 'filesystem', description: 'Back up the staged plugin and matching import manifest', operation: 'backup', paths }, + { kind: 'command', description: 'Uninstall the existing Antigravity N|Solid plugin', command: { executable: uninstall.executable, executableIdentity: identity, args: uninstall.args, timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS } }, + { kind: 'command', description: 'Install the fixed NodeSource GitHub plugin root at the planned commit', command: { executable: install.executable, executableIdentity: identity, args: install.args, timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS } }, + { kind: 'validation', description: 'Validate the staged plugin and matching import manifest', checks: ['plugin.json', 'bundle.json', 'canonical skills', 'nsolid-plugin import entry'] }, + { kind: 'filesystem', description: 'Remove the successful Antigravity backup', operation: 'cleanup', paths }, + ], + [{ kind: 'filesystem', description: 'Restore the staged plugin and matching import manifest', operation: 'restore', paths }], + 'Restart Antigravity to load the updated plugin' + ), + manualCommands: ['agy plugin uninstall nsolid-plugin', `agy plugin install ${pinnedSource}`], + } + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return failedResult(item, item.planningError) + if (item.steps.length === 0) return resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)) + const transaction = await executeAntigravityTransaction(item, context.commandRunner) + if (!transaction.success) return failedResult(item, transaction.error ?? { code: 'ANTIGRAVITY_TRANSACTION_FAILED', message: 'Antigravity replacement failed' }, { attempted: transaction.rollbackAttempted, succeeded: transaction.rollbackSucceeded }) + return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest }) + }, +} diff --git a/packages/core/src/update/strategies/claude.ts b/packages/core/src/update/strategies/claude.ts new file mode 100644 index 0000000..5c54058 --- /dev/null +++ b/packages/core/src/update/strategies/claude.ts @@ -0,0 +1,116 @@ +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import { DEFAULT_COMMAND_TIMEOUT_MS, isCommandSuccessful, resolveExecutableIdentity } from '../command-runner.js' +import { managerArgsForIdentity } from '../package-manager.js' +import { nativePayloadDigest, nativeSourceHonorsArtifact } from '../native-evidence.js' +import { readClaudePluginScope } from '../claude-record.js' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { commandFailure, failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' + +export const claudeStrategy: UpdateStrategy = { + target: 'claude', + ownership: 'native-plugin', + + async plan (installation: UpdateInstallation): Promise { + const source = installation.source + if (source.kind !== 'claude-marketplace' || !isMutableVersion(installation)) return planItem(installation) + if (!nativeSourceHonorsArtifact(source, installation.artifact)) { + return planItem(installation, [], [], undefined, { code: 'NATIVE_SOURCE_NOT_PINNED', message: 'Claude marketplace source cannot honor the resolved immutable commit during execution' }) + } + if (!/^nsolid-plugin@[A-Za-z0-9][A-Za-z0-9._-]*$/.test(source.pluginId)) { + return planItem(installation, [], [], undefined, { code: 'INVALID_PLUGIN_ID', message: 'Detected Claude plugin identity is ambiguous' }) + } + const identity = resolveExecutableIdentity('claude') + if (identity.kind === 'unsupported') { + return { + ...planItem(installation, [], [], undefined, { code: 'UNSAFE_HARNESS_LAUNCHER', message: 'Claude launcher cannot be verified as a safe executable identity' }), + manualCommands: [ + `claude plugin marketplace update ${source.marketplace}`, + `claude plugin update ${source.pluginId} --scope ${source.scope}`, + ], + } + } + const refresh = managerArgsForIdentity(identity, ['plugin', 'marketplace', 'update', source.marketplace]) + const update = managerArgsForIdentity(identity, ['plugin', 'update', source.pluginId, '--scope', source.scope]) + return planItem( + installation, + [ + { + kind: 'command', + description: `Refresh the detected ${source.marketplace} Claude marketplace`, + command: { + executable: refresh.executable, + executableIdentity: identity, + args: refresh.args, + timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS, + }, + }, + { + kind: 'command', + description: `Update ${source.pluginId} in its detected ${source.scope} scope`, + command: { + executable: update.executable, + executableIdentity: identity, + args: update.args, + timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS, + }, + }, + ], + [], + '/reload-plugins or restart Claude Code' + ) + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return failedResult(item, item.planningError) + if (!nativeSourceHonorsArtifact(item.source, item.artifact)) { + return failedResult(item, { code: 'NATIVE_SOURCE_NOT_PINNED', message: 'Claude marketplace source no longer proves the planned immutable identity' }) + } + if (item.steps.length === 0) return resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)) + const commands = item.steps.filter((step) => step.kind === 'command') + if (commands.length === 0) return failedResult(item, { code: 'INVALID_PLAN', message: 'Claude update plan has no command' }) + for (const step of commands) { + const result = await context.commandRunner.run(step.command) + if (!isCommandSuccessful(result)) return failedResult(item, commandFailure(step.command.executable, result.timedOut, result.spawnErrorCode)) + } + if (item.artifact && (item.artifact.kind === 'git' || item.artifact.kind === 'local-snapshot')) { + const versionSource = item.source.kind === 'claude-marketplace' ? item.source.versionSource : undefined + const manifestPath = versionSource && versionSource.kind !== 'unknown' ? versionSource.manifestPath : undefined + const packageRoot = item.source.kind === 'claude-marketplace' + ? updatedClaudePackageRoot(item.metadata?.configPath, item.source.pluginId, item.source.scope, item.version.latest) + : undefined + const digest = packageRoot ? nativePayloadDigest(packageRoot, manifestPath) : undefined + if (!digest || digest !== item.artifact.contentDigest) return failedResult(item, { code: 'CLAUDE_CONTENT_MISMATCH', message: 'Claude installed payload did not match the planned source identity' }) + } + return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest }) + }, +} + +function updatedClaudePackageRoot ( + configPath: string | undefined, + pluginId: string, + scope: string, + expectedVersion: string | undefined +): string | undefined { + if (!configPath || !path.isAbsolute(configPath)) return undefined + try { + const data = JSON.parse(readFileSync(configPath, 'utf8')) as unknown + if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined + const plugins = (data as Record).plugins + if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return undefined + const value = (plugins as Record)[pluginId] + const records = Array.isArray(value) ? value : [value] + const roots = records.flatMap((record) => { + if (!record || typeof record !== 'object' || Array.isArray(record)) return [] + const entry = record as Record + if (readClaudePluginScope(entry) !== scope) return [] + if (expectedVersion && typeof entry.version === 'string' && entry.version !== expectedVersion) return [] + if (typeof entry.installPath !== 'string' || !path.isAbsolute(entry.installPath)) return [] + const root = path.resolve(entry.installPath) + return existsSync(root) ? [root] : [] + }) + return roots.length === 1 ? roots[0] : undefined + } catch { + return undefined + } +} diff --git a/packages/core/src/update/strategies/cli-package.ts b/packages/core/src/update/strategies/cli-package.ts new file mode 100644 index 0000000..768adf4 --- /dev/null +++ b/packages/core/src/update/strategies/cli-package.ts @@ -0,0 +1,106 @@ +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import { buildGlobalUpdateCommand, formatRollbackCommand, managerArgsForIdentity, verifyGlobalPackage, verifyLocalArtifact } from '../package-manager.js' +import { isCommandSuccessful } from '../command-runner.js' +import { commandFailure, failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' +import { cleanupNpmArtifact } from '../version-source.js' + +export const cliPackageStrategy: UpdateStrategy = { + target: 'cli', + ownership: 'global-package', + + async plan (installation: UpdateInstallation): Promise { + if (installation.source.kind !== 'global-package' || !installation.metadata?.packagePath) { + const item = planItem(installation) + const version = installation.version.latest ?? '' + return { + ...item, + manualCommands: [ + `npm install --global nsolid-plugin@${version}`, + `pnpm add --global nsolid-plugin@${version}`, + `npx -y nsolid-plugin@${version} `, + ], + } + } + if (!isMutableVersion(installation)) return planItem(installation) + if (!installation.metadata.packageManagerExecutable || installation.metadata.packageManagerExecutable.kind === 'unsupported') { + return planItem(installation, [], [], undefined, { code: 'UNSAFE_PACKAGE_MANAGER', message: 'CLI update requires a verified absolute package-manager executable identity' }) + } + if (installation.version.latest && (installation.artifact?.kind !== 'npm' || !installation.artifact.tarballPath)) { + return planItem(installation, [], [], undefined, { code: 'ARTIFACT_IDENTITY_REQUIRED', message: 'CLI update requires a verified registry tarball identity' }) + } + const ownership = { + manager: installation.source.packageManager, + packageRoot: installation.metadata.packageRoot ?? '', + packagePath: installation.metadata.packagePath, + executable: installation.metadata.packageManagerExecutable, + rollbackCommand: installation.metadata.rollbackCommand ?? formatRollbackCommand(installation.source.packageManager, installation.version.current), + } + const command = buildGlobalUpdateCommand(ownership, installation.version.latest!, installation.artifact?.kind === 'npm' ? installation.artifact : undefined) + const rollbackArgs = installation.version.current + ? managerArgsForIdentity(ownership.executable, installation.source.packageManager === 'npm' + ? ['install', '--global', `nsolid-plugin@${installation.version.current}`] + : ['add', '--global', `nsolid-plugin@${installation.version.current}`]) + : undefined + return planItem( + installation, + [ + { kind: 'command', description: 'Update the globally owned CLI package at the resolved version', command }, + { + kind: 'validation', + description: 'Verify the positively identified global package root', + checks: [`${installation.metadata.packagePath}/package.json has name nsolid-plugin and version ${installation.version.latest}`], + }, + ], + rollbackArgs + ? [{ + kind: 'command', + description: 'Restore the previously installed CLI version', + command: { + executable: rollbackArgs.executable, + args: rollbackArgs.args, + timeoutMs: 120_000, + }, + }] + : [], + 'Invoke nsolid-plugin again (or start a new shell) to use the new CLI code' + ) + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return failedResult(item, item.planningError) + if (item.steps.length === 0) { + if (item.source.kind === 'unsupported') return resultFromPlan(item, 'unsupported') + return resultFromPlan(item, noMutationStatus(item.version)) + } + const commandStep = item.steps.find((step) => step.kind === 'command') + if (!commandStep || commandStep.kind !== 'command') return failedResult(item, { code: 'INVALID_PLAN', message: 'CLI update plan has no command' }) + if (item.artifact?.kind === 'npm' && !verifyLocalArtifact(item.artifact)) { + await cleanupNpmArtifact(item.artifact) + return failedResult(item, { code: 'ARTIFACT_INTEGRITY_FAILED', message: 'The planned CLI tarball no longer matches its registry integrity' }) + } + const result = await context.commandRunner.run(commandStep.command) + if (!isCommandSuccessful(result)) { + if (result.timedOut && result.treeTerminated !== true) { + return failedResult(item, { code: 'CLI_TREE_TERMINATION_UNCONFIRMED', message: 'CLI update timed out and descendant termination could not be confirmed; the package artifact was preserved' }) + } + await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + return failedResult(item, commandFailure(commandStep.command.executable, result.timedOut, result.spawnErrorCode)) + } + const packagePath = item.metadata?.packagePath + if (!packagePath || !item.version.latest || !verifyGlobalPackage({ + manager: item.source.kind === 'global-package' ? item.source.packageManager : 'npm', + packageRoot: item.metadata?.packageRoot ?? '', + packagePath, + executable: item.metadata?.packageManagerExecutable ?? { kind: 'unsupported', reason: 'not-found' }, + rollbackCommand: '', + }, item.version.latest, item.artifact?.kind === 'npm' ? item.artifact : undefined)) { + await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + return failedResult(item, { + code: 'CLI_VERSION_MISMATCH', + message: 'Package manager completed but the identified global package has the wrong version', + }) + } + await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest }) + }, +} diff --git a/packages/core/src/update/strategies/codex.ts b/packages/core/src/update/strategies/codex.ts new file mode 100644 index 0000000..28406b0 --- /dev/null +++ b/packages/core/src/update/strategies/codex.ts @@ -0,0 +1,109 @@ +import path from 'node:path' +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import { DEFAULT_COMMAND_TIMEOUT_MS, resolveExecutableIdentity } from '../command-runner.js' +import { managerArgsForIdentity } from '../package-manager.js' +import { nativeSourceHonorsArtifact } from '../native-evidence.js' +import { executeCodexTransaction, resolveCodexPluginCachePath } from '../codex-transaction.js' +import { failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' +import { resolveHome } from '../../utils/path.js' + +export const codexStrategy: UpdateStrategy = { + target: 'codex', + ownership: 'native-plugin', + + async plan (installation: UpdateInstallation): Promise { + const source = installation.source + if (source.kind !== 'codex-marketplace' || !isMutableVersion(installation)) return planItem(installation) + if (!nativeSourceHonorsArtifact(source, installation.artifact)) { + return planItem(installation, [], [], undefined, { code: 'NATIVE_SOURCE_NOT_PINNED', message: 'Codex marketplace source cannot honor the resolved immutable commit during execution' }) + } + if (!/^nsolid-plugin@[A-Za-z0-9][A-Za-z0-9._-]*$/.test(source.pluginId)) { + return planItem(installation, [], [], undefined, { code: 'INVALID_PLUGIN_ID', message: 'Detected Codex plugin identity is ambiguous' }) + } + if (!/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?$/.test(source.marketplace)) { + return planItem(installation, [], [], undefined, { code: 'INVALID_MARKETPLACE_ID', message: 'Detected Codex marketplace identity is ambiguous' }) + } + // Resolve the launcher once; all three commands share the same verified + // identity. An unverifiable launcher degrades to an unsupported plan with + // manual commands instead of failing at execution time. + const identity = resolveExecutableIdentity('codex') + if (identity.kind === 'unsupported') { + return { + ...planItem(installation, [], [], undefined, { code: 'UNSAFE_HARNESS_LAUNCHER', message: 'Codex launcher cannot be verified as a safe executable identity' }), + manualCommands: [ + `codex plugin marketplace upgrade ${source.marketplace}`, + `codex plugin remove ${source.pluginId}`, + `codex plugin add ${source.pluginId}`, + ], + } + } + const upgrade = managerArgsForIdentity(identity, ['plugin', 'marketplace', 'upgrade', source.marketplace]) + const remove = managerArgsForIdentity(identity, ['plugin', 'remove', source.pluginId]) + const add = managerArgsForIdentity(identity, ['plugin', 'add', source.pluginId]) + const configPath = path.resolve(installation.metadata?.configPath ?? process.env.CODEX_CONFIG_PATH ?? resolveHome('~/.codex/config.toml')) + const plannedInstallation = { + ...installation, + metadata: { ...(installation.metadata ?? {}), configPath }, + } + const cachePath = resolveCodexPluginCachePath(configPath, source.pluginId, source.marketplace, installation.metadata?.packageRoot) + if (!cachePath) { + return { + ...planItem(plannedInstallation, [], [], undefined, { code: 'CODEX_CACHE_UNRESOLVED', message: 'The exact Codex plugin cache could not be identified safely' }), + manualCommands: [ + `codex plugin marketplace upgrade ${source.marketplace}`, + `codex plugin remove ${source.pluginId}`, + `codex plugin add ${source.pluginId}`, + ], + } + } + return { + ...planItem( + plannedInstallation, + [ + { + kind: 'command', + description: `Refresh the detected Codex marketplace ${source.marketplace}`, + command: { executable: upgrade.executable, executableIdentity: identity, args: upgrade.args, timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS }, + }, + { kind: 'filesystem', description: 'Back up the exact Codex plugin registration and cached payload', operation: 'backup', paths: [configPath, cachePath] }, + { + kind: 'command', + description: `Remove the detected plugin ${source.pluginId} before reinstalling the refreshed snapshot`, + command: { executable: remove.executable, executableIdentity: identity, args: remove.args, timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS }, + }, + { + kind: 'command', + description: `Reinstall the detected plugin ${source.pluginId} from the refreshed marketplace`, + command: { executable: add.executable, executableIdentity: identity, args: add.args, timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS }, + }, + { kind: 'validation', description: 'Validate the reinstalled local Codex plugin version and preserved configuration', checks: [`${source.pluginId} matches refreshed version ${installation.version.latest}`, 'unrelated Codex configuration remains unchanged'] }, + { kind: 'filesystem', description: 'Remove the successful Codex transaction backup', operation: 'cleanup', paths: [configPath] }, + ], + [ + { kind: 'filesystem', description: 'Restore the prior Codex plugin registration and cached payload', operation: 'restore', paths: [configPath, cachePath] }, + ], + 'Start a new Codex session to load the updated plugin' + ), + manualCommands: [ + `codex plugin remove ${source.pluginId}`, + `codex plugin add ${source.pluginId}`, + ], + } + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return failedResult(item, item.planningError) + if (!nativeSourceHonorsArtifact(item.source, item.artifact)) { + return failedResult(item, { code: 'NATIVE_SOURCE_NOT_PINNED', message: 'Codex marketplace source no longer proves the planned immutable identity' }) + } + if (item.steps.length === 0) return resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)) + const transaction = await executeCodexTransaction(item, context.commandRunner) + if (!transaction.success) { + return failedResult(item, transaction.error ?? { code: 'CODEX_TRANSACTION_FAILED', message: 'Codex replacement failed' }, { + attempted: transaction.rollbackAttempted, + succeeded: transaction.rollbackSucceeded, + }) + } + return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest, rollback: { attempted: false } }) + }, +} diff --git a/packages/core/src/update/strategies/common.ts b/packages/core/src/update/strategies/common.ts new file mode 100644 index 0000000..150a009 --- /dev/null +++ b/packages/core/src/update/strategies/common.ts @@ -0,0 +1,88 @@ +import type { + UpdateError, + UpdateInstallation, + UpdatePlanItem, + UpdatePlanStep, + UpdateResult, + UpdateStatus, + VersionInfo, +} from '../types.js' + +export function planItem ( + installation: UpdateInstallation, + steps: readonly UpdatePlanStep[] = [], + rollbackSteps: readonly UpdatePlanStep[] = [], + restartHint?: string, + planningError?: UpdateError +): UpdatePlanItem { + return { + installationId: installation.installationId, + target: installation.target, + ownership: installation.ownership, + installed: installation.installed, + source: installation.source, + version: installation.version, + steps, + rollbackSteps, + planningError, + requiresConfirmation: steps.length > 0 && !planningError, + restartHint, + metadata: installation.metadata, + artifact: installation.artifact, + fallbackTransaction: installation.fallbackTransaction, + } +} + +export function resultFromPlan (item: UpdatePlanItem, status: UpdateStatus, extra: Partial = {}): UpdateResult { + return { + installationId: item.installationId, + target: item.target, + ownership: item.ownership, + status, + currentVersion: item.version.current, + latestVersion: item.version.latest, + changed: status === 'updated', + restartHint: item.restartHint, + manualCommands: item.manualCommands, + rollbackCommand: item.metadata?.rollbackCommand, + ...extra, + } +} + +export function noMutationStatus (version: VersionInfo): UpdateStatus { + switch (version.status) { + case 'current': return 'current' + case 'newer-than-registry': return 'newer-than-registry' + default: return 'unknown' + } +} + +export function failedResult (item: UpdatePlanItem, error: UpdateError, rollback?: UpdateResult['rollback']): UpdateResult { + return resultFromPlan(item, 'failed', { changed: false, error, rollback }) +} + +export function commandFailure (executable: string, timedOut = false, spawnErrorCode?: string): UpdateError { + if (spawnErrorCode === 'ENOENT') { + return { code: 'MISSING_EXECUTABLE', message: `${executable} executable was not found on PATH` } + } + return { + code: timedOut ? 'COMMAND_TIMEOUT' : 'COMMAND_FAILED', + message: timedOut ? `${executable} timed out` : `${executable} exited unsuccessfully`, + } +} + +export function isMutableVersion (item: UpdateInstallation): boolean { + if (item.version.status === 'update-available') return true + + // Native registrations commonly omit the installed version entirely. Once + // the source and ownership are positively identified, an exact refresh is + // still safe and is preferable to silently treating the target as current. + // The same rule repairs a tracked/package-owned cache whose manifest is + // missing, but never enables mutation for unsupported or uninstalled data. + return item.version.status === 'unknown' && + typeof item.version.latest === 'string' && + item.installed && + item.ownership !== 'none' && + item.source.kind !== 'none' && + item.source.kind !== 'unsupported' +} diff --git a/packages/core/src/update/strategies/fallback.ts b/packages/core/src/update/strategies/fallback.ts new file mode 100644 index 0000000..78694f8 --- /dev/null +++ b/packages/core/src/update/strategies/fallback.ts @@ -0,0 +1,254 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import type { HarnessType } from '../../types.js' +import { DEFAULT_COMMAND_TIMEOUT_MS, resolveExecutableIdentity, isCommandSuccessful } from '../command-runner.js' +import { failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' +import { getTrackingFilePath } from '../../utils/path.js' +import { getHarnessSkillsPath } from '../../skills/skill-linker.js' +import { beginFallbackJournal, captureFallbackJournalState, commitFallbackJournal, markFallbackJournalMutating, recoverFallbackJournal, trackingDigest, valueDigest, restoreFallbackJournal, type FallbackJournal } from '../fallback-journal.js' +import { cleanupNpmArtifact } from '../version-source.js' +import { managerArgsForIdentity, verifyLocalArtifact } from '../package-manager.js' +import { readTrackingFile } from '../../skills/skill-tracker.js' +import { readJsonFile, readJsoncFile, readTomlFile } from '../../utils/config.js' + +export const fallbackStrategy: UpdateStrategy = { + target: 'opencode', + ownership: 'fallback', + + async plan (installation: UpdateInstallation): Promise { + if (installation.source.kind !== 'fallback') { + return { + ...planItem(installation), + manualCommands: [`nsolid-plugin install --harness ${installation.target}`], + } + } + if (!isMutableVersion(installation)) return planItem(installation) + const identity = createFallbackIdentity(installation) + if (!identity) { + const unsupportedInstallation = { + ...installation, + source: { + kind: 'unsupported' as const, + source: `${installation.target}:tracking`, + reason: 'untracked' as const, + }, + } + return { + ...planItem(unsupportedInstallation), + manualCommands: [ + `nsolid-plugin install --harness ${installation.target}`, + `nsolid-plugin update --harness ${installation.target} --check`, + ], + } + } + const executor = installation.source.executor ?? detectExecutor() + if (!executor) { + const manifestPath = await createManifest(identity) + const unsupportedInstallation = { + ...installation, + source: { + kind: 'unsupported' as const, + source: `${installation.target}:fallback executor`, + reason: 'unsupported-manager' as const, + }, + } + return { + ...planItem(unsupportedInstallation), + manualCommands: [ + `npm exec --yes --package=nsolid-plugin@${installation.version.latest ?? ''} -- nsolid-plugin-refresh-owned --transaction ${manifestPath}`, + `pnpm --package=nsolid-plugin@${installation.version.latest ?? ''} dlx nsolid-plugin-refresh-owned --transaction ${manifestPath}`, + ], + } + } + if (installation.artifact?.kind !== 'npm' || !installation.artifact.tarballPath) { + return planItem(installation, [], [], undefined, { code: 'ARTIFACT_IDENTITY_REQUIRED', message: 'Fallback update requires a verified registry tarball identity' }) + } + const executableIdentity = resolveExecutableIdentity(executor === 'npm-exec' ? 'npm' : 'pnpm') + if (executableIdentity.kind === 'unsupported') { + return planItem(installation, [], [], undefined, { code: 'UNSAFE_FALLBACK_EXECUTOR', message: 'Fallback update requires a verified absolute npm or pnpm executable identity' }) + } + const manifestPath = await createManifest(identity) + const version = installation.version.latest! + const managerArgs = executor === 'npm-exec' + ? ['exec', '--yes', `--package=${installation.artifact.tarballPath}`, '--', 'nsolid-plugin-refresh-owned', '--transaction', manifestPath] + : [`--package=${installation.artifact.tarballPath}`, 'dlx', 'nsolid-plugin-refresh-owned', '--transaction', manifestPath] + const spawn = managerArgsForIdentity(executableIdentity, managerArgs) + const command = { executable: spawn.executable, executableIdentity, args: spawn.args, timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS } + const paths = installation.metadata?.trackedSkills?.map((skill) => skill.path) ?? [] + if (installation.metadata?.trackedMcpConfigPath) paths.push(installation.metadata.trackedMcpConfigPath) + return planItem( + { ...installation, source: { ...installation.source, executor }, fallbackTransaction: identity }, + [ + { kind: 'filesystem', description: 'Back up tracked NodeSource-owned fallback assets', operation: 'backup', paths }, + { kind: 'command', description: `Refresh the owned ${installation.target} bundle at exact version ${version}`, command }, + { kind: 'validation', description: 'Validate skills, MCP ownership, tracking paths, and per-harness bundle version evidence', checks: ['tracked skills match new bundle', 'unrelated MCP entries are preserved', `${installation.target} bundleVersions entry is ${version}`] }, + { kind: 'filesystem', description: 'Remove the successful fallback backup', operation: 'cleanup', paths }, + ], + [{ kind: 'filesystem', description: 'Restore tracked fallback assets and tracking state', operation: 'restore', paths }], + installation.target === 'opencode' ? 'Restart OpenCode to load refreshed skills' : undefined + ) + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return failedResult(item, item.planningError) + if (item.steps.length === 0) return resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)) + const step = item.steps.find((entry) => entry.kind === 'command') + if (!step || step.kind !== 'command') return failedResult(item, { code: 'INVALID_PLAN', message: 'Fallback update plan has no command' }) + const workspace = await mkdtemp(path.join(tmpdir(), 'nsolid-plugin-update-')) + let journal: FallbackJournal | undefined + let preserveRecoveryArtifacts = false + try { + await chmod(workspace, 0o700) + // Anchor npm/pnpm's project discovery inside the private directory so + // parent-level /tmp/package.json, .npmrc, or node_modules/.bin entries + // cannot influence exact-package execution. + await writeFile(path.join(workspace, 'package.json'), '{"private":true}\n', { mode: 0o600 }) + await writeFile(path.join(workspace, '.npmrc'), '', { mode: 0o600 }) + if (item.artifact?.kind === 'npm' && !verifyLocalArtifact(item.artifact)) { + return failedResult(item, { code: 'ARTIFACT_INTEGRITY_FAILED', message: 'The planned fallback tarball no longer matches its registry integrity' }) + } + if (item.fallbackTransaction) { + const recovery = await recoverFallbackJournal(item.fallbackTransaction.trackingPath, true) + if (!recovery.recovered) return failedResult(item, { code: 'FALLBACK_RECOVERY_FAILED', message: 'A previous fallback transaction could not be recovered' }, { attempted: true, succeeded: false }) + try { + journal = (await beginFallbackJournal(item.fallbackTransaction)).journal + journal = await markFallbackJournalMutating(journal) + } catch (error) { + if (error instanceof Error && error.message === 'FALLBACK_TRACKING_DRIFT') { + return failedResult(item, { code: 'FALLBACK_TRACKING_DRIFT', message: 'Fallback tracking file changed after planning' }, { attempted: false }) + } + return failedResult(item, { code: 'FALLBACK_BACKUP_FAILED', message: 'Fallback parent snapshot could not be completed' }, { attempted: false }) + } + } + const result = await context.commandRunner.run({ + ...step.command, + cwd: workspace, + env: { + ...step.command.env, + NPM_CONFIG_USERCONFIG: path.join(workspace, '.npmrc'), + npm_config_userconfig: path.join(workspace, '.npmrc'), + }, + }) + if (!isCommandSuccessful(result)) { + if (result.timedOut && result.treeTerminated !== true) { + preserveRecoveryArtifacts = true + return failedResult(item, { + code: 'FALLBACK_TREE_TERMINATION_UNCONFIRMED', + message: 'Fallback refresh timed out and descendant termination could not be confirmed; recovery artifacts were preserved', + }, { attempted: false }) + } + const rollback = parseRollbackState(`${result.stdout}\n${result.stderr}`) + const parentRecovered = journal ? await restoreFallbackJournal(journal) : undefined + return failedResult( + item, + { + code: result.spawnErrorCode === 'ENOENT' + ? 'MISSING_EXECUTABLE' + : result.timedOut + ? 'FALLBACK_COMMAND_TIMEOUT' + : rollback?.attempted && rollback.succeeded === false ? 'FALLBACK_ROLLBACK_FAILED' : 'FALLBACK_COMMAND_FAILED', + message: result.spawnErrorCode === 'ENOENT' + ? `${step.command.executable} executable was not found on PATH` + : rollback?.attempted && rollback.succeeded === false + ? 'Fallback refresh command failed and its rollback was incomplete' + : 'Fallback refresh command failed', + }, + parentRecovered === false ? { attempted: true, succeeded: false } : rollback ?? (journal ? { attempted: true, succeeded: parentRecovered === true } : { attempted: false }) + ) + } + if (journal) { + try { + journal = await captureFallbackJournalState(journal) + } catch { + preserveRecoveryArtifacts = true + return failedResult(item, { code: 'FALLBACK_STATE_UNPROVEN', message: 'Fallback child completed but the resulting owned state could not be captured safely' }, { attempted: false }) + } + const tracking = await readTrackingFile() + const bundleEvidence = tracking?.bundleVersions?.[item.target as keyof typeof tracking.bundleVersions] ?? tracking?.bundleVersion + if (!tracking || bundleEvidence !== item.version.latest || !validateFallbackPostconditions(tracking, item.target)) { + const recovered = await restoreFallbackJournal(journal) + return failedResult(item, { code: recovered ? 'FALLBACK_VALIDATION_FAILED' : 'FALLBACK_ROLLBACK_FAILED', message: recovered ? 'Fallback child completed without the planned bundle evidence' : 'Fallback validation failed and parent recovery was incomplete' }, { attempted: true, succeeded: recovered }) + } + } + if (journal) await commitFallbackJournal(journal) + await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest, rollback: { attempted: false } }) + } finally { + if (!preserveRecoveryArtifacts) await rm(workspace, { recursive: true, force: true }).catch(() => {}) + const transactionIndex = step.command.args.indexOf('--transaction') + const manifestPath = transactionIndex >= 0 ? step.command.args[transactionIndex + 1] : undefined + if (manifestPath && !preserveRecoveryArtifacts) await rm(path.dirname(manifestPath), { recursive: true, force: true }).catch(() => {}) + } + }, +} + +function createFallbackIdentity (installation: UpdateInstallation) { + const trackingPath = getTrackingFilePath() + const digest = trackingDigest(trackingPath) + if (!digest) return undefined + const skills = installation.metadata?.trackedSkills ?? [] + const configPath = installation.metadata?.trackedMcpConfigPath + const names = installation.metadata?.trackedMcpNames ?? [] + const trackedFields = installation.metadata?.trackedMcpFields ?? [] + if (names.length > 0 && installation.metadata?.trackedMcpOwnershipComplete === false) return undefined + return { + installationId: installation.installationId, + harness: installation.target as HarnessType, + trackingPath, + trackingDigest: digest, + ownedSkillPaths: skills.map((skill) => path.resolve(skill.path)), + ownedLinkPaths: skills.map((skill) => path.join(getHarnessSkillsPath(installation.target as HarnessType), skill.name)), + ownedMcpFields: trackedFields.length > 0 + ? trackedFields.map((field) => ({ ...field, configPath: path.resolve(field.configPath) })) + : configPath + ? names.flatMap((name) => Object.entries(readMcpRecord(configPath, name) ?? {}).map(([field, value]) => ({ configPath: path.resolve(configPath), server: name, field, expectedDigest: valueDigest(value) }))) + : [], + } as const +} + +async function createManifest (identity: NonNullable>): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'nsolid-plugin-manifest-')) + const manifestPath = path.join(directory, 'transaction.json') + await writeFile(manifestPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 }) + return manifestPath +} + +function readMcpRecord (configPath: string, name: string): Record | undefined { + try { + const value = configPath.endsWith('.toml') + ? readTomlFile>(configPath) + : configPath.endsWith('.jsonc') + ? readJsoncFile>(configPath) + : readJsonFile>(configPath) + const servers = value?.mcpServers ?? value?.mcp_servers ?? value?.mcp + const record = servers && typeof servers === 'object' ? (servers as Record)[name] : undefined + return record && typeof record === 'object' && !Array.isArray(record) ? record as Record : undefined + } catch { return undefined } +} + +function parseRollbackState (output: string): UpdateResult['rollback'] | undefined { + const match = output.match(/(?:^|\n)rollback:\s*(succeeded|failed|not-attempted)\s*(?:\n|$)/i) + if (!match) return undefined + if (match[1].toLowerCase() === 'not-attempted') return { attempted: false } + return { attempted: true, succeeded: match[1].toLowerCase() === 'succeeded' } +} + +function detectExecutor (): 'npm-exec' | 'pnpm-dlx' | undefined { + if (resolveExecutableIdentity('npm').kind !== 'unsupported') return 'npm-exec' + if (resolveExecutableIdentity('pnpm').kind !== 'unsupported') return 'pnpm-dlx' + return undefined +} + +function validateFallbackPostconditions (tracking: Awaited>, harness: UpdatePlanItem['target']): boolean { + if (!tracking || harness === 'cli') return false + const scopedSkills = tracking.skills.filter((entry) => entry.harnesses.includes(harness)) + if (scopedSkills.some((entry) => { + const skillPath = entry.paths?.[harness] ?? entry.path + return !path.isAbsolute(skillPath) || !existsSync(skillPath) + })) return false + const scopedMcp = tracking.mcpServers.filter((entry) => entry.harness === harness) + return scopedMcp.every((entry) => path.isAbsolute(entry.configPath) && existsSync(entry.configPath)) +} diff --git a/packages/core/src/update/strategies/pi.ts b/packages/core/src/update/strategies/pi.ts new file mode 100644 index 0000000..2de73a2 --- /dev/null +++ b/packages/core/src/update/strategies/pi.ts @@ -0,0 +1,258 @@ +import type { NpmArtifactIdentity, UpdateContext, UpdateError, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { DEFAULT_COMMAND_TIMEOUT_MS, isCommandSuccessful, resolveExecutableIdentity } from '../command-runner.js' +import { managerArgsForIdentity } from '../package-manager.js' +import { compareVersions, isStableVersion } from '../version.js' +import { commandFailure, failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' +import { readPackageVersion, verifyLocalArtifact } from '../package-manager.js' +import { resolveRegistryArtifactVersion } from '../version-source.js' +import { parseIntegrity } from '../integrity.js' + +export const piStrategy: UpdateStrategy = { + target: 'pi', + ownership: 'package-owned', + + async plan (installation: UpdateInstallation): Promise { + const source = installation.source + if (source.kind !== 'pi-package' || !isMutableVersion(installation)) return planItem(installation) + const approve = (source.scopes as readonly string[]).includes('project') + const projectRoot = 'projectRoot' in source ? source.projectRoot : undefined + const evidenceError = piEvidencePlanningError(installation) + if (evidenceError) { + return { + ...planItem(installation, [], [], undefined, evidenceError), + manualCommands: [`pi update npm:nsolid-pi-plugin ${approve ? '--approve' : '--no-approve'}`], + } + } + const identity = resolveExecutableIdentity('pi') + if (identity.kind === 'unsupported') { + return { + ...planItem(installation, [], [], undefined, { code: 'UNSAFE_HARNESS_LAUNCHER', message: 'Pi launcher cannot be verified as a safe executable identity' }), + manualCommands: [`pi update npm:nsolid-pi-plugin ${approve ? '--approve' : '--no-approve'}`], + } + } + const spawn = managerArgsForIdentity(identity, ['update', 'npm:nsolid-pi-plugin', approve ? '--approve' : '--no-approve']) + const registry = installation.artifact?.kind === 'npm' ? installation.artifact.registry : undefined + return planItem( + installation, + [{ + kind: 'command', + description: `Update Pi package caches (${source.scopes.join(' and ')})${projectRoot ? ` at ${projectRoot}` : ''}`, + command: { + executable: spawn.executable, + executableIdentity: identity, + args: spawn.args, + cwd: projectRoot, + env: registry + ? { npm_config_registry: registry, NPM_CONFIG_REGISTRY: registry } + : undefined, + timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS, + }, + }, + { kind: 'validation', description: 'Verify every affected Pi package cache is at least the planned version', checks: ['nsolid-pi-plugin package name', `version >= ${installation.version.latest}`] }], + [], + '/reload or restart Pi' + ) + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return failedResult(item, item.planningError) + if (item.steps.length === 0) return resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)) + const step = item.steps.find((entry) => entry.kind === 'command') + if (!step || step.kind !== 'command') return failedResult(item, { code: 'INVALID_PLAN', message: 'Pi update plan has no command' }) + if (item.version.latest && (item.artifact?.kind !== 'npm' || !item.artifact.integrity)) { + return failedResult(item, { code: 'ARTIFACT_IDENTITY_REQUIRED', message: 'Pi update could not prove the planned registry artifact identity' }) + } + if (item.artifact?.kind === 'npm' && !verifyLocalArtifact(item.artifact)) return failedResult(item, { code: 'ARTIFACT_INTEGRITY_FAILED', message: 'The planned Pi package artifact no longer matches its registry integrity' }) + const drift = revalidatePiPlan(item) + if (drift) return failedResult(item, drift) + const result = await context.commandRunner.run(step.command) + if (!isCommandSuccessful(result)) { + const error = commandFailure(step.command.executable, result.timedOut, result.spawnErrorCode) + return failedResult(item, error.code === 'COMMAND_FAILED' ? { code: 'PI_COMMAND_FAILED', message: 'Pi package update failed' } : error) + } + + const roots = item.metadata?.packageRoots ?? [] + const versions = roots.map((root) => readPackageVersion(root, 'nsolid-pi-plugin')).filter((version): version is string => isStableVersion(version)) + if (roots.length > 0 && versions.length !== roots.length) { + return failedResult(item, { code: 'PI_PACKAGE_MISSING', message: 'An affected Pi package cache is missing after update' }) + } + if (item.version.latest && versions.length > 0 && versions.some((version) => compareVersions(version, item.version.latest!) < 0)) { + return failedResult(item, { code: 'PI_VERSION_MISMATCH', message: 'One affected Pi package cache is older than the planned version' }) + } + const evidenceError = await validatePiEvidence(item, versions, context.options.fetchImpl) + if (evidenceError) return failedResult(item, evidenceError) + return resultFromPlan(item, 'updated', { resultingVersion: versions.sort((a, b) => compareVersions(b, a))[0] ?? item.version.latest }) + }, +} + +function piEvidencePlanningError (installation: UpdateInstallation): UpdateError | undefined { + const roots = installation.metadata?.packageRoots ?? [] + if (roots.length === 0) return { code: 'PI_PROVENANCE_UNVERIFIED', message: 'Pi did not provide an affected package cache to verify' } + const paths = installation.metadata?.packageEvidencePaths ?? [] + if (paths.length !== roots.length) return { code: 'PI_PROVENANCE_UNVERIFIED', message: 'Pi did not provide provenance evidence for every affected cache' } + for (let index = 0; index < roots.length; index++) { + const evidence = readPiEvidence(paths[index]!, roots[index]!) + if (!evidence || evidence.packageName !== 'nsolid-pi-plugin' || typeof evidence.version !== 'string' || !isStableVersion(evidence.version) || typeof evidence.resolved !== 'string' || typeof evidence.integrity !== 'string' || !isValidIntegrity(evidence.integrity)) { + return { code: 'PI_PROVENANCE_UNVERIFIED', message: 'Pi package provenance evidence is missing or invalid' } + } + } + return undefined +} + +function revalidatePiPlan (item: UpdatePlanItem) { + const metadata = item.metadata + if (!metadata) return undefined + if (metadata.projectRoot && metadata.projectRootIdentity && safeRealpath(metadata.projectRoot) !== metadata.projectRootIdentity) { + return { code: 'PI_SCOPE_DRIFT', message: 'Pi project root changed after planning' } + } + const paths = metadata.settingsPaths ?? [] + const expected = metadata.settingsDigests ?? [] + if (paths.length !== expected.length || paths.some((filePath, index) => digest(filePath) !== expected[index])) { + return { code: 'PI_SETTINGS_DRIFT', message: 'Pi settings changed after planning' } + } + const roots = metadata.packageRoots ?? [] + const rootIdentities = metadata.packageRootIdentities ?? [] + const cacheDigests = metadata.cacheDigests ?? [] + if (roots.length !== rootIdentities.length || roots.some((root, index) => safeRealpath(root) !== rootIdentities[index])) { + return { code: 'PI_CACHE_DRIFT', message: 'Pi package cache roots changed after planning' } + } + if (roots.length !== cacheDigests.length || roots.some((root, index) => digest(path.join(root, 'package.json')) !== cacheDigests[index])) { + return { code: 'PI_CACHE_DRIFT', message: 'Pi package cache contents changed after planning' } + } + const evidencePaths = metadata.packageEvidencePaths ?? [] + const evidenceDigests = metadata.packageEvidenceDigests ?? [] + if (roots.length !== evidencePaths.length || evidencePaths.length !== evidenceDigests.length || evidencePaths.some((filePath, index) => digest(filePath) !== evidenceDigests[index])) { + return { code: 'PI_EVIDENCE_DRIFT', message: 'Pi package provenance evidence changed after planning' } + } + const sources = metadata.sourceEntries ?? [] + if (sources.some((source) => source !== 'npm:nsolid-pi-plugin')) { + return { code: 'PI_SOURCE_DRIFT', message: 'Pi package source changed after planning' } + } + return undefined +} + +function digest (filePath: string): string { + try { return createHash('sha256').update(readFileSync(filePath)).digest('hex') } catch { return '' } +} + +function safeRealpath (filePath: string): string { + try { return realpathSync(filePath) } catch { return path.resolve(filePath) } +} + +async function validatePiEvidence (item: UpdatePlanItem, versions: readonly string[], fetchImpl?: typeof fetch): Promise { + const metadata = item.metadata + const artifact = item.artifact?.kind === 'npm' ? item.artifact : undefined + const roots = metadata?.packageRoots ?? [] + const paths = metadata?.packageEvidencePaths ?? [] + if (!artifact || roots.length === 0 || roots.length !== paths.length || versions.length !== roots.length) { + return { code: 'PI_PROVENANCE_UNVERIFIED', message: 'Pi did not provide verifiable package provenance for every affected cache' } + } + + for (let index = 0; index < roots.length; index++) { + const evidence = readPiEvidence(paths[index]!, roots[index]!) + if (!evidence || evidence.packageName !== 'nsolid-pi-plugin' || typeof evidence.version !== 'string' || !isStableVersion(evidence.version) || !isStableVersion(versions[index]) || evidence.version !== versions[index] || typeof evidence.resolved !== 'string' || typeof evidence.integrity !== 'string') { + return { code: 'PI_PROVENANCE_UNVERIFIED', message: 'Pi package provenance evidence is missing or invalid' } + } + if (compareVersions(evidence.version, item.version.latest ?? evidence.version) < 0 || !isValidIntegrity(evidence.integrity) || !belongsToRegistry(evidence.resolved, artifact.registry)) { + return { code: 'PI_PROVENANCE_MISMATCH', message: 'Pi package provenance does not match the frozen registry identity' } + } + + let expectedArtifact: NpmArtifactIdentity | undefined = artifact + let expectedError: UpdateError | undefined + if (compareVersions(evidence.version, item.version.latest ?? evidence.version) !== 0) { + const expected = await resolveRegistryArtifactVersion('nsolid-pi-plugin', evidence.version, { + fetchImpl, + registry: artifact.registry, + }) + expectedArtifact = expected.artifact?.kind === 'npm' ? expected.artifact : undefined + expectedError = expected.error + } + const evidenceUrl = normalizeUrl(evidence.resolved) + const expectedUrl = expectedArtifact ? normalizeUrl(expectedArtifact.tarball) : undefined + if (expectedError || !expectedArtifact || expectedArtifact.registry !== artifact.registry || !evidenceUrl || !expectedUrl || evidenceUrl !== expectedUrl || evidence.integrity !== expectedArtifact.integrity) { + return { code: 'PI_PROVENANCE_MISMATCH', message: 'Pi package provenance does not match the verified package artifact' } + } + } + return undefined +} + +interface PiEvidence { + packageName?: unknown + version?: unknown + resolved?: unknown + integrity?: unknown +} + +function readPiEvidence (filePath: string, packageRoot: string): PiEvidence | undefined { + try { + if (!existsSync(filePath)) return undefined + const data = JSON.parse(readFileSync(filePath, 'utf8')) as unknown + if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined + if (path.basename(filePath) === 'package.json') { + const packageJson = data as Record + return { + packageName: packageJson.name, + version: packageJson.version, + resolved: packageJson._resolved ?? packageJson.resolved, + integrity: packageJson._integrity ?? packageJson.integrity, + } + } + const lock = data as Record + const packages = lock.packages + if (packages && typeof packages === 'object' && !Array.isArray(packages)) { + const relative = path.relative(path.dirname(filePath), packageRoot).split(path.sep).join('/') + const candidates = [relative, `node_modules/${'nsolid-pi-plugin'}`] + for (const key of candidates) { + const record = (packages as Record)[key] + if (record && typeof record === 'object' && !Array.isArray(record)) { + return { ...(record as PiEvidence), packageName: (record as Record).name ?? 'nsolid-pi-plugin' } + } + } + for (const [key, value] of Object.entries(packages as Record)) { + if (key.endsWith('/node_modules/nsolid-pi-plugin') || key === 'node_modules/nsolid-pi-plugin') { + if (value && typeof value === 'object' && !Array.isArray(value)) return { ...(value as PiEvidence), packageName: (value as Record).name ?? 'nsolid-pi-plugin' } + } + } + } + const dependencies = lock.dependencies + const dependency = dependencies && typeof dependencies === 'object' && !Array.isArray(dependencies) + ? (dependencies as Record)['nsolid-pi-plugin'] + : undefined + return dependency && typeof dependency === 'object' && !Array.isArray(dependency) ? dependency as PiEvidence : undefined + } catch { + return undefined + } +} + +function isValidIntegrity (value: string): boolean { + return parseIntegrity(value) !== undefined +} + +function belongsToRegistry (resolved: string, registry: string): boolean { + try { + const base = new URL(registry) + const candidate = new URL(resolved, base) + if (candidate.protocol !== base.protocol || candidate.hostname.toLowerCase() !== base.hostname.toLowerCase() || candidate.port !== base.port) return false + const basePath = base.pathname.replace(/\/+$/, '') + return candidate.pathname === basePath || candidate.pathname.startsWith(`${basePath}/`) + } catch { + return false + } +} + +function normalizeUrl (value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + try { + const url = new URL(value) + url.username = '' + url.password = '' + url.search = '' + url.hash = '' + return url.toString() + } catch { + return undefined + } +} diff --git a/packages/core/src/update/transaction-commands.ts b/packages/core/src/update/transaction-commands.ts new file mode 100644 index 0000000..afc086a --- /dev/null +++ b/packages/core/src/update/transaction-commands.ts @@ -0,0 +1,20 @@ +import type { CommandResult, CommandRunner, CommandSpec, UpdatePlanStep } from './types.js' +import { isCommandSuccessful } from './command-runner.js' + +export type TransactionCommandResult = + | { success: true; completed: readonly CommandSpec[] } + | { success: false; completed: readonly CommandSpec[]; command: CommandSpec; result: CommandResult } + +export async function runTransactionCommands ( + steps: readonly UpdatePlanStep[], + commandRunner: CommandRunner +): Promise { + const completed: CommandSpec[] = [] + for (const step of steps) { + if (step.kind !== 'command') continue + const result = await commandRunner.run(step.command) + if (!isCommandSuccessful(result)) return { success: false, completed, command: step.command, result } + completed.push(step.command) + } + return { success: true, completed } +} diff --git a/packages/core/src/update/types.ts b/packages/core/src/update/types.ts new file mode 100644 index 0000000..cdf7f56 --- /dev/null +++ b/packages/core/src/update/types.ts @@ -0,0 +1,367 @@ +import type { HarnessType } from '../types.js' + +export type UpdateTarget = 'cli' | HarnessType + +export type UpdateOwnership = + | 'global-package' + | 'native-plugin' + | 'package-owned' + | 'fallback' + | 'none' + +export type VersionStatus = + | 'current' + | 'update-available' + | 'newer-than-registry' + | 'unknown' + +export type UpdateStatus = + | 'current' + | 'update-available' + | 'newer-than-registry' + | 'updated' + | 'skipped' + | 'not-installed' + | 'unsupported' + | 'unknown' + | 'failed' + +export interface VersionInfo { + current?: string + latest?: string + status: VersionStatus + /** All detected copies when one logical target spans multiple caches/scopes. */ + currentVersions?: readonly (string | undefined)[] +} + +export interface RunningVersionInfo { + cliVersion: string + bundleVersion: string +} + +export type ClaudePluginScope = 'user' | 'project' | 'local' | 'managed' + +export type MarketplaceVersionSource = + | { + kind: 'git' + repository: string + revision?: string + commit?: string + contentDigest?: string + manifestPath: string + } + | { + kind: 'local-snapshot' + root: string + manifestPath: string + freshness: 'verified' | 'stale' | 'unknown' + contentDigest?: string + } + | { + kind: 'unknown' + reason: 'missing-metadata' | 'ambiguous' | 'unsupported' + } + +export type PiPackageLocation = + | { scopes: readonly ['user'] } + | { scopes: readonly ['project']; projectRoot: string } + | { scopes: readonly ['user', 'project']; projectRoot: string } + +export type FallbackPackageExecutor = 'npm-exec' | 'pnpm-dlx' + +export interface NpmArtifactIdentity { + kind: 'npm' + packageName: 'nsolid-plugin' | 'nsolid-pi-plugin' + version: string + registry: string + tarball: string + integrity: string + /** Planner-only local path; never render this in public output. */ + tarballPath?: string + tempDirectory?: string + contentDigest?: string +} + +export interface GitArtifactIdentity { + kind: 'git' + repository: string + commit: string + contentDigest: string +} + +export interface LocalArtifactIdentity { + kind: 'local-snapshot' + root: string + contentDigest: string +} + +export type ResolvedArtifactIdentity = NpmArtifactIdentity | GitArtifactIdentity | LocalArtifactIdentity + +export interface FallbackTransactionIdentity { + installationId: string + harness: HarnessType + trackingPath: string + trackingDigest: string + ownedSkillPaths: readonly string[] + ownedLinkPaths: readonly string[] + ownedMcpFields: readonly { + configPath: string + server: string + field: string + expectedDigest: string + }[] +} + +export type AntigravityLayout = + | { + kind: 'shared' + pluginRoot: '~/.gemini/config/plugins/nsolid-plugin' + manifestPath: '~/.gemini/config/import_manifest.json' + } + | { + kind: 'agy-cli' + pluginRoot: '~/.gemini/antigravity-cli/plugins/nsolid-plugin' + manifestPath: '~/.gemini/antigravity-cli/import_manifest.json' + } + +export type UpdateSource = + | { kind: 'none' } + | { kind: 'global-package'; packageManager: 'npm' | 'pnpm'; packageName: 'nsolid-plugin' } + | { + kind: 'claude-marketplace' + pluginId: string + marketplace: string + scope: ClaudePluginScope + versionSource: MarketplaceVersionSource + } + | { + kind: 'codex-marketplace' + pluginId: string + marketplace: string + versionSource: MarketplaceVersionSource + } + | ({ kind: 'pi-package'; spec: 'npm:nsolid-pi-plugin' } & PiPackageLocation) + | { + kind: 'unsupported' + source: string + reason: 'local' | 'git' | 'pinned' | 'ambiguous' | 'conflicting' | 'untracked' | 'unsupported-manager' + } + | { + kind: 'antigravity-git' + url: 'https://github.com/NodeSource/nsolid-plugin.git' + layout: AntigravityLayout + } + | { kind: 'fallback'; bundleVersion?: string; executor?: FallbackPackageExecutor } + +/** Additional read-only evidence used by strategies. It never reaches CLI output verbatim. */ +export interface UpdateInstallationMetadata { + /** Exact native configuration path approved during planning. */ + configPath?: string + packageRoot?: string + packagePath?: string + previousVersion?: string + rollbackCommand?: string + trackedSkills?: readonly { name: string; path: string }[] + trackedMcpConfigPath?: string + trackedMcpNames?: readonly string[] + trackedMcpFields?: readonly { + configPath: string + server: string + field: string + expectedDigest: string + }[] + trackedMcpOwnershipComplete?: boolean + projectRoot?: string + packageRoots?: readonly string[] + packageRootIdentities?: readonly string[] + pluginRoot?: string + manifestPath?: string + packageManagerExecutable?: ExecutableIdentity + projectRootIdentity?: string + settingsPaths?: readonly string[] + settingsDigests?: readonly string[] + sourceEntries?: readonly string[] + cacheDigests?: readonly string[] + packageEvidencePaths?: readonly string[] + packageEvidenceDigests?: readonly string[] +} + +export interface UpdateInstallation { + installationId: string + target: UpdateTarget + ownership: UpdateOwnership + installed: boolean + source: UpdateSource + version: VersionInfo + inventoryError?: UpdateError + metadata?: UpdateInstallationMetadata + artifact?: ResolvedArtifactIdentity + fallbackTransaction?: FallbackTransactionIdentity +} + +export interface UpdateOptions { + harness?: HarnessType + all?: boolean + check?: boolean + yes?: boolean + json?: boolean + verbose?: boolean + noColor?: boolean + cwd?: string + packageRoot?: string + /** Explicit npm registry for the update lookup and execution plan. */ + registry?: string + fetchImpl?: typeof fetch + commandRunner?: CommandRunner + confirm?: UpdateConfirmation +} + +export interface CommandSpec { + executable: string + /** Frozen identity evidence revalidated immediately before spawn. */ + executableIdentity?: ExecutableIdentity + args: readonly string[] + cwd?: string + env?: Readonly> + timeoutMs: number +} + +/** + * How a command step is actually spawned on the host. `shell: true` and + * launching through `cmd.exe` are never used; on Windows a validated npm + * shim is derived to a JS entrypoint and run with `process.execPath`. + */ +export type ExecutableIdentity = + | { kind: 'native'; executable: string } + | { + kind: 'node' + executable: string + entrypoint: string + } + | { + kind: 'unsupported' + reason: 'not-found' | 'powershell-only' | 'unverifiable-shim' + } + +export interface CommandResult { + exitCode: number | null + signal?: NodeJS.Signals + /** OS error raised before the child process started, for example ENOENT. */ + spawnErrorCode?: string + stdout: string + stderr: string + timedOut: boolean + /** + * When a timeout occurred, whether the whole descendant process tree was + * terminated before the caller proceeds to rollback. `true` for a clean + * non-timeout run. Callers must treat a timed-out run with + * `treeTerminated === false` as requiring deferral/recovery, never restoring + * concurrently with a possibly-live child. + */ + treeTerminated?: boolean +} + +export interface CommandRunner { + run(spec: CommandSpec): Promise +} + +export type { ExecutableIdentity as ResolvedExecutable } + +export type UpdatePlanStep = + | { + kind: 'command' + description: string + command: CommandSpec + } + | { + kind: 'filesystem' + description: string + operation: 'backup' | 'replace' | 'reconcile' | 'restore' | 'cleanup' + paths: readonly string[] + } + | { + kind: 'validation' + description: string + checks: readonly string[] + } + +export interface UpdateError { + code: string + message: string +} + +export interface UpdatePlanItem { + installationId: string + target: UpdateTarget + ownership: UpdateOwnership + installed: boolean + source: UpdateSource + version: VersionInfo + steps: readonly UpdatePlanStep[] + rollbackSteps: readonly UpdatePlanStep[] + planningError?: UpdateError + requiresConfirmation: boolean + restartHint?: string + manualCommands?: readonly string[] + metadata?: UpdateInstallationMetadata + artifact?: ResolvedArtifactIdentity + fallbackTransaction?: FallbackTransactionIdentity +} + +export interface UpdatePlan { + checkOnly: boolean + items: readonly UpdatePlanItem[] +} + +export interface UpdateConfirmationContext { + items: readonly UpdatePlanItem[] +} + +export type UpdateConfirmation = ( + context: UpdateConfirmationContext +) => boolean | Promise + +export interface UpdateResult { + installationId: string + target: UpdateTarget + ownership: UpdateOwnership + status: UpdateStatus + currentVersion?: string + latestVersion?: string + resultingVersion?: string + changed: boolean + restartHint?: string + rollbackCommand?: string + manualCommands?: readonly string[] + rollback?: { + attempted: boolean + succeeded?: boolean + } + error?: UpdateError +} + +export interface UpdateSummary { + checkOnly: boolean + results: UpdateResult[] + counts: Record + success: boolean + exitCode: 0 | 1 | 2 +} + +export interface UpdateContext { + options: Readonly + commandRunner: CommandRunner +} + +export interface UpdateStrategy { + readonly target: UpdateTarget + readonly ownership: UpdateOwnership + plan(installation: UpdateInstallation, context: UpdateContext): Promise + execute(item: UpdatePlanItem, context: UpdateContext): Promise +} + +export interface VersionLookupResult { + version?: string + error?: UpdateError + artifact?: ResolvedArtifactIdentity +} diff --git a/packages/core/src/update/version-source.ts b/packages/core/src/update/version-source.ts new file mode 100644 index 0000000..b5f53e6 --- /dev/null +++ b/packages/core/src/update/version-source.ts @@ -0,0 +1,415 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import type { MarketplaceVersionSource, NpmArtifactIdentity, UpdateError, VersionLookupResult } from './types.js' +import { isStableVersion } from './version.js' +import { bytesMatchIntegrity } from './integrity.js' +import { redactSecrets } from './redaction.js' + +export interface VersionSourceOptions { + fetchImpl?: typeof fetch + /** Effective npm registry captured for this lookup (defaults to npmjs). */ + registry?: string + timeoutMs?: number + /** Download and verify the immutable npm artifact for a mutating plan. */ + downloadArtifact?: boolean + /** Reject mutable Git refs that could not be resolved to a commit. */ + requireImmutable?: boolean +} + +const DEFAULT_TIMEOUT_MS = 15_000 +const SAFE_RELATIVE_PATH = /^(?![\\/])(?!(?:.*[\\/])?\.\.(?:[\\/]|$))[A-Za-z0-9._/-]+$/ + +export async function resolveRegistryVersion ( + packageName: string, + options: VersionSourceOptions = {} +): Promise { + if (!/^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/.test(packageName)) { + return { error: lookupError('INVALID_PACKAGE', 'Package name is invalid') } + } + let registry: string + try { + registry = normalizeRegistryUrl(options.registry) + } catch { + return { error: lookupError('INVALID_REGISTRY_URL', 'Configured npm registry URL is invalid') } + } + + try { + const data = await fetchJson(`${registry}${encodeURIComponent(packageName)}`, options) + const metadata = data as { + 'dist-tags'?: { latest?: unknown } + versions?: Record + dist?: { tarball?: unknown; integrity?: unknown } + registry?: unknown + } + const latest = metadata['dist-tags']?.latest + if (!isStableVersion(latest)) { + return { error: lookupError('INVALID_REGISTRY_VERSION', 'Registry latest version is invalid') } + } + const selectedVersion = metadata.versions?.[latest] + if (selectedVersion?.version !== undefined && selectedVersion.version !== latest) { + return { error: lookupError('INVALID_REGISTRY_ARTIFACT', 'Registry artifact version does not match latest') } + } + // A normal npm packument stores artifact identity under versions[latest]. + // Keep accepting top-level dist for version-specific registry responses. + const dist = selectedVersion?.dist ?? metadata.dist + const tarball = typeof dist?.tarball === 'string' ? dist.tarball : undefined + const integrity = typeof dist?.integrity === 'string' ? dist.integrity : undefined + const declaredRegistry = selectedVersion && Object.prototype.hasOwnProperty.call(selectedVersion, 'registry') + ? selectedVersion.registry + : Object.prototype.hasOwnProperty.call(metadata, 'registry') + ? metadata.registry + : undefined + let artifactRegistry = registry.replace(/\/$/, '') + if (declaredRegistry !== undefined || (selectedVersion && Object.prototype.hasOwnProperty.call(selectedVersion, 'registry')) || Object.prototype.hasOwnProperty.call(metadata, 'registry')) { + if (typeof declaredRegistry !== 'string') return { error: lookupError('INVALID_REGISTRY_ARTIFACT', 'Registry artifact declares an invalid registry URL') } + try { + artifactRegistry = normalizeRegistryUrl(declaredRegistry).replace(/\/$/, '') + } catch { + return { error: lookupError('INVALID_REGISTRY_ARTIFACT', 'Registry artifact declares an invalid registry URL') } + } + } + // Keep version-only lookup compatibility for registries/proxies that omit + // dist metadata. Mutation strategies that require byte identity reject the + // resulting lookup before constructing an executable plan. + if (!tarball || !integrity) return { version: latest } + let parsedTarball: URL + try { + parsedTarball = new URL(tarball, registry) + if (!['https:', 'http:'].includes(parsedTarball.protocol)) throw new Error('unsupported tarball protocol') + } catch { + return { error: lookupError('INVALID_REGISTRY_ARTIFACT', 'Registry tarball URL is invalid') } + } + const artifact: NpmArtifactIdentity = { + kind: 'npm', + packageName: packageName as NpmArtifactIdentity['packageName'], + version: latest, + registry: artifactRegistry, + tarball: parsedTarball.toString(), + integrity, + } + if (options.downloadArtifact) { + try { + const downloaded = await downloadAndVerifyTarball(parsedTarball.toString(), integrity, options) + artifact.tarballPath = downloaded.path + artifact.tempDirectory = downloaded.directory + artifact.contentDigest = downloaded.contentDigest + } catch (error) { + return { error: lookupError('ARTIFACT_INTEGRITY_FAILED', sanitizeLookupMessage(error)) } + } + } + return { version: latest, artifact } + } catch (error) { + return { error: lookupError('REGISTRY_LOOKUP_FAILED', sanitizeLookupMessage(error)) } + } +} + +export async function resolveRegistryArtifactVersion ( + packageName: string, + version: string, + options: VersionSourceOptions = {} +): Promise { + if (!/^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/.test(packageName)) { + return { error: lookupError('INVALID_PACKAGE', 'Package name is invalid') } + } + if (!isStableVersion(version)) return { error: lookupError('INVALID_REGISTRY_VERSION', 'Registry version is invalid') } + + let registry: string + try { + registry = normalizeRegistryUrl(options.registry) + } catch { + return { error: lookupError('INVALID_REGISTRY_URL', 'Configured npm registry URL is invalid') } + } + + try { + const data = await fetchJson(`${registry}${encodeURIComponent(packageName)}`, options) as { + versions?: Record + registry?: unknown + } + const selected = data.versions?.[version] + if (!selected || (selected.version !== undefined && selected.version !== version)) { + return { error: lookupError('INVALID_REGISTRY_ARTIFACT', 'Registry did not return the requested package version') } + } + const tarball = typeof selected.dist?.tarball === 'string' ? selected.dist.tarball : undefined + const integrity = typeof selected.dist?.integrity === 'string' ? selected.dist.integrity : undefined + if (!tarball || !integrity) return { error: lookupError('INVALID_REGISTRY_ARTIFACT', 'Registry package version has no immutable artifact identity') } + const declaredRegistry = Object.prototype.hasOwnProperty.call(selected, 'registry') + ? selected.registry + : Object.prototype.hasOwnProperty.call(data, 'registry') ? data.registry : undefined + let artifactRegistry = registry.replace(/\/$/, '') + if (declaredRegistry !== undefined || Object.prototype.hasOwnProperty.call(selected, 'registry') || Object.prototype.hasOwnProperty.call(data, 'registry')) { + if (typeof declaredRegistry !== 'string') return { error: lookupError('INVALID_REGISTRY_ARTIFACT', 'Registry artifact declares an invalid registry URL') } + try { artifactRegistry = normalizeRegistryUrl(declaredRegistry).replace(/\/$/, '') } catch { return { error: lookupError('INVALID_REGISTRY_ARTIFACT', 'Registry artifact declares an invalid registry URL') } } + } + let parsedTarball: URL + try { + parsedTarball = new URL(tarball, registry) + if (!['https:', 'http:'].includes(parsedTarball.protocol)) throw new Error('unsupported tarball protocol') + } catch { + return { error: lookupError('INVALID_REGISTRY_ARTIFACT', 'Registry tarball URL is invalid') } + } + return { + version, + artifact: { + kind: 'npm', + packageName: packageName as NpmArtifactIdentity['packageName'], + version, + registry: artifactRegistry, + tarball: parsedTarball.toString(), + integrity, + }, + } + } catch (error) { + return { error: lookupError('REGISTRY_LOOKUP_FAILED', sanitizeLookupMessage(error)) } + } +} + +export async function resolveMarketplaceVersion ( + source: MarketplaceVersionSource, + options: VersionSourceOptions = {} +): Promise { + if (source.kind === 'unknown') { + return {} + } + + if (!isSafeManifestPath(source.manifestPath)) { + return {} + } + + if (source.kind === 'local-snapshot') { + if (source.freshness !== 'verified') { + return {} + } + const manifestPath = path.resolve(source.root, source.manifestPath) + const result = await readManifestVersion(manifestPath) + if (result.version) { + const contentDigest = await digestFile(manifestPath) + if (source.contentDigest && contentDigest && source.contentDigest !== contentDigest) return { error: lookupError('SOURCE_CONTENT_MISMATCH', 'Marketplace snapshot content changed after discovery') } + if (contentDigest) result.artifact = { kind: 'local-snapshot', root: path.resolve(source.root), contentDigest } + } + return result + } + + const repository = sanitizeRepository(source.repository) + if (!repository) return { error: lookupError('INVALID_MARKETPLACE_SOURCE', 'Marketplace repository is invalid') } + let revision = isFullCommit(source.commit) ? source.commit : source.commit ?? source.revision ?? 'HEAD' + if (options.requireImmutable && !isFullCommit(revision)) { + const resolvedCommit = await resolveGitCommit(repository, revision, options) + if (!resolvedCommit) return { error: lookupError('IMMUTABLE_SOURCE_UNAVAILABLE', 'Marketplace ref could not be resolved to an immutable commit') } + revision = resolvedCommit + } + if (!isSafeRevision(revision)) return {} + const rawUrl = toRawManifestUrl(repository, revision, source.manifestPath) + try { + const response = await fetchWithTimeout(rawUrl, options) + if (!response.ok) throw new Error(`marketplace returned ${response.status}`) + const body = await response.text() + const parsed = parseJsonResponse(body, 'marketplace response was not valid JSON') + const version = extractVersion(parsed) + if (!isStableVersion(version)) throw new Error('marketplace manifest version is invalid') + const responseCommit = response.headers.get('x-commit-sha') ?? response.headers.get('x-git-commit') ?? undefined + const commit = isFullCommit(responseCommit) ? responseCommit : isFullCommit(source.commit) ? source.commit : isFullCommit(revision) ? revision : undefined + if (options.requireImmutable && !commit) return { error: lookupError('IMMUTABLE_SOURCE_UNAVAILABLE', 'Marketplace response did not identify an immutable commit') } + const contentDigest = sha256(body) + if (source.contentDigest && source.contentDigest !== contentDigest) return { error: lookupError('SOURCE_CONTENT_MISMATCH', 'Marketplace content changed after discovery') } + return commit + ? { version, artifact: { kind: 'git', repository, commit, contentDigest } } + : { version } + } catch (error) { + return { error: lookupError('MARKETPLACE_LOOKUP_FAILED', sanitizeLookupMessage(error)) } + } +} + +export async function resolveFixedGitBundleVersion ( + options: VersionSourceOptions = {} +): Promise { + try { + const repository = 'https://github.com/NodeSource/nsolid-plugin.git' + const revision = options.requireImmutable + ? await resolveGitCommit(repository, 'main', options) + : 'main' + if (options.requireImmutable && !revision) return { error: lookupError('IMMUTABLE_SOURCE_UNAVAILABLE', 'Fixed source could not be resolved to an immutable commit') } + const effectiveRevision = revision ?? 'main' + const response = await fetchWithTimeout( + `https://raw.githubusercontent.com/NodeSource/nsolid-plugin/${effectiveRevision}/bundle.json`, + options + ) + if (!response.ok) throw new Error(`fixed source returned ${response.status}`) + const body = await response.text() + const data = parseJsonResponse(body, 'fixed source response was not valid JSON') + const version = extractVersion(data) + if (!isStableVersion(version)) throw new Error('fixed source version is invalid') + const commit = response.headers.get('x-commit-sha') ?? response.headers.get('x-git-commit') ?? effectiveRevision + if (options.requireImmutable && !isFullCommit(commit)) return { error: lookupError('IMMUTABLE_SOURCE_UNAVAILABLE', 'Fixed source response did not identify an immutable commit') } + return isFullCommit(commit) + ? { version, artifact: { kind: 'git', repository, commit, contentDigest: sha256(body) } } + : { version } + } catch (error) { + return { error: lookupError('FIXED_SOURCE_LOOKUP_FAILED', sanitizeLookupMessage(error)) } + } +} + +export function sanitizeRepository (repository: string): string | undefined { + const githubShorthand = repository.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\.git)?$/) + if (githubShorthand) return `https://github.com/${githubShorthand[1]}/${githubShorthand[2]}.git` + try { + const parsed = new URL(repository) + if (!['https:', 'http:', 'ssh:'].includes(parsed.protocol)) return undefined + parsed.username = '' + parsed.password = '' + parsed.hash = '' + parsed.search = '' + return parsed.toString().replace(/\/$/, '') + } catch { + return undefined + } +} + +export function isSafeManifestPath (manifestPath: string): boolean { + return SAFE_RELATIVE_PATH.test(manifestPath) && !manifestPath.includes('\\') +} + +function isSafeRevision (revision: string): boolean { + return revision.length > 0 && !revision.startsWith('/') && !revision.includes('\\') && !revision.split('/').includes('..') && !/[\s?#]/.test(revision) +} + +function toRawManifestUrl (repository: string, revision: string, manifestPath: string): string { + const parsed = new URL(repository) + const segments = parsed.pathname.replace(/\.git$/, '').split('/').filter(Boolean) + if (segments.length < 2) throw new Error('marketplace repository has no owner/name') + const host = parsed.hostname.toLowerCase() + if (host === 'github.com') { + const encodedRevision = revision.split('/').map((segment) => encodeURIComponent(segment)).join('/') + return `https://raw.githubusercontent.com/${segments.join('/')}/${encodedRevision}/${manifestPath}` + } + const encodedRevision = revision.split('/').map((segment) => encodeURIComponent(segment)).join('/') + return `${repository}/raw/${encodedRevision}/${manifestPath}` +} + +async function readManifestVersion (filePath: string): Promise { + try { + const parsed = JSON.parse(await readFile(filePath, 'utf8')) as unknown + const version = extractVersion(parsed) + return isStableVersion(version) + ? { version } + : { error: lookupError('INVALID_MARKETPLACE_VERSION', 'Marketplace manifest version is invalid') } + } catch { + return { error: lookupError('MARKETPLACE_LOOKUP_FAILED', 'Marketplace snapshot could not be read') } + } +} + +async function fetchJson (url: string, options: VersionSourceOptions): Promise { + const response = await fetchWithTimeout(url, options) + if (!response.ok) throw new Error(`registry returned ${response.status}`) + return parseJsonResponse(await response.text(), 'registry response was not valid JSON') +} + +function parseJsonResponse (body: string, failureMessage: string): unknown { + try { + return JSON.parse(body) as unknown + } catch { + throw new Error(failureMessage) + } +} + +async function fetchWithTimeout (url: string, options: VersionSourceOptions): Promise { + const fetchImpl = options.fetchImpl ?? fetch + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + try { + return await fetchImpl(url, { signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} + +function extractVersion (value: unknown): unknown { + if (!value || typeof value !== 'object') return undefined + const object = value as Record + if (typeof object.version === 'string') return object.version + const plugin = object.plugin + if (plugin && typeof plugin === 'object' && typeof (plugin as { version?: unknown }).version === 'string') { + return (plugin as { version: string }).version + } + return undefined +} + +function lookupError (code: string, message: string): UpdateError { + return { code, message: message.replace(/[\r\n]/g, ' ').slice(0, 240) } +} + +function sanitizeLookupMessage (error: unknown): string { + const message = error instanceof Error ? error.message : 'version lookup failed' + return redactSecrets(message) + .replace(/[\r\n]/g, ' ') + .slice(0, 240) +} + +function normalizeRegistryUrl (value?: string): string { + const candidate = value ?? process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY ?? 'https://registry.npmjs.org/' + const url = new URL(candidate) + if (!['https:', 'http:'].includes(url.protocol)) throw new Error('unsupported registry protocol') + url.username = '' + url.password = '' + url.search = '' + url.hash = '' + url.pathname = url.pathname.replace(/\/+$/, '') + '/' + return url.toString() +} + +function isFullCommit (value: unknown): value is string { + return typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value) +} + +async function resolveGitCommit (repository: string, revision: string, options: VersionSourceOptions): Promise { + try { + const parsed = new URL(repository) + if (parsed.hostname.toLowerCase() !== 'github.com') return undefined + const segments = parsed.pathname.replace(/\.git$/, '').split('/').filter(Boolean) + if (segments.length !== 2) return undefined + const url = `https://api.github.com/repos/${segments[0]}/${segments[1]}/commits/${revision.split('/').map(encodeURIComponent).join('/')}` + const response = await fetchWithTimeout(url, options) + if (!response.ok) return undefined + const body = JSON.parse(await response.text()) as { sha?: unknown; object?: { sha?: unknown }; commit?: { sha?: unknown } } + const commit = body.sha ?? body.object?.sha ?? body.commit?.sha + return isFullCommit(commit) ? commit : undefined + } catch { return undefined } +} + +function sha256 (value: string | Uint8Array): string { + return createHash('sha256').update(value).digest('hex') +} + +async function digestFile (filePath: string): Promise { + try { return sha256(await readFile(filePath)) } catch { return undefined } +} + +async function downloadAndVerifyTarball ( + url: string, + integrity: string, + options: VersionSourceOptions +): Promise<{ path: string; directory: string; contentDigest: string }> { + const response = await fetchWithTimeout(url, options) + if (!response.ok) throw new Error(`registry tarball returned ${response.status}`) + const bytes = new Uint8Array(await response.arrayBuffer()) + if (!bytesMatchIntegrity(bytes, integrity)) throw new Error('registry tarball integrity mismatch') + const directory = await mkdtemp(path.join(os.tmpdir(), 'nsolid-plugin-artifact-')) + const tarballPath = path.join(directory, 'package.tgz') + await writeFile(tarballPath, bytes, { mode: 0o600 }) + return { path: tarballPath, directory, contentDigest: sha256(bytes) } +} + +export async function cleanupNpmArtifact (artifact: NpmArtifactIdentity | undefined): Promise { + if (!artifact?.tempDirectory) return + await rm(artifact.tempDirectory, { recursive: true, force: true }).catch(() => {}) +} + +export async function downloadNpmArtifact ( + artifact: NpmArtifactIdentity, + options: VersionSourceOptions = {} +): Promise { + if (artifact.tarballPath) return artifact + const downloaded = await downloadAndVerifyTarball(artifact.tarball, artifact.integrity, options) + return { ...artifact, tarballPath: downloaded.path, tempDirectory: downloaded.directory, contentDigest: downloaded.contentDigest } +} diff --git a/packages/core/src/update/version.ts b/packages/core/src/update/version.ts new file mode 100644 index 0000000..54c79b6 --- /dev/null +++ b/packages/core/src/update/version.ts @@ -0,0 +1,119 @@ +import path from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import type { RunningVersionInfo, VersionInfo, VersionStatus } from './types.js' + +export interface ParsedVersion { + major: number + minor: number + patch: number +} + +const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ + +export function parseStableVersion (value: unknown): ParsedVersion | null { + if (typeof value !== 'string') return null + const match = value.match(STABLE_VERSION) + if (!match) return null + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + } +} + +export function isStableVersion (value: unknown): value is string { + return parseStableVersion(value) !== null +} + +export function compareVersions (left: string, right: string): number { + const a = parseStableVersion(left) + const b = parseStableVersion(right) + if (!a || !b) throw new Error('Only stable semantic versions can be compared') + if (a.major !== b.major) return a.major - b.major + if (a.minor !== b.minor) return a.minor - b.minor + return a.patch - b.patch +} + +export function classifyVersions (current: unknown, latest: unknown): VersionInfo { + const currentVersion = isStableVersion(current) ? current : undefined + const latestVersion = isStableVersion(latest) ? latest : undefined + let status: VersionStatus = 'unknown' + + if (currentVersion && latestVersion) { + const comparison = compareVersions(currentVersion, latestVersion) + status = comparison === 0 + ? 'current' + : comparison < 0 + ? 'update-available' + : 'newer-than-registry' + } + + return { current: currentVersion, latest: latestVersion, status } +} + +/** + * Classify every physical copy behind one logical installation. A missing or + * malformed copy is actionable when a newer package is known: the update can + * repair that cache even though no version can be read from it. + */ +export function classifyVersionSet (currents: readonly unknown[], latest: unknown): VersionInfo { + const latestVersion = isStableVersion(latest) ? latest : undefined + const currentVersions = currents.map((value) => isStableVersion(value) ? value : undefined) + const stableVersions = currentVersions.filter((value): value is string => value !== undefined) + let status: VersionStatus = 'unknown' + + if (latestVersion && currentVersions.length > 0) { + const hasMissing = stableVersions.length !== currentVersions.length + const hasOlder = stableVersions.some((value) => compareVersions(value, latestVersion) < 0) + const allPresent = stableVersions.length === currentVersions.length && stableVersions.length > 0 + const hasNewer = stableVersions.some((value) => compareVersions(value, latestVersion) > 0) + if (hasMissing || hasOlder) status = 'update-available' + else if (allPresent && hasNewer) status = 'newer-than-registry' + else if (allPresent) status = 'current' + } else if (stableVersions.length === currentVersions.length && stableVersions.length > 0) { + status = 'unknown' + } + + const current = stableVersions.length > 0 + ? [...stableVersions].sort(compareVersions)[0] + : undefined + return { current, latest: latestVersion, status, currentVersions } +} + +export function readRunningVersionInfo (packageRoot = defaultPackageRoot()): RunningVersionInfo { + const packageJson = readJson(path.join(packageRoot, 'package.json')) as { version?: unknown } + const bundle = readJson(path.join(packageRoot, 'bundle.json')) as { version?: unknown } + if (!isStableVersion(packageJson.version)) throw new Error('Package version is missing or invalid') + if (!isStableVersion(bundle.version)) throw new Error('Bundle version is missing or invalid') + return { cliVersion: packageJson.version, bundleVersion: bundle.version } +} + +/** Find the nearest package root containing both runtime manifests. */ +export function resolvePackageRoot (startDir = path.dirname(fileURLToPath(import.meta.url))): string { + let candidate = path.resolve(startDir) + while (true) { + if (existsSync(path.join(candidate, 'package.json')) && existsSync(path.join(candidate, 'bundle.json'))) return candidate + const parent = path.dirname(candidate) + if (parent === candidate) break + candidate = parent + } + throw new Error(`Package root could not be resolved from ${path.resolve(startDir)}`) +} + +export function readPackageVersion (packageRoot: string): string | undefined { + try { + const value = (readJson(path.join(packageRoot, 'package.json')) as { version?: unknown }).version + return isStableVersion(value) ? value : undefined + } catch { + return undefined + } +} + +function readJson (filePath: string): unknown { + return JSON.parse(readFileSync(filePath, 'utf8')) as unknown +} + +function defaultPackageRoot (): string { + return resolvePackageRoot() +} diff --git a/packages/core/test/integration/update-flow.test.ts b/packages/core/test/integration/update-flow.test.ts new file mode 100644 index 0000000..007a60c --- /dev/null +++ b/packages/core/test/integration/update-flow.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { checkUpdates, executeUpdatePlan, planUpdates } from '../../src/update/index.js' + +let home: string +let project: string +let previousHome: string | undefined +let previousUserProfile: string | undefined +let previousCodexConfigPath: string | undefined +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') + +beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-update-home-')) + project = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-update-project-')) + previousHome = process.env.HOME + previousUserProfile = process.env.USERPROFILE + previousCodexConfigPath = process.env.CODEX_CONFIG_PATH + process.env.HOME = home + process.env.USERPROFILE = home + delete process.env.CODEX_CONFIG_PATH +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true }) + rmSync(project, { recursive: true, force: true }) + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile + if (previousCodexConfigPath === undefined) delete process.env.CODEX_CONFIG_PATH + else process.env.CODEX_CONFIG_PATH = previousCodexConfigPath +}) + +function registryFetch (version: string): typeof fetch { + return async () => new Response(JSON.stringify({ 'dist-tags': { latest: version } }), { status: 200 }) +} + +describe('update flow coordinator', () => { + it('checks the CLI without probing a package manager', async () => { + let calls = 0 + const commandRunner = { + run: async () => { + calls++ + throw new Error('read-only check must not probe npm or pnpm') + }, + } + const summary = await checkUpdates({ + packageRoot, + cwd: project, + fetchImpl: registryFetch('999.0.0'), + commandRunner, + }) + + const cli = summary.results.find((result) => result.installationId === 'cli:global') + assert.equal(cli?.status, 'update-available') + assert.equal(calls, 0) + }) + + it('does not probe a package manager for a CLI newer than the registry', async () => { + let calls = 0 + const commandRunner = { + run: async () => { + calls++ + throw new Error('newer-than-registry must not probe npm or pnpm') + }, + } + const plan = await planUpdates({ + packageRoot, + cwd: project, + fetchImpl: registryFetch('0.0.1'), + commandRunner, + }) + + const cli = plan.items.find((item) => item.installationId === 'cli:global') + assert.equal(cli?.version.status, 'newer-than-registry') + assert.equal(cli?.requiresConfirmation, false) + assert.equal(calls, 0) + }) + + it('emits a non-mutating not-installed item for an absent requested harness', async () => { + const calls: string[] = [] + const commandRunner = { + run: async (spec: { executable: string }) => { + calls.push(spec.executable) + return { exitCode: 1, stdout: '', stderr: '', timedOut: false } + }, + } + const summary = await checkUpdates({ + harness: 'pi', + cwd: project, + fetchImpl: registryFetch('1.0.2'), + commandRunner, + }) + + assert.equal(summary.checkOnly, true) + assert.equal(summary.results[0]?.status, 'not-installed') + assert.equal(summary.success, true) + assert.ok(!calls.includes('pi')) + }) + + it('keeps update plans immutable and does not execute check plans', async () => { + const commandRunner = { + run: async () => { + throw new Error('check mode must not execute') + }, + } + const plan = await planUpdates({ + harness: 'pi', + cwd: project, + check: true, + fetchImpl: registryFetch('1.0.2'), + commandRunner, + }) + const summary = await executeUpdatePlan(plan, { check: true, commandRunner }) + assert.equal(summary.checkOnly, true) + assert.equal(plan.items[0]?.steps.length, 0) + }) +}) diff --git a/packages/core/test/unit/update/antigravity-transaction.test.ts b/packages/core/test/unit/update/antigravity-transaction.test.ts new file mode 100644 index 0000000..3288196 --- /dev/null +++ b/packages/core/test/unit/update/antigravity-transaction.test.ts @@ -0,0 +1,70 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { executeAntigravityTransaction, validateStagedPlugin } from '../../../src/update/antigravity-transaction.js' +import type { UpdatePlanItem } from '../../../src/update/types.js' + +function agyItem (): UpdatePlanItem { + return { + installationId: 'antigravity:native:nsolid-plugin@github', + target: 'antigravity', + ownership: 'native-plugin', + installed: true, + source: { + kind: 'antigravity-git', + url: 'https://github.com/NodeSource/nsolid-plugin.git', + layout: { kind: 'shared', pluginRoot: '~/.gemini/config/plugins/nsolid-plugin', manifestPath: '~/.gemini/config/import_manifest.json' }, + }, + version: { current: undefined, latest: '1.0.1', status: 'update-available' }, + steps: [], + rollbackSteps: [], + requiresConfirmation: true, + } +} + +describe('Antigravity staged plugin validation', () => { + it('requires the staged bundle version to match the planned version', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-agy-validation-')) + try { + mkdirSync(path.join(root, 'skills', 'example'), { recursive: true }) + writeFileSync(path.join(root, 'plugin.json'), JSON.stringify({ name: 'nsolid-plugin' })) + writeFileSync(path.join(root, 'bundle.json'), JSON.stringify({ version: '1.0.1', skills: [{ name: 'example', path: 'skills/example' }] })) + writeFileSync(path.join(root, 'skills', 'example', 'SKILL.md'), '# example') + const manifest = path.join(root, 'import_manifest.json') + writeFileSync(manifest, JSON.stringify({ imports: [{ name: 'nsolid-plugin' }] })) + + assert.equal(validateStagedPlugin(root, manifest, '1.0.0'), false) + assert.equal(validateStagedPlugin(root, manifest, '1.0.1'), true) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('returns a structured backup failure when the plugin root parent directory is missing', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-agy-transaction-')) + const previousHome = process.env.HOME + const previousUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home + try { + // `~/.gemini/config/plugins` is deliberately absent: the sibling backup + // parent is missing, which used to escape as a rejected ENOENT promise. + const result = await executeAntigravityTransaction(agyItem(), { + run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }), + }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, false) + assert.equal(result.error?.code, 'ANTIGRAVITY_BACKUP_FAILED') + assert.equal(existsSync(path.join(home, '.gemini')), false) + } finally { + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile + rmSync(home, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/test/unit/update/claude-record.test.ts b/packages/core/test/unit/update/claude-record.test.ts new file mode 100644 index 0000000..6763bf7 --- /dev/null +++ b/packages/core/test/unit/update/claude-record.test.ts @@ -0,0 +1,11 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { readClaudePluginScope } from '../../../src/update/claude-record.js' + +describe('Claude plugin scope records', () => { + it('accepts equivalent scope aliases and rejects conflicting aliases', () => { + assert.equal(readClaudePluginScope({ scope: 'user', installationScope: 'user', metadata: { scope: 'user' } }), 'user') + assert.equal(readClaudePluginScope({ scope: 'user', installationScope: 'project' }), undefined) + assert.equal(readClaudePluginScope({ installationScope: 'local' }), 'local') + }) +}) diff --git a/packages/core/test/unit/update/cli-package-strategy.test.ts b/packages/core/test/unit/update/cli-package-strategy.test.ts new file mode 100644 index 0000000..49b5d98 --- /dev/null +++ b/packages/core/test/unit/update/cli-package-strategy.test.ts @@ -0,0 +1,25 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { cliPackageStrategy } from '../../../src/update/strategies/cli-package.js' + +describe('CLI package update strategy', () => { + it('uses the resolved version in unsupported-source manual commands', async () => { + const item = await cliPackageStrategy.plan({ + installationId: 'cli:global', + target: 'cli', + ownership: 'none', + installed: true, + source: { kind: 'unsupported', source: '/workspace/cli.ts', reason: 'unsupported-manager' }, + version: { current: '1.0.0', latest: '1.2.3', status: 'update-available' }, + }, { + options: {}, + commandRunner: { run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }) }, + }) + + assert.deepEqual(item.manualCommands, [ + 'npm install --global nsolid-plugin@1.2.3', + 'pnpm add --global nsolid-plugin@1.2.3', + 'npx -y nsolid-plugin@1.2.3 ', + ]) + }) +}) diff --git a/packages/core/test/unit/update/codex-transaction.test.ts b/packages/core/test/unit/update/codex-transaction.test.ts new file mode 100644 index 0000000..4bdd059 --- /dev/null +++ b/packages/core/test/unit/update/codex-transaction.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { writeTomlFileSync } from '../../../src/utils/config.js' +import { executeCodexTransaction, readCodexPayloadVersion } from '../../../src/update/codex-transaction.js' +import type { UpdatePlanItem } from '../../../src/update/types.js' + +let home: string +let previousHome: string | undefined +let previousUserProfile: string | undefined + +beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-codex-transaction-')) + previousHome = process.env.HOME + previousUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true }) + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile +}) + +function item (cachePath?: string): UpdatePlanItem { + return { + installationId: 'codex:native:nsolid-plugin@nodesource', + target: 'codex', + ownership: 'native-plugin', + installed: true, + source: { + kind: 'codex-marketplace', + pluginId: 'nsolid-plugin@nodesource', + marketplace: 'NodeSource/nsolid-plugin', + versionSource: { kind: 'git', repository: 'https://github.com/NodeSource/nsolid-plugin.git', manifestPath: 'bundle.json' }, + }, + version: { current: undefined, latest: '1.0.1', status: 'update-available' }, + metadata: { ...(cachePath ? { packageRoot: cachePath } : {}), trackedMcpConfigPath: path.join(home, '.codex', 'config.toml') }, + steps: [ + { kind: 'command', description: 'upgrade', command: { executable: 'codex', args: ['plugin', 'marketplace', 'upgrade', 'NodeSource/nsolid-plugin'], timeoutMs: 1000 } }, + { kind: 'command', description: 'remove', command: { executable: 'codex', args: ['plugin', 'remove', 'nsolid-plugin@nodesource'], timeoutMs: 1000 } }, + { kind: 'command', description: 'add', command: { executable: 'codex', args: ['plugin', 'add', 'nsolid-plugin@nodesource'], timeoutMs: 1000 } }, + { kind: 'validation', description: 'payload', checks: [] }, + ], + rollbackSteps: [], + requiresConfirmation: true, + } +} + +describe('Codex update transaction', () => { + it('validates the refreshed cached payload rather than a versionless registration', async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + mkdirSync(cachePath, { recursive: true }) + mkdirSync(path.dirname(path.join(home, '.codex', 'config.toml')), { recursive: true }) + writeTomlFileSync(path.join(home, '.codex', 'config.toml'), { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + + const result = await executeCodexTransaction(item(cachePath), { + run: async (command) => { + if (command.args.includes('add')) writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1', skills: [] })) + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, true) + assert.equal(readCodexPayloadVersion(cachePath, 'nsolid-plugin@nodesource'), '1.0.1') + assert.match(readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8'), /enabled = true/) + }) + + it('validates content in the exact version directory when real config has no payload path', async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nodesource', 'nsolid-plugin') + const oldPayload = path.join(cachePath, '1.0.0') + const newPayload = path.join(cachePath, '1.0.1') + const newBundle = JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1', skills: [] }) + mkdirSync(oldPayload, { recursive: true }) + mkdirSync(path.dirname(path.join(home, '.codex', 'config.toml')), { recursive: true }) + writeTomlFileSync(path.join(home, '.codex', 'config.toml'), { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) + writeFileSync(path.join(oldPayload, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + const candidate = item(cachePath) + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: createHash('sha256').update(newBundle).digest('hex'), + } + + const result = await executeCodexTransaction(candidate, { + run: async (command) => { + if (command.args.includes('add')) { + mkdirSync(newPayload, { recursive: true }) + writeFileSync(path.join(newPayload, 'bundle.json'), newBundle) + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, true) + }) + + it('snapshots only the selected plugin cache when metadata has no package root', async () => { + const cacheBase = path.join(home, '.codex', 'plugins', 'cache') + const selectedCache = path.join(cacheBase, 'NodeSource', 'nsolid-plugin') + const unrelatedCache = path.join(cacheBase, 'other-marketplace', 'other-plugin') + mkdirSync(selectedCache, { recursive: true }) + mkdirSync(unrelatedCache, { recursive: true }) + mkdirSync(path.dirname(path.join(home, '.codex', 'config.toml')), { recursive: true }) + writeTomlFileSync(path.join(home, '.codex', 'config.toml'), { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) + writeFileSync(path.join(selectedCache, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + writeFileSync(path.join(unrelatedCache, 'bundle.json'), JSON.stringify({ name: 'other-plugin', version: '2.0.0', skills: [] })) + + const result = await executeCodexTransaction(item(), { + run: async (command) => { + if (command.args.includes('add')) { + writeFileSync(path.join(selectedCache, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '0.9.0', skills: [] })) + writeFileSync(path.join(unrelatedCache, 'bundle.json'), JSON.stringify({ name: 'other-plugin', version: '9.9.9', skills: [] })) + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_VERSION_MISMATCH') + assert.equal(readFileSync(path.join(unrelatedCache, 'bundle.json'), 'utf8').includes('9.9.9'), true) + assert.equal(readFileSync(path.join(selectedCache, 'bundle.json'), 'utf8').includes('1.0.0'), true) + }) + + it('fails when Codex add does not recreate the exact registration', async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + mkdirSync(cachePath, { recursive: true }) + mkdirSync(path.dirname(path.join(home, '.codex', 'config.toml')), { recursive: true }) + writeTomlFileSync(path.join(home, '.codex', 'config.toml'), { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + + const result = await executeCodexTransaction(item(cachePath), { + run: async (command) => { + if (command.args.includes('add')) { + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1', skills: [] })) + writeTomlFileSync(path.join(home, '.codex', 'config.toml'), { plugins: {} }) + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_REGISTRATION_MISSING') + assert.match(readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8'), /nsolid-plugin@nodesource/) + assert.match(readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8'), /enabled = true/) + }) + + it('validates the payload selected by the recreated registration', async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + const oldPayload = path.join(cachePath, '1.0.0') + const latestPayload = path.join(cachePath, '1.0.1') + mkdirSync(oldPayload, { recursive: true }) + mkdirSync(latestPayload, { recursive: true }) + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(path.dirname(configPath), { recursive: true }) + writeTomlFileSync(configPath, { plugins: { 'nsolid-plugin@nodesource': { enabled: true, cachePath: oldPayload } } }) + writeFileSync(path.join(oldPayload, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + writeFileSync(path.join(latestPayload, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1', skills: [] })) + + const result = await executeCodexTransaction(item(cachePath), { + run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }), + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_VERSION_MISMATCH') + }) + + it('does not rewrite config TOML when preserved fields already match', async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + mkdirSync(cachePath, { recursive: true }) + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(configPath, [ + '# user comment must survive', + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true', + '', + ].join('\n')) + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + + const result = await executeCodexTransaction(item(cachePath), { + run: async (command) => { + if (command.args.includes('add')) writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1', skills: [] })) + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, true) + assert.match(readFileSync(configPath, 'utf8'), /# user comment must survive/) + }) + + it('preserves unrelated Codex TOML bytes while patching only engine-owned fields', async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nodesource', 'nsolid-plugin') + const oldPayload = path.join(cachePath, '1.0.0') + const newPayload = path.join(cachePath, '1.0.1') + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(oldPayload, { recursive: true }) + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(path.join(oldPayload, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + const original = [ + '# top-level comment', + '[unrelated]', + 'keep = "yes" # inline comment', + '', + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true # user-owned setting', + 'userField = "original"', + `installPath = ${JSON.stringify(oldPayload)} # engine-owned path`, + '', + '[plugins."other"]', + 'enabled = false', + '', + ].join('\r\n') + writeFileSync(configPath, original) + const expected = original.replace(JSON.stringify(oldPayload), JSON.stringify(newPayload)) + + const result = await executeCodexTransaction(item(cachePath), { + run: async (command) => { + if (command.args.includes('add')) { + mkdirSync(newPayload, { recursive: true }) + writeFileSync(path.join(newPayload, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1', skills: [] })) + writeFileSync(configPath, [ + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = false', + 'userField = "changed-by-codex"', + `installPath = ${JSON.stringify(newPayload)}`, + 'codexAdded = "must not survive"', + '', + '[unrelated]', + 'keep = "changed-by-codex"', + '', + ].join('\n')) + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, true) + assert.equal(readFileSync(configPath, 'utf8'), expected) + assert.equal(readFileSync(configPath, 'utf8').includes('codexAdded'), false) + assert.equal(readFileSync(configPath, 'utf8').includes('\r\n'), true) + }) + + it('rolls back exactly when an engine-owned TOML value is ambiguous', async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(cachePath, { recursive: true }) + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + const original = [ + '[plugins."nsolid-plugin@nodesource"]', + 'installPath = { root = "ambiguous" }', + 'enabled = true', + '', + ].join('\r\n') + writeFileSync(configPath, original) + + const result = await executeCodexTransaction(item(cachePath), { + run: async (command) => { + if (command.args.includes('add')) { + writeFileSync(configPath, [ + '[plugins."nsolid-plugin@nodesource"]', + 'installPath = "changed"', + 'enabled = false', + '', + ].join('\n')) + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(readFileSync(configPath, 'utf8'), original) + }) + + it('returns a structured backup failure when the config parent directory is missing', async () => { + // `~/.codex` is deliberately not created: the sibling backup parent is + // absent, which used to escape as a rejected ENOENT promise. + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + const result = await executeCodexTransaction(item(cachePath), { + run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }), + }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, false) + assert.equal(result.error?.code, 'CODEX_BACKUP_FAILED') + assert.equal(existsSync(path.join(home, '.codex')), false) + }) +}) diff --git a/packages/core/test/unit/update/command-runner.test.ts b/packages/core/test/unit/update/command-runner.test.ts new file mode 100644 index 0000000..dabf40a --- /dev/null +++ b/packages/core/test/unit/update/command-runner.test.ts @@ -0,0 +1,307 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { deriveShimEntrypoint, resolveExecutableIdentity, runCommand } from '../../../src/update/command-runner.js' + +describe('update command runner', () => { + it('preserves ENOENT as a structured missing-executable error', async () => { + const result = await runCommand({ + executable: 'nsolid-plugin-command-that-does-not-exist', + args: [], + timeoutMs: 1_000, + }) + + assert.equal(result.exitCode, null) + assert.equal(result.spawnErrorCode, 'ENOENT') + assert.equal(result.treeTerminated, true) + }) + + it('confirms descendant-tree termination before returning a timeout', async () => { + const result = await runCommand({ + executable: process.execPath, + args: ['-e', 'setInterval(() => {}, 10_000)'], + timeoutMs: 50, + }) + + assert.equal(result.timedOut, true) + assert.equal(result.treeTerminated, true) + }) + + it('derives a verified npm Windows shim through mixed-case Path and PATHEXT', { skip: process.platform !== 'win32' }, () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + const entrypoint = path.join(root, 'node_modules', 'npm', 'bin', 'npm-cli.js') + const shim = path.join(root, 'npm.CMD') + mkdirSync(path.dirname(entrypoint), { recursive: true }) + writeFileSync(entrypoint, '#!/usr/bin/env node\n') + writeFileSync(path.join(root, 'node_modules', 'npm', 'package.json'), JSON.stringify({ name: 'npm', bin: { npm: 'bin/npm-cli.js' } })) + writeFileSync(shim, '@ECHO off\r\n"node" "%~dp0\\node_modules\\npm\\bin\\npm-cli.js" %*\r\n') + + assert.deepEqual(resolveExecutableIdentity('npm', { PaTh: root, pathext: '.PS1;.CMD' }), { + kind: 'node', + executable: process.execPath, + entrypoint, + }) + }) + + it('rejects an unverified Windows command shim', { skip: process.platform !== 'win32' }, () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + writeFileSync(path.join(root, 'npm.cmd'), '@ECHO off\r\necho unsafe\r\n') + + assert.deepEqual(resolveExecutableIdentity('npm', { Path: root, PATHEXT: '.CMD' }), { + kind: 'unsupported', + reason: 'unverifiable-shim', + }) + }) + + it('derives the entrypoint only from the node invocation line', { skip: process.platform !== 'win32' }, () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + const realEntrypoint = path.join(root, 'node_modules', 'npm', 'bin', 'npm-cli.js') + const decoyEntrypoint = path.join(root, 'node_modules', 'decoy', 'dummy.js') + mkdirSync(path.dirname(realEntrypoint), { recursive: true }) + mkdirSync(path.dirname(decoyEntrypoint), { recursive: true }) + writeFileSync(realEntrypoint, '#!/usr/bin/env node\n') + writeFileSync(decoyEntrypoint, 'throw new Error("must not execute")\n') + writeFileSync(path.join(root, 'node_modules', 'npm', 'package.json'), JSON.stringify({ name: 'npm', bin: { npm: 'bin/npm-cli.js' } })) + writeFileSync(path.join(root, 'npm.cmd'), [ + '@ECHO off', + 'echo node_modules\\decoy\\dummy.js', + '"node" "%~dp0\\node_modules\\npm\\bin\\npm-cli.js" %*', + '', + ].join('\r\n')) + + const identity = resolveExecutableIdentity('npm', { Path: root, PATHEXT: '.CMD' }) + assert.equal(identity.kind, 'node') + if (identity.kind === 'node') assert.equal(identity.entrypoint, realEntrypoint) + }) + + it('derives the exact modern cmd-shim invocation template to a verified node identity (cross-platform)', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + const entrypoint = path.join(root, 'node_modules', 'npm', 'bin', 'npm-cli.js') + const shim = path.join(root, 'npm.cmd') + mkdirSync(path.dirname(entrypoint), { recursive: true }) + writeFileSync(entrypoint, '#!/usr/bin/env node\n') + writeFileSync(path.join(root, 'node_modules', 'npm', 'package.json'), JSON.stringify({ name: 'npm', bin: { npm: 'bin/npm-cli.js' } })) + writeFileSync(shim, [ + '@ECHO off', + 'GOTO start', + ':find_dp0', + 'SET dp0=%~dp0', + 'EXIT /b', + ':start', + 'SETLOCAL', + 'CALL :find_dp0', + '', + 'IF EXIST "%dp0%\\node.exe" (', + ' SET "_prog=%dp0%\\node.exe"', + ') ELSE (', + ' SET "_prog=node"', + ' SET PATHEXT=%PATHEXT:;.JS;=;%', + ')', + '', + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\npm\\bin\\npm-cli.js" %*', + '', + ].join('\r\n')) + try { + assert.deepEqual(resolveExecutableIdentity('npm', { Path: root, PATHEXT: '.cmd' }, 'win32'), { + kind: 'node', + executable: process.execPath, + entrypoint, + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects a modern shim whose entrypoint has no owning package manifest', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + const evil = path.join(root, 'node_modules', 'evil', 'dummy.js') + const shim = path.join(root, 'npm.cmd') + mkdirSync(path.dirname(evil), { recursive: true }) + writeFileSync(evil, '#!/usr/bin/env node\n') + writeFileSync(shim, 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\evil\\dummy.js" %*\r\n') + try { + assert.equal(deriveShimEntrypoint(shim, 'win32'), undefined) + assert.deepEqual(resolveExecutableIdentity('npm', { Path: root, PATHEXT: '.cmd' }, 'win32'), { + kind: 'unsupported', + reason: 'unverifiable-shim', + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('skips a decoy invocation line whose package does not own a matching bin', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + const realEntrypoint = path.join(root, 'node_modules', 'npm', 'bin', 'npm-cli.js') + const decoyEntrypoint = path.join(root, 'node_modules', 'evil', 'decoy.js') + const shim = path.join(root, 'npm.cmd') + mkdirSync(path.dirname(realEntrypoint), { recursive: true }) + mkdirSync(path.dirname(decoyEntrypoint), { recursive: true }) + writeFileSync(realEntrypoint, '#!/usr/bin/env node\n') + writeFileSync(decoyEntrypoint, 'throw new Error("must not execute")\n') + writeFileSync(path.join(root, 'node_modules', 'npm', 'package.json'), JSON.stringify({ name: 'npm', bin: { npm: 'bin/npm-cli.js' } })) + writeFileSync(path.join(root, 'node_modules', 'evil', 'package.json'), JSON.stringify({ name: 'evil', bin: { evil: 'decoy.js' } })) + writeFileSync(shim, [ + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\evil\\decoy.js" %*', + '"node" "%~dp0\\node_modules\\npm\\bin\\npm-cli.js" %*', + '', + ].join('\r\n')) + try { + assert.equal(deriveShimEntrypoint(shim, 'win32'), realEntrypoint) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('derives a scoped package shim (bin object) declared by the owning package (cross-platform)', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + const entrypoint = path.join(root, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js') + const shim = path.join(root, 'claude.cmd') + mkdirSync(path.dirname(entrypoint), { recursive: true }) + writeFileSync(entrypoint, '#!/usr/bin/env node\n') + writeFileSync(path.join(root, 'node_modules', '@anthropic-ai', 'claude-code', 'package.json'), JSON.stringify({ name: '@anthropic-ai/claude-code', bin: { claude: './cli.js' } })) + writeFileSync(shim, 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*\r\n') + try { + assert.deepEqual(resolveExecutableIdentity('claude', { Path: root, PATHEXT: '.cmd' }, 'win32'), { + kind: 'node', + executable: process.execPath, + entrypoint, + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('derives a renamed non-scoped bin owned by a differently named package (cross-platform)', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + const entrypoint = path.join(root, 'node_modules', 'foo', 'lib', 'bar.js') + const shim = path.join(root, 'bar.cmd') + mkdirSync(path.dirname(entrypoint), { recursive: true }) + writeFileSync(entrypoint, '#!/usr/bin/env node\n') + writeFileSync(path.join(root, 'node_modules', 'foo', 'package.json'), JSON.stringify({ name: 'foo', bin: { bar: './lib/bar.js' } })) + writeFileSync(shim, 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\foo\\lib\\bar.js" %*\r\n') + try { + assert.deepEqual(resolveExecutableIdentity('bar', { Path: root, PATHEXT: '.cmd' }, 'win32'), { + kind: 'node', + executable: process.execPath, + entrypoint, + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('derives a shim whose owning package declares bin as a string (cross-platform)', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + const entrypoint = path.join(root, 'node_modules', 'my-tool', 'bin', 'my-tool.js') + const shim = path.join(root, 'my-tool.cmd') + mkdirSync(path.dirname(entrypoint), { recursive: true }) + writeFileSync(entrypoint, '#!/usr/bin/env node\n') + writeFileSync(path.join(root, 'node_modules', 'my-tool', 'package.json'), JSON.stringify({ name: 'my-tool', bin: './bin/my-tool.js' })) + writeFileSync(shim, 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\my-tool\\bin\\my-tool.js" %*\r\n') + try { + assert.deepEqual(resolveExecutableIdentity('my-tool', { Path: root, PATHEXT: '.cmd' }, 'win32'), { + kind: 'node', + executable: process.execPath, + entrypoint, + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects a shim whose package bin value points to a different file (cross-platform)', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + const entrypoint = path.join(root, 'node_modules', 'npm', 'bin', 'npm-cli.js') + const shim = path.join(root, 'npm.cmd') + mkdirSync(path.dirname(entrypoint), { recursive: true }) + writeFileSync(entrypoint, '#!/usr/bin/env node\n') + writeFileSync(path.join(root, 'node_modules', 'npm', 'package.json'), JSON.stringify({ name: 'npm', bin: { npm: 'bin/other.js' } })) + writeFileSync(shim, 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\npm\\bin\\npm-cli.js" %*\r\n') + try { + assert.equal(deriveShimEntrypoint(shim, 'win32'), undefined) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects a shim whose owning package has no bin field (cross-platform)', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + const entrypoint = path.join(root, 'node_modules', 'npm', 'bin', 'npm-cli.js') + const shim = path.join(root, 'npm.cmd') + mkdirSync(path.dirname(entrypoint), { recursive: true }) + writeFileSync(entrypoint, '#!/usr/bin/env node\n') + writeFileSync(path.join(root, 'node_modules', 'npm', 'package.json'), JSON.stringify({ name: 'npm' })) + writeFileSync(shim, 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\npm\\bin\\npm-cli.js" %*\r\n') + try { + assert.equal(deriveShimEntrypoint(shim, 'win32'), undefined) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects a shim whose target traverses out of node_modules (cross-platform)', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + const shim = path.join(root, 'npm.cmd') + writeFileSync(shim, 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\..\\evil\\dummy.js" %*\r\n') + try { + assert.equal(deriveShimEntrypoint(shim, 'win32'), undefined) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects a shim whose verified entrypoint does not exist (cross-platform)', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-')) + const shim = path.join(root, 'npm.cmd') + mkdirSync(path.join(root, 'node_modules', 'npm'), { recursive: true }) + writeFileSync(path.join(root, 'node_modules', 'npm', 'package.json'), JSON.stringify({ name: 'npm', bin: { npm: 'bin/npm-cli.js' } })) + // The bin is declared and matches, but the entrypoint file is absent. + writeFileSync(shim, 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\npm\\bin\\npm-cli.js" %*\r\n') + try { + assert.equal(deriveShimEntrypoint(shim, 'win32'), undefined) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('reports identity drift when the planned native executable no longer exists', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-drift-')) + const dead = path.join(root, 'planned-native.exe') + writeFileSync(dead, '') + rmSync(dead) + try { + const result = await runCommand({ + executable: process.execPath, + executableIdentity: { kind: 'native', executable: dead }, + args: ['-e', 'process.exit(0)'], + timeoutMs: 1_000, + }) + assert.equal(result.spawnErrorCode, 'EXECUTABLE_IDENTITY_DRIFT') + assert.equal(result.exitCode, null) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('reports identity drift when the planned node entrypoint does not match the command', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-drift-')) + const alive = path.join(root, 'node_modules', 'npm', 'bin', 'npm-cli.js') + mkdirSync(path.dirname(alive), { recursive: true }) + writeFileSync(alive, '#!/usr/bin/env node\n') + try { + const result = await runCommand({ + executable: process.execPath, + executableIdentity: { kind: 'node', executable: process.execPath, entrypoint: path.join(root, 'planned-entry.js') }, + args: [alive], + timeoutMs: 1_000, + }) + assert.equal(result.spawnErrorCode, 'EXECUTABLE_IDENTITY_DRIFT') + assert.equal(result.exitCode, null) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/test/unit/update/coordinator.test.ts b/packages/core/test/unit/update/coordinator.test.ts new file mode 100644 index 0000000..17a608f --- /dev/null +++ b/packages/core/test/unit/update/coordinator.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { executeUpdatePlan, planUpdates, update } from '../../../src/update/coordinator.js' +import { fallbackJournalPath } from '../../../src/update/fallback-journal.js' +import { getTrackingFilePath } from '../../../src/utils/path.js' +import type { UpdatePlanItem } from '../../../src/update/types.js' + +let home: string +let previousHome: string | undefined +let previousUserProfile: string | undefined + +beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-coordinator-')) + previousHome = process.env.HOME + previousUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true }) + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile +}) + +function writeInvalidJournal (): void { + const trackingPath = getTrackingFilePath() + mkdirSync(path.dirname(trackingPath), { recursive: true }) + writeFileSync(fallbackJournalPath(trackingPath), '{ invalid journal') +} + +function mutableCliItem (): UpdatePlanItem { + return { + installationId: 'cli:global', + target: 'cli', + ownership: 'global-package', + installed: true, + source: { kind: 'global-package', packageManager: 'npm', packageName: 'nsolid-plugin' }, + version: { current: '1.0.0', latest: '1.0.1', status: 'update-available' }, + steps: [{ + kind: 'command', + description: 'update', + command: { executable: process.execPath, args: [], timeoutMs: 1000 }, + }], + rollbackSteps: [], + requiresConfirmation: true, + } +} + +function artifact (packageName: 'nsolid-plugin' | 'nsolid-pi-plugin' = 'nsolid-plugin') { + const directory = mkdtempSync(path.join(home, 'artifact-')) + const bytes = Buffer.from('verified artifact') + const tarballPath = path.join(directory, 'package.tgz') + writeFileSync(tarballPath, bytes) + const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}` + return { + kind: 'npm' as const, + packageName, + version: '1.0.1', + registry: 'https://registry.example', + tarball: 'https://registry.example/package.tgz', + integrity, + tarballPath, + tempDirectory: directory, + } +} + +describe('update coordinator recovery gate', () => { + it('returns only the recovery item before inventory when recovery is unresolved', async () => { + writeInvalidJournal() + let fetchCalls = 0 + let runnerCalls = 0 + + const plan = await planUpdates({ + all: true, + check: true, + fetchImpl: async () => { + fetchCalls++ + return new Response('{}', { status: 200 }) + }, + commandRunner: { + run: async () => { + runnerCalls++ + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }, + }) + + assert.equal(plan.items.length, 1) + assert.equal(plan.items[0]?.installationId, 'fallback:recovery') + assert.equal(plan.items[0]?.planningError?.code, 'FALLBACK_RECOVERY_PENDING') + assert.equal(fetchCalls, 0) + assert.equal(runnerCalls, 0) + }) + + it('does not execute mutable targets while recovery remains unresolved', async () => { + writeInvalidJournal() + let runnerCalls = 0 + const summary = await update({ + all: true, + yes: true, + commandRunner: { + run: async () => { + runnerCalls++ + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }, + fetchImpl: async () => new Response('{}', { status: 200 }), + }) + + assert.equal(summary.results.length, 1) + assert.equal(summary.results[0]?.error?.code, 'FALLBACK_RECOVERY_FAILED') + assert.equal(summary.results[0]?.status, 'failed') + assert.equal(runnerCalls, 0) + }) + + it('defends the recovery gate for externally constructed plans', async () => { + let runnerCalls = 0 + const recovery: UpdatePlanItem = { + installationId: 'fallback:recovery', + target: 'opencode', + ownership: 'fallback', + installed: true, + source: { kind: 'fallback' }, + version: { status: 'unknown' }, + steps: [], + rollbackSteps: [], + planningError: { code: 'FALLBACK_RECOVERY_FAILED', message: 'recovery failed' }, + requiresConfirmation: false, + } + + const summary = await executeUpdatePlan({ checkOnly: false, items: [recovery, mutableCliItem()] }, { + yes: true, + commandRunner: { + run: async () => { + runnerCalls++ + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }, + }) + + assert.equal(runnerCalls, 0) + assert.equal(summary.results[1]?.status, 'failed') + assert.equal(summary.results[1]?.error?.code, 'FALLBACK_RECOVERY_FAILED') + }) + + it('preserves fallback artifacts and transaction state when tree termination is unconfirmed', async () => { + const transactionDirectory = mkdtempSync(path.join(home, 'transaction-')) + const manifestPath = path.join(transactionDirectory, 'transaction.json') + writeFileSync(manifestPath, '{}') + const plannedArtifact = artifact() + let workspace = '' + const item: UpdatePlanItem = { + installationId: 'opencode:fallback', + target: 'opencode', + ownership: 'fallback', + installed: true, + source: { kind: 'fallback', executor: 'npm-exec' }, + version: { current: '1.0.0', latest: '1.0.1', status: 'update-available' }, + artifact: plannedArtifact, + steps: [{ + kind: 'command', + description: 'refresh', + command: { executable: process.execPath, args: ['--transaction', manifestPath], timeoutMs: 1000 }, + }], + rollbackSteps: [], + requiresConfirmation: true, + } + + const summary = await executeUpdatePlan({ checkOnly: false, items: [item] }, { + yes: true, + commandRunner: { + run: async (command) => { + workspace = command.cwd ?? '' + return { exitCode: null, stdout: '', stderr: '', timedOut: true, treeTerminated: false } + }, + }, + }) + + assert.equal(summary.results[0]?.error?.code, 'FALLBACK_TREE_TERMINATION_UNCONFIRMED') + assert.equal(existsSync(plannedArtifact.tempDirectory), true) + assert.equal(existsSync(manifestPath), true) + assert.equal(existsSync(workspace), true) + rmSync(plannedArtifact.tempDirectory, { recursive: true, force: true }) + rmSync(transactionDirectory, { recursive: true, force: true }) + rmSync(workspace, { recursive: true, force: true }) + }) + + it('cleans fallback artifacts and transaction state after a confirmed failure', async () => { + const transactionDirectory = mkdtempSync(path.join(home, 'transaction-')) + const manifestPath = path.join(transactionDirectory, 'transaction.json') + writeFileSync(manifestPath, '{}') + const plannedArtifact = artifact() + const item: UpdatePlanItem = { + installationId: 'opencode:fallback', + target: 'opencode', + ownership: 'fallback', + installed: true, + source: { kind: 'fallback', executor: 'npm-exec' }, + version: { current: '1.0.0', latest: '1.0.1', status: 'update-available' }, + artifact: plannedArtifact, + steps: [{ kind: 'command', description: 'refresh', command: { executable: process.execPath, args: ['--transaction', manifestPath], timeoutMs: 1000 } }], + rollbackSteps: [], + requiresConfirmation: true, + } + + const summary = await executeUpdatePlan({ checkOnly: false, items: [item] }, { + yes: true, + commandRunner: { run: async () => ({ exitCode: 1, stdout: '', stderr: '', timedOut: false, treeTerminated: true }) }, + }) + + assert.equal(summary.results[0]?.status, 'failed') + assert.equal(existsSync(plannedArtifact.tempDirectory), false) + assert.equal(existsSync(transactionDirectory), false) + }) + + it('preserves the CLI artifact when package-manager tree termination is unconfirmed', async () => { + const plannedArtifact = artifact() + const item: UpdatePlanItem = { + ...mutableCliItem(), + artifact: plannedArtifact, + metadata: { packagePath: path.join(home, 'global', 'nsolid-plugin') }, + steps: [{ kind: 'command', description: 'update', command: { executable: process.execPath, args: [], timeoutMs: 1000 } }], + } + + const summary = await executeUpdatePlan({ checkOnly: false, items: [item] }, { + yes: true, + commandRunner: { run: async () => ({ exitCode: null, stdout: '', stderr: '', timedOut: true, treeTerminated: false }) }, + }) + + assert.equal(summary.results[0]?.error?.code, 'CLI_TREE_TERMINATION_UNCONFIRMED') + assert.equal(existsSync(plannedArtifact.tempDirectory), true) + rmSync(plannedArtifact.tempDirectory, { recursive: true, force: true }) + }) +}) diff --git a/packages/core/test/unit/update/fallback-journal.test.ts b/packages/core/test/unit/update/fallback-journal.test.ts new file mode 100644 index 0000000..fb33ad1 --- /dev/null +++ b/packages/core/test/unit/update/fallback-journal.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { beginFallbackJournal, captureFallbackJournalState, commitFallbackJournal, restoreFallbackJournal, trackingDigest } from '../../../src/update/fallback-journal.js' +import { getHarnessSkillsPath } from '../../../src/skills/skill-linker.js' +import { getTrackingFilePath } from '../../../src/utils/path.js' +import type { FallbackTransactionIdentity } from '../../../src/update/types.js' + +let home: string +let previousHome: string | undefined +let previousUserProfile: string | undefined + +beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-journal-')) + previousHome = process.env.HOME + previousUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true }) + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile +}) + +describe('fallback journal ownership validation', () => { + it('refuses rollback paths that are not owned by the snapshotted tracking record', async () => { + const trackingPath = getTrackingFilePath() + const skillPath = path.join(home, '.agents', 'skills', 'tracked') + mkdirSync(skillPath, { recursive: true }) + mkdirSync(path.dirname(trackingPath), { recursive: true }) + writeFileSync(trackingPath, JSON.stringify({ + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + skills: [{ name: 'tracked', path: skillPath, paths: { claude: skillPath }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [], + })) + const manifest: FallbackTransactionIdentity = { + installationId: 'claude:fallback', + harness: 'claude', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + ownedSkillPaths: [skillPath], + ownedLinkPaths: [path.join(getHarnessSkillsPath('claude'), 'tracked')], + ownedMcpFields: [], + } + const { journal } = await beginFallbackJournal(manifest) + const victim = path.join(home, 'user-owned.txt') + writeFileSync(victim, 'keep') + const malicious = { + ...journal, + manifest: { ...journal.manifest, ownedSkillPaths: [...journal.manifest.ownedSkillPaths, victim] }, + entries: [...journal.entries, { path: victim, backup: path.join(journal.snapshotDirectory, 'attacker'), existed: false }], + } + + assert.equal(await restoreFallbackJournal(malicious), false) + assert.equal(readFileSync(victim, 'utf8'), 'keep') + }) + + it('restores the snapshotted bytes of owned state after a mutation', async () => { + const { trackingPath, skillPath, linkPath, manifest, trackingJson } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + writeFileSync(path.join(skillPath, 'SKILL.md'), '# mutated\n') + writeFileSync(linkPath, 'mutated\n') + writeFileSync(trackingPath, JSON.stringify({ ...JSON.parse(trackingJson), installedAt: 'mutated' })) + journal = await captureFallbackJournalState(journal) + + assert.equal(await restoreFallbackJournal(journal), true) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(readFileSync(linkPath, 'utf8'), 'link\n') + assert.equal(readFileSync(trackingPath, 'utf8'), trackingJson) + assert.equal(existsSync(journal.journalPath), false) + assert.equal(existsSync(journal.snapshotDirectory), false) + }) + + it('refuses to overwrite state that changed after the authorized mutation snapshot', async () => { + const { linkPath, manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + writeFileSync(linkPath, 'child mutation\n') + journal = await captureFallbackJournalState(journal) + writeFileSync(linkPath, 'concurrent user edit\n') + + assert.equal(await restoreFallbackJournal(journal), false) + assert.equal(readFileSync(linkPath, 'utf8'), 'concurrent user edit\n') + assert.equal(existsSync(journal.journalPath), true) + assert.equal(existsSync(journal.snapshotDirectory), true) + }) + + it('rejects a nested user-owned link path whose basename matches the expected link', async () => { + const { manifest } = setupValidFixture() + const expectedLink = path.join(getHarnessSkillsPath('claude'), 'tracked') + const nested = path.join(getHarnessSkillsPath('claude'), 'user-owned', 'tracked') + mkdirSync(path.dirname(nested), { recursive: true }) + writeFileSync(nested, 'keep') + const { journal } = await beginFallbackJournal(manifest) + const malicious = { + ...journal, + manifest: { ...journal.manifest, ownedLinkPaths: [nested] }, + entries: journal.entries.map((entry) => path.resolve(entry.path) === path.resolve(expectedLink) + ? { path: nested, backup: path.join(journal.snapshotDirectory, 'attacker'), existed: false } + : entry), + } + + assert.equal(await restoreFallbackJournal(malicious), false) + assert.equal(readFileSync(nested, 'utf8'), 'keep') + }) + + it('commits a valid journal and removes its journal and snapshot artifacts', async () => { + const { manifest } = setupValidFixture() + const { journal } = await beginFallbackJournal(manifest) + + await commitFallbackJournal(journal) + assert.equal(existsSync(journal.journalPath), false) + assert.equal(existsSync(journal.snapshotDirectory), false) + }) +}) + +function setupValidFixture (): { trackingPath: string; skillPath: string; linkPath: string; manifest: FallbackTransactionIdentity; trackingJson: string } { + const trackingPath = getTrackingFilePath() + const skillPath = path.join(home, '.agents', 'skills', 'tracked') + const linkPath = path.join(getHarnessSkillsPath('claude'), 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), '# tracked\n') + mkdirSync(path.dirname(trackingPath), { recursive: true }) + const trackingJson = JSON.stringify({ + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + skills: [{ name: 'tracked', path: skillPath, paths: { claude: skillPath }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [], + }) + writeFileSync(trackingPath, trackingJson) + mkdirSync(path.dirname(linkPath), { recursive: true }) + writeFileSync(linkPath, 'link\n') + const manifest: FallbackTransactionIdentity = { + installationId: 'claude:fallback', + harness: 'claude', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + ownedSkillPaths: [skillPath], + ownedLinkPaths: [linkPath], + ownedMcpFields: [], + } + return { trackingPath, skillPath, linkPath, manifest, trackingJson } +} diff --git a/packages/core/test/unit/update/fallback-strategy.test.ts b/packages/core/test/unit/update/fallback-strategy.test.ts new file mode 100644 index 0000000..a33cac8 --- /dev/null +++ b/packages/core/test/unit/update/fallback-strategy.test.ts @@ -0,0 +1,93 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fallbackStrategy } from '../../../src/update/strategies/fallback.js' +import type { UpdatePlanItem } from '../../../src/update/types.js' + +function item (): UpdatePlanItem { + return { + installationId: 'opencode:fallback', + target: 'opencode', + ownership: 'fallback', + installed: true, + source: { kind: 'fallback', bundleVersion: '1.0.0', executor: 'npm-exec' }, + version: { current: '1.0.0', latest: '1.0.1', status: 'update-available' }, + steps: [{ kind: 'command', description: 'refresh', command: { executable: 'npm', args: ['exec'], cwd: tmpdir(), timeoutMs: 1000 } }], + rollbackSteps: [], + requiresConfirmation: true, + } +} + +describe('fallback update strategy', () => { + it('uses a private temporary cwd and propagates the child rollback result', async () => { + let observedCwd = '' + const result = await fallbackStrategy.execute(item(), { + options: {}, + commandRunner: { + run: async (command) => { + observedCwd = command.cwd ?? '' + assert.notEqual(observedCwd, tmpdir()) + // POSIX exposes the restrictive mode bits that the implementation + // applies. Windows filesystems do not expose chmod(0700) through + // stat(), so verify the private temp location there instead. + if (process.platform !== 'win32') { + assert.equal(statSync(observedCwd).mode & 0o777, 0o700) + } else { + assert.equal(path.dirname(observedCwd), path.resolve(tmpdir())) + } + return { exitCode: 1, stdout: '', stderr: 'refresh failed\nrollback: succeeded\n', timedOut: false } + }, + }, + }) + + assert.equal(result.status, 'failed') + assert.deepEqual(result.rollback, { attempted: true, succeeded: true }) + assert.equal(existsSync(path.resolve(observedCwd)), false) + }) + + it('reports a missing package executor as unsupported instead of failed planning', async () => { + const previousPath = process.env.PATH + const previousHome = process.env.HOME + const previousUserProfile = process.env.USERPROFILE + const home = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-fallback-plan-')) + const skillPath = path.join(home, '.agents', 'skills', 'tracked') + const trackingPath = path.join(home, '.agents', '.nodesource-installed.json') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(trackingPath, JSON.stringify({ + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + skills: [{ name: 'tracked', path: skillPath, paths: { opencode: skillPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }], + mcpServers: [], + })) + process.env.PATH = '' + process.env.HOME = home + process.env.USERPROFILE = home + let manifestDirectory: string | undefined + try { + const planned = await fallbackStrategy.plan({ + ...item(), + source: { kind: 'fallback', bundleVersion: '1.0.0' }, + metadata: { trackedSkills: [{ name: 'tracked', path: skillPath }] }, + }, { options: {}, commandRunner: { run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }) } }) + assert.equal(planned.planningError, undefined) + assert.equal(planned.source.kind, 'unsupported') + assert.equal(planned.manualCommands?.length, 2) + assert.ok(planned.manualCommands?.every((command) => command.includes(' --transaction ') && !command.includes(' --harness '))) + const manifestPath = planned.manualCommands?.[0]?.split(' --transaction ')[1] + assert.ok(manifestPath && existsSync(manifestPath)) + manifestDirectory = manifestPath ? path.dirname(manifestPath) : undefined + } finally { + if (previousPath === undefined) delete process.env.PATH + else process.env.PATH = previousPath + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile + if (manifestDirectory) rmSync(manifestDirectory, { recursive: true, force: true }) + rmSync(home, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/test/unit/update/fallback-transaction.test.ts b/packages/core/test/unit/update/fallback-transaction.test.ts new file mode 100644 index 0000000..e8c121c --- /dev/null +++ b/packages/core/test/unit/update/fallback-transaction.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { refreshOwnedInstallation } from '../../../src/update/fallback-transaction.js' +import { readTrackingFile } from '../../../src/skills/skill-tracker.js' + +let home: string +let previousHome: string | undefined +let previousUserProfile: string | undefined + +beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-transaction-')) + previousHome = process.env.HOME + previousUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true }) + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile +}) + +function writeJson (filePath: string, value: unknown): void { + mkdirSync(path.dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(value, null, 2)) +} + +describe('fallback refresh transaction', () => { + it('replaces owned directories, reconciles shared ownership, and recreates harness links', async () => { + const sharedDir = path.join(home, '.agents', 'skills') + const retainedDir = path.join(sharedDir, 'retained') + const removedDir = path.join(sharedDir, 'removed') + mkdirSync(retainedDir, { recursive: true }) + mkdirSync(removedDir, { recursive: true }) + writeFileSync(path.join(retainedDir, 'SKILL.md'), 'old retained') + writeFileSync(path.join(retainedDir, 'obsolete.txt'), 'must disappear') + writeFileSync(path.join(removedDir, 'SKILL.md'), 'shared with Codex') + + const claudeSkills = path.join(home, '.claude', 'skills') + mkdirSync(claudeSkills, { recursive: true }) + symlinkSync(removedDir, path.join(claudeSkills, 'removed'), 'dir') + symlinkSync(retainedDir, path.join(claudeSkills, 'retained'), 'dir') + + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + const retainedSource = path.join(sourceRoot, 'skills', 'retained') + const addedSource = path.join(sourceRoot, 'skills', 'added') + mkdirSync(retainedSource, { recursive: true }) + mkdirSync(addedSource, { recursive: true }) + writeFileSync(path.join(retainedSource, 'SKILL.md'), 'new retained') + writeFileSync(path.join(addedSource, 'SKILL.md'), 'new skill') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [ + { name: 'retained', path: 'skills/retained', description: 'retained' }, + { name: 'added', path: 'skills/added', description: 'added' }, + ], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + saasToken: 'saas', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://example.com/mcp', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + skills: [ + { name: 'retained', path: retainedDir, paths: { claude: retainedDir }, installedAt: new Date().toISOString(), harnesses: ['claude'] }, + { name: 'removed', path: removedDir, paths: { claude: removedDir, codex: removedDir }, installedAt: new Date().toISOString(), harnesses: ['claude', 'codex'] }, + ], + mcpServers: [], + }) + + try { + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource: sourceRoot }) + assert.equal(result.success, true) + assert.equal(readFileSync(path.join(retainedDir, 'SKILL.md'), 'utf8'), 'new retained') + assert.equal(existsSync(path.join(retainedDir, 'obsolete.txt')), false) + assert.equal(existsSync(removedDir), true) + assert.equal(existsSync(path.join(claudeSkills, 'removed')), false) + assert.equal(existsSync(path.join(claudeSkills, 'retained')), true) + assert.equal(existsSync(path.join(claudeSkills, 'added')), true) + + const tracking = await readTrackingFile() + const removed = tracking?.skills.find((entry) => entry.name === 'removed') + assert.deepEqual(removed?.harnesses, ['codex']) + assert.equal(tracking?.bundleVersions?.claude, '1.0.1') + } finally { + rmSync(sourceRoot, { recursive: true, force: true }) + } + }) + + it('rejects a new harness link when its destination is untracked', async () => { + const sharedDir = path.join(home, '.agents', 'skills') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + const trackedSource = path.join(sourceRoot, 'skills', 'tracked') + const addedSource = path.join(sourceRoot, 'skills', 'added') + mkdirSync(trackedSource, { recursive: true }) + mkdirSync(addedSource, { recursive: true }) + writeFileSync(path.join(trackedSource, 'SKILL.md'), 'tracked') + writeFileSync(path.join(addedSource, 'SKILL.md'), 'added') + mkdirSync(path.join(sharedDir, 'tracked'), { recursive: true }) + writeFileSync(path.join(sharedDir, 'tracked', 'SKILL.md'), 'old tracked') + const harnessDir = path.join(home, '.claude', 'skills') + mkdirSync(path.join(harnessDir, 'added'), { recursive: true }) + writeFileSync(path.join(harnessDir, 'added', 'user-owned.txt'), 'keep me') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [ + { name: 'tracked', path: 'skills/tracked', description: 'tracked' }, + { name: 'added', path: 'skills/added', description: 'added' }, + ], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + bundleVersions: { claude: '1.0.0' }, + skills: [{ name: 'tracked', path: path.join(sharedDir, 'tracked'), paths: { claude: path.join(sharedDir, 'tracked') }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [], + }) + + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'UNTRACKED_DESTINATION') + assert.equal(existsSync(path.join(harnessDir, 'added', 'user-owned.txt')), true) + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('does not roll back or delete owned state when backup creation fails', async () => { + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + const skillSource = path.join(sourceRoot, 'skills', 'tracked') + mkdirSync(skillSource, { recursive: true }) + writeFileSync(path.join(skillSource, 'SKILL.md'), 'new') + const longPath = path.join(home, ...Array.from({ length: 4 }, () => 'a'.repeat(70)), 'tracked') + mkdirSync(path.dirname(longPath), { recursive: true }) + writeFileSync(longPath, 'original') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersion: '1.0.0', + skills: [{ name: 'tracked', path: longPath, paths: { opencode: longPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }], + mcpServers: [], + }) + + const result = await refreshOwnedInstallation({ harness: 'opencode', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'FALLBACK_BACKUP_FAILED') + assert.equal(result.rollbackAttempted, false) + assert.equal(readFileSync(longPath, 'utf8'), 'original') + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('does not advance fallback evidence when MCP reconciliation is skipped', async () => { + const sharedDir = path.join(home, '.agents', 'skills') + const skillPath = path.join(sharedDir, 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'new-server', url: 'https://example.com/mcp', headers: {} }], + }) + const configPath = path.join(home, '.claude.json') + writeJson(configPath, { mcpServers: { 'old-server': { type: 'http', url: 'https://old.example/mcp' } } }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + bundleVersion: '1.0.0', + bundleVersions: { claude: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { claude: skillPath }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [{ name: 'old-server', configPath, harness: 'claude', configuredAt: new Date().toISOString() }], + }) + + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'MCP_RECONCILIATION_REQUIRED') + const tracking = await readTrackingFile() + assert.equal(tracking?.bundleVersions?.claude, '1.0.0') + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'old') + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('repoints the legacy path when the referenced harness drops a shared skill', async () => { + const sharedDir = path.join(home, '.agents', 'skills') + const claudeDroppedPath = path.join(sharedDir, 'dropped') + const codexRemainingPath = path.join(home, 'codex-owned', 'dropped') + const retainedPath = path.join(sharedDir, 'retained') + for (const skillPath of [claudeDroppedPath, codexRemainingPath, retainedPath]) { + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + } + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + mkdirSync(path.join(sourceRoot, 'skills', 'retained'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'retained', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'retained', path: 'skills/retained', description: 'retained' }], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + saasToken: 'saas', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://example.com/mcp', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + bundleVersions: { claude: '1.0.0', codex: '1.0.0' }, + skills: [ + { name: 'dropped', path: claudeDroppedPath, paths: { claude: claudeDroppedPath, codex: codexRemainingPath }, installedAt: new Date().toISOString(), harnesses: ['claude', 'codex'] }, + { name: 'retained', path: retainedPath, paths: { claude: retainedPath }, installedAt: new Date().toISOString(), harnesses: ['claude'] }, + ], + mcpServers: [], + }) + + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, true, JSON.stringify(result)) + const tracking = await readTrackingFile() + const dropped = tracking?.skills.find((entry) => entry.name === 'dropped') + assert.equal(dropped?.path, codexRemainingPath) + assert.deepEqual(dropped?.harnesses, ['codex']) + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('rejects a bundle whose version does not match its package manifest', async () => { + const sharedDir = path.join(home, '.agents', 'skills') + const skillPath = path.join(sharedDir, 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + writeJson(path.join(sourceRoot, 'package.json'), { name: 'nsolid-plugin', version: '1.0.2' }) + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersions: { opencode: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { opencode: skillPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }], + mcpServers: [], + }) + + const result = await refreshOwnedInstallation({ harness: 'opencode', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'FALLBACK_BUNDLE_VERSION_MISMATCH') + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'old') + rmSync(sourceRoot, { recursive: true, force: true }) + }) +}) diff --git a/packages/core/test/unit/update/integrity.test.ts b/packages/core/test/unit/update/integrity.test.ts new file mode 100644 index 0000000..4a6be6f --- /dev/null +++ b/packages/core/test/unit/update/integrity.test.ts @@ -0,0 +1,25 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { bytesMatchIntegrity, parseIntegrity } from '../../../src/update/integrity.js' + +describe('npm artifact integrity', () => { + it('accepts unpadded base64url SRI and canonicalizes its padding', () => { + const bytes = new TextEncoder().encode('verified artifact bytes') + const canonical = createHash('sha512').update(bytes).digest('base64') + const base64url = canonical + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + const integrity = `sha512-${base64url}` + + assert.equal(bytesMatchIntegrity(bytes, integrity), true) + assert.equal(parseIntegrity(integrity)?.digest, canonical) + }) + + it('rejects malformed or mismatched integrity values', () => { + const bytes = new TextEncoder().encode('artifact') + assert.equal(bytesMatchIntegrity(bytes, 'md5-invalid'), false) + assert.equal(bytesMatchIntegrity(bytes, 'sha512-d3Jvbmc='), false) + }) +}) diff --git a/packages/core/test/unit/update/inventory.test.ts b/packages/core/test/unit/update/inventory.test.ts new file mode 100644 index 0000000..01fd211 --- /dev/null +++ b/packages/core/test/unit/update/inventory.test.ts @@ -0,0 +1,389 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { detectInstallations } from '../../../src/update/inventory.js' +import { checkUpdates, planUpdates, update } from '../../../src/update/coordinator.js' + +let home: string +let previousHome: string | undefined +let previousUserProfile: string | undefined +let previousCodexConfigPath: string | undefined + +beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-inventory-')) + previousHome = process.env.HOME + previousUserProfile = process.env.USERPROFILE + previousCodexConfigPath = process.env.CODEX_CONFIG_PATH + process.env.HOME = home + process.env.USERPROFILE = home + delete process.env.CODEX_CONFIG_PATH +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true }) + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile + if (previousCodexConfigPath === undefined) delete process.env.CODEX_CONFIG_PATH + else process.env.CODEX_CONFIG_PATH = previousCodexConfigPath +}) + +function writeJson (filePath: string, value: unknown): void { + mkdirSync(path.dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(value, null, 2)) +} + +function packageRoot (root: string, version: string): string { + writeJson(path.join(root, 'package.json'), { name: 'nsolid-pi-plugin', version }) + return root +} + +function runner () { + return { run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }) } +} + +function registryFetch (version: string): typeof fetch { + return async () => new Response(JSON.stringify({ + 'dist-tags': { latest: version }, + versions: { + [version]: { + name: 'nsolid-pi-plugin', + version, + dist: { tarball: `https://registry.example/nsolid-pi-plugin-${version}.tgz`, integrity: 'sha512-dGVzdA==' }, + }, + }, + }), { status: 200 }) +} + +describe('update installation inventory', () => { + it('evaluates user and project Pi caches instead of selecting the first valid one', async () => { + const project = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-pi-project-')) + try { + writeJson(path.join(home, '.pi', 'agent', 'settings.json'), { packages: ['npm:nsolid-pi-plugin'] }) + writeJson(path.join(project, '.pi', 'settings.json'), { packages: ['npm:nsolid-pi-plugin'] }) + packageRoot(path.join(home, '.pi', 'agent', 'npm', 'node_modules', 'nsolid-pi-plugin'), '1.0.2') + packageRoot(path.join(project, '.pi', 'npm', 'node_modules', 'nsolid-pi-plugin'), '1.0.0') + + const detected = await detectInstallations({ includeCli: false, cwd: project, commandRunner: runner() }) + const pi = detected.find((installation) => installation.target === 'pi') + assert.equal(pi?.version.current, '1.0.0') + assert.deepEqual(pi?.version.currentVersions, ['1.0.2', '1.0.0']) + + const plan = await planUpdates({ + harness: 'pi', + check: true, + cwd: project, + fetchImpl: registryFetch('1.0.2'), + commandRunner: runner(), + }) + assert.equal(plan.items[0]?.version.status, 'update-available') + } finally { + rmSync(project, { recursive: true, force: true }) + } + }) + + it('keeps a canonical Pi scope updateable when the other scope is non-canonical', async () => { + const project = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-pi-project-')) + try { + const userSettings = path.join(home, '.pi', 'agent', 'settings.json') + const projectSettings = path.join(project, '.pi', 'settings.json') + writeJson(userSettings, { packages: ['npm:nsolid-pi-plugin'] }) + writeJson(projectSettings, { packages: ['npm:nsolid-pi-plugin@1.0.0'] }) + packageRoot(path.join(home, '.pi', 'agent', 'npm', 'node_modules', 'nsolid-pi-plugin'), '1.0.0') + + const detected = await detectInstallations({ includeCli: false, cwd: project, commandRunner: runner() }) + const pi = detected.find((installation) => installation.target === 'pi') + + assert.equal(pi?.installationId, 'pi:package:user') + assert.equal(pi?.source.kind, 'pi-package') + if (pi?.source.kind === 'pi-package') assert.deepEqual(pi.source.scopes, ['user']) + assert.deepEqual(pi?.metadata?.settingsPaths, [userSettings]) + + writeJson(userSettings, { packages: ['npm:nsolid-pi-plugin@1.0.0'] }) + writeJson(projectSettings, { packages: ['npm:nsolid-pi-plugin'] }) + packageRoot(path.join(project, '.pi', 'npm', 'node_modules', 'nsolid-pi-plugin'), '1.0.0') + + const inverse = await detectInstallations({ includeCli: false, cwd: project, commandRunner: runner() }) + const inversePi = inverse.find((installation) => installation.target === 'pi') + + assert.equal(inversePi?.installationId, 'pi:package:project') + assert.equal(inversePi?.source.kind, 'pi-package') + if (inversePi?.source.kind === 'pi-package') assert.deepEqual(inversePi.source.scopes, ['project']) + assert.deepEqual(inversePi?.metadata?.settingsPaths, [projectSettings]) + } finally { + rmSync(project, { recursive: true, force: true }) + } + }) + + it('does not infer a Pi source from a leftover package cache', async () => { + const root = path.join(home, '.pi', 'agent', 'npm', 'node_modules', 'nsolid-pi-plugin') + packageRoot(root, '1.0.0') + assert.equal(existsSync(root), true) + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + assert.equal(detected.some((installation) => installation.target === 'pi'), false) + }) + + it('ignores unrelated Pi npm package names', async () => { + writeJson(path.join(home, '.pi', 'agent', 'settings.json'), { + packages: ['npm:nsolid-pi-plugin-helper', { source: 'npm:another-pi-plugin' }], + }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + assert.equal(detected.some((installation) => installation.target === 'pi'), false) + }) + + it('does not accept a different package name as Pi cache version evidence', async () => { + writeJson(path.join(home, '.pi', 'agent', 'settings.json'), { packages: ['npm:nsolid-pi-plugin'] }) + const root = path.join(home, '.pi', 'agent', 'npm', 'node_modules', 'nsolid-pi-plugin') + writeJson(path.join(root, 'package.json'), { name: 'different-package', version: '1.0.1' }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const pi = detected.find((installation) => installation.target === 'pi') + assert.equal(pi?.version.current, undefined) + + const plan = await planUpdates({ + harness: 'pi', + check: true, + cwd: home, + fetchImpl: registryFetch('1.0.1'), + commandRunner: runner(), + }) + assert.equal(plan.items[0]?.version.status, 'update-available') + }) + + it('carries the approved custom Codex config path into inventory metadata', async () => { + const configPath = path.join(home, 'custom-codex', 'config.toml') + process.env.CODEX_CONFIG_PATH = configPath + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(configPath, [ + '[marketplaces.nodesource]', + 'source = "https://github.com/NodeSource/nsolid-plugin.git"', + '', + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true', + '', + ].join('\n')) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const codex = detected.find((installation) => installation.target === 'codex') + assert.equal(codex?.metadata?.configPath, configPath) + }) + + it('reports an existing invalid Codex config as a planning failure', async () => { + const configPath = path.join(home, '.codex', 'config.toml') + process.env.CODEX_CONFIG_PATH = configPath + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(configPath, '[plugins."nsolid-plugin@nodesource"\n') + + let fetchCalls = 0 + let runnerCalls = 0 + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const codex = detected.find((installation) => installation.target === 'codex') + assert.equal(codex?.inventoryError?.code, 'CODEX_CONFIG_PARSE_FAILED') + + const summary = await checkUpdates({ + harness: 'codex', + cwd: home, + fetchImpl: async () => { + fetchCalls++ + return new Response('{}', { status: 200 }) + }, + commandRunner: { + run: async () => { + runnerCalls++ + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }, + }) + + assert.equal(summary.results[0]?.status, 'failed') + assert.equal(summary.results[0]?.error?.code, 'CODEX_CONFIG_PARSE_FAILED') + assert.equal(summary.success, false) + assert.equal(summary.exitCode, 1) + assert.equal(fetchCalls, 0) + assert.equal(runnerCalls, 0) + assert.doesNotMatch(summary.results[0]?.error?.message ?? '', /plugins|nsolid-plugin@nodesource/) + }) + + it('reads the current Codex version from the uniquely registered marketplace cache', async () => { + const configPath = path.join(home, '.codex', 'config.toml') + const cacheRoot = path.join(home, '.codex', 'plugins', 'cache', 'nodesource', 'nsolid-plugin') + process.env.CODEX_CONFIG_PATH = configPath + mkdirSync(path.join(cacheRoot, '1.0.1'), { recursive: true }) + writeFileSync(configPath, [ + '[marketplaces.nodesource]', + 'source = "https://github.com/NodeSource/nsolid-plugin.git"', + 'ref = "qa/update-flow-e2e"', + '', + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true', + '', + ].join('\n')) + writeJson(path.join(cacheRoot, '1.0.1', 'bundle.json'), { name: 'nsolid-plugin', version: '1.0.1' }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const codex = detected.find((installation) => installation.target === 'codex') + assert.equal(codex?.version.current, '1.0.1') + assert.equal(codex?.metadata?.packageRoot, cacheRoot) + }) + + it('classifies a Claude registration without marketplace metadata as unsupported', async () => { + writeJson(path.join(home, '.claude', 'plugins', 'installed_plugins.json'), { + plugins: { 'nsolid-plugin@nodesource': [{ scope: 'user' }] }, + }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const claude = detected.find((installation) => installation.target === 'claude') + assert.equal(claude?.source.kind, 'unsupported') + + const check = await checkUpdates({ + harness: 'claude', + cwd: home, + fetchImpl: registryFetch('1.0.1'), + commandRunner: runner(), + }) + assert.equal(check.results[0]?.status, 'unsupported') + + const result = await update({ + harness: 'claude', + cwd: home, + yes: true, + fetchImpl: registryFetch('1.0.1'), + commandRunner: runner(), + }) + assert.equal(result.results[0]?.status, 'unsupported') + assert.equal(result.success, false) + }) + + it('classifies a Codex registration without marketplace metadata as unsupported', async () => { + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(configPath, [ + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true', + '', + ].join('\n')) + process.env.CODEX_CONFIG_PATH = configPath + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const codex = detected.find((installation) => installation.target === 'codex') + assert.equal(codex?.source.kind, 'unsupported') + + const check = await checkUpdates({ + harness: 'codex', + cwd: home, + fetchImpl: registryFetch('1.0.1'), + commandRunner: runner(), + }) + assert.equal(check.results[0]?.status, 'unsupported') + + const result = await update({ + harness: 'codex', + cwd: home, + yes: true, + fetchImpl: registryFetch('1.0.1'), + commandRunner: runner(), + }) + assert.equal(result.results[0]?.status, 'unsupported') + assert.equal(result.success, false) + }) + + it('sanitizes unsupported Pi sources before they enter the update plan', async () => { + writeJson(path.join(home, '.pi', 'agent', 'settings.json'), { + packages: ['https://user:token@host/nsolid-pi-plugin\nmalicious'], + }) + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const pi = detected.find((installation) => installation.target === 'pi') + assert.equal(pi?.source.kind, 'unsupported') + if (pi?.source.kind === 'unsupported') { + assert.equal(pi.source.source.includes('token'), false) + assert.equal(pi.source.source.includes('\n'), false) + assert.equal(pi.source.source.includes('https://host/'), true) + } + }) + + it('does not let one fallback harness reuse another harness version evidence', async () => { + const sharedSkill = path.join(home, '.agents', 'skills', 'shared') + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersion: '1.0.0', + bundleVersions: { opencode: '1.0.0' }, + skills: [{ name: 'shared', path: sharedSkill, paths: { claude: sharedSkill, opencode: sharedSkill }, installedAt: new Date().toISOString(), harnesses: ['claude', 'opencode'] }], + mcpServers: [], + }) + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const claude = detected.find((installation) => installation.installationId === 'claude:fallback') + const opencode = detected.find((installation) => installation.installationId === 'opencode:fallback') + assert.equal(claude?.version.current, undefined) + assert.equal(opencode?.version.current, '1.0.0') + }) + + it('keeps malformed tracking isolated from native inventory discovery', async () => { + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + skills: {}, + mcpServers: [], + }) + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const fallback = detected.find((installation) => installation.installationId === 'opencode:fallback') + assert.equal(fallback?.source.kind, 'unsupported') + }) + + it('does not require npm or pnpm to report a fallback update in check mode', async () => { + const skillPath = path.join(home, '.agents', 'skills', 'tracked') + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersion: '1.0.0', + bundleVersions: { opencode: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { opencode: skillPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }], + mcpServers: [], + }) + const summary = await checkUpdates({ + harness: 'opencode', + cwd: home, + fetchImpl: registryFetch('1.0.1'), + commandRunner: { run: async () => { throw new Error('check must not probe an executor') } }, + }) + assert.equal(summary.results[0]?.status, 'update-available') + assert.equal(summary.success, true) + }) + + it('does not emit a Pi fallback target for MCP-only tracking', async () => { + writeJson(path.join(home, '.pi', 'agent', 'settings.json'), { packages: ['npm:nsolid-pi-plugin'] }) + packageRoot(path.join(home, '.pi', 'agent', 'npm', 'node_modules', 'nsolid-pi-plugin'), '1.0.0') + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'pi', + skills: [], + mcpServers: [{ name: 'nsolid-console', configPath: path.join(home, '.pi', 'settings.json'), harness: 'pi', configuredAt: new Date().toISOString() }], + }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + + assert.equal(detected.some((installation) => installation.installationId === 'pi:fallback'), false) + assert.equal(detected.some((installation) => installation.installationId === 'pi:package:user'), true) + }) + + it('ignores an unrelated Antigravity import manifest when checking ambiguity', async () => { + const pluginRoot = path.join(home, '.gemini', 'config', 'plugins', 'nsolid-plugin') + mkdirSync(pluginRoot, { recursive: true }) + writeJson(path.join(pluginRoot, 'bundle.json'), { version: '1.0.0' }) + writeJson(path.join(home, '.gemini', 'config', 'import_manifest.json'), { imports: [{ name: 'nsolid-plugin' }] }) + writeJson(path.join(home, '.gemini', 'antigravity-cli', 'import_manifest.json'), { imports: [{ name: 'unrelated-plugin' }] }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const antigravity = detected.find((installation) => installation.target === 'antigravity') + + assert.equal(antigravity?.source.kind, 'antigravity-git') + }) +}) diff --git a/packages/core/test/unit/update/package-manager.test.ts b/packages/core/test/unit/update/package-manager.test.ts new file mode 100644 index 0000000..b765656 --- /dev/null +++ b/packages/core/test/unit/update/package-manager.test.ts @@ -0,0 +1,285 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { gzipSync } from 'node:zlib' +import { detectGlobalPackageOwnership, verifyGlobalPackage } from '../../../src/update/package-manager.js' + +function fixture (managers: readonly string[] = ['npm']) { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-manager-')) + const packagePath = path.join(root, 'lib', 'node_modules', 'nsolid-plugin') + const executablePath = path.join(packagePath, 'dist', 'src', 'cli.js') + const binPath = path.join(root, 'bin') + mkdirSync(path.dirname(executablePath), { recursive: true }) + mkdirSync(binPath, { recursive: true }) + writeFileSync(path.join(packagePath, 'package.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1' })) + writeFileSync(executablePath, '#!/usr/bin/env node\n') + for (const manager of managers) { + const managerPath = path.join(binPath, process.platform === 'win32' ? `${manager}.CMD` : manager) + if (process.platform === 'win32') { + // On Windows a bare launcher name must resolve through a validated + // npm-generated shim to a JS entrypoint. Emit a real npm-style shim plus + // its node_modules entrypoint so `resolveExecutableIdentity` accepts it. + const entrypoint = path.join(binPath, 'node_modules', manager, 'bin', `${manager}-cli.js`) + const entryDir = path.dirname(entrypoint) + mkdirSync(entryDir, { recursive: true }) + writeFileSync(entrypoint, '#!/usr/bin/env node\n') + writeFileSync(path.join(binPath, 'node_modules', manager, 'package.json'), JSON.stringify({ + name: manager, + bin: { [manager]: `bin/${manager}-cli.js` }, + })) + writeFileSync(managerPath, + '@ECHO off\r\nSETLOCAL\r\nCALL :find_dp0\r\nIF EXIST "%dp0%\\node.exe" (SET "_prog=%dp0%\\node.exe") ELSE (SET "_prog=node")\r\n' + + `"%_prog%" "%dp0%\\node_modules\\${manager}\\bin\\${manager}-cli.js" %*\r\n` + + 'exit /b %errorlevel%\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n') + } else { + writeFileSync(managerPath, '#!/bin/sh\n') + chmodSync(managerPath, 0o755) + } + } + return { + root, + packagePath, + executablePath, + env: { + PATH: binPath, + ...(process.platform === 'win32' ? { PATHEXT: '.CMD' } : {}), + }, + } +} + +describe('global CLI package ownership', () => { + it('accepts a package contained by the npm-reported global root', async () => { + const paths = fixture() + const calls: Array<{ executable: string; args: readonly string[] }> = [] + const result = await detectGlobalPackageOwnership({ + commandRunner: { + run: async (spec) => { + calls.push({ executable: spec.executable, args: spec.args }) + return { exitCode: 0, stdout: `${path.join(paths.root, 'lib', 'node_modules')}\n`, stderr: '', timedOut: false } + }, + }, + packageRoot: paths.packagePath, + executablePath: paths.executablePath, + env: paths.env, + }) + + assert.equal(result.ownership?.manager, 'npm') + const managerPath = path.join(paths.env.PATH, process.platform === 'win32' ? 'npm.CMD' : 'npm') + const entrypoint = path.join(paths.env.PATH, 'node_modules', 'npm', 'bin', 'npm-cli.js') + assert.deepEqual(calls, [{ + executable: process.platform === 'win32' ? process.execPath : managerPath, + args: process.platform === 'win32' ? [entrypoint, 'root', '--global'] : ['root', '--global'], + }]) + assert.equal(result.ownership?.rollbackCommand, 'npm install --global nsolid-plugin@1.0.1') + }) + + it('normalizes pnpm symlinked package roots before proving ownership', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-pnpm-')) + const globalRoot = path.join(root, 'global', 'node_modules') + const storePackage = path.join(root, 'store', 'nsolid-plugin') + const executablePath = path.join(storePackage, 'dist', 'src', 'cli.js') + const binPath = path.join(root, 'bin') + mkdirSync(path.dirname(executablePath), { recursive: true }) + mkdirSync(globalRoot, { recursive: true }) + mkdirSync(binPath, { recursive: true }) + writeFileSync(path.join(storePackage, 'package.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1' })) + writeFileSync(executablePath, '#!/usr/bin/env node\n') + chmodSync(executablePath, 0o755) + const pnpmExecutable = path.join(binPath, process.platform === 'win32' ? 'pnpm.CMD' : 'pnpm') + if (process.platform === 'win32') { + const pnpmEntrypoint = path.join(binPath, 'node_modules', 'pnpm', 'bin', 'pnpm-cli.js') + mkdirSync(path.dirname(pnpmEntrypoint), { recursive: true }) + writeFileSync(pnpmEntrypoint, '#!/usr/bin/env node\n') + writeFileSync(path.join(binPath, 'node_modules', 'pnpm', 'package.json'), JSON.stringify({ + name: 'pnpm', + bin: { pnpm: 'bin/pnpm-cli.js' }, + })) + writeFileSync(pnpmExecutable, '@ECHO off\r\n"node" "%~dp0\\node_modules\\pnpm\\bin\\pnpm-cli.js" %*\r\n') + } else { + writeFileSync(pnpmExecutable, '#!/bin/sh\n') + chmodSync(pnpmExecutable, 0o755) + } + const symlinkedPackage = path.join(globalRoot, 'nsolid-plugin') + symlinkSync(storePackage, symlinkedPackage, 'dir') + + const result = await detectGlobalPackageOwnership({ + commandRunner: { + run: async () => ({ exitCode: 0, stdout: `${globalRoot}\n`, stderr: '', timedOut: false }), + }, + packageRoot: storePackage, + executablePath, + env: { PATH: binPath }, + }) + + assert.equal(result.ownership?.manager, 'pnpm') + assert.equal(result.ownership?.packagePath, symlinkedPackage) + + const nextStorePackage = path.join(root, 'store', 'nsolid-plugin-next') + mkdirSync(nextStorePackage, { recursive: true }) + writeFileSync(path.join(nextStorePackage, 'package.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.2' })) + rmSync(symlinkedPackage, { force: true }) + symlinkSync(nextStorePackage, symlinkedPackage, 'dir') + assert.equal(verifyGlobalPackage(result.ownership!, '1.0.2'), true) + }) + + it('rejects an exact artifact when the installed package cannot prove its content identity', () => { + const paths = fixture() + writeFileSync(path.join(paths.packagePath, 'package.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.2' })) + + assert.equal(verifyGlobalPackage({ + manager: 'npm', + packageRoot: path.dirname(paths.packagePath), + packagePath: paths.packagePath, + executable: { kind: 'native', executable: path.join(paths.env.PATH, 'npm') }, + rollbackCommand: 'npm install --global nsolid-plugin@1.0.1', + }, '1.0.2', { + kind: 'npm', + packageName: 'nsolid-plugin', + version: '1.0.2', + registry: 'https://registry.npmjs.org', + tarball: 'https://registry.npmjs.org/nsolid-plugin/-/nsolid-plugin-1.0.2.tgz', + integrity: 'sha512-dGVzdA==', + contentDigest: 'planned-content', + }), false) + }) + + it('compares installed bytes with the integrity-verified npm tarball when manager metadata is absent', () => { + const paths = fixture() + const packageJson = JSON.stringify({ name: 'nsolid-plugin', version: '1.0.2' }) + const cli = '#!/usr/bin/env node\n' + writeFileSync(path.join(paths.packagePath, 'package.json'), packageJson) + writeFileSync(paths.executablePath, cli) + const tarballPath = path.join(paths.root, 'nsolid-plugin-1.0.2.tgz') + const tarball = npmTarball({ + 'package/package.json': packageJson, + 'package/dist/src/cli.js': cli, + }) + writeFileSync(tarballPath, tarball) + const artifact = { + kind: 'npm' as const, + packageName: 'nsolid-plugin' as const, + version: '1.0.2', + registry: 'https://registry.npmjs.org', + tarball: 'https://registry.npmjs.org/nsolid-plugin/-/nsolid-plugin-1.0.2.tgz', + integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}`, + tarballPath, + contentDigest: createHash('sha256').update(tarball).digest('hex'), + } + const ownership = { + manager: 'npm' as const, + packageRoot: path.dirname(paths.packagePath), + packagePath: paths.packagePath, + executable: { kind: 'native' as const, executable: path.join(paths.env.PATH, 'npm') }, + rollbackCommand: 'npm install --global nsolid-plugin@1.0.1', + } + + assert.equal(verifyGlobalPackage(ownership, '1.0.2', artifact), true) + writeFileSync(paths.executablePath, '#!/usr/bin/env node\nconsole.log("tampered")\n') + assert.equal(verifyGlobalPackage(ownership, '1.0.2', artifact), false) + }) + + it('does not reject a normal package because wrapper home variables are ambient', async () => { + const paths = fixture() + const result = await detectGlobalPackageOwnership({ + commandRunner: { + run: async () => ({ exitCode: 0, stdout: `${path.join(paths.root, 'lib', 'node_modules')}\n`, stderr: '', timedOut: false }), + }, + packageRoot: paths.packagePath, + executablePath: paths.executablePath, + env: { ...paths.env, VOLTA_HOME: '/tmp/volta', BUN_INSTALL: '/tmp/bun', YARN_VERSION: '1' }, + }) + + assert.equal(result.ownership?.manager, 'npm') + }) + + it('rejects a broken entrypoint and a manager root mismatch', async () => { + const paths = fixture() + const broken = path.join(paths.root, 'missing', 'nsolid-plugin') + const mismatch = await detectGlobalPackageOwnership({ + commandRunner: { + run: async () => ({ exitCode: 0, stdout: `${path.join(paths.root, 'other', 'node_modules')}\n`, stderr: '', timedOut: false }), + }, + packageRoot: paths.packagePath, + executablePath: paths.executablePath, + env: paths.env, + }) + const brokenResult = await detectGlobalPackageOwnership({ + commandRunner: { + run: async () => ({ exitCode: 0, stdout: `${path.join(paths.root, 'lib', 'node_modules')}\n`, stderr: '', timedOut: false }), + }, + packageRoot: paths.packagePath, + executablePath: broken, + env: paths.env, + }) + + assert.equal(mismatch.ownership, undefined) + assert.equal(brokenResult.ownership, undefined) + assert.equal(mismatch.unsupported?.code, 'UNSUPPORTED_CLI_SOURCE') + }) + + it('does not invoke a package manager in read-only mode', async () => { + const paths = fixture() + let calls = 0 + const result = await detectGlobalPackageOwnership({ + commandRunner: { + run: async () => { + calls++ + throw new Error('must not run') + }, + }, + packageRoot: paths.packagePath, + executablePath: paths.executablePath, + env: paths.env, + readOnly: true, + }) + + assert.equal(calls, 0) + assert.equal(result.ownership, undefined) + assert.equal(result.unsupported?.code, 'UNSUPPORTED_CLI_SOURCE') + }) + + it('rejects ambiguous ownership when npm and pnpm both claim the package', async () => { + const paths = fixture(['npm', 'pnpm']) + const result = await detectGlobalPackageOwnership({ + commandRunner: { + run: async () => ({ exitCode: 0, stdout: `${path.join(paths.root, 'lib', 'node_modules')}\n`, stderr: '', timedOut: false }), + }, + packageRoot: paths.packagePath, + executablePath: paths.executablePath, + env: paths.env, + }) + + assert.equal(result.ownership, undefined) + assert.match(result.unsupported?.message ?? '', /ambiguous/i) + }) +}) + +function npmTarball (files: Readonly>): Buffer { + const blocks: Buffer[] = [] + for (const [name, contents] of Object.entries(files)) { + const body = Buffer.from(contents) + const header = Buffer.alloc(512) + header.write(name, 0, 100, 'utf8') + writeTarOctal(header, 100, 8, 0o644) + writeTarOctal(header, 108, 8, 0) + writeTarOctal(header, 116, 8, 0) + writeTarOctal(header, 124, 12, body.length) + writeTarOctal(header, 136, 12, 0) + header.fill(0x20, 148, 156) + header[156] = '0'.charCodeAt(0) + header.write('ustar\0', 257, 6, 'ascii') + header.write('00', 263, 2, 'ascii') + writeTarOctal(header, 148, 8, [...header].reduce((sum, byte) => sum + byte, 0)) + blocks.push(header, body, Buffer.alloc((512 - body.length % 512) % 512)) + } + blocks.push(Buffer.alloc(1024)) + return gzipSync(Buffer.concat(blocks)) +} + +function writeTarOctal (buffer: Buffer, offset: number, length: number, value: number): void { + const encoded = value.toString(8).padStart(length - 1, '0') + '\0' + buffer.write(encoded, offset, length, 'ascii') +} diff --git a/packages/core/test/unit/update/pi-provenance.test.ts b/packages/core/test/unit/update/pi-provenance.test.ts new file mode 100644 index 0000000..4dbaae4 --- /dev/null +++ b/packages/core/test/unit/update/pi-provenance.test.ts @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { piStrategy } from '../../../src/update/strategies/pi.js' +import type { UpdatePlanItem } from '../../../src/update/types.js' + +const REGISTRY = 'https://registry.example/npm' +let root: string +let previousPath: string | undefined +let previousPathExt: string | undefined + +beforeEach(() => { + root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-pi-provenance-')) + previousPath = process.env.PATH + previousPathExt = process.env.PATHEXT +}) + +afterEach(() => { + if (previousPath === undefined) delete process.env.PATH + else process.env.PATH = previousPath + if (previousPathExt === undefined) delete process.env.PATHEXT + else process.env.PATHEXT = previousPathExt + rmSync(root, { recursive: true, force: true }) +}) + +function digest (filePath: string): string { + return createHash('sha256').update(readFileSync(filePath)).digest('hex') +} + +function integrity (value: string): string { + return `sha512-${createHash('sha512').update(value).digest('base64')}` +} + +function writeJson (filePath: string, value: unknown): void { + mkdirSync(path.dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(value, null, 2)) +} + +function createFixture (): { + item: UpdatePlanItem + packageRoot: string + lockPath: string + artifactTarball: string + artifactIntegrity: string +} { + const bin = path.join(root, 'bin') + const npmRoot = path.join(root, 'npm') + const packageRoot = path.join(npmRoot, 'node_modules', 'nsolid-pi-plugin') + const lockPath = path.join(npmRoot, 'package-lock.json') + const settingsPath = path.join(root, 'settings.json') + const artifactTarball = `${REGISTRY}/nsolid-pi-plugin/-/nsolid-pi-plugin-1.0.1.tgz` + const artifactIntegrity = integrity('planned artifact') + mkdirSync(packageRoot, { recursive: true }) + mkdirSync(bin, { recursive: true }) + writeLauncher(bin, 'pi') + writeJson(path.join(packageRoot, 'package.json'), { name: 'nsolid-pi-plugin', version: '1.0.0' }) + writeFileSync(path.join(root, 'planned.tgz'), 'planned artifact') + writeJson(settingsPath, { packages: ['npm:nsolid-pi-plugin'] }) + writeJson(lockPath, { + lockfileVersion: 3, + packages: { + 'node_modules/nsolid-pi-plugin': { + name: 'nsolid-pi-plugin', + version: '1.0.0', + resolved: `${REGISTRY}/nsolid-pi-plugin/-/nsolid-pi-plugin-1.0.0.tgz`, + integrity: integrity('old artifact'), + }, + }, + }) + process.env.PATH = bin + + return { + packageRoot, + lockPath, + artifactTarball, + artifactIntegrity, + item: { + installationId: 'pi:package:user', + target: 'pi', + ownership: 'package-owned', + installed: true, + source: { kind: 'pi-package', spec: 'npm:nsolid-pi-plugin', scopes: ['user'] }, + version: { current: '1.0.0', latest: '1.0.1', status: 'update-available' }, + artifact: { + kind: 'npm', + packageName: 'nsolid-pi-plugin', + version: '1.0.1', + registry: REGISTRY, + tarball: artifactTarball, + integrity: artifactIntegrity, + tarballPath: path.join(root, 'planned.tgz'), + }, + metadata: { + packageRoots: [packageRoot], + packageRootIdentities: [realpathSync(packageRoot)], + settingsPaths: [settingsPath], + settingsDigests: [digest(settingsPath)], + sourceEntries: ['npm:nsolid-pi-plugin'], + cacheDigests: [digest(path.join(packageRoot, 'package.json'))], + packageEvidencePaths: [lockPath], + packageEvidenceDigests: [digest(lockPath)], + }, + steps: [], + rollbackSteps: [], + requiresConfirmation: true, + }, + } +} + +function writeLauncher (bin: string, name: string): void { + if (process.platform !== 'win32') { + writeFileSync(path.join(bin, name), '#!/bin/sh\nexit 0\n', { mode: 0o755 }) + return + } + const entrypoint = path.join(bin, 'node_modules', name, 'bin', `${name}.js`) + mkdirSync(path.dirname(entrypoint), { recursive: true }) + writeFileSync(entrypoint, '#!/usr/bin/env node\n') + writeJson(path.join(bin, 'node_modules', name, 'package.json'), { name, bin: { [name]: `bin/${name}.js` } }) + writeFileSync(path.join(bin, `${name}.cmd`), `@ECHO off\r\n"node" "%~dp0\\node_modules\\${name}\\bin\\${name}.js" %*\r\n`) + process.env.PATHEXT = '.CMD' +} + +function updateEvidence (lockPath: string, version: string, resolved: string, packageIntegrity: string): void { + writeJson(lockPath, { + lockfileVersion: 3, + packages: { + 'node_modules/nsolid-pi-plugin': { + name: 'nsolid-pi-plugin', + version, + resolved, + integrity: packageIntegrity, + }, + }, + }) +} + +async function executeFixture (configure: (fixture: ReturnType) => void | Promise, fetchImpl?: typeof fetch) { + const current = createFixture() + const item = await piStrategy.plan(current.item, { options: { fetchImpl }, commandRunner: { run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }) } }) + return piStrategy.execute(item, { + options: { fetchImpl }, + commandRunner: { + run: async (command) => { + assert.equal(command.env?.npm_config_registry, REGISTRY) + assert.equal(command.env?.NPM_CONFIG_REGISTRY, REGISTRY) + await configure(current) + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }, + }) +} + +describe('Pi package provenance validation', () => { + it('rejects a matching version with different integrity', async () => { + const result = await executeFixture(({ packageRoot, lockPath, artifactTarball }) => { + writeJson(path.join(packageRoot, 'package.json'), { name: 'nsolid-pi-plugin', version: '1.0.1' }) + updateEvidence(lockPath, '1.0.1', artifactTarball, integrity('wrong artifact')) + }) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'PI_PROVENANCE_MISMATCH') + }) + + it('rejects a matching version resolved from a different registry', async () => { + const result = await executeFixture(({ packageRoot, lockPath, artifactIntegrity }) => { + writeJson(path.join(packageRoot, 'package.json'), { name: 'nsolid-pi-plugin', version: '1.0.1' }) + updateEvidence(lockPath, '1.0.1', 'https://other.example/nsolid-pi-plugin.tgz', artifactIntegrity) + }) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'PI_PROVENANCE_MISMATCH') + }) + + it('rejects an update with missing package evidence', async () => { + const result = await executeFixture(({ packageRoot, lockPath }) => { + writeJson(path.join(packageRoot, 'package.json'), { name: 'nsolid-pi-plugin', version: '1.0.1' }) + unlinkSync(lockPath) + }) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'PI_PROVENANCE_UNVERIFIED') + }) + + it('accepts matching evidence for the planned artifact', async () => { + const result = await executeFixture(({ packageRoot, lockPath, artifactTarball, artifactIntegrity }) => { + writeJson(path.join(packageRoot, 'package.json'), { name: 'nsolid-pi-plugin', version: '1.0.1' }) + updateEvidence(lockPath, '1.0.1', artifactTarball, artifactIntegrity) + }) + + assert.equal(result.status, 'updated', JSON.stringify(result)) + assert.equal(result.resultingVersion, '1.0.1') + }) + + it('resolves and validates exact metadata for a newer installed version', async () => { + const newerTarball = `${REGISTRY}/nsolid-pi-plugin/-/nsolid-pi-plugin-1.0.2.tgz` + const newerIntegrity = integrity('newer artifact') + const result = await executeFixture(({ packageRoot, lockPath }) => { + writeJson(path.join(packageRoot, 'package.json'), { name: 'nsolid-pi-plugin', version: '1.0.2' }) + updateEvidence(lockPath, '1.0.2', newerTarball, newerIntegrity) + }, async () => new Response(JSON.stringify({ + versions: { + '1.0.2': { version: '1.0.2', dist: { tarball: newerTarball, integrity: newerIntegrity } }, + }, + }), { status: 200 })) + + assert.equal(result.status, 'updated') + assert.equal(result.resultingVersion, '1.0.2') + }) + + it('rejects a newer version whose exact registry metadata cannot be proven', async () => { + const newerTarball = `${REGISTRY}/nsolid-pi-plugin/-/nsolid-pi-plugin-1.0.2.tgz` + const result = await executeFixture(({ packageRoot, lockPath }) => { + writeJson(path.join(packageRoot, 'package.json'), { name: 'nsolid-pi-plugin', version: '1.0.2' }) + updateEvidence(lockPath, '1.0.2', newerTarball, integrity('local newer artifact')) + }, async () => new Response(JSON.stringify({ + versions: { + '1.0.2': { version: '1.0.2', dist: { tarball: newerTarball, integrity: integrity('different registry artifact') } }, + }, + }), { status: 200 })) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'PI_PROVENANCE_MISMATCH') + }) +}) diff --git a/packages/core/test/unit/update/strategies.test.ts b/packages/core/test/unit/update/strategies.test.ts new file mode 100644 index 0000000..b5299fb --- /dev/null +++ b/packages/core/test/unit/update/strategies.test.ts @@ -0,0 +1,436 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { claudeStrategy } from '../../../src/update/strategies/claude.js' +import { codexStrategy } from '../../../src/update/strategies/codex.js' +import { piStrategy } from '../../../src/update/strategies/pi.js' +import { antigravityStrategy } from '../../../src/update/strategies/antigravity.js' +import type { UpdateInstallation, UpdateSource } from '../../../src/update/types.js' + +let previousPath: string | undefined +let previousPathExt: string | undefined +let previousHome: string | undefined +let previousUserProfile: string | undefined + +beforeEach(() => { + previousPath = process.env.PATH + previousPathExt = process.env.PATHEXT + previousHome = process.env.HOME + previousUserProfile = process.env.USERPROFILE +}) + +afterEach(() => { + if (previousPath === undefined) delete process.env.PATH + else process.env.PATH = previousPath + if (previousPathExt === undefined) delete process.env.PATHEXT + else process.env.PATHEXT = previousPathExt + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile +}) + +function installation (target: UpdateInstallation['target'], source: UpdateSource): UpdateInstallation { + return { + installationId: `${target}:native:nsolid-plugin@nodesource`, + target, + ownership: target === 'pi' ? 'package-owned' : 'native-plugin', + installed: true, + source, + version: { current: undefined, latest: '1.0.1', status: 'update-available' }, + } +} + +function context () { + return { + options: {}, + commandRunner: { run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }) }, + } +} + +function writeVerifiedLauncher (directory: string, name: string): string { + const executable = path.join(directory, process.platform === 'win32' ? `${name}.exe` : name) + writeFileSync(executable, process.platform === 'win32' ? '' : '#!/bin/sh\nexit 0\n', { mode: 0o755 }) + if (process.platform === 'win32') process.env.PATHEXT = '.EXE;.COM;.CMD;.BAT' + return executable +} + +// Windows is case-insensitive: PATHEXT (.EXE) + NTFS resolution may return a +// different case than the path the fixture wrote (.exe). Compare accordingly. +function normalizeExecutable (p: string): string { + return process.platform === 'win32' ? p.toLowerCase() : p +} + +function assertSameExecutable (actual: string, expected: string): void { + assert.equal(normalizeExecutable(actual), normalizeExecutable(expected)) +} + +function assertNativeIdentity (identity: unknown, exe: string): void { + assert.ok(identity !== null && typeof identity === 'object') + const id = identity as { kind?: string; executable?: string } + assert.equal(id.kind, 'native') + assertSameExecutable(id.executable ?? '', exe) +} + +function claudeSource (): UpdateSource { + return { + kind: 'claude-marketplace', + pluginId: 'nsolid-plugin@nodesource', + marketplace: 'nodesource', + scope: 'user', + versionSource: { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + revision: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + manifestPath: 'bundle.json', + }, + } +} + +function codexSource (): UpdateSource { + return { + kind: 'codex-marketplace', + pluginId: 'nsolid-plugin@nodesource', + marketplace: 'nodesource', + versionSource: { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + revision: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + manifestPath: 'bundle.json', + }, + } +} + +function antigravitySource (): UpdateSource { + return { + kind: 'antigravity-git', + url: 'https://github.com/NodeSource/nsolid-plugin.git', + layout: { kind: 'shared', pluginRoot: '~/.gemini/config/plugins/nsolid-plugin', manifestPath: '~/.gemini/config/import_manifest.json' }, + } +} + +function piInstallation (root: string, source: UpdateSource): UpdateInstallation { + const candidate = installation('pi', source) + const packageRoot = path.join(root, 'npm', 'node_modules', 'nsolid-pi-plugin') + const evidencePath = path.join(root, 'npm', 'package-lock.json') + mkdirSync(packageRoot, { recursive: true }) + writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ name: 'nsolid-pi-plugin', version: '1.0.0' })) + writeFileSync(evidencePath, JSON.stringify({ + packages: { + 'node_modules/nsolid-pi-plugin': { + name: 'nsolid-pi-plugin', + version: '1.0.0', + resolved: 'https://registry.npmjs.org/nsolid-pi-plugin/-/nsolid-pi-plugin-1.0.0.tgz', + integrity: 'sha512-dGVzdA==', + }, + }, + })) + candidate.metadata = { packageRoots: [packageRoot], packageEvidencePaths: [evidencePath] } + return candidate +} + +describe('harness strategies degrade unsupported launchers at plan time', () => { + it('native strategies reject a mutable marketplace source that cannot honor the resolved commit', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-unpinned-')) + writeVerifiedLauncher(root, 'claude') + writeVerifiedLauncher(root, 'codex') + process.env.PATH = root + try { + for (const [strategy, target, source] of [ + [claudeStrategy, 'claude', claudeSource()], + [codexStrategy, 'codex', codexSource()], + ] as const) { + if (source.kind !== 'claude-marketplace' && source.kind !== 'codex-marketplace') continue + source.versionSource = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + revision: 'main', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + manifestPath: 'bundle.json', + } + const candidate = installation(target, source) + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: 'planned-content', + } + + const item = await strategy.plan(candidate, context()) + assert.equal(item.steps.length, 0, target) + assert.equal(item.planningError?.code, 'NATIVE_SOURCE_NOT_PINNED', target) + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('claude: plans a spawn-safe command with embedded identity for a verified launcher', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-claude-')) + const exe = writeVerifiedLauncher(root, 'claude') + process.env.PATH = root + try { + const item = await claudeStrategy.plan(installation('claude', claudeSource()), context()) + assert.equal(item.planningError, undefined) + const commands = item.steps.filter((step) => step.kind === 'command') + assert.equal(commands.length, 2) + for (const step of commands) { + if (step.kind !== 'command') continue + assertSameExecutable(step.command.executable, exe) + assertNativeIdentity(step.command.executableIdentity, exe) + } + assert.deepEqual(commands.map((step) => (step.kind === 'command' ? step.command.args : [])), [ + ['plugin', 'marketplace', 'update', 'nodesource'], + ['plugin', 'update', 'nsolid-plugin@nodesource', '--scope', 'user'], + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('claude: degrades an unverifiable launcher to planningError + manualCommands', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-claude-')) + writeFileSync(path.join(root, 'claude'), 'not executable\n', { mode: 0o644 }) + process.env.PATH = root + try { + const item = await claudeStrategy.plan(installation('claude', claudeSource()), context()) + assert.equal(item.steps.length, 0) + assert.equal(item.planningError?.code, 'UNSAFE_HARNESS_LAUNCHER') + assert.deepEqual(item.manualCommands, [ + 'claude plugin marketplace update nodesource', + 'claude plugin update nsolid-plugin@nodesource --scope user', + ]) + const result = await claudeStrategy.execute(item, context()) + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'UNSAFE_HARNESS_LAUNCHER') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('claude: validates the newly registered versioned payload after update', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-claude-update-')) + const bin = path.join(root, 'bin') + const oldPayload = path.join(root, '.claude', 'plugins', 'cache', 'nodesource', 'nsolid-plugin', '1.0.0') + const newPayload = path.join(root, '.claude', 'plugins', 'cache', 'nodesource', 'nsolid-plugin', '1.0.1') + const installedPath = path.join(root, '.claude', 'plugins', 'installed_plugins.json') + const newBundle = JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1' }) + mkdirSync(bin, { recursive: true }) + mkdirSync(oldPayload, { recursive: true }) + mkdirSync(newPayload, { recursive: true }) + writeVerifiedLauncher(bin, 'claude') + writeFileSync(path.join(oldPayload, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0' })) + writeFileSync(path.join(newPayload, 'bundle.json'), newBundle) + process.env.PATH = bin + process.env.HOME = root + process.env.USERPROFILE = root + try { + const candidate = installation('claude', claudeSource()) + candidate.metadata = { packageRoot: oldPayload, configPath: installedPath } + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: createHash('sha256').update(newBundle).digest('hex'), + } + const item = await claudeStrategy.plan(candidate, context()) + const result = await claudeStrategy.execute(item, { + ...context(), + commandRunner: { + run: async (command) => { + if (command.args[1] === 'update') { + writeFileSync(installedPath, JSON.stringify({ + version: 2, + plugins: { + 'nsolid-plugin@nodesource': [{ scope: 'user', installPath: newPayload, version: '1.0.1' }], + }, + })) + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }, + }) + + assert.equal(result.status, 'updated') + assert.equal(result.resultingVersion, '1.0.1') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('claude: uses inventory-compatible scope fields during payload validation', async () => { + for (const scopeField of ['installationScope', 'metadata'] as const) { + const root = mkdtempSync(path.join(os.tmpdir(), `nsolid-strategy-claude-${scopeField}-`)) + const bin = path.join(root, 'bin') + const newPayload = path.join(root, '.claude', 'plugins', 'cache', 'nodesource', 'nsolid-plugin', '1.0.1') + const installedPath = path.join(root, '.claude', 'plugins', 'installed_plugins.json') + const newBundle = JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1' }) + mkdirSync(bin, { recursive: true }) + mkdirSync(newPayload, { recursive: true }) + writeVerifiedLauncher(bin, 'claude') + writeFileSync(path.join(newPayload, 'bundle.json'), newBundle) + process.env.PATH = bin + process.env.HOME = root + process.env.USERPROFILE = root + try { + const candidate = installation('claude', claudeSource()) + candidate.metadata = { configPath: installedPath } + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: createHash('sha256').update(newBundle).digest('hex'), + } + const item = await claudeStrategy.plan(candidate, context()) + const result = await claudeStrategy.execute(item, { + ...context(), + commandRunner: { + run: async (command) => { + if (command.args[1] === 'update') { + const registration = { + ...(scopeField === 'installationScope' ? { installationScope: 'user' } : { metadata: { scope: 'user' } }), + installPath: newPayload, + version: '1.0.1', + } + writeFileSync(installedPath, JSON.stringify({ plugins: { 'nsolid-plugin@nodesource': [registration] } })) + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }, + }) + + assert.equal(result.status, 'updated', scopeField) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + }) + + it('codex: resolves the launcher once and shares the identity across all three commands', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-codex-')) + const exe = writeVerifiedLauncher(root, 'codex') + process.env.PATH = root + try { + const candidate = installation('codex', codexSource()) + const configPath = path.join(root, 'config.toml') + const cachePath = path.join(root, 'plugins', 'cache', 'nodesource', 'nsolid-plugin') + mkdirSync(cachePath, { recursive: true }) + writeFileSync(configPath, '') + candidate.metadata = { configPath, packageRoot: cachePath } + const item = await codexStrategy.plan(candidate, context()) + assert.equal(item.planningError, undefined) + const commands = item.steps.filter((step) => step.kind === 'command') + assert.equal(commands.length, 3) + for (const step of commands) { + if (step.kind !== 'command') continue + assertSameExecutable(step.command.executable, exe) + assertNativeIdentity(step.command.executableIdentity, exe) + } + assert.deepEqual(commands.map((step) => (step.kind === 'command' ? step.command.args : [])), [ + ['plugin', 'marketplace', 'upgrade', 'nodesource'], + ['plugin', 'remove', 'nsolid-plugin@nodesource'], + ['plugin', 'add', 'nsolid-plugin@nodesource'], + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('codex: degrades an unverifiable launcher to planningError + manualCommands', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-codex-')) + writeFileSync(path.join(root, 'codex'), 'not executable\n', { mode: 0o644 }) + process.env.PATH = root + try { + const item = await codexStrategy.plan(installation('codex', codexSource()), context()) + assert.equal(item.steps.length, 0) + assert.equal(item.planningError?.code, 'UNSAFE_HARNESS_LAUNCHER') + assert.deepEqual(item.manualCommands, [ + 'codex plugin marketplace upgrade nodesource', + 'codex plugin remove nsolid-plugin@nodesource', + 'codex plugin add nsolid-plugin@nodesource', + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('pi: plans a spawn-safe command with embedded identity for a verified launcher', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-pi-')) + const exe = writeVerifiedLauncher(root, 'pi') + process.env.PATH = root + try { + const source: UpdateSource = { kind: 'pi-package', spec: 'npm:nsolid-pi-plugin', scopes: ['project'], projectRoot: '/tmp/project' } + const item = await piStrategy.plan(piInstallation(root, source), context()) + assert.equal(item.planningError, undefined) + const step = item.steps.find((entry) => entry.kind === 'command') + assert.equal(step?.kind, 'command') + if (!step || step.kind !== 'command') return + assertSameExecutable(step.command.executable, exe) + assertNativeIdentity(step.command.executableIdentity, exe) + assert.deepEqual(step.command.args, ['update', 'npm:nsolid-pi-plugin', '--approve']) + assert.equal(step.command.cwd, '/tmp/project') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('pi: degrades an unverifiable launcher to planningError + manualCommands', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-pi-')) + writeFileSync(path.join(root, 'pi'), 'not executable\n', { mode: 0o644 }) + process.env.PATH = root + try { + const source: UpdateSource = { kind: 'pi-package', spec: 'npm:nsolid-pi-plugin', scopes: ['user'] } + const item = await piStrategy.plan(piInstallation(root, source), context()) + assert.equal(item.steps.length, 0) + assert.equal(item.planningError?.code, 'UNSAFE_HARNESS_LAUNCHER') + assert.deepEqual(item.manualCommands, ['pi update npm:nsolid-pi-plugin --no-approve']) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('antigravity: plans spawn-safe commands with embedded identity for a verified launcher', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-agy-')) + const exe = writeVerifiedLauncher(root, 'agy') + process.env.PATH = root + try { + const item = await antigravityStrategy.plan(installation('antigravity', antigravitySource()), context()) + assert.equal(item.planningError, undefined) + const commands = item.steps.filter((step) => step.kind === 'command') + assert.equal(commands.length, 2) + for (const step of commands) { + if (step.kind !== 'command') continue + assertSameExecutable(step.command.executable, exe) + assertNativeIdentity(step.command.executableIdentity, exe) + } + assert.deepEqual(commands.map((step) => (step.kind === 'command' ? step.command.args : [])), [ + ['plugin', 'uninstall', 'nsolid-plugin'], + ['plugin', 'install', 'https://github.com/NodeSource/nsolid-plugin.git'], + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('antigravity: degrades an unverifiable launcher to planningError + manualCommands', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-agy-')) + writeFileSync(path.join(root, 'agy'), 'not executable\n', { mode: 0o644 }) + process.env.PATH = root + try { + const item = await antigravityStrategy.plan(installation('antigravity', antigravitySource()), context()) + assert.equal(item.steps.length, 0) + assert.equal(item.planningError?.code, 'UNSAFE_HARNESS_LAUNCHER') + assert.deepEqual(item.manualCommands, [ + 'agy plugin uninstall nsolid-plugin', + 'agy plugin install https://github.com/NodeSource/nsolid-plugin.git', + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/test/unit/update/version-source.test.ts b/packages/core/test/unit/update/version-source.test.ts new file mode 100644 index 0000000..dacabb6 --- /dev/null +++ b/packages/core/test/unit/update/version-source.test.ts @@ -0,0 +1,203 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { isSafeManifestPath, resolveMarketplaceVersion, resolveRegistryVersion, sanitizeRepository } from '../../../src/update/version-source.js' + +describe('update version sources', () => { + it('redacts repository credentials and rejects traversal paths', () => { + assert.equal(sanitizeRepository('https://user:secret@example.com/org/repo.git'), 'https://example.com/org/repo.git') + assert.equal(sanitizeRepository('NodeSource/nsolid-plugin'), 'https://github.com/NodeSource/nsolid-plugin.git') + assert.equal(isSafeManifestPath('bundle.json'), true) + assert.equal(isSafeManifestPath('../bundle.json'), false) + assert.equal(isSafeManifestPath('/tmp/bundle.json'), false) + }) + + it('does not substitute a canonical source for stale local snapshots', async () => { + const result = await resolveMarketplaceVersion({ + kind: 'local-snapshot', + root: '/does-not-exist', + manifestPath: 'bundle.json', + freshness: 'stale', + }) + assert.deepEqual(result, {}) + }) + + it('resolves GitHub shorthand repositories using the carried revision', async () => { + let requested = '' + const result = await resolveMarketplaceVersion({ + kind: 'git', + repository: 'NodeSource/nsolid-plugin', + revision: 'feature/update-flow', + manifestPath: 'bundle.json', + }, { + fetchImpl: async (url) => { + requested = String(url) + return new Response(JSON.stringify({ version: '1.0.2' }), { status: 200 }) + }, + }) + + assert.deepEqual(result, { version: '1.0.2' }) + assert.equal(requested, 'https://raw.githubusercontent.com/NodeSource/nsolid-plugin/feature/update-flow/bundle.json') + }) + + it('resolves immutable artifact metadata from the latest packument version', async () => { + const result = await resolveRegistryVersion('nsolid-plugin', { + registry: 'http://127.0.0.1:4873', + fetchImpl: async () => new Response(JSON.stringify({ + 'dist-tags': { latest: '90.0.1' }, + versions: { + '90.0.0': { + name: 'nsolid-plugin', + version: '90.0.0', + dist: { tarball: 'http://127.0.0.1:4873/nsolid-plugin/-/nsolid-plugin-90.0.0.tgz', integrity: 'sha512-old' }, + }, + '90.0.1': { + name: 'nsolid-plugin', + version: '90.0.1', + dist: { tarball: '/nsolid-plugin/-/nsolid-plugin-90.0.1.tgz', integrity: 'sha512-latest' }, + }, + }, + }), { status: 200 }), + }) + + assert.equal(result.version, '90.0.1') + assert.deepEqual(result.artifact, { + kind: 'npm', + packageName: 'nsolid-plugin', + version: '90.0.1', + registry: 'http://127.0.0.1:4873', + tarball: 'http://127.0.0.1:4873/nsolid-plugin/-/nsolid-plugin-90.0.1.tgz', + integrity: 'sha512-latest', + }) + }) + + it('fails closed for invalid configured registries without fetching', async () => { + const candidates = ['file:///private', 'ssh://registry.example/npm', 'not a url', ''] + + for (const registry of candidates) { + let calls = 0 + const result = await resolveRegistryVersion('nsolid-plugin', { + registry, + fetchImpl: async () => { + calls++ + return new Response('{}', { status: 200 }) + }, + }) + + assert.equal(result.error?.code, 'INVALID_REGISTRY_URL') + assert.equal(calls, 0, `fetch was called for registry ${JSON.stringify(registry)}`) + } + }) + + it('uses npmjs only when no registry is configured', async () => { + const previousNpm = process.env.npm_config_registry + const previousUpper = process.env.NPM_CONFIG_REGISTRY + delete process.env.npm_config_registry + delete process.env.NPM_CONFIG_REGISTRY + try { + let requested = '' + const result = await resolveRegistryVersion('nsolid-plugin', { + fetchImpl: async (url) => { + requested = String(url) + return new Response(JSON.stringify({ 'dist-tags': { latest: '1.0.0' } }), { status: 200 }) + }, + }) + + assert.equal(result.version, '1.0.0') + assert.equal(requested, 'https://registry.npmjs.org/nsolid-plugin') + } finally { + if (previousNpm === undefined) delete process.env.npm_config_registry + else process.env.npm_config_registry = previousNpm + if (previousUpper === undefined) delete process.env.NPM_CONFIG_REGISTRY + else process.env.NPM_CONFIG_REGISTRY = previousUpper + } + }) + + it('rejects invalid npm registry environment values without fetching', async () => { + const previousNpm = process.env.npm_config_registry + const previousUpper = process.env.NPM_CONFIG_REGISTRY + try { + for (const [name, value] of [['npm_config_registry', 'file:///private'], ['NPM_CONFIG_REGISTRY', 'ssh://registry.example/npm']] as const) { + delete process.env.npm_config_registry + delete process.env.NPM_CONFIG_REGISTRY + process.env[name] = value + let calls = 0 + const result = await resolveRegistryVersion('nsolid-plugin', { + fetchImpl: async () => { + calls++ + return new Response('{}', { status: 200 }) + }, + }) + + assert.equal(result.error?.code, 'INVALID_REGISTRY_URL') + assert.equal(calls, 0) + } + } finally { + if (previousNpm === undefined) delete process.env.npm_config_registry + else process.env.npm_config_registry = previousNpm + if (previousUpper === undefined) delete process.env.NPM_CONFIG_REGISTRY + else process.env.NPM_CONFIG_REGISTRY = previousUpper + } + }) + + it('preserves a valid private registry base path', async () => { + let requested = '' + const result = await resolveRegistryVersion('nsolid-plugin', { + registry: 'https://user:secret@artifactory.example/api/npm/private/?token=hidden#fragment', + fetchImpl: async (url) => { + requested = String(url) + return new Response(JSON.stringify({ + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + version: '1.0.0', + dist: { + tarball: '/nsolid-plugin/-/nsolid-plugin-1.0.0.tgz', + integrity: 'sha512-dGVzdA==', + }, + }, + }, + }), { status: 200 }) + }, + }) + + assert.equal(requested, 'https://artifactory.example/api/npm/private/nsolid-plugin') + assert.equal(result.artifact?.kind === 'npm' ? result.artifact.registry : undefined, 'https://artifactory.example/api/npm/private') + assert.doesNotMatch(JSON.stringify(result), /secret|hidden|fragment/) + }) + + it('rejects an invalid registry declared by the selected artifact', async () => { + const result = await resolveRegistryVersion('nsolid-plugin', { + registry: 'https://registry.example/npm', + fetchImpl: async () => new Response(JSON.stringify({ + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + version: '1.0.0', + registry: 'file:///private', + dist: { tarball: '/nsolid-plugin.tgz', integrity: 'sha512-dGVzdA==' }, + }, + }, + }), { status: 200 }), + }) + + assert.equal(result.error?.code, 'INVALID_REGISTRY_ARTIFACT') + assert.doesNotMatch(result.error?.message ?? '', /private|registry\.npmjs\.org/) + }) + + it('does not expose malformed response bodies in lookup errors', async () => { + const secretBody = 'PRIVATE_RESPONSE_BODY_DO_NOT_PRINT' + const registry = await resolveRegistryVersion('nsolid-plugin', { + fetchImpl: async () => new Response(secretBody, { status: 200 }), + }) + const marketplace = await resolveMarketplaceVersion({ + kind: 'git', + repository: 'NodeSource/nsolid-plugin', + manifestPath: 'bundle.json', + }, { + fetchImpl: async () => new Response(secretBody, { status: 200 }), + }) + + assert.ok(!(registry.error?.message ?? '').includes(secretBody)) + assert.ok(!(marketplace.error?.message ?? '').includes(secretBody)) + }) +}) diff --git a/packages/core/test/unit/update/version.test.ts b/packages/core/test/unit/update/version.test.ts new file mode 100644 index 0000000..d4a1332 --- /dev/null +++ b/packages/core/test/unit/update/version.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { classifyVersionSet, classifyVersions, compareVersions, isStableVersion, parseStableVersion } from '../../../src/update/version.js' + +describe('update semantic versions', () => { + it('parses only stable semantic versions', () => { + assert.deepEqual(parseStableVersion('1.2.3'), { major: 1, minor: 2, patch: 3 }) + assert.equal(parseStableVersion('1.2'), null) + assert.equal(parseStableVersion('1.2.3-beta.1'), null) + assert.equal(parseStableVersion(' 1.2.3 '), null) + assert.equal(isStableVersion('0.0.0'), true) + }) + + it('compares versions without a runtime semver dependency', () => { + assert.equal(compareVersions('1.2.3', '1.2.3'), 0) + assert.ok(compareVersions('1.2.4', '1.2.3') > 0) + assert.ok(compareVersions('2.0.0', '10.0.0') < 0) + }) + + it('distinguishes current, update, newer, and unknown states', () => { + assert.equal(classifyVersions('1.0.0', '1.0.0').status, 'current') + assert.equal(classifyVersions('1.0.0', '1.0.1').status, 'update-available') + assert.equal(classifyVersions('1.0.2', '1.0.1').status, 'newer-than-registry') + assert.equal(classifyVersions(undefined, '1.0.1').status, 'unknown') + }) + + it('marks a multi-cache target updateable when any affected cache is stale or missing', () => { + const result = classifyVersionSet(['1.0.2', '1.0.0'], '1.0.2') + assert.equal(result.status, 'update-available') + assert.equal(result.current, '1.0.0') + assert.deepEqual(result.currentVersions, ['1.0.2', '1.0.0']) + assert.equal(classifyVersionSet(['1.0.2', undefined], '1.0.2').status, 'update-available') + }) + + it('does not update a multi-cache target when every copy is at least latest', () => { + const result = classifyVersionSet(['1.0.1', '1.0.2'], '1.0.1') + assert.equal(result.status, 'newer-than-registry') + assert.equal(result.current, '1.0.1') + }) +}) diff --git a/packages/pi-plugin/README.md b/packages/pi-plugin/README.md index 38ccb74..5875baa 100644 --- a/packages/pi-plugin/README.md +++ b/packages/pi-plugin/README.md @@ -20,6 +20,15 @@ pi install npm:pi-mcp-adapter /reload ``` +To update the package-owned skills through Pi, use the N|Solid updater after the canonical unpinned package is installed: + +```bash +nsolid-plugin update --harness pi +nsolid-plugin update --harness pi --check --json +``` + +The updater coalesces matching user and project entries into one `pi update npm:nsolid-pi-plugin` operation. User-only updates use `--no-approve`; a detected project scope is disclosed and uses `--approve` after confirmation. Source entries, filters, trust settings, MCP configuration, and credentials are left to Pi/the user and are not rewritten by the updater. + After local packaging tests, run `pnpm plugin:clean` to remove materialized skills from the source tree. Then verify: diff --git a/scripts/check-release-version.mjs b/scripts/check-release-version.mjs new file mode 100644 index 0000000..0dfd6a8 --- /dev/null +++ b/scripts/check-release-version.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { readFileSync, existsSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const releaseMode = process.argv.includes('--release') +const versionFiles = ['bundle.json', 'packages/core/package.json', 'packages/pi-plugin/package.json'] +const payload = [ + 'skills/**', + 'packages/core/src/**', + 'packages/pi-plugin/index.js', + 'bundle.json', + '.claude-plugin/marketplace.json', + '.claude-plugin/plugin.json', + '.agents/plugins/marketplace.json', + '.codex-plugin/plugin.json', + '.claude-mcp.json', + '.mcp.json', + 'plugin.json', + 'mcp_config.json', + 'scripts/mcp-wrapper.js', +] +const errors = [] + +const versions = versionFiles.map((rel) => ({ rel, version: readJson(rel)?.version })) +const canonical = versions[0].version +if (!isStable(canonical)) errors.push(`bundle.json has invalid version ${String(canonical)}`) +for (const entry of versions.slice(1)) { + if (entry.version !== canonical) errors.push(`${entry.rel}: expected ${canonical}, found ${String(entry.version)}`) +} + +checkCommand('packages/core/scripts/check-bundle-sync.mjs', '--check') +checkCommand('scripts/materialize-github-marketplace.mjs', '--check') +checkGeneratedVersions(canonical) + +if (releaseMode) checkPayloadVersion(canonical) + +if (errors.length > 0) { + console.error('release:check failed') + for (const error of errors) console.error(` ${error}`) + process.exitCode = 1 +} else { + console.log(`release:check OK (${canonical})`) +} + +function checkCommand (script, argument) { + try { + execFileSync(process.execPath, [path.join(root, script), argument], { cwd: root, stdio: 'pipe' }) + } catch (error) { + // Some constrained runners report EPERM after a successful child with a + // zero exit status when its stdio pipe is closed by the supervisor. + if (error?.status === 0) return + const output = Buffer.isBuffer(error?.stderr) ? error.stderr.toString().trim() : '' + errors.push(`${script}: ${output || 'generated files are out of sync'}`) + } +} + +function checkGeneratedVersions (expected) { + for (const rel of ['.claude-plugin/marketplace.json', '.claude-plugin/plugin.json', '.agents/plugins/marketplace.json', '.codex-plugin/plugin.json']) { + if (!existsSync(path.join(root, rel))) { + errors.push(`${rel}: file is missing`) + continue + } + const values = findVersions(readJson(rel)) + const mismatches = [...new Set(values.filter((value) => value !== expected))] + if (mismatches.length > 0) errors.push(`${rel}: expected generated version ${expected}, found ${mismatches.join(', ')}`) + } +} + +function checkPayloadVersion (expected) { + const tag = latestSemanticTag() + if (!tag) { + if (!errors.some((error) => error.includes('semantic-version Git tag') || error.includes('semantic release tag') || error.includes('shallow'))) errors.push('release mode requires an eligible semantic-version Git tag') + return + } + const tagVersion = tag.version + if (expected !== tagVersion) return + const untrackedPayload = untrackedAllowlistedPayload() + if (untrackedPayload.length > 0) { + errors.push(`untracked plugin payload changed since ${tag.name}: ${untrackedPayload.join(', ')}`) + return + } + // Compare the release tag with the complete current worktree. Release + // checks are normally run before commit/tag publication, so HEAD-only + // comparison would miss staged or unstaged payload changes. + const unchanged = runGitQuiet(['diff', '--quiet', tag.name, '--', ...payload]) + if (unchanged === false) { + errors.push(`plugin payload changed since ${tag.name} without an update-visible version; run release:prepare`) + } else if (unchanged === undefined) { + errors.push(`could not compare plugin payload with ${tag.name}`) + } +} + +function latestSemanticTag () { + const output = runGitOutput(['tag', '--list']) + if (output === undefined) return undefined + const names = output + .split(/\r?\n/) + .map((tag) => tag.trim()) + .filter(Boolean) + const semanticNames = names.filter((name) => /^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(name)) + const candidates = names + .map((name) => { + const match = name.match(/^v?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/) + if (!match) return undefined + const commit = runGitOutput(['rev-parse', `${name}^{commit}`])?.trim() + if (!commit) return undefined + const ancestry = runGitQuiet(['merge-base', '--is-ancestor', commit, 'HEAD']) + if (ancestry !== true) return undefined + return { name, version: match[1], commit } + }) + .filter(Boolean) + if (candidates.length === 0) { + const shallow = runGitOutput(['rev-parse', '--is-shallow-repository'])?.trim() === 'true' + if (shallow) errors.push('release mode cannot prove an eligible tag because repository history is shallow') + else if (!output.trim()) errors.push('release mode found no local semantic-version Git tag; remote tags are not considered until fetched locally') + else if (semanticNames.length === 0) errors.push('release mode found only malformed or non-semantic Git tags') + else if (semanticNames.length > 0) errors.push('release mode found no semantic release tag that is an ancestor of HEAD') + return undefined + } + candidates.sort((left, right) => compareStable(right.version, left.version)) + const selected = candidates[0] + const duplicates = candidates.filter((entry) => entry.version === selected.version && entry.commit !== selected.commit) + if (duplicates.length > 0) { + errors.push(`release mode found ambiguous duplicate tags for version ${selected.version}: ${[selected, ...duplicates].map((entry) => entry.name).join(', ')}`) + return undefined + } + return selected +} + +function runGitOutput (args) { + try { + return execFileSync('git', args, { cwd: root, encoding: 'utf8' }) + } catch (error) { + // Some constrained runners throw after a successful child process has + // already populated stdout. Preserve that output just as checkCommand() + // preserves a status-zero result. + if (error?.status !== 0) return undefined + if (typeof error?.stdout === 'string') return error.stdout + if (Buffer.isBuffer(error?.stdout)) return error.stdout.toString('utf8') + return undefined + } +} + +function runGitQuiet (args) { + try { + execFileSync('git', args, { cwd: root, stdio: 'pipe' }) + return true + } catch (error) { + if (error?.status === 0) return true + if (error?.status === 1) return false + return undefined + } +} + +function untrackedAllowlistedPayload () { + const output = runGitOutput(['status', '--porcelain=v1', '--untracked-files=all', '-z']) + if (output === undefined) { + errors.push('could not inspect untracked plugin payload') + return [] + } + return output + .split('\0') + .filter((entry) => entry.startsWith('?? ')) + .map((entry) => entry.slice(3)) + .filter(isPayloadPath) +} + +function isPayloadPath (relativePath) { + const normalized = relativePath.replaceAll('\\', '/') + return normalized === 'bundle.json' || normalized.startsWith('skills/') || normalized.startsWith('packages/core/src/') || normalized === 'packages/pi-plugin/index.js' || payload.includes(normalized) +} + +function findVersions (value) { + if (!value || typeof value !== 'object') return [] + const output = [] + if (typeof value.version === 'string') output.push(value.version) + for (const child of Object.values(value)) output.push(...findVersions(child)) + return output +} + +function readJson (relative) { + try { return JSON.parse(readFileSync(path.join(root, relative), 'utf8')) } catch { return null } +} + +function isStable (value) { return typeof value === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value) } + +function compareStable (left, right) { + const a = left.split('.').map(Number) + const b = right.split('.').map(Number) + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2] +} diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 0000000..86cc2b6 --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const request = process.argv.slice(2).find((arg) => !arg.startsWith('-')) +const sourceFiles = ['bundle.json', 'packages/core/package.json', 'packages/pi-plugin/package.json'] +const generatedFiles = [ + 'packages/core/bundle.json', + '.claude-plugin/marketplace.json', + '.claude-plugin/plugin.json', + '.agents/plugins/marketplace.json', + '.codex-plugin/plugin.json', + '.claude-mcp.json', + '.mcp.json', + 'plugin.json', + 'mcp_config.json', + 'scripts/mcp-wrapper.js', +] +const materializedDirs = ['packages/core/skills', 'packages/pi-plugin/skills'] +const snapshots = new Map() +const directorySnapshots = new Map() + +try { + if (!request) throw new Error('Usage: pnpm release:prepare -- patch|minor|major|') + const currentBundle = readJson('bundle.json') + const current = currentBundle.version + if (!isStable(current)) throw new Error(`Current bundle version is invalid: ${current}`) + const next = nextVersion(current, request) + if (!next) throw new Error(`Requested release version is invalid or not greater than ${current}: ${request}`) + + for (const rel of [...sourceFiles, ...generatedFiles]) snapshot(rel) + for (const rel of materializedDirs) snapshotDirectory(rel) + for (const rel of sourceFiles) { + const value = readJson(rel) + value.version = next + writeJson(rel, value) + } + + run('packages/core/scripts/check-bundle-sync.mjs') + run('scripts/materialize-github-marketplace.mjs') + run('scripts/sync-plugin-assets.mjs') + validate(next) + + console.log(`Prepared release ${next}`) + for (const rel of changedFiles()) console.log(` ${rel}`) +} catch (error) { + restore() + console.error(`release:prepare failed: ${error instanceof Error ? error.message : 'unknown error'}`) + process.exitCode = 1 +} + +function nextVersion (current, requestValue) { + if (requestValue === 'patch' || requestValue === 'minor' || requestValue === 'major') { + const [major, minor, patch] = current.split('.').map(Number) + if (requestValue === 'major') return `${major + 1}.0.0` + if (requestValue === 'minor') return `${major}.${minor + 1}.0` + return `${major}.${minor}.${patch + 1}` + } + if (!isStable(requestValue) || compare(requestValue, current) <= 0) return null + return requestValue +} + +function validate (version) { + for (const rel of sourceFiles) { + if (readJson(rel).version !== version) throw new Error(`${rel} did not receive ${version}`) + } + if (readFile('bundle.json') !== readFile('packages/core/bundle.json')) throw new Error('packages/core/bundle.json is not synchronized') + for (const rel of ['.claude-plugin/marketplace.json', '.claude-plugin/plugin.json', '.agents/plugins/marketplace.json', '.codex-plugin/plugin.json']) { + const value = readJson(rel) + const versions = findVersions(value) + if (versions.some((value) => value !== version)) throw new Error(`${rel} contains a stale version`) + } +} + +function findVersions (value) { + if (!value || typeof value !== 'object') return [] + const output = [] + if (typeof value.version === 'string') output.push(value.version) + for (const child of Object.values(value)) output.push(...findVersions(child)) + return output +} + +function run (relativeScript) { + execFileSync(process.execPath, [path.join(root, relativeScript)], { cwd: root, stdio: 'inherit' }) +} + +function snapshot (relative) { + const file = path.join(root, relative) + snapshots.set(relative, existsSync(file) ? readFile(relative) : null) +} + +function restore () { + for (const [relative, content] of snapshots) { + const file = path.join(root, relative) + if (content === null) { + if (existsSync(file)) rmSync(file, { recursive: true, force: true }) + } else { + writeFileSync(file, content) + } + } + for (const [relative, files] of directorySnapshots) { + const directory = path.join(root, relative) + rmSync(directory, { recursive: true, force: true }) + if (!files) continue + for (const [file, content] of files) { + const target = path.join(root, file) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, content) + } + } +} + +function snapshotDirectory (relative) { + const directory = path.join(root, relative) + if (!existsSync(directory)) { + directorySnapshots.set(relative, null) + return + } + const files = new Map() + collectDirectoryFiles(directory, relative, files) + directorySnapshots.set(relative, files) +} + +function collectDirectoryFiles (directory, relative, files) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const child = path.join(directory, entry.name) + const childRelative = path.join(relative, entry.name) + if (entry.isDirectory()) collectDirectoryFiles(child, childRelative, files) + else if (entry.isFile()) files.set(childRelative, readFileSync(child)) + } +} + +function changedFiles () { + try { + return execFileSync('git', ['diff', '--name-only', '--', ...sourceFiles, ...generatedFiles], { cwd: root, encoding: 'utf8' }).trim().split(/\r?\n/).filter(Boolean) + } catch { return [] } +} + +function readJson (relative) { return JSON.parse(readFile(relative)) } +function readFile (relative) { return readFileSync(path.join(root, relative), 'utf8') } +function writeJson (relative, value) { writeFileSync(path.join(root, relative), JSON.stringify(value, null, 2) + '\n') } +function isStable (value) { return typeof value === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value) } +function compare (a, b) { return a.split('.').map(Number).reduce((result, part, index) => result || part - Number(b.split('.')[index]), 0) } From 121655a55117ccca1469cbc0bb52b9822be3e155 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Tue, 25 Aug 2026 21:41:31 +0200 Subject: [PATCH 06/12] fix(update): match equivalent Codex TOML headers --- packages/core/src/update/codex-config.ts | 16 +++++++- .../unit/update/codex-transaction.test.ts | 41 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/core/src/update/codex-config.ts b/packages/core/src/update/codex-config.ts index 39a47bc..a501e42 100644 --- a/packages/core/src/update/codex-config.ts +++ b/packages/core/src/update/codex-config.ts @@ -60,8 +60,7 @@ export function codexUserOwnedFieldsMatch (current: Record, ori function patchCodexPluginTable (source: string, pluginId: string, current: Record): string | undefined { const lines = splitTomlLines(source) - const header = `[plugins.${JSON.stringify(pluginId)}]` - const matchingHeaders = lines.filter((line) => line.text.trim().split('#', 1)[0].trim() === header) + const matchingHeaders = lines.filter((line) => isMatchingPluginTableHeader(line.text, pluginId)) if (matchingHeaders.length !== 1) return undefined const headerLine = matchingHeaders[0]! const headerIndex = lines.indexOf(headerLine) @@ -112,6 +111,19 @@ function patchCodexPluginTable (source: string, pluginId: string, current: Recor .reduce((value, replacement) => value.slice(0, replacement.start) + replacement.value + value.slice(replacement.end), source) } +function isMatchingPluginTableHeader (line: string, pluginId: string): boolean { + const marker = '__nsolid_plugin_header_marker__' + try { + const parsed = parseToml(`${line}\n${marker} = true\n`) as Record + const plugins = parsed.plugins + if (!isRecord(plugins)) return false + const plugin = plugins[pluginId] + return isRecord(plugin) && plugin[marker] === true + } catch { + return false + } +} + interface TomlLine { start: number; end: number; text: string; newline: string } function splitTomlLines (source: string): TomlLine[] { diff --git a/packages/core/test/unit/update/codex-transaction.test.ts b/packages/core/test/unit/update/codex-transaction.test.ts index 4bdd059..083e057 100644 --- a/packages/core/test/unit/update/codex-transaction.test.ts +++ b/packages/core/test/unit/update/codex-transaction.test.ts @@ -5,6 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import os from 'node:os' import path from 'node:path' import { writeTomlFileSync } from '../../../src/utils/config.js' +import { restoreCodexUserOwnedFields } from '../../../src/update/codex-config.js' import { executeCodexTransaction, readCodexPayloadVersion } from '../../../src/update/codex-transaction.js' import type { UpdatePlanItem } from '../../../src/update/types.js' @@ -54,6 +55,46 @@ function item (cachePath?: string): UpdatePlanItem { } describe('Codex update transaction', () => { + it('matches equivalent TOML plugin table headers without splitting quoted keys', () => { + const variants = [ + { pluginId: 'nsolid-plugin', header: '[plugins.nsolid-plugin]' }, + { pluginId: 'nsolid-plugin@nodesource', header: '[ plugins . "nsolid-plugin@nodesource" ]' }, + { pluginId: 'nsolid-plugin@nodesource', header: "[plugins.'nsolid-plugin@nodesource']" }, + { pluginId: 'plugin.with.dot', header: '[plugins."plugin.with.dot"]' }, + { pluginId: 'plugin#with#hash', header: "[plugins.'plugin#with#hash'] # table comment" }, + ] + + for (const [index, variant] of variants.entries()) { + const configPath = path.join(home, '.codex', `config-${index}.toml`) + mkdirSync(path.dirname(configPath), { recursive: true }) + const originalText = [ + variant.header, + 'enabled = true', + 'installPath = "old-path"', + '', + ].join('\n') + writeFileSync(configPath, [ + `[plugins.${JSON.stringify(variant.pluginId)}]`, + 'enabled = false', + 'installPath = "new-path"', + '', + ].join('\n')) + + const restored = restoreCodexUserOwnedFields( + configPath, + variant.pluginId, + { enabled: true, installPath: 'old-path' }, + originalText + ) + + assert.equal(restored, true, variant.header) + const updated = readFileSync(configPath, 'utf8') + assert.match(updated, /enabled = true/) + assert.match(updated, /installPath = "new-path"/) + assert.ok(updated.includes(variant.header)) + } + }) + it('validates the refreshed cached payload rather than a versionless registration', async () => { const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') mkdirSync(cachePath, { recursive: true }) From bcae55ab4ea72dea9e1a903e35b7abeefdff30eb Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Thu, 27 Aug 2026 11:14:26 +0200 Subject: [PATCH 07/12] fix(update): report unsupported Pi scopes --- packages/core/src/update/inventory.ts | 31 ++++++++++++------- .../core/test/unit/update/inventory.test.ts | 14 +++++++-- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/core/src/update/inventory.ts b/packages/core/src/update/inventory.ts index f887239..f6471c5 100644 --- a/packages/core/src/update/inventory.ts +++ b/packages/core/src/update/inventory.ts @@ -226,22 +226,31 @@ function detectPiInstallations (cwd: string): UpdateInstallation[] { const matching = allEntries.filter((entry) => isPiPluginName(entry.source)) const packageRoots: string[] = [] const invalidScopes = new Set(matching.filter((entry) => !entry.canonical).map((entry) => entry.scope)) - const hasUserCanonical = !invalidScopes.has('user') && matching.some((entry) => entry.scope === 'user' && entry.canonical) - const hasProjectCanonical = !invalidScopes.has('project') && matching.some((entry) => entry.scope === 'project' && entry.canonical) - // A cache directory is not source evidence. Only an explicit canonical - // settings entry makes a Pi package installation updateable. - if (!hasUserCanonical && !hasProjectCanonical) { - const invalid = matching.find((entry) => !entry.canonical) - if (!invalid) return [] - return [{ - installationId: 'pi:package:unsupported', + const unsupportedInstallations: UpdateInstallation[] = [] + for (const scope of ['user', 'project'] as const) { + const invalid = matching.find((entry) => entry.scope === scope && !entry.canonical) + if (!invalid) continue + const settingsPath = scope === 'user' ? userSettings : projectSettings + unsupportedInstallations.push({ + installationId: `pi:package:unsupported:${scope}`, target: 'pi', ownership: 'package-owned', installed: true, source: makeUnsupportedSource(invalid.source, invalid.reason), version: { status: 'unknown' }, - }] + metadata: { + settingsPaths: [settingsPath], + settingsDigests: [fileDigest(settingsPath)], + projectRoot: scope === 'project' ? cwd : undefined, + projectRootIdentity: scope === 'project' ? safeRealpath(cwd) : undefined, + }, + }) } + const hasUserCanonical = !invalidScopes.has('user') && matching.some((entry) => entry.scope === 'user' && entry.canonical) + const hasProjectCanonical = !invalidScopes.has('project') && matching.some((entry) => entry.scope === 'project' && entry.canonical) + // A cache directory is not source evidence. Only an explicit canonical + // settings entry makes a Pi package installation updateable. + if (!hasUserCanonical && !hasProjectCanonical) return unsupportedInstallations const scopes: Array<'user' | 'project'> = [] if (hasUserCanonical) scopes.push('user') @@ -282,7 +291,7 @@ function detectPiInstallations (cwd: string): UpdateInstallation[] { packageEvidencePaths, packageEvidenceDigests: packageEvidencePaths.map(fileDigest), }, - }] + }, ...unsupportedInstallations] } async function detectFallbackInstallations (): Promise { diff --git a/packages/core/test/unit/update/inventory.test.ts b/packages/core/test/unit/update/inventory.test.ts index 01fd211..221c3bb 100644 --- a/packages/core/test/unit/update/inventory.test.ts +++ b/packages/core/test/unit/update/inventory.test.ts @@ -85,7 +85,7 @@ describe('update installation inventory', () => { } }) - it('keeps a canonical Pi scope updateable when the other scope is non-canonical', async () => { + it('reports each Pi scope independently when one is non-canonical', async () => { const project = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-pi-project-')) try { const userSettings = path.join(home, '.pi', 'agent', 'settings.json') @@ -95,24 +95,32 @@ describe('update installation inventory', () => { packageRoot(path.join(home, '.pi', 'agent', 'npm', 'node_modules', 'nsolid-pi-plugin'), '1.0.0') const detected = await detectInstallations({ includeCli: false, cwd: project, commandRunner: runner() }) - const pi = detected.find((installation) => installation.target === 'pi') + const pi = detected.find((installation) => installation.installationId === 'pi:package:user') + const unsupportedProject = detected.find((installation) => installation.installationId === 'pi:package:unsupported:project') assert.equal(pi?.installationId, 'pi:package:user') assert.equal(pi?.source.kind, 'pi-package') if (pi?.source.kind === 'pi-package') assert.deepEqual(pi.source.scopes, ['user']) assert.deepEqual(pi?.metadata?.settingsPaths, [userSettings]) + assert.equal(unsupportedProject?.source.kind, 'unsupported') + if (unsupportedProject?.source.kind === 'unsupported') assert.equal(unsupportedProject.source.reason, 'pinned') + assert.deepEqual(unsupportedProject?.metadata?.settingsPaths, [projectSettings]) writeJson(userSettings, { packages: ['npm:nsolid-pi-plugin@1.0.0'] }) writeJson(projectSettings, { packages: ['npm:nsolid-pi-plugin'] }) packageRoot(path.join(project, '.pi', 'npm', 'node_modules', 'nsolid-pi-plugin'), '1.0.0') const inverse = await detectInstallations({ includeCli: false, cwd: project, commandRunner: runner() }) - const inversePi = inverse.find((installation) => installation.target === 'pi') + const inversePi = inverse.find((installation) => installation.installationId === 'pi:package:project') + const unsupportedUser = inverse.find((installation) => installation.installationId === 'pi:package:unsupported:user') assert.equal(inversePi?.installationId, 'pi:package:project') assert.equal(inversePi?.source.kind, 'pi-package') if (inversePi?.source.kind === 'pi-package') assert.deepEqual(inversePi.source.scopes, ['project']) assert.deepEqual(inversePi?.metadata?.settingsPaths, [projectSettings]) + assert.equal(unsupportedUser?.source.kind, 'unsupported') + if (unsupportedUser?.source.kind === 'unsupported') assert.equal(unsupportedUser.source.reason, 'pinned') + assert.deepEqual(unsupportedUser?.metadata?.settingsPaths, [userSettings]) } finally { rmSync(project, { recursive: true, force: true }) } From 49d2eef8583dfaf1b50d4230d6933ae9b3015ab3 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Mon, 31 Aug 2026 12:55:28 +0200 Subject: [PATCH 08/12] fix(update): close review blockers for safe native and fallback updates Harden fallback recovery, MCP reconciliation, and native payload identity. Add Windows-safe skill materialization and byte-localized TOML edits. Persist verified Claude recovery bundles before mutation and restore registration state with immutable digest and exact mode checks. --- packages/core/package.json | 1 + packages/core/src/mcp/mcp-config-writer.ts | 212 +--- packages/core/src/skills/skill-linker.ts | 78 +- .../src/update/antigravity-transaction.ts | 142 ++- .../core/src/update/claude-transaction.ts | 439 +++++++++ packages/core/src/update/codex-transaction.ts | 4 +- packages/core/src/update/command-runner.ts | 7 +- packages/core/src/update/fallback-journal.ts | 372 ++++++- .../core/src/update/fallback-ownership.ts | 24 +- .../core/src/update/fallback-transaction.ts | 544 ++++++++-- packages/core/src/update/index.ts | 1 + packages/core/src/update/inventory.ts | 35 +- packages/core/src/update/mcp-edit.ts | 243 +++++ packages/core/src/update/mcp-lookup.ts | 103 ++ .../core/src/update/mcp-reconciliation.ts | 142 +++ packages/core/src/update/mcp-toml-edit.ts | 652 ++++++++++++ packages/core/src/update/native-evidence.ts | 82 +- packages/core/src/update/native-payload.ts | 185 ++++ packages/core/src/update/strategies/claude.ts | 81 +- packages/core/src/update/strategies/codex.ts | 12 +- .../core/src/update/strategies/fallback.ts | 46 +- packages/core/src/update/types.ts | 16 + packages/core/src/update/version-source.ts | 89 +- .../test/unit/mcp/mcp-config-writer.test.ts | 39 + .../test/unit/skills/skill-linker.test.ts | 104 ++ .../update/antigravity-transaction.test.ts | 167 +++- .../unit/update/claude-transaction.test.ts | 635 ++++++++++++ .../unit/update/codex-transaction.test.ts | 8 +- .../test/unit/update/command-runner.test.ts | 8 +- .../test/unit/update/fallback-journal.test.ts | 286 +++++- .../unit/update/fallback-ownership.test.ts | 12 + .../unit/update/fallback-strategy.test.ts | 7 +- .../unit/update/fallback-transaction.test.ts | 926 +++++++++++++++++- .../core/test/unit/update/inventory.test.ts | 41 + .../core/test/unit/update/mcp-edit.test.ts | 182 ++++ .../unit/update/mcp-reconciliation.test.ts | 129 +++ .../test/unit/update/mcp-toml-edit.test.ts | 217 ++++ .../test/unit/update/native-evidence.test.ts | 101 ++ .../test/unit/update/native-payload.test.ts | 116 +++ .../core/test/unit/update/strategies.test.ts | 153 ++- .../test/unit/update/version-source.test.ts | 53 +- pnpm-lock.yaml | 8 + 42 files changed, 6164 insertions(+), 538 deletions(-) create mode 100644 packages/core/src/update/claude-transaction.ts create mode 100644 packages/core/src/update/mcp-edit.ts create mode 100644 packages/core/src/update/mcp-lookup.ts create mode 100644 packages/core/src/update/mcp-reconciliation.ts create mode 100644 packages/core/src/update/mcp-toml-edit.ts create mode 100644 packages/core/src/update/native-payload.ts create mode 100644 packages/core/test/unit/update/claude-transaction.test.ts create mode 100644 packages/core/test/unit/update/fallback-ownership.test.ts create mode 100644 packages/core/test/unit/update/mcp-edit.test.ts create mode 100644 packages/core/test/unit/update/mcp-reconciliation.test.ts create mode 100644 packages/core/test/unit/update/mcp-toml-edit.test.ts create mode 100644 packages/core/test/unit/update/native-evidence.test.ts create mode 100644 packages/core/test/unit/update/native-payload.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 39cbe49..2736eb7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,6 +32,7 @@ "access": "public" }, "dependencies": { + "jsonc-parser": "3.3.1", "semver": "7.8.5", "smol-toml": "^1.3.1", "write-file-atomic": "^5.0.1", diff --git a/packages/core/src/mcp/mcp-config-writer.ts b/packages/core/src/mcp/mcp-config-writer.ts index 64c5976..cf40332 100644 --- a/packages/core/src/mcp/mcp-config-writer.ts +++ b/packages/core/src/mcp/mcp-config-writer.ts @@ -3,9 +3,10 @@ import path from 'node:path' import type { HarnessType, McpServerRef } from '../types.js' import { resolveHome } from '../utils/path.js' import { readJsonFile, readTomlFile, readJsoncFile, writeTomlFileSync } from '../utils/config.js' -import { writeJsonFileSync, atomicWriteSync, ensureDir } from '../utils/fs.js' +import { atomicWriteSync, ensureDir } from '../utils/fs.js' import { mergeMcpConfig, removeMcpServers, expandVariables } from './mcp-config-merger.js' import type { NormalizedMcpConfig } from './mcp-config-merger.js' +import { editMcpJsonBytes } from '../update/mcp-edit.js' import { createConfigBackup } from '../utils/backup.js' import type { Logger } from '../types.js' @@ -118,19 +119,40 @@ function writeConfigFile ( ensureDir(path.dirname(configPath)) switch (format) { - case 'json': { - const existingFull = readJsonObjectAllowEmpty(configPath) ?? {} - existingFull[jsonMcpKey] = config.mcpServers - if (jsonMcpKey === 'mcp') delete existingFull.mcpServers - writeJsonFileSync(configPath, existingFull) + case 'json': + case 'jsonc': { + // Localized AST edits: only servers whose value actually changes are + // rewritten, so comments, CRLF endings, indentation, and foreign + // servers survive byte-for-byte. + const raw = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '' + const existingRaw = raw + const existingParsed = format === 'jsonc' ? readJsoncObjectAllowEmpty(configPath) : readJsonObjectAllowEmpty(configPath) + const existingServers = (existingParsed?.[jsonMcpKey] && typeof existingParsed[jsonMcpKey] === 'object' && !Array.isArray(existingParsed[jsonMcpKey]) + ? existingParsed[jsonMcpKey] + : {}) as Record + const upsertServers: Record = {} + for (const [name, value] of Object.entries(config.mcpServers)) { + if (JSON.stringify(existingServers[name]) !== JSON.stringify(value)) upsertServers[name] = value + } + const removeServers = Object.keys(existingServers).filter((name) => !(name in config.mcpServers)) + // The OpenCode harness stores servers under "mcp"; a legacy + // "mcpServers" container from older versions is migrated away + // wholesale, exactly as the previous block-rewriting writer did. + const legacyKey = jsonMcpKey === 'mcp' && existingParsed && existingParsed.mcpServers !== undefined + ? 'mcpServers' + : undefined + const next = editMcpJsonBytes(existingRaw, { + upsertServers, + removeServers, + removeBlock: format === 'jsonc' && Object.keys(config.mcpServers).length === 0, + removeKeys: legacyKey ? [legacyKey] : undefined, + }, { mcpKey: jsonMcpKey }) + atomicWriteSync(configPath, next.endsWith('\n') ? next : next + '\n') break } case 'toml': writeTomlConfig(configPath, config) break - case 'jsonc': - writeJsoncConfig(configPath, config, jsonMcpKey) - break } } @@ -154,175 +176,6 @@ function writeTomlConfig (configPath: string, config: NormalizedMcpConfig): void writeTomlFileSync(configPath, tomlData) } -// --- JSONC comment-preserving write --- - -function writeJsoncConfig ( - configPath: string, - config: NormalizedMcpConfig, - jsonMcpKey: 'mcpServers' | 'mcp' -): void { - if (!existsSync(configPath)) { - atomicWriteSync(configPath, JSON.stringify({ [jsonMcpKey]: config.mcpServers }, null, 2) + '\n') - return - } - - let raw = readFileSync(configPath, 'utf-8') - if (jsonMcpKey === 'mcp') { - raw = removeMcpServersBlockFromRaw(raw, 'mcpServers') - } - const serverNames = Object.keys(config.mcpServers) - const mcpBlock = findMcpServersBlock(raw, jsonMcpKey) - - if (serverNames.length === 0) { - atomicWriteSync(configPath, removeMcpServersBlockFromRaw(raw, jsonMcpKey)) - return - } - - if (mcpBlock) { - const indent = detectIndent(raw, mcpBlock.openBrace) - const innerIndent = indent.repeat(2) - - const innerContent = serverNames - .map((name) => innerIndent + JSON.stringify(name) + ': ' + JSON.stringify(config.mcpServers[name])) - .join(',\n') - - const before = raw.slice(0, mcpBlock.openBrace + 1) - const after = raw.slice(mcpBlock.closeBrace) - const updated = before + '\n' + innerContent + '\n' + indent + after - atomicWriteSync(configPath, updated) - return - } - - // No MCP key in existing file — insert before outer closing brace - atomicWriteSync(configPath, insertMcpBlockBeforeClosing(raw, config.mcpServers, jsonMcpKey)) -} - -function findMcpServersBlock ( - raw: string, - jsonMcpKey: 'mcpServers' | 'mcp' -): { start: number; openBrace: number; closeBrace: number } | null { - const escapedKey = jsonMcpKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const keyMatch = raw.match(new RegExp(`"${escapedKey}"\\s*:\\s*\\{`)) - if (!keyMatch || keyMatch.index === undefined) return null - - const start = keyMatch.index - const openBrace = keyMatch.index + keyMatch[0].length - 1 - const closeBrace = findMatchingBrace(raw, openBrace) - if (closeBrace === -1) return null - - return { start, openBrace, closeBrace } -} - -function findMatchingBrace (str: string, openIndex: number): number { - let depth = 0 - let inString = false - let escaped = false - - for (let i = openIndex; i < str.length; i++) { - const ch = str[i] - - if (ch === '"' && !escaped) inString = !inString - - if (!inString) { - if (ch === '{') depth++ - if (ch === '}') { - depth-- - if (depth === 0) return i - } - } - - escaped = ch === '\\' && !escaped - if (ch !== '\\') escaped = false - } - - return -1 -} - -function detectIndent (raw: string, bracePos: number): string { - const lineStart = raw.lastIndexOf('\n', bracePos) - if (lineStart === -1) return ' ' - - const line = raw.slice(lineStart + 1, bracePos) - const match = line.match(/^(\s+)/) - return match ? match[1] : ' ' -} - -function insertMcpBlockBeforeClosing ( - raw: string, - mcpServers: NormalizedMcpConfig['mcpServers'], - jsonMcpKey: 'mcpServers' | 'mcp' -): string { - const outerCloseBrace = findOuterClosingBrace(raw) - if (outerCloseBrace === -1) { - return JSON.stringify({ [jsonMcpKey]: mcpServers }, null, 2) + '\n' - } - - const indent = detectIndent(raw, outerCloseBrace) - const innerIndent = indent.repeat(2) - const serverNames = Object.keys(mcpServers) - - const innerContent = serverNames - .map((name) => innerIndent + JSON.stringify(name) + ': ' + JSON.stringify(mcpServers[name])) - .join(',\n') - - const mcpServersBlock = JSON.stringify(jsonMcpKey) + ': {\n' + innerContent + '\n' + indent + '}' - - const before = raw.slice(0, outerCloseBrace) - const after = raw.slice(outerCloseBrace) - const beforeTrimmed = before.trimEnd() - const hasContentAfterOpen = raw.slice(raw.indexOf('{') + 1, outerCloseBrace).trim().length > 0 - const separator = (hasContentAfterOpen && !beforeTrimmed.endsWith(',')) ? ',\n' : '\n' - - return before + separator + indent + mcpServersBlock + '\n' + after -} - -function findOuterClosingBrace (raw: string): number { - let depth = 0 - let lastCloseBrace = -1 - let inString = false - let escaped = false - - for (let i = 0; i < raw.length; i++) { - const ch = raw[i] - - if (ch === '"' && !escaped) inString = !inString - - if (!inString) { - if (ch === '{') depth++ - if (ch === '}') { - depth-- - if (depth === 0) lastCloseBrace = i - } - } - - escaped = ch === '\\' && !escaped - if (ch !== '\\') escaped = false - } - - return lastCloseBrace -} - -function removeMcpServersBlockFromRaw (raw: string, jsonMcpKey: 'mcpServers' | 'mcp'): string { - const block = findMcpServersBlock(raw, jsonMcpKey) - if (!block) return raw - - const before = raw.slice(0, block.start).trimEnd() - const after = raw.slice(block.closeBrace + 1) - - // Remove trailing comma before the block if present - if (before.endsWith(',')) { - return before.slice(0, -1) + '\n' + after.trimStart() - } - - // Otherwise, remove leading comma from after if present - const trimmedAfter = after.trimStart() - if (trimmedAfter.startsWith(',')) { - return before + '\n' + trimmedAfter.slice(1).trimStart() - } - - return before + after -} - /** * Apply harness-specific MCP server schema before writing to disk. * @@ -347,7 +200,8 @@ function backupMcpConfig ( } } -function applyHarnessWriteFormat ( +/** Harness-specific server schema applied before bytes reach a config file. */ +export function applyHarnessWriteFormat ( harness: HarnessType, config: NormalizedMcpConfig ): NormalizedMcpConfig { diff --git a/packages/core/src/skills/skill-linker.ts b/packages/core/src/skills/skill-linker.ts index d0ca274..a0b51fe 100644 --- a/packages/core/src/skills/skill-linker.ts +++ b/packages/core/src/skills/skill-linker.ts @@ -15,7 +15,55 @@ export interface LinkResult { target: string; } -const IS_WINDOWS = process.platform === 'win32' +export interface SkillLinkFsOps { + symlink (existingPath: string, newPath: string, type?: 'dir' | 'junction'): Promise + cp (source: string, destination: string, options?: { recursive?: boolean, force?: boolean }): Promise +} + +export interface SkillLinkMaterializationOptions { + /** The path a symlink or junction should reference: the final live skill path. */ + linkSource: string + /** The path to create. */ + target: string + /** + * The directory copied when linking is unsupported or unwanted. Defaults to + * linkSource; fallback staging sets it to the newly prepared staged bytes so + * a copy never captures the old live content. + */ + copySource?: string + /** Always copy instead of linking (the Pi harness policy). */ + alwaysCopy?: boolean + /** Platform used to choose the link strategy; defaults to process.platform. */ + platform?: NodeJS.Platform + /** Filesystem operations; defaults to node:fs/promises, injectable in tests. */ + fs?: SkillLinkFsOps +} + +/** + * The single policy for materializing a harness skill link: Pi always copies; + * Windows attempts a junction and recursively copies `copySource` on failure + * (junctions do not require elevated privileges); other platforms create a + * directory symlink and never fall back to copying on unrelated errors. + */ +export async function materializeSkillLink (options: SkillLinkMaterializationOptions): Promise { + const { linkSource, target, copySource = linkSource, alwaysCopy = false, platform = process.platform } = options + const ops = options.fs ?? { symlink, cp } + if (alwaysCopy) { + await ops.cp(copySource, target, { recursive: true, force: true }) + return + } + + if (platform === 'win32') { + try { + await ops.symlink(linkSource, target, 'junction') + } catch { + await ops.cp(copySource, target, { recursive: true, force: true }) + } + return + } + + await ops.symlink(linkSource, target, 'dir') +} export function getHarnessSkillsPath (harness: HarnessType): string { // Delegate to the adapter so each harness's skills directory has a single @@ -92,42 +140,20 @@ async function createIdempotentLink ( } await rm(target, { force: true }) - await doCreateLink(source, target, alwaysCopy) + await materializeSkillLink({ linkSource: source, target, alwaysCopy }) return 'replaced' } // Regular file or directory: backup const backupPath = `${target}.bak.${Date.now()}` await rename(target, backupPath) - await doCreateLink(source, target, alwaysCopy) + await materializeSkillLink({ linkSource: source, target, alwaysCopy }) return 'backed-up' } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - await doCreateLink(source, target, alwaysCopy) + await materializeSkillLink({ linkSource: source, target, alwaysCopy }) return 'created' } throw err } } - -async function doCreateLink ( - source: string, - target: string, - alwaysCopy: boolean -): Promise { - if (alwaysCopy) { - await cp(source, target, { recursive: true, force: true }) - return - } - - if (IS_WINDOWS) { - try { - await symlink(source, target, 'junction') - } catch { - await cp(source, target, { recursive: true, force: true }) - } - return - } - - await symlink(source, target, 'dir') -} diff --git a/packages/core/src/update/antigravity-transaction.ts b/packages/core/src/update/antigravity-transaction.ts index 5fed1e6..caa9d0e 100644 --- a/packages/core/src/update/antigravity-transaction.ts +++ b/packages/core/src/update/antigravity-transaction.ts @@ -1,13 +1,15 @@ import { readFile, writeFile } from 'node:fs/promises' import { existsSync, readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' import path from 'node:path' +import { findNodeAtLocation, getNodeValue, parseTree, type Node } from 'jsonc-parser' import { resolveHome } from '../utils/path.js' import type { CommandRunner, UpdateError, UpdatePlanItem } from './types.js' import { isStableVersion } from './version.js' -import { createHash } from 'node:crypto' import { copyOwnedPath, createSiblingBackupPath, ownedPathKind, removeOwnedPath } from './fs-transaction.js' import type { SiblingBackupPath } from './fs-transaction.js' import { runTransactionCommands } from './transaction-commands.js' +import { nativePayloadTreeDigest } from './native-payload.js' export interface AntigravityTransactionResult { success: boolean @@ -21,9 +23,15 @@ interface AntigravityBackupSnapshot { manifest: { target: string; backup: string; existed: boolean; complete: boolean } } +/** Injectable dependencies for deterministic tests. */ +export interface AntigravityTransactionDependencies { + restoreState?: () => Promise +} + export async function executeAntigravityTransaction ( item: UpdatePlanItem, - commandRunner: CommandRunner + commandRunner: CommandRunner, + dependencies: AntigravityTransactionDependencies = {} ): Promise { if (item.source.kind !== 'antigravity-git') { return { success: false, rollbackAttempted: false, error: { code: 'INVALID_ANTIGRAVITY_SOURCE', message: 'Antigravity source is not the fixed GitHub root' } } @@ -66,10 +74,27 @@ export async function executeAntigravityTransaction ( let mutationStarted = false let rollbackAttempted = false let preserveBackup = false + let originalManifestText: string | undefined + // Exact post-mutation state this transaction is authorized to replace + // during rollback. + let authorizedRootDigest: string | null | undefined + let authorizedManifestDigest: string | null | undefined const backupSnapshot = (): AntigravityBackupSnapshot => ({ root: { target: pluginRoot, backup: rootBackup, existed: rootExisted, complete: rootBackupComplete }, manifest: { target: manifestPath, backup: manifestBackup, existed: manifestExisted, complete: manifestBackupComplete }, }) + // Single guarded post-mutation rollback path: a failed restore always + // preserves both sibling backup containers for manual recovery. + let rollbackSucceeded: boolean | undefined + const attemptRollback = async (): Promise => { + rollbackAttempted = true + const succeeded = await (dependencies.restoreState + ? dependencies.restoreState() + : restore(backupSnapshot(), { rootDigest: authorizedRootDigest, manifestDigest: authorizedManifestDigest })) + rollbackSucceeded = succeeded + if (!succeeded) preserveBackup = true + return succeeded + } try { // Do not enter rollback handling until every original asset has a complete @@ -82,7 +107,9 @@ export async function executeAntigravityTransaction ( rootBackupComplete = true } if (manifestExisted) { - await writeFile(manifestBackup, await readFile(manifestPath), { mode: 0o600 }) + const originalManifest = await readFile(manifestPath) + originalManifestText = originalManifest.toString('utf8') + await writeFile(manifestBackup, originalManifest, { mode: 0o600 }) manifestBackupComplete = true } backupsComplete = rootBackupComplete && manifestBackupComplete @@ -96,6 +123,10 @@ export async function executeAntigravityTransaction ( mutationStarted = true const commandResult = await runTransactionCommands(item.steps, commandRunner) + // Capture the exact post-mutation state this transaction is authorized to + // replace during rollback, whether the commands succeeded or not. + authorizedRootDigest = existsSync(pluginRoot) ? treeDigest(pluginRoot) : null + authorizedManifestDigest = existsSync(manifestPath) ? sha256Hex(readFileSync(manifestPath)) : null if (!commandResult.success) { const { result } = commandResult if (result.timedOut && result.treeTerminated !== true) { @@ -109,8 +140,7 @@ export async function executeAntigravityTransaction ( }, } } - rollbackAttempted = true - const rollbackSucceeded = await restore(backupSnapshot()) + await attemptRollback() return { success: false, rollbackAttempted, @@ -121,9 +151,9 @@ export async function executeAntigravityTransaction ( } } - if (!validateStagedPlugin(pluginRoot, manifestPath, item.version.latest, item.artifact?.kind === 'git' ? item.artifact.contentDigest : undefined)) { - rollbackAttempted = true - const rollbackSucceeded = await restore(backupSnapshot()) + if (!validateStagedPlugin(pluginRoot, manifestPath, item.version.latest, item.artifact?.kind === 'git' ? item.artifact.contentDigest : undefined) || + (originalManifestText !== undefined && !preservesUnrelatedManifestBytes(originalManifestText, readFileSync(manifestPath, 'utf8')))) { + await attemptRollback() return { success: false, rollbackAttempted, @@ -133,10 +163,7 @@ export async function executeAntigravityTransaction ( } return { success: true, rollbackAttempted: false } } catch { - rollbackAttempted = backupsComplete && mutationStarted - const rollbackSucceeded = rollbackAttempted - ? await restore(backupSnapshot()) - : undefined + if (backupsComplete && mutationStarted) await attemptRollback() return { success: false, rollbackAttempted, @@ -165,7 +192,7 @@ export function validateStagedPlugin (pluginRoot: string, manifestPath: string, if (!plugin || typeof plugin !== 'object') return false const bundle = JSON.parse(readFileSync(path.join(pluginRoot, 'bundle.json'), 'utf8')) as { version?: unknown; skills?: Array<{ name?: unknown; path?: unknown }> } if (expectedVersion !== undefined && (!isStableVersion(bundle.version) || bundle.version !== expectedVersion)) return false - if (expectedDigest && createHash('sha256').update(readFileSync(path.join(pluginRoot, 'bundle.json'))).digest('hex') !== expectedDigest) return false + if (expectedDigest && nativePayloadTreeDigest(pluginRoot) !== expectedDigest) return false if (!Array.isArray(bundle.skills) || bundle.skills.length === 0) return false for (const skill of bundle.skills) { if (typeof skill.name !== 'string' || typeof skill.path !== 'string') return false @@ -184,17 +211,100 @@ export function validateStagedPlugin (pluginRoot: string, manifestPath: string, } } +/** + * Byte-level preservation check for the Antigravity import manifest. + * + * An import belongs to this plugin only when its key is exactly + * `nsolid-plugin` or its value declares exactly `name`/`plugin: + * nsolid-plugin`. Everything outside the owned node(s) — comments, CRLF line + * endings, indentation, sibling imports such as `my-nsolid-plugin-helper` — + * must remain byte-for-byte identical. + */ +export function preservesUnrelatedManifestBytes (beforeText: string, afterText: string): boolean { + try { + const beforeRanges = locateOwnImportRanges(beforeText) + if (beforeRanges.length === 0) return afterText === beforeText + const afterRanges = locateOwnImportRanges(afterText) + if (afterRanges.length === 0) return false + const outsideBefore = removeRanges(beforeText, beforeRanges) + const outsideAfter = removeRanges(afterText, afterRanges) + if (outsideBefore !== outsideAfter) return false + // Anchor the position of the owned node relative to the untouched bytes. + const first = beforeRanges[0] + const last = beforeRanges[beforeRanges.length - 1] + return afterText.startsWith(beforeText.slice(0, first.start)) && afterText.endsWith(beforeText.slice(last.end)) + } catch { + return false + } +} + +interface ByteRange { start: number; end: number } + +function removeRanges (text: string, ranges: readonly ByteRange[]): string { + let result = '' + let cursor = 0 + for (const range of ranges) { + result += text.slice(cursor, range.start) + cursor = range.end + } + return result + text.slice(cursor) +} + +function locateOwnImportRanges (text: string): ByteRange[] { + const tree = parseTree(text) + if (!tree || tree.type !== 'object') throw new Error('manifest is not a JSON object') + const imports = findNodeAtLocation(tree, ['imports']) + if (!imports) return [] + const ranges: ByteRange[] = [] + if (imports.type === 'array') { + for (const item of imports.children ?? []) { + if (isPluginImport(getNodeValue(item))) ranges.push({ start: item.offset, end: item.offset + item.length }) + } + } else if (imports.type === 'object') { + for (const property of imports.children ?? []) { + const keyNode: Node | undefined = property.children?.[0] + const valueNode: Node | undefined = property.children?.[1] + if (!keyNode || !valueNode) continue + const key = getNodeValue(keyNode) + if (key === 'nsolid-plugin' || isPluginImport(getNodeValue(valueNode))) { + ranges.push({ start: property.offset, end: property.offset + property.length }) + } + } + } + return ranges.sort((left, right) => left.start - right.start) +} + function isPluginImport (entry: unknown): boolean { - if (!entry || typeof entry !== 'object') return false + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false const value = entry as { name?: unknown; plugin?: unknown } return value.name === 'nsolid-plugin' || value.plugin === 'nsolid-plugin' } +function treeDigest (target: string): string | undefined { + return nativePayloadTreeDigest(target) +} + +function sha256Hex (value: Buffer): string { + return createHash('sha256').update(value).digest('hex') +} + async function restore ( - snapshot: AntigravityBackupSnapshot + snapshot: AntigravityBackupSnapshot, + authorized: { rootDigest?: string | null; manifestDigest?: string | null } ): Promise { try { + const rootOriginalDigest = snapshot.root.existed ? treeDigest(snapshot.root.backup) : null + const manifestOriginalDigest = snapshot.manifest.existed ? sha256Hex(readFileSync(snapshot.manifest.backup)) : null if (!snapshot.root.complete || !snapshot.manifest.complete) return false + // Only restore while the live bytes are still exactly the state this + // transaction produced (or its original state). Concurrent drift is never + // overwritten. + const currentRootDigest = existsSync(snapshot.root.target) ? treeDigest(snapshot.root.target) : null + const expectedRoot = authorized.rootDigest !== undefined ? authorized.rootDigest : rootOriginalDigest + if (currentRootDigest !== expectedRoot) return false + const currentManifestDigest = existsSync(snapshot.manifest.target) ? sha256Hex(readFileSync(snapshot.manifest.target)) : null + const expectedManifest = authorized.manifestDigest !== undefined ? authorized.manifestDigest : manifestOriginalDigest + if (currentManifestDigest !== expectedManifest) return false if (snapshot.root.existed) { await removeOwnedPath(snapshot.root.target) await copyOwnedPath(snapshot.root.backup, snapshot.root.target) @@ -206,6 +316,8 @@ async function restore ( const rootRestored = snapshot.root.existed ? existsSync(snapshot.root.target) : !existsSync(snapshot.root.target) const manifestRestored = snapshot.manifest.existed ? existsSync(snapshot.manifest.target) : !existsSync(snapshot.manifest.target) if (!rootRestored || !manifestRestored) return false + if (snapshot.root.existed && treeDigest(snapshot.root.target) !== rootOriginalDigest) return false + if (snapshot.manifest.existed && sha256Hex(readFileSync(snapshot.manifest.target)) !== manifestOriginalDigest) return false return snapshot.root.existed && snapshot.manifest.existed ? validateStagedPlugin(snapshot.root.target, snapshot.manifest.target) : true } catch { return false diff --git a/packages/core/src/update/claude-transaction.ts b/packages/core/src/update/claude-transaction.ts new file mode 100644 index 0000000..ea9bd21 --- /dev/null +++ b/packages/core/src/update/claude-transaction.ts @@ -0,0 +1,439 @@ +import { createHash, randomBytes } from 'node:crypto' +import { readFile, rm } from 'node:fs/promises' +import { closeSync, existsSync, fchmodSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, statSync, writeSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { CommandRunner, CommandSpec, ResolvedArtifactIdentity, UpdateError } from './types.js' +import { isCommandSuccessful } from './command-runner.js' +import { readClaudePluginScope } from './claude-record.js' +import { nativePayloadDigest } from './native-evidence.js' +import { copyOwnedPath, createSiblingBackupPath, ownedPathKind, removeOwnedPath, type OwnedPathKind, type SiblingBackupPath } from './fs-transaction.js' + +export interface ClaudeTransactionSpec { + commands: readonly CommandSpec[] + /** Byte evidence files bound at planning (plugin registry, marketplace records). */ + registrationPaths: readonly string[] + configPath?: string + pluginId: string + scope: string + expectedVersion?: string + artifact?: ResolvedArtifactIdentity +} + +export interface ClaudeTransactionResult { + success: boolean + rollbackAttempted: boolean + rollbackSucceeded?: boolean + error?: UpdateError + /** Recovery bundle root preserved for deferred or rejected rollbacks. */ + recoveryPath?: string +} + +interface RegistrationSnapshot { + path: string + existed: boolean + bytes?: Buffer + digest?: string + /** Verified on-disk copy inside the recovery bundle (existed files only). */ + backupPath?: string + postDigest?: string | null +} + +/** Self-describing evidence bundle written before the first command runs. */ +interface RecoveryManifest { + version: 1 + complete: boolean + createdAt: string + registration: Array<{ + path: string + existed: boolean + digest?: string + backup?: string + }> + payload?: { + root?: string + kind?: OwnedPathKind + digest?: string + backupPath?: string + backupDirectory?: string + } +} + +interface PayloadSnapshot { + root?: string + backupStorage?: SiblingBackupPath + kind?: OwnedPathKind + /** Immutable pre-mutation digest captured before any command runs. */ + originalDigest?: string + postRoot?: string + postDigest?: string | null +} + +/** Injectable filesystem dependencies for deterministic tests. */ +export interface ClaudeTransactionDependencies { + copyOwnedPath?: typeof copyOwnedPath + /** Deterministic recovery-root allocation for tests; production defaults to a sibling of the primary registration path. */ + allocateWorkspace?: () => string + /** Injectable drift-gated restore used to exercise rejected-rollback retention. */ + restoreState?: (payload: PayloadSnapshot, registration: readonly RegistrationSnapshot[]) => Promise +} + +/** + * Run the native Claude marketplace refresh inside a byte-level transaction: + * registration records, marketplace evidence, and the installed payload are + * backed up before the first command and restored when a command or the + * post-update validation fails. A concurrent modification of the post-update + * state blocks the restore; the backup is preserved and CLAUDE_ROLLBACK_FAILED + * is reported instead of overwriting unknown bytes. + */ +export async function executeClaudeTransaction ( + spec: ClaudeTransactionSpec, + commandRunner: CommandRunner, + dependencies: ClaudeTransactionDependencies = {} +): Promise { + const copyOwned = dependencies.copyOwnedPath ?? copyOwnedPath + const registration: RegistrationSnapshot[] = [] + const payload: PayloadSnapshot = {} + // Authoritative recovery bundle root: created before any evidence is read. + let recoveryRoot: string | undefined + let backupsComplete = false + let mutationStarted = false + let preserveBackup = false + const keepBackup = () => { preserveBackup = true } + + // ---- Backup phase: any read or copy failure aborts before the first + // command. Nothing has been mutated, so there is nothing to restore and a + // partial backup container is removed (it is not recoverable evidence). + try { + recoveryRoot = dependencies.allocateWorkspace?.() ?? defaultRecoveryRoot(spec.registrationPaths) + mkdirSync(path.join(recoveryRoot, 'registration'), { recursive: true }) + for (const [index, evidencePath] of spec.registrationPaths.entries()) { + const target = path.resolve(evidencePath) + if (existsSync(target) && statSync(target).isFile()) { + const bytes = await readFile(target) + const digest = sha256(bytes) + const backupPath = path.join(recoveryRoot, 'registration', `${String(index).padStart(4, '0')}.bin`) + writeDurableFile(backupPath, bytes) + const stored = readFileSync(backupPath) + if (sha256(stored) !== digest) throw new Error('registration backup verification failed') + registration.push({ path: target, existed: true, bytes, digest, backupPath }) + } else { + registration.push({ path: target, existed: false }) + } + } + const previousRoot = installedClaudePayloadRoot(spec.configPath, spec.pluginId, spec.scope) + if (previousRoot) { + payload.root = previousRoot + payload.kind = await ownedPathKind(previousRoot) + if (payload.kind !== 'missing') { + payload.backupStorage = await createSiblingBackupPath(previousRoot, 'payload-backup') + payload.originalDigest = stateDigest(previousRoot) + await copyOwned(previousRoot, payload.backupStorage.path) + // Completeness evidence: only a backup with the same path kind and an + // identical digest may ever be restored. + const backupKind = await ownedPathKind(payload.backupStorage.path) + const backupDigest = stateDigest(payload.backupStorage.path) + if (backupKind !== payload.kind || !backupDigest || backupDigest !== payload.originalDigest) { + throw new Error('backup completeness verification failed') + } + } + } + // The manifest is written last: it only ever describes verified evidence. + const manifest: RecoveryManifest = { + version: 1, + complete: true, + createdAt: new Date().toISOString(), + registration: registration.map((entry) => entry.existed + ? { path: entry.path, existed: true, digest: entry.digest, backup: path.relative(recoveryRoot!, entry.backupPath!) } + : { path: entry.path, existed: false }), + payload: payload.backupStorage + ? { + root: payload.root, + kind: payload.kind, + digest: payload.originalDigest, + backupPath: payload.backupStorage.path, + backupDirectory: payload.backupStorage.directory, + } + : undefined, + } + const manifestPath = path.join(recoveryRoot, 'recovery.json') + writeDurableFile(manifestPath, Buffer.from(JSON.stringify(manifest, null, 2) + '\n')) + const verification = JSON.parse(readFileSync(manifestPath, 'utf8')) as RecoveryManifest + if (verification.complete !== true || verification.registration.length !== registration.length) { + throw new Error('recovery manifest verification failed') + } + backupsComplete = true + } catch { + if (payload.backupStorage) await removeOwnedPath(payload.backupStorage.directory).catch(() => {}) + if (recoveryRoot) await rm(recoveryRoot, { recursive: true, force: true }).catch(() => {}) + return { + success: false, + rollbackAttempted: false, + error: { code: 'CLAUDE_BACKUP_FAILED', message: 'Claude registration or payload backup could not be completed' }, + } + } + + try { + mutationStarted = true + for (const command of spec.commands) { + const result = await commandRunner.run(command) + if (!isCommandSuccessful(result)) { + // Never restore while descendants may still be writing the same bytes: + // defer rollback and keep the backup recoverable, as the Codex and + // Antigravity transactions do. + if (result.timedOut && result.treeTerminated !== true) { + keepBackup() + return { + success: false, + rollbackAttempted: false, + recoveryPath: recoveryRoot, + error: { + code: 'CLAUDE_TREE_TERMINATION_UNCONFIRMED', + message: `Claude timed out and descendant termination could not be confirmed; the pre-update recovery bundle was preserved at ${recoveryRoot}`, + }, + } + } + const error: UpdateError = result.spawnErrorCode === 'ENOENT' + ? { code: 'MISSING_EXECUTABLE', message: `${command.executable} executable was not found on PATH` } + : result.timedOut + ? { code: 'CLAUDE_COMMAND_TIMEOUT', message: 'Claude marketplace refresh timed out' } + : { code: 'CLAUDE_COMMAND_FAILED', message: 'Claude marketplace refresh command failed' } + return await fail(spec, payload, registration, recoveryRoot, { success: false, rollbackAttempted: true }, error, keepBackup, dependencies) + } + } + + payload.postRoot = installedClaudePayloadRoot(spec.configPath, spec.pluginId, spec.scope, spec.expectedVersion) + payload.postDigest = payload.postRoot ? stateDigest(payload.postRoot) : null + for (const entry of registration) entry.postDigest = existsSync(entry.path) ? stateDigest(entry.path) : null + + if (spec.artifact && (spec.artifact.kind === 'git' || spec.artifact.kind === 'local-snapshot')) { + if (!payload.postRoot || !payload.postDigest || payload.postDigest !== spec.artifact.contentDigest) { + return await fail(spec, payload, registration, recoveryRoot, { success: false, rollbackAttempted: true }, { + code: 'CLAUDE_CONTENT_MISMATCH', + message: 'Claude installed payload did not match the planned source identity', + }, keepBackup, dependencies) + } + } + return { success: true, rollbackAttempted: false } + } catch { + return await fail(spec, payload, registration, recoveryRoot, { + success: false, + rollbackAttempted: mutationStarted && backupsComplete, + }, { code: 'CLAUDE_TRANSACTION_FAILED', message: 'Claude replacement transaction failed' }, keepBackup, dependencies) + } finally { + if (!preserveBackup) { + if (recoveryRoot) await rm(recoveryRoot, { recursive: true, force: true }).catch(() => {}) + if (payload.backupStorage) await removeOwnedPath(payload.backupStorage.directory).catch(() => {}) + } + } +} + +async function fail ( + transactionSpec: ClaudeTransactionSpec, + payload: PayloadSnapshot, + registration: readonly RegistrationSnapshot[], + recoveryRoot: string | undefined, + base: ClaudeTransactionResult, + error: UpdateError, + keepBackup: () => void, + dependencies: ClaudeTransactionDependencies = {} +): Promise { + // Anchor the authorized post-mutation state to whatever this transaction + // actually left behind when capture did not run (command failures). + if (payload.postRoot === undefined) payload.postRoot = installedClaudePayloadRoot(transactionSpec.configPath, transactionSpec.pluginId, transactionSpec.scope) + if (payload.postDigest === undefined && payload.postRoot) payload.postDigest = stateDigest(payload.postRoot) ?? null + for (const entry of registration) { + if (entry.postDigest === undefined) entry.postDigest = existsSync(entry.path) ? stateDigest(entry.path) : null + } + const rollback = dependencies.restoreState + ? await dependencies.restoreState(payload, registration) + : await restore(payload, registration) + if (!rollback) { + keepBackup() + return { + ...base, + rollbackSucceeded: false, + recoveryPath: recoveryRoot, + error: { + code: 'CLAUDE_ROLLBACK_FAILED', + message: `Claude native state drifted during the failed update and could not be restored; the pre-update recovery bundle was preserved at ${recoveryRoot}`, + }, + } + } + return { ...base, rollbackSucceeded: true, error } +} + +/** + * Restore the backed-up native state. Exposed for direct drift-gate testing: + * restoration only proceeds while every live byte still matches the exact + * post-update state this transaction produced. + */ +export async function restoreClaudeNativeState ( + payload: PayloadSnapshot, + registration: readonly RegistrationSnapshot[] +): Promise { + return restore(payload, registration) +} + +export interface ClaudeRegistrationSnapshot { + path: string + existed: boolean + bytes?: Buffer + digest?: string + postDigest?: string | null +} + +async function restore (payload: PayloadSnapshot, registration: readonly RegistrationSnapshot[]): Promise { + try { + // Authoritative pre-mutation digest for every backup comparison below. + // Before any live byte is touched, the backup must still be the same path + // kind and still hash to the exact original digest: a backup altered after + // its initial verification is never restored. Manually constructed + // drift-gate snapshots without a captured original fall back to the + // current backup bytes. + let payloadOriginalDigest: string | null = null + if (payload.kind && payload.kind !== 'missing' && payload.backupStorage) { + const backupKind = await ownedPathKind(payload.backupStorage.path) + const backupDigest = stateDigest(payload.backupStorage.path) ?? null + payloadOriginalDigest = payload.originalDigest ?? backupDigest + if (backupKind !== payload.kind || backupDigest !== payloadOriginalDigest) return false + } + // Only restore while every live byte still matches the exact post-update + // state this transaction produced. Anything else is concurrent drift and + // must never be overwritten. + for (const entry of registration) { + if (entry.postDigest === undefined) return false + const current = existsSync(entry.path) ? stateDigest(entry.path) : null + if (current !== entry.postDigest) return false + } + if (payload.postRoot !== undefined) { + const current = existsSync(payload.postRoot) ? stateDigest(payload.postRoot) : null + if (current !== payload.postDigest) return false + } else if (payload.root) { + // The failed update removed the plugin registration, so no post-update + // root remains resolvable. The only authorized states for the original + // payload location are the original bytes or their absence. + const original = payloadOriginalDigest + const current = existsSync(payload.root) ? stateDigest(payload.root) : null + if (current !== original && current !== null) return false + } + // Restore the payload first so the restored registration never points at + // missing bytes. The original payload comes back even when the failed + // update left no resolvable post-update root. + if (payload.root && payload.kind && payload.kind !== 'missing' && payload.backupStorage) { + if (payload.postRoot) await removeOwnedPath(payload.postRoot) + await removeOwnedPath(payload.root) + await copyOwnedPath(payload.backupStorage.path, payload.root) + } else if (payload.postRoot) { + await removeOwnedPath(payload.postRoot) + } + for (const entry of registration) { + if (!entry.existed) { + await rm(entry.path, { force: true }) + continue + } + // Restore from the verified on-disk backup; in-memory bytes are only a + // fallback for manually constructed drift-gate snapshots. + let restoreBytes = entry.bytes + if (entry.backupPath) { + if (!existsSync(entry.backupPath)) return false + const diskBytes = readFileSync(entry.backupPath) + if (entry.digest && sha256(diskBytes) !== entry.digest) return false + restoreBytes = diskBytes + } + if (!restoreBytes) return false + // Durable 0600 restore: temp file with the private mode plus rename, so + // an existing live file cannot keep its wider permissions. + writeDurableFile(entry.path, restoreBytes) + // A private mode is part of the restored contract: the final file is + // verified explicitly because creation modes are umask-filtered. + if ((statSync(entry.path).mode & 0o777) !== 0o600) return false + } + for (const entry of registration) { + const restored = existsSync(entry.path) ? stateDigest(entry.path) : null + if (restored !== (entry.existed ? entry.digest ?? null : null)) return false + } + if (payload.root) { + const restored = existsSync(payload.root) ? stateDigest(payload.root) : null + if (restored !== payloadOriginalDigest) return false + } + return true + } catch { + return false + } +} + +/** Resolve the single installed payload directory for a scoped Claude plugin. */ +export function installedClaudePayloadRoot ( + configPath: string | undefined, + pluginId: string, + scope: string, + expectedVersion?: string +): string | undefined { + if (!configPath || !path.isAbsolute(configPath)) return undefined + try { + const data = JSON.parse(readFileSync(configPath, 'utf8')) as unknown + if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined + const plugins = (data as Record).plugins + if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return undefined + const value = (plugins as Record)[pluginId] + const records = Array.isArray(value) ? value : [value] + const roots = records.flatMap((record) => { + if (!record || typeof record !== 'object' || Array.isArray(record)) return [] + const entry = record as Record + if (readClaudePluginScope(entry) !== scope) return [] + if (expectedVersion && typeof entry.version === 'string' && entry.version !== expectedVersion) return [] + if (typeof entry.installPath !== 'string' || !path.isAbsolute(entry.installPath)) return [] + const root = path.resolve(entry.installPath) + return existsSync(root) ? [root] : [] + }) + return roots.length === 1 ? roots[0] : undefined + } catch { + return undefined + } +} + +/** Canonical digest of a registration file or payload directory tree. */ +function stateDigest (target: string): string | undefined { + try { + if (statSync(target).isFile()) { + return createHash('sha256').update(readFileSync(target)).digest('hex') + } + return nativePayloadDigest(target) + } catch { + return undefined + } +} + +function defaultRecoveryRoot (registrationPaths: readonly string[]): string { + const primary = registrationPaths.length > 0 + ? path.dirname(path.resolve(registrationPaths[0])) + : tmpdir() + // Synchronous allocation keeps backup setup free of interleaving, and the + // sibling location keeps the bundle associated with the registration evidence. + return mkdtempSync(path.join(primary, '.nsolid-claude-recovery-')) +} + +/** Repo-standard durable write: temp file, fsync, rename, best-effort dir fsync. */ +function writeDurableFile (target: string, bytes: Buffer): void { + const temporary = `${target}.${process.pid}.${randomBytes(6).toString('hex')}.tmp` + const fd = openSync(temporary, 'w', 0o600) + // The open(2) mode is umask-filtered, so the private mode is enforced + // explicitly and applies to every durable write this module performs. + fchmodSync(fd, 0o600) + try { + writeSync(fd, bytes) + fsyncSync(fd) + } finally { + closeSync(fd) + } + renameSync(temporary, target) + try { + const directory = openSync(path.dirname(target), 'r') + try { fsyncSync(directory) } finally { closeSync(directory) } + } catch { /* directory fsync is unavailable on some platforms */ } +} + +function sha256 (value: Buffer): string { + return createHash('sha256').update(value).digest('hex') +} diff --git a/packages/core/src/update/codex-transaction.ts b/packages/core/src/update/codex-transaction.ts index aafa1bb..177789c 100644 --- a/packages/core/src/update/codex-transaction.ts +++ b/packages/core/src/update/codex-transaction.ts @@ -178,9 +178,7 @@ export async function executeCodexTransaction ( } } if (item.artifact && (item.artifact.kind === 'git' || item.artifact.kind === 'local-snapshot')) { - const versionSource = item.source.kind === 'codex-marketplace' ? item.source.versionSource : undefined - const manifestPath = versionSource && versionSource.kind !== 'unknown' ? versionSource.manifestPath : undefined - const digest = selectedPayload ? nativePayloadDigest(selectedPayload, manifestPath) : undefined + const digest = selectedPayload ? nativePayloadDigest(selectedPayload) : undefined if (!selectedPayload || !digest || digest !== item.artifact.contentDigest) { rollbackAttempted = true const rollbackSucceeded = await restoreFiles(backupSnapshot()) diff --git a/packages/core/src/update/command-runner.ts b/packages/core/src/update/command-runner.ts index af7393a..5a496cb 100644 --- a/packages/core/src/update/command-runner.ts +++ b/packages/core/src/update/command-runner.ts @@ -388,7 +388,7 @@ async function terminateTree (pid: number): Promise { // `taskkill /T` includes descendants. Do not report success until taskkill // itself exits successfully and the original pid is no longer observable. const exitCode = await new Promise((resolve) => { - const killer = spawn('taskkill.exe', ['/pid', String(pid), '/T', '/F'], { + const killer = spawn(windowsTaskkillPath(process.env.SystemRoot), ['/pid', String(pid), '/T', '/F'], { shell: false, windowsHide: true, stdio: 'ignore', @@ -419,6 +419,11 @@ async function terminateTree (pid: number): Promise { return await waitForProcessGroupExit(pid, 500) } +export function windowsTaskkillPath (systemRoot = 'C:\\Windows'): string { + if (!path.win32.isAbsolute(systemRoot) || /^[\\/]{2}/.test(systemRoot)) return 'C:\\Windows\\System32\\taskkill.exe' + return path.win32.join(path.win32.normalize(systemRoot), 'System32', 'taskkill.exe') +} + async function waitForProcessExit (pid: number, timeoutMs = 1_000): Promise { return await waitUntilGone(() => process.kill(pid, 0), timeoutMs) } diff --git a/packages/core/src/update/fallback-journal.ts b/packages/core/src/update/fallback-journal.ts index 37fd848..5b0388e 100644 --- a/packages/core/src/update/fallback-journal.ts +++ b/packages/core/src/update/fallback-journal.ts @@ -1,31 +1,46 @@ -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { cp, lstat, mkdtemp, open, readFile, readlink, readdir, rename, rm, writeFile } from 'node:fs/promises' import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import type { FallbackTransactionIdentity } from './types.js' import { isValidTrackingData, type TrackingData } from '../skills/skill-tracker.js' import { isCanonicalPath, isSameOrContained, matchesTrackedOwnership } from './fallback-ownership.js' +import { assertSafeSkillName } from '../utils/skill-name.js' export type FallbackJournalPhase = 'prepared' | 'mutating' | 'committed' +export type FallbackPathKind = 'missing' | 'file' | 'directory' | 'symlink' | 'other' + +export interface FallbackJournalEntry { + path: string + existed: boolean + kind: FallbackPathKind + /** Digest of the original live state at journal time. */ + digest?: string + /** Backup of the original state inside the journal snapshot directory. */ + backup?: string + /** Sibling staged payload (same volume) waiting to be swapped into place. */ + stage?: string + stageDigest?: string + /** Exact live state the parent is authorized to replace during rollback. */ + expectedCurrentDigest?: string | null + /** Set once the staged payload (or deletion) has been swapped in. */ + applied?: boolean + /** Sibling quarantine path receiving the replaced live bytes until commit. */ + quarantine?: string +} export interface FallbackJournal { - version: 1 + version: 2 phase: FallbackJournalPhase manifest: FallbackTransactionIdentity journalPath: string snapshotDirectory: string + /** Secret shared with the authorized child transaction. Authenticates only. */ + nonce?: string + mutator?: { pid: number; nonce: string; claimedAt: string } entries: readonly FallbackJournalEntry[] } -interface FallbackJournalEntry { - path: string - backup: string - existed: boolean - digest?: string - /** Exact live state the parent is authorized to replace during rollback. */ - expectedCurrentDigest?: string | null -} - export interface FallbackJournalResult { journal: FallbackJournal rollbackSucceeded?: boolean @@ -55,19 +70,27 @@ export async function beginFallbackJournal (manifest: FallbackTransactionIdentit trackingPath, ...manifest.ownedSkillPaths, ...manifest.ownedLinkPaths, - ...manifest.ownedMcpFields.map((field) => field.configPath), + ...manifest.ownedMcpConfigPaths, ].map((value) => path.resolve(value)))] const entries: FallbackJournalEntry[] = [] try { for (const [index, target] of paths.entries()) { - const existed = existsSync(target) + const kind = await pathKind(target) const backup = path.join(snapshotDirectory, String(index)) - const digest = existed ? await pathDigest(target) : undefined - if (existed && !digest) throw new Error(`cannot digest ${target}`) - if (existed) await cp(target, backup, { recursive: true, force: true }) - entries.push({ path: target, backup, existed, digest, expectedCurrentDigest: digest ?? null }) + const digest = kind !== 'missing' ? await pathDigest(target) : undefined + if (kind !== 'missing' && !digest) throw new Error(`cannot digest ${target}`) + if (kind !== 'missing') await cp(target, backup, { recursive: true, force: true, verbatimSymlinks: true, dereference: false }) + entries.push({ path: target, kind, existed: kind !== 'missing', digest, backup: kind !== 'missing' ? backup : undefined, expectedCurrentDigest: digest ?? null }) + } + const journal: FallbackJournal = { + version: 2, + phase: 'prepared', + manifest, + journalPath, + snapshotDirectory, + nonce: manifest.nonce ?? randomUUID(), + entries, } - const journal: FallbackJournal = { version: 1, phase: 'prepared', manifest, journalPath, snapshotDirectory, entries } await writeDurable(journalPath, journal) return { journal } } catch (error) { @@ -76,53 +99,203 @@ export async function beginFallbackJournal (manifest: FallbackTransactionIdentit } } +/** + * Durable append of new bundle destinations (skills and harness links) that + * were unknown when the parent created the journal. The verified child calls + * this before staging anything: each destination that is not journaled yet + * becomes an entry whose original state (usually `missing`) is snapshotted + * first, so recovery can explain and undo the created path. + */ +export async function appendFallbackJournalEntries (journal: FallbackJournal, targets: readonly string[]): Promise { + journal = await reloadFallbackJournal(journal) + if (!isSafeJournal(journal) || !await journalOwnershipIsValid(journal)) throw new Error('Invalid fallback journal') + const approvedRoots = new Set((journal.manifest.approvedDestinationRoots ?? []).map((value) => path.resolve(value))) + const entries = [...journal.entries] + for (const target of targets) { + const resolved = path.resolve(target) + if (entries.some((entry) => path.resolve(entry.path) === resolved)) continue + // Defense in depth: the child may only journal new destinations directly + // inside a manifest-approved root, under a safe skill name. + if (!approvedRoots.has(path.dirname(resolved))) throw new Error(`Fallback destination ${resolved} is outside the approved destination roots`) + try { + assertSafeSkillName(path.basename(resolved)) + } catch { + throw new Error(`Fallback destination ${resolved} has an unsafe name`) + } + const kind = await pathKind(resolved) + const index = entries.length + const backup = path.join(journal.snapshotDirectory, String(index)) + const digest = kind !== 'missing' ? await pathDigest(resolved) : undefined + if (kind !== 'missing' && !digest) throw new Error(`cannot digest ${resolved}`) + if (kind !== 'missing') await cp(resolved, backup, { recursive: true, force: true, verbatimSymlinks: true, dereference: false }) + entries.push({ path: resolved, kind, existed: kind !== 'missing', digest, backup: kind !== 'missing' ? backup : undefined, expectedCurrentDigest: digest ?? null }) + } + if (entries.length === journal.entries.length) return journal + const updated = { ...journal, entries } + await writeDurable(journal.journalPath, updated) + return updated +} + export async function markFallbackJournalMutating (journal: FallbackJournal): Promise { const updated = { ...journal, phase: 'mutating' as const } await writeDurable(journal.journalPath, updated) return updated } +/** + * Claim the right to mutate. The manifest nonce authenticates the child + * process spawned by the journal owner; it never authorizes a destructive + * restoration by itself. + */ +export async function claimFallbackJournalMutation (manifest: FallbackTransactionIdentity, pid = process.pid): Promise { + const journalPath = fallbackJournalPath(manifest.trackingPath) + if (!existsSync(journalPath)) return true + try { + const journal = JSON.parse(await readFile(journalPath, 'utf8')) as FallbackJournal + if (!isSafeJournal(journal) || journal.phase !== 'mutating' || !sameManifest(journal.manifest, manifest)) return false + if (!journal.nonce || journal.nonce !== manifest.nonce) return false + if (!await journalOwnershipIsValid(journal)) return false + await writeDurable(journalPath, { ...journal, mutator: { pid, nonce: manifest.nonce ?? '', claimedAt: new Date().toISOString() } }) + return true + } catch { + return false + } +} + +/** Register a staged replacement payload for one entry. The stage is a sibling of the target. */ +export async function registerFallbackStage (journal: FallbackJournal, target: string, payload: { directory?: string; bytes?: Buffer }): Promise { + journal = await reloadFallbackJournal(journal) + if (!isSafeJournal(journal) || !await journalOwnershipIsValid(journal)) throw new Error('Invalid fallback journal') + const resolved = path.resolve(target) + const entry = journal.entries.find((candidate) => path.resolve(candidate.path) === resolved) + if (!entry) throw new Error(`No fallback journal entry for ${resolved}`) + const stageDirectory = await mkdtemp(path.join(path.dirname(resolved), `.${path.basename(resolved).replace(/^\.+/, '')}.nsolid-stage-`)) + const stagePath = path.join(stageDirectory, 'payload') + if (payload.directory) { + await cp(payload.directory, stagePath, { recursive: true, force: true, verbatimSymlinks: true, dereference: false }) + } else if (payload.bytes) { + await writeFile(stagePath, payload.bytes, { mode: 0o600 }) + } else { + throw new Error('A staged payload requires a directory or bytes') + } + const stageDigest = await pathDigest(stagePath) + if (!stageDigest) { + await rm(stageDirectory, { recursive: true, force: true }).catch(() => {}) + throw new Error(`Cannot digest staged payload for ${resolved}`) + } + const entries = journal.entries.map((candidate) => candidate === entry + ? { ...candidate, stage: stagePath, stageDigest, applied: false } + : candidate) + const updated = { ...journal, entries } + await writeDurable(journal.journalPath, updated) + return updated +} + +/** + * Swap one entry's staged payload into place on the same volume. The replaced + * live bytes move to a sibling quarantine and survive until commit; a deletion + * is a quarantine move only. + */ +export async function applyFallbackEntry (journal: FallbackJournal, target: string): Promise { + journal = await reloadFallbackJournal(journal) + if (!isSafeJournal(journal) || !await journalOwnershipIsValid(journal)) throw new Error('Invalid fallback journal') + const resolved = path.resolve(target) + const entry = journal.entries.find((candidate) => path.resolve(candidate.path) === resolved) + if (!entry) throw new Error(`No fallback journal entry for ${resolved}`) + if (entry.stage) { + const stageDigest = await pathDigest(entry.stage) + if (!stageDigest || stageDigest !== entry.stageDigest) throw new Error(`Staged payload for ${resolved} no longer matches its registered digest`) + } + const updatedEntries = [...journal.entries] + const index = updatedEntries.indexOf(entry) + + // Pre-allocate the quarantine container and durably register it BEFORE the + // live path moves. A termination in any later window leaves a journal that + // explains the missing target and stays recoverable. + const kind = await pathKind(resolved) + // Re-validate the live path against the journaled snapshot immediately + // before the first mutation: a concurrent writer must never be clobbered by + // the swap. A destination journaled as missing that now exists is drift too. + const liveDigest = kind !== 'missing' ? await pathDigest(resolved) : null + if (liveDigest === undefined || liveDigest !== (entry.expectedCurrentDigest ?? null)) { + // Abort without touching the concurrent bytes. Any pre-registered + // quarantine and the staged payload stay journaled and recoverable. + throw new Error(`Fallback target ${resolved} drifted after journaling; aborting without touching concurrent bytes`) + } + if (kind !== 'missing' && entry.quarantine === undefined) { + const storage = await mkdtemp(path.join(path.dirname(resolved), `.${path.basename(resolved).replace(/^\.+/, '')}.nsolid-quarantine-`)) + const quarantinePath = path.join(storage, path.basename(resolved)) + updatedEntries[index] = { ...entry, quarantine: quarantinePath } + await writeDurable(journal.journalPath, { ...journal, entries: updatedEntries }) + } + + if (kind !== 'missing') { + await rename(resolved, updatedEntries[index].quarantine!) + } + if (entry.stage) { + await rename(entry.stage, resolved) + const appliedDigest = await pathDigest(resolved) + if (!appliedDigest || appliedDigest !== entry.stageDigest) throw new Error(`Swap for ${resolved} did not produce the staged digest`) + } + updatedEntries[index] = { ...updatedEntries[index], applied: true } + const updated = { ...journal, entries: updatedEntries } + await writeDurable(journal.journalPath, updated) + return updated +} + export async function captureFallbackJournalState (journal: FallbackJournal): Promise { + journal = await reloadFallbackJournal(journal) if (!isSafeJournal(journal) || !await journalOwnershipIsValid(journal)) throw new Error('Invalid fallback journal') const entries: FallbackJournalEntry[] = [] for (const entry of journal.entries) { - const expectedCurrentDigest = existsSync(entry.path) ? await pathDigest(entry.path) : null + const expectedCurrentDigest = await pathKind(entry.path) !== 'missing' ? await pathDigest(entry.path) : null if (expectedCurrentDigest === undefined) throw new Error('Fallback state cannot be identified') entries.push({ ...entry, expectedCurrentDigest }) } - const updated = { ...journal, entries } + const updated = { ...journal, entries, mutator: undefined } await writeDurable(journal.journalPath, updated) return updated } export async function commitFallbackJournal (journal: FallbackJournal): Promise { + journal = await reloadFallbackJournal(journal) if (!isSafeJournal(journal) || !await journalOwnershipIsValid(journal)) throw new Error('Invalid fallback journal') await writeDurable(journal.journalPath, { ...journal, phase: 'committed' }) await rm(journal.journalPath, { force: true }) - await rm(journal.snapshotDirectory, { recursive: true, force: true }) + await rm(journal.snapshotDirectory, { recursive: true, force: true }).catch(() => {}) + await removeEntryArtifacts(journal.entries) } +/** + * Restore every entry to its original registered state. Restoring is only + * allowed while target, stage, and backup all still match the digests + * registered in the journal; an unknown digest is drift, nothing is + * overwritten, and the artifacts are preserved. Process liveness never + * substitutes for that proof. + */ export async function restoreFallbackJournal (journal: FallbackJournal): Promise { if (!isSafeJournal(journal) || !await journalOwnershipIsValid(journal)) return false try { - if (!await journalStateMatchesExpected(journal)) return false for (const entry of journal.entries) { - if (!await entryStateMatchesExpected(entry)) return false - if (entry.existed) { - await rm(entry.path, { recursive: true, force: true }) - await cp(entry.backup, entry.path, { recursive: true, force: true }) - } else { - await rm(entry.path, { recursive: true, force: true }) + if (!await entryStateIsAuthorized(entry)) return false + } + for (const entry of journal.entries) { + const kind = await pathKind(entry.path) + if (kind !== 'missing') await rm(entry.path, { recursive: true, force: true }) + if (entry.existed && entry.backup) { + await cp(entry.backup, entry.path, { recursive: true, force: true, verbatimSymlinks: true, dereference: false }) } } const valid = await Promise.all(journal.entries.map(async (entry) => { - if (!entry.existed) return !existsSync(entry.path) - if (!existsSync(entry.path) || !entry.digest) return false + const kind = await pathKind(entry.path) + if (entry.kind === 'missing') return kind === 'missing' + if (kind === 'missing' || !entry.digest) return false return await pathDigest(entry.path) === entry.digest })).then((values) => values.every(Boolean)) if (valid) { await rm(journal.journalPath, { force: true }) - await rm(journal.snapshotDirectory, { recursive: true, force: true }) + await rm(journal.snapshotDirectory, { recursive: true, force: true }).catch(() => {}) + await removeEntryArtifacts(journal.entries) } return valid } catch { @@ -130,22 +303,68 @@ export async function restoreFallbackJournal (journal: FallbackJournal): Promise } } +/** + * A state is authorized for replacement when the live digest equals one of the + * digests registered by this transaction: the original state, the applied + * staged payload, or the captured post-mutation state. Anything else is drift. + */ +async function entryStateIsAuthorized (entry: FallbackJournalEntry): Promise { + const kind = await pathKind(entry.path) + const current = kind !== 'missing' ? await pathDigest(entry.path) : null + if (current === undefined) return false + const allowed = new Set() + allowed.add(entry.expectedCurrentDigest !== undefined ? entry.expectedCurrentDigest : entry.digest ?? null) + if (entry.digest !== undefined) allowed.add(entry.digest) + if (entry.stageDigest !== undefined) allowed.add(entry.stageDigest) + if (kind === 'missing') { + // A missing target is only explainable by this transaction when an applied + // deletion removed it, a complete staged payload is still waiting to be + // swapped (mid-swap crash), or the deletion-only apply was interrupted + // with the original bytes sitting in the persisted quarantine. An applied + // staged replacement that vanished was deleted concurrently: drift. + if (entry.applied === true && entry.stage === undefined) return true + if (entry.stage !== undefined && entry.stageDigest !== undefined && await pathDigest(entry.stage) === entry.stageDigest) return true + if (entry.stage === undefined && entry.quarantine) { + const quarantined = await pathDigest(entry.quarantine) + const expected = entry.expectedCurrentDigest !== undefined ? entry.expectedCurrentDigest : entry.digest + if (quarantined !== undefined && expected !== undefined && quarantined === expected) return true + } + return allowed.has(null) + } + return allowed.has(current) +} + export async function recoverFallbackJournal (trackingPath: string, mutate: boolean): Promise<{ pending: boolean; recovered: boolean }> { const journalPath = fallbackJournalPath(trackingPath) if (!existsSync(journalPath)) return { pending: false, recovered: true } let journal: FallbackJournal try { journal = JSON.parse(await readFile(journalPath, 'utf8')) as FallbackJournal } catch { return { pending: true, recovered: false } } + // Version 1 journals predate staged swaps and digest-authorized recovery. + // They fail closed: no recovery, no snapshot cleanup. + if (!journal || journal.version !== 2) return { pending: true, recovered: false } if (!isSafeJournal(journal) || journal.journalPath !== journalPath || !await journalOwnershipIsValid(journal)) return { pending: true, recovered: false } if (!mutate) return { pending: true, recovered: false } if (journal.phase === 'committed') { await rm(journal.journalPath, { force: true }).catch(() => {}) await rm(journal.snapshotDirectory, { recursive: true, force: true }).catch(() => {}) + await removeEntryArtifacts(journal.entries) return { pending: false, recovered: true } } + // A live mutator still owns the transaction; never run concurrently with it. + if (journal.mutator && processIsRunning(journal.mutator.pid)) return { pending: true, recovered: false } const recovered = await restoreFallbackJournal(journal) return { pending: true, recovered } } +async function removeEntryArtifacts (entries: readonly FallbackJournalEntry[]): Promise { + for (const entry of entries) { + if (entry.stage) await rm(path.dirname(entry.stage), { recursive: true, force: true }).catch(() => {}) + // The quarantine path lives inside its own sibling container directory; + // remove the container, not only the moved payload. + if (entry.quarantine) await rm(path.dirname(entry.quarantine), { recursive: true, force: true }).catch(() => {}) + } +} + async function writeDurable (filePath: string, value: unknown): Promise { const temporary = `${filePath}.${process.pid}.tmp` await writeFile(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }) @@ -159,7 +378,20 @@ async function writeDurable (filePath: string, value: unknown): Promise { } catch { /* directory fsync is unavailable on some platforms */ } } -async function pathDigest (target: string): Promise { +export async function pathKind (target: string): Promise { + try { + const stat = await lstat(target) + if (stat.isSymbolicLink()) return 'symlink' + if (stat.isDirectory()) return 'directory' + if (stat.isFile()) return 'file' + return 'other' + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing' + return 'other' + } +} + +export async function pathDigest (target: string): Promise { try { const stat = await lstat(target) const hash = createHash('sha256') @@ -190,12 +422,16 @@ async function pathDigest (target: string): Promise { } function isSafeJournal (journal: FallbackJournal): boolean { - if (!journal || journal.version !== 1 || !['prepared', 'mutating', 'committed'].includes(journal.phase) || !journal.manifest || !Array.isArray(journal.entries)) return false - if (!Array.isArray(journal.manifest.ownedSkillPaths) || !Array.isArray(journal.manifest.ownedLinkPaths) || !Array.isArray(journal.manifest.ownedMcpFields)) return false + if (!journal || journal.version !== 2 || !['prepared', 'mutating', 'committed'].includes(journal.phase) || !journal.manifest || !Array.isArray(journal.entries)) return false + if (!Array.isArray(journal.manifest.ownedSkillPaths) || !Array.isArray(journal.manifest.ownedLinkPaths) || !Array.isArray(journal.manifest.ownedMcpFields) || !Array.isArray(journal.manifest.ownedMcpConfigPaths)) return false + if (journal.manifest.ownedMcpConfigPaths.some((value) => typeof value !== 'string')) return false if (journal.manifest.ownedMcpFields.some((field) => !field || typeof field.configPath !== 'string' || typeof field.server !== 'string' || typeof field.field !== 'string' || typeof field.expectedDigest !== 'string')) return false if (journal.manifest.ownedSkillPaths.some((value) => typeof value !== 'string') || journal.manifest.ownedLinkPaths.some((value) => typeof value !== 'string')) return false if (typeof journal.manifest.trackingPath !== 'string' || typeof journal.manifest.trackingDigest !== 'string' || typeof journal.manifest.harness !== 'string' || typeof journal.manifest.installationId !== 'string') return false + if (journal.manifest.nonce !== undefined && typeof journal.manifest.nonce !== 'string') return false if (typeof journal.journalPath !== 'string' || typeof journal.snapshotDirectory !== 'string') return false + if (journal.nonce !== undefined && typeof journal.nonce !== 'string') return false + if (journal.mutator !== undefined && (!Number.isSafeInteger(journal.mutator.pid) || journal.mutator.pid <= 0 || typeof journal.mutator.nonce !== 'string' || typeof journal.mutator.claimedAt !== 'string' || !Number.isFinite(Date.parse(journal.mutator.claimedAt)))) return false const trackingPath = path.resolve(journal.manifest.trackingPath) if (journal.journalPath !== fallbackJournalPath(trackingPath)) return false if (!isSameOrContained(path.resolve(journal.snapshotDirectory), path.dirname(trackingPath))) return false @@ -204,36 +440,76 @@ function isSafeJournal (journal: FallbackJournal): boolean { trackingPath, ...journal.manifest.ownedSkillPaths, ...journal.manifest.ownedLinkPaths, - ...journal.manifest.ownedMcpFields.map((field) => field.configPath), + ...journal.manifest.ownedMcpConfigPaths, ].map((value) => path.resolve(value))) if ([...expectedPaths].some((value) => !isCanonicalPath(value))) return false + // New bundle destinations are appended by the verified child after the + // parent created the journal: they are only trusted when they sit directly + // inside a manifest-approved destination root under a safe skill name. + const approvedRoots = new Set((journal.manifest.approvedDestinationRoots ?? []) + .map((value) => path.resolve(value))) + if ([...approvedRoots].some((value) => !isCanonicalPath(value))) return false + const flexibleEntries = new Set() const entries = new Set() for (const entry of journal.entries) { - if (!entry || typeof entry.path !== 'string' || typeof entry.backup !== 'string' || typeof entry.existed !== 'boolean') return false + if (!entry || typeof entry.path !== 'string' || typeof entry.existed !== 'boolean' || typeof entry.kind !== 'string') return false + if (!['missing', 'file', 'directory', 'symlink', 'other'].includes(entry.kind)) return false const target = path.resolve(entry.path) - if (!isCanonicalPath(target) || !expectedPaths.has(target) || entries.has(target)) return false - if (!isSameOrContained(path.resolve(entry.backup), path.resolve(journal.snapshotDirectory))) return false + if (!isCanonicalPath(target)) return false + if (expectedPaths.has(target)) { + if (entries.has(target)) return false + } else { + if (flexibleEntries.has(target)) return false + if (!approvedRoots.has(path.dirname(target))) return false + try { + assertSafeSkillName(path.basename(target)) + } catch { + return false + } + flexibleEntries.add(target) + } + if (entry.backup !== undefined && (!isSameOrContained(path.resolve(entry.backup), path.resolve(journal.snapshotDirectory)) || !isCanonicalPath(path.resolve(entry.backup)))) return false + if (entry.stage !== undefined) { + const stageDir = path.resolve(path.dirname(entry.stage)) + if (!isSameOrContained(stageDir, path.dirname(target)) || stageDir === path.dirname(target) || !isCanonicalPath(stageDir)) return false + } + if (entry.quarantine !== undefined) { + const quarantineDir = path.resolve(path.dirname(entry.quarantine)) + if (!isSameOrContained(quarantineDir, path.dirname(target)) || quarantineDir === path.dirname(target) || !isCanonicalPath(quarantineDir)) return false + } + if (entry.stageDigest !== undefined && typeof entry.stageDigest !== 'string') return false if (entry.expectedCurrentDigest !== undefined && entry.expectedCurrentDigest !== null && typeof entry.expectedCurrentDigest !== 'string') return false - entries.add(target) + if (expectedPaths.has(target)) entries.add(target) } - return entries.size === expectedPaths.size && [...expectedPaths].every((target) => entries.has(target)) + return [...expectedPaths].every((target) => entries.has(target)) } -async function journalStateMatchesExpected (journal: FallbackJournal): Promise { - const matches = await Promise.all(journal.entries.map(entryStateMatchesExpected)) - return matches.every(Boolean) +export async function reloadFallbackJournal (journal: FallbackJournal): Promise { + try { + const current = JSON.parse(await readFile(journal.journalPath, 'utf8')) as FallbackJournal + return current.journalPath === journal.journalPath && sameManifest(current.manifest, journal.manifest) ? current : journal + } catch { + return journal + } } -async function entryStateMatchesExpected (entry: FallbackJournalEntry): Promise { - const expected = entry.expectedCurrentDigest !== undefined ? entry.expectedCurrentDigest : entry.digest ?? null - const current = existsSync(entry.path) ? await pathDigest(entry.path) : null - return current !== undefined && current === expected +function sameManifest (left: FallbackTransactionIdentity, right: FallbackTransactionIdentity): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +function processIsRunning (pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } } async function journalOwnershipIsValid (journal: FallbackJournal): Promise { const trackingPath = path.resolve(journal.manifest.trackingPath) const trackingEntry = journal.entries.find((entry) => path.resolve(entry.path) === trackingPath) - if (!trackingEntry?.existed || !trackingEntry.digest) return false + if (!trackingEntry?.existed || !trackingEntry.digest || !trackingEntry.backup) return false if (await pathDigest(trackingEntry.backup) !== trackingEntry.digest) return false if (trackingDigest(trackingEntry.backup) !== journal.manifest.trackingDigest) return false try { diff --git a/packages/core/src/update/fallback-ownership.ts b/packages/core/src/update/fallback-ownership.ts index 1792739..3294b0d 100644 --- a/packages/core/src/update/fallback-ownership.ts +++ b/packages/core/src/update/fallback-ownership.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import { getAdapter } from '../harnesses/index.js' import { getHarnessSkillsPath } from '../skills/skill-linker.js' import type { TrackingData } from '../skills/skill-tracker.js' import type { FallbackTransactionIdentity } from './types.js' @@ -21,6 +22,16 @@ export function matchesTrackedOwnership (tracking: TrackingData, identity: Fallb .flatMap((entry) => Object.entries(entry.fields ?? {}).map(([field, expectedDigest]) => `${path.resolve(entry.configPath)}\0${entry.name}\0${field}\0${expectedDigest}`)) const ownedMcpFields = new Set(identity.ownedMcpFields.map((field) => `${path.resolve(field.configPath)}\0${field.server}\0${field.field}\0${field.expectedDigest}`)) if (expectedMcpFields.length > 0 && (ownedMcpFields.size !== expectedMcpFields.length || !expectedMcpFields.every((value) => ownedMcpFields.has(value)))) return false + // The MCP config-path set is the union of tracked paths and the adapter's + // canonical path for this harness, recomputed from the same environment the + // transaction will run in. + const canonical = getAdapter(identity.harness).getMcpConfigPath() + const expectedConfigPaths = new Set([ + ...tracking.mcpServers.filter((entry) => entry.harness === identity.harness).map((entry) => path.resolve(entry.configPath)), + ...(canonical ? [path.resolve(canonical)] : []), + ]) + const ownedConfigPaths = new Set(identity.ownedMcpConfigPaths.map((value) => path.resolve(value))) + if (ownedConfigPaths.size !== expectedConfigPaths.size || ![...expectedConfigPaths].every((value) => ownedConfigPaths.has(value))) return false return identity.ownedMcpFields.every((field) => tracking.mcpServers.some((entry) => { if (entry.harness !== identity.harness || entry.name !== field.server || path.resolve(entry.configPath) !== path.resolve(field.configPath)) return false return entry.fields?.[field.field] === field.expectedDigest @@ -28,10 +39,19 @@ export function matchesTrackedOwnership (tracking: TrackingData, identity: Fallb } export function isCanonicalPath (value: string): boolean { - return path.isAbsolute(value) && !value.split(path.sep).includes('..') && path.resolve(value) === value + return !isRemotePath(value) && path.isAbsolute(value) && !value.split(path.sep).includes('..') && path.resolve(value) === value +} + +export function isRemotePath (value: string): boolean { + const normalized = value.replace(/\\/g, '/') + return normalized.startsWith('//') || path.win32.parse(value).root.startsWith('\\\\') } export function isSameOrContained (candidate: string, parent: string): boolean { const relative = path.relative(path.resolve(parent), path.resolve(candidate)) - return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) + // A leading '..' segment means traversal; a filename that merely starts + // with dots (for example '..claude.json.nsolid-stage-x') does not. + if (relative === '') return true + if (path.isAbsolute(relative)) return false + return relative !== '..' && !relative.startsWith(`..${path.sep}`) } diff --git a/packages/core/src/update/fallback-transaction.ts b/packages/core/src/update/fallback-transaction.ts index c2ef692..2b4ffe2 100644 --- a/packages/core/src/update/fallback-transaction.ts +++ b/packages/core/src/update/fallback-transaction.ts @@ -1,20 +1,25 @@ -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' import { existsSync, lstatSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import type { BundleDescriptor, Credentials, HarnessType } from '../types.js' +import type { BundleDescriptor, Credentials, HarnessType, McpServerRef } from '../types.js' import { validateBundle } from '../validate.js' -import { readJsonFile, readJsoncFile, readTomlFile } from '../utils/config.js' +import { readJsonFile } from '../utils/config.js' import { resolveHome, getSkillsDir, getAuthFilePath } from '../utils/path.js' import { deriveMcpUrlFromConsoleUrl } from '../auth/mcp-url.js' -import { removeMcpConfig, writeMcpConfig } from '../mcp/mcp-config-writer.js' +import { expandVariables } from '../mcp/mcp-config-merger.js' +import { applyHarnessWriteFormat } from '../mcp/mcp-config-writer.js' import { readTrackingFile, writeTrackingFile, type SkillTrackingEntry, type TrackingData } from '../skills/skill-tracker.js' import { installSkillsToDirectory } from '../skills/skill-copier.js' -import { getHarnessSkillsPath, linkSkillsToHarness, unlinkSkillsFromHarness } from '../skills/skill-linker.js' +import { getHarnessSkillsPath, linkSkillsToHarness, materializeSkillLink, unlinkSkillsFromHarness } from '../skills/skill-linker.js' import { assertSafeSkillName } from '../utils/skill-name.js' import { getAdapter } from '../harnesses/index.js' import type { FallbackTransactionIdentity, UpdateError } from './types.js' -import { trackingDigest, valueDigest } from './fallback-journal.js' +import { appendFallbackJournalEntries, applyFallbackEntry, claimFallbackJournalMutation, fallbackJournalPath, registerFallbackStage, trackingDigest, valueDigest, pathDigest, pathKind, type FallbackJournal } from './fallback-journal.js' +import { planMcpReconciliation, type McpConfigPlanEntry } from './mcp-reconciliation.js' +import { detectJsonMcpKey, editMcpJsonBytes, McpEditError } from './mcp-edit.js' +import { editMcpTomlBytes, McpTomlEditError } from './mcp-toml-edit.js' +import { harnessMcpKey, mcpFieldDigestsFromBytes, readMcpFieldDigests, readMcpServerField, readMcpServerRecord } from './mcp-lookup.js' import { readPackageVersion } from './package-manager.js' import { isStableVersion } from './version.js' import { isCanonicalPath, matchesTrackedOwnership } from './fallback-ownership.js' @@ -91,14 +96,18 @@ export async function refreshOwnedInstallation (options: FallbackRefreshOptions) const backupDir = await mkdtemp(path.join(os.tmpdir(), 'nsolid-plugin-fallback-')) const trackingBackup = path.join(backupDir, 'tracking.json') - const configPath = previousMcps[0]?.configPath ?? getAdapter(options.harness).getMcpConfigPath() - const configExisted = configPath ? existsSync(configPath) : false - const configBackup = configPath ? path.join(backupDir, 'mcp-config') : undefined + const previousConfigPaths = [...new Set(previousMcps.map((entry) => path.resolve(entry.configPath)))] + // The child uses the canonical path transported by the transaction; the + // environment is only consulted to validate it has not moved. + const adapterCanonical = getAdapter(options.harness).getMcpConfigPath() + const canonicalConfigPath = options.transaction && adapterCanonical + ? options.transaction.ownedMcpConfigPaths.find((value) => path.resolve(value) === path.resolve(adapterCanonical)) + : adapterCanonical + const allConfigPaths = [...new Set([...previousConfigPaths, canonicalConfigPath].filter((value): value is string => typeof value === 'string'))] + const configBackups = new Map() const skillsBackup = path.join(backupDir, 'skills') const linkPaths = linkDir ? [...new Set([...previousSkills, ...bundle.skills].map((skill) => path.join(linkDir, skill.name)))] : [] const linksBackup = path.join(backupDir, 'links') - const sharedNewPaths = newPaths.filter((value) => existsSync(value) && trackedPathSet.has(path.resolve(value))) - const backupPaths = [...new Set([...oldPaths, ...sharedNewPaths])] const previousSkillNames = new Set(previousSkills.map((entry) => entry.name)) // linkSkillsToHarness historically renamed any regular destination to a @@ -115,8 +124,13 @@ export async function refreshOwnedInstallation (options: FallbackRefreshOptions) } } + let backupSkillPaths: string[] = [] + let stagedSkillsRoot: string | undefined + let linksStageRoot: string | undefined let backupsComplete = false let mutationStarted = false + let journal: FallbackJournal | undefined + let preserveRecoveryArtifacts = false try { // Keep backup creation outside the mutation catch. A partial backup is // never safe input to rollback: deleting the live paths and restoring the @@ -124,7 +138,11 @@ export async function refreshOwnedInstallation (options: FallbackRefreshOptions) try { await writeFile(trackingBackup, JSON.stringify(tracking, null, 2) + '\n', { mode: 0o600 }) await mkdir(skillsBackup, { recursive: true, mode: 0o700 }) - for (const oldPath of backupPaths) { + backupSkillPaths = [...new Set([ + ...oldPaths, + ...newPaths.filter((value) => existsSync(value) && trackedPathSet.has(path.resolve(value))), + ])] + for (const oldPath of backupSkillPaths) { if (pathExists(oldPath)) { const target = path.join(skillsBackup, encodeURIComponent(oldPath)) await cp(oldPath, target, { recursive: true, force: true }) @@ -136,7 +154,12 @@ export async function refreshOwnedInstallation (options: FallbackRefreshOptions) if (pathExists(linkPath)) await cp(linkPath, path.join(linksBackup, encodeURIComponent(linkPath)), { recursive: true, force: true }) } } - if (configPath && configBackup && existsSync(configPath)) await writeFile(configBackup, await readFile(configPath), { mode: 0o600 }) + for (const configPath of allConfigPaths) { + if (!existsSync(configPath)) continue + const backup = path.join(backupDir, `mcp-config-${encodeURIComponent(configPath)}`) + await writeFile(backup, await readFile(configPath), { mode: 0o600 }) + configBackups.set(configPath, { backup, existed: true }) + } backupsComplete = true } catch { return { @@ -147,7 +170,142 @@ export async function refreshOwnedInstallation (options: FallbackRefreshOptions) } try { + const credentials = readValidCredentials() + const canReconcileMcp = credentials !== null + const previousMcpNames = previousMcps.map((entry) => entry.name) + const desiredMcpNames = bundle.mcpServers.map((server) => server.name) + if (!canReconcileMcp && !sameNameSet(previousMcpNames, desiredMcpNames)) { + throw new FallbackTransactionError('MCP_RECONCILIATION_REQUIRED', 'Fallback MCP state changed but valid credentials are unavailable') + } + + // Plan every MCP change grouped by owning file before anything is + // staged or written. + const configuredMcpServers = canReconcileMcp ? bundle.mcpServers : [] + const plan = canReconcileMcp && credentials + ? planMcpReconciliation({ + previousServers: previousMcps.map((entry) => ({ name: entry.name, configPath: path.resolve(entry.configPath), fields: entry.fields })), + desiredServers: bundle.mcpServers, + desiredValues: Object.fromEntries(bundle.mcpServers.map((server) => [server.name, harnessServerValue(options.harness, server, credentials)])), + canonicalConfigPath: canonicalConfigPath ?? undefined, + }) + : { kind: 'planned' as const, entries: [] as McpConfigPlanEntry[], destinations: {} } + if (plan.kind === 'reconciliation-required') { + throw new FallbackTransactionError(plan.code, plan.message) + } + const staleByName = new Map(previousMcps + .filter((entry) => !desiredMcpNames.includes(entry.name)) + .map((entry) => [entry.name, entry])) + for (const planEntry of plan.entries) { + for (const name of planEntry.removeServers) { + const entry = staleByName.get(name) + if (!entry || !mcpRecordIsExclusivelyOwned(entry.configPath, entry.name, entry.fields, harnessMcpKey(options.harness))) { + throw new FallbackTransactionError('MCP_RECONCILIATION_REQUIRED', 'Fallback MCP cleanup would remove fields that are not proven NodeSource-owned') + } + } + } + + // Render preflight: compute every final MCP byte from the observed + // source bytes BEFORE the journal is claimed or any live path changes. + // Parse or editor failures here abort with zero mutation. Source + // digests are retained and revalidated right before staging/applying so + // the drift gates keep working at mutation time. + const plannedMcpBytes = new Map() + for (const planEntry of plan.entries) { + // A missing config file is a legitimate preflight state (fresh + // installs): null revalidates as still-missing at mutation time. + const sourceDigest = existsSync(planEntry.configPath) ? await pathDigest(planEntry.configPath) : null + const finalBytes = await renderConfigBytes(planEntry, harnessMcpKey(options.harness)) + if (finalBytes === undefined) continue + if (sourceDigest === undefined) { + throw new FallbackTransactionError('FALLBACK_MCP_DRIFT', `The MCP configuration ${planEntry.configPath} could not be hashed for the transaction`) + } + plannedMcpBytes.set(path.resolve(planEntry.configPath), { bytes: finalBytes, sourceDigest }) + } + + if (options.transaction && !await claimFallbackJournalMutation(options.transaction)) { + return failure('FALLBACK_JOURNAL_CLAIM_FAILED', 'Fallback mutation journal could not be claimed safely') + } mutationStarted = true + + if (options.transaction) { + journal = await loadJournalForChild(options.transaction) + // The bundle's brand-new destinations were unknown to the parent at + // journal time. Journal their original state durably BEFORE any live + // path is touched, so recovery can undo a crash mid-install. + const newTargets = [ + ...bundle.skills.map((skill) => path.join(destination, skill.name)), + ...(linkDir ? bundle.skills.map((skill) => path.join(linkDir, skill.name)) : []), + ] + journal = await appendFallbackJournalEntries(journal, newTargets) + } + + // ---- STAGE: skills, links, MCP bytes, and tracking bytes are prepared + // completely before any live path is touched. + { + const skillsStage = await mkdtemp(path.join(path.dirname(destination), `.${path.basename(destination)}.nsolid-stage-`)) + stagedSkillsRoot = skillsStage + await installSkillsToDirectory(bundle.skills, options.skillsSource, skillsStage) + if (journal) { + for (const skill of bundle.skills) { + const livePath = path.join(destination, skill.name) + if (isJournalEntry(journal, livePath)) { + journal = await registerFallbackStage(journal, livePath, { directory: path.join(skillsStage, skill.name) }) + } + } + if (linkDir) { + const linksStage = await mkdtemp(path.join(path.dirname(linkDir), `.${path.basename(linkDir)}.nsolid-stage-`)) + linksStageRoot = linksStage + for (const skill of bundle.skills) { + const linkPath = path.join(linkDir, skill.name) + if (!isJournalEntry(journal, linkPath)) continue + const stagedLink = path.join(linksStage, skill.name) + // Staged links follow the same Windows-safe policy as normal + // harness linking: the junction/symlink references the final + // live shared skill path, and the copy fallback comes from the + // newly prepared staged bytes, never the old live content. + await materializeSkillLink({ + linkSource: path.join(destination, skill.name), + copySource: path.join(skillsStage, skill.name), + target: stagedLink, + alwaysCopy: options.harness === 'pi', + }) + journal = await registerFallbackStage(journal, linkPath, { directory: stagedLink }) + } + } + const stagedMcpBytes = new Map() + for (const planEntry of plan.entries) { + const planned = plannedMcpBytes.get(path.resolve(planEntry.configPath)) + if (planned === undefined) continue + // The staged bytes were rendered from an earlier observation: + // revalidate the source digest so drift between preflight and + // staging is rejected before anything is registered. + const currentDigest = existsSync(planEntry.configPath) ? await pathDigest(planEntry.configPath) : null + if (currentDigest === undefined || currentDigest !== planned.sourceDigest) { + throw new FallbackTransactionError('FALLBACK_MCP_DRIFT', `The MCP configuration ${planEntry.configPath} changed after the render preflight`) + } + if (!isJournalEntry(journal, planEntry.configPath)) { + throw new FallbackTransactionError('FALLBACK_MCP_DRIFT', `The MCP configuration ${planEntry.configPath} is not part of the approved transaction`) + } + journal = await registerFallbackStage(journal, planEntry.configPath, { bytes: planned.bytes }) + stagedMcpBytes.set(planEntry.configPath, planned.bytes) + } + if (isJournalEntry(journal, options.transaction!.trackingPath)) { + // Field evidence must describe the bytes that will exist after the + // swap, not the pre-update files. + const preferredKey = harnessMcpKey(options.harness) + const resolveFieldDigests = (configPath: string, name: string): Record | undefined => { + const staged = stagedMcpBytes.get(configPath) + if (staged !== undefined) return mcpFieldDigestsFromBytes(configPath, staged, name, { preferredKey }) + return readMcpFieldDigests(configPath, name, { preferredKey }) + } + const updatedTracking = buildTrackingUpdate(tracking, options.harness, destination, bundle, plan, configuredMcpServers, staleByName, resolveFieldDigests) + journal = await registerFallbackStage(journal, options.transaction!.trackingPath, { bytes: Buffer.from(JSON.stringify(updatedTracking, null, 2) + '\n', 'utf8') }) + } + } + } + + // ---- APPLY: same-volume swaps, one entry at a time; deletions are + // quarantined until the parent commits. const newNames = new Set(bundle.skills.map((skill) => skill.name)) const pathsToReplace = previousSkills .filter((entry) => newNames.has(entry.name)) @@ -155,50 +313,89 @@ export async function refreshOwnedInstallation (options: FallbackRefreshOptions) const pathsToRemove = previousSkills .filter((entry) => !newNames.has(entry.name) && canRemoveOwnedPath(entry, options.harness)) .map((entry) => entry.paths?.[options.harness] ?? entry.path) - for (const ownedPath of [...pathsToReplace, ...pathsToRemove, ...sharedNewPaths]) { - await rm(ownedPath, { recursive: true, force: true }) + for (const ownedPath of [...pathsToReplace, ...pathsToRemove]) { + if (journal && isJournalEntry(journal, ownedPath)) { + journal = await applyFallbackEntry(journal, ownedPath) + } else { + await rm(ownedPath, { recursive: true, force: true }) + } } - - await installSkillsToDirectory(bundle.skills, options.skillsSource, destination) - for (const oldEntry of previousSkills) { - if (!newNames.has(oldEntry.name)) { - if (linkSkills) await unlinkSkillsFromHarness(options.harness, [{ name: oldEntry.name, path: oldEntry.name, description: '' }]) + // Fresh installs (new skills and shared destinations) come from the + // staged tree; already-swapped entries are left untouched. + if (stagedSkillsRoot) { + for (const skill of bundle.skills) { + const livePath = path.join(destination, skill.name) + if (journal && isJournalEntry(journal, livePath)) { + if (!journal.entries.some((entry) => path.resolve(entry.path) === path.resolve(livePath) && entry.applied)) { + journal = await applyFallbackEntry(journal, livePath) + } + continue + } + const staged = path.join(stagedSkillsRoot, skill.name) + if (!existsSync(staged)) continue + await rm(livePath, { recursive: true, force: true }) + await cp(staged, livePath, { recursive: true, force: true }) } } - if (linkSkills) await linkSkillsToHarness(options.harness, bundle.skills) - const credentials = readValidCredentials() - const canReconcileMcp = credentials !== null - const previousMcpNames = previousMcps.map((entry) => entry.name) - const desiredMcpNames = bundle.mcpServers.map((server) => server.name) - if (!canReconcileMcp && !sameNameSet(previousMcpNames, desiredMcpNames)) { - throw new FallbackTransactionError('MCP_RECONCILIATION_REQUIRED', 'Fallback MCP state changed but valid credentials are unavailable') - } - const newMcpNames = canReconcileMcp - ? new Set(desiredMcpNames) - : new Set(previousMcpNames) - const staleMcpNames = previousMcps - .filter((entry) => !newMcpNames.has(entry.name)) - .filter((entry) => !tracking.mcpServers.some((other) => other !== entry && other.name === entry.name && path.resolve(other.configPath) === path.resolve(configPath))) - .map((entry) => entry.name) - if (configPath && staleMcpNames.length > 0) { - await removeMcpConfig(options.harness, [...new Set(staleMcpNames)], { configPath }) + if (linkDir) { + for (const oldEntry of previousSkills) { + if (newNames.has(oldEntry.name)) continue + const staleLink = path.join(linkDir, oldEntry.name) + if (journal && isJournalEntry(journal, staleLink)) { + journal = await applyFallbackEntry(journal, staleLink) + } else if (linkSkills) { + await unlinkSkillsFromHarness(options.harness, [{ name: oldEntry.name, path: oldEntry.name, description: '' }]) + } + } + for (const skill of bundle.skills) { + const linkPath = path.join(linkDir, skill.name) + if (journal && isJournalEntry(journal, linkPath)) { + if (!journal.entries.some((entry) => path.resolve(entry.path) === path.resolve(linkPath) && entry.applied)) { + journal = await applyFallbackEntry(journal, linkPath) + } + } else { + await linkSkillsToHarness(options.harness, [skill]) + } + } } - const configuredMcpServers = canReconcileMcp ? bundle.mcpServers : [] - if (credentials && bundle.mcpServers.length > 0) { - const variables = await mcpVariables(credentials) - await writeMcpConfig(options.harness, bundle.mcpServers, variables, { configPath }) + + for (const planEntry of plan.entries) { + if (!planHasByteChanges(planEntry)) continue + if (journal && isJournalEntry(journal, planEntry.configPath)) { + journal = await applyFallbackEntry(journal, planEntry.configPath) + } else { + const planned = plannedMcpBytes.get(path.resolve(planEntry.configPath)) + if (planned === undefined) continue + // Revalidate the preflight source digest before writing: a file + // changed between preflight and apply must abort, not overwrite. + const currentDigest = existsSync(planEntry.configPath) ? await pathDigest(planEntry.configPath) : null + if (currentDigest === undefined || currentDigest !== planned.sourceDigest) { + throw new FallbackTransactionError('FALLBACK_MCP_DRIFT', `The MCP configuration ${planEntry.configPath} changed after the render preflight`) + } + await atomicWriteFile(planEntry.configPath, planned.bytes) + } } - const updated = reconcileTracking(tracking, options.harness, destination, bundle.skills, configPath, configuredMcpServers, staleMcpNames) - updated.bundleVersion = bundle.version - updated.bundleVersions = { ...(updated.bundleVersions ?? {}), [options.harness]: bundle.version } - await writeTrackingFile(updated) + if (journal && isJournalEntry(journal, options.transaction!.trackingPath)) { + // The staged tracking bytes were built from the staged MCP bytes; the + // swap installs exactly those. + journal = await applyFallbackEntry(journal, options.transaction!.trackingPath) + } else { + const updatedTracking = buildTrackingUpdate(tracking, options.harness, destination, bundle, plan, configuredMcpServers, staleByName) + await writeTrackingFile(updatedTracking) + } return { success: true } } catch (error) { + // Preflight rejection: the failure happened before the journal was + // claimed, so nothing was mutated and there is nothing to roll back. + if (!mutationStarted && error instanceof FallbackTransactionError) { + return failure(error.code, error.message) + } const rollback = backupsComplete && mutationStarted - ? await rollbackFallback({ trackingBackup, configBackup, configPath, configExisted, skillsBackup, backupPaths, newPaths, linksBackup, linkPaths }) + ? await rollbackFallback({ backupDir, trackingBackup, configBackups, skillsBackup, backupPaths: backupSkillPaths, newPaths, linksBackup, linkPaths, journal }) : false + preserveRecoveryArtifacts = !rollback if (error instanceof FallbackTransactionError && rollback) { return failure(error.code, error.message, { attempted: true, succeeded: true }) } @@ -207,8 +404,126 @@ export async function refreshOwnedInstallation (options: FallbackRefreshOptions) : failure('FALLBACK_ROLLBACK_FAILED', 'Owned fallback refresh failed and rollback was incomplete', { attempted: true, succeeded: false }) } } finally { - await rm(backupDir, { recursive: true, force: true }).catch(() => {}) + if (!preserveRecoveryArtifacts) { + await rm(backupDir, { recursive: true, force: true }).catch(() => {}) + } + // Staging containers are transaction-owned scratch. The journal-owned + // stage copies live beside each target and survive for recovery. + if (stagedSkillsRoot) await rm(stagedSkillsRoot, { recursive: true, force: true }).catch(() => {}) + if (linksStageRoot) await rm(linksStageRoot, { recursive: true, force: true }).catch(() => {}) + } +} + +function isJournalEntry (journal: FallbackJournal, target: string): boolean { + const resolved = path.resolve(target) + return journal.entries.some((entry) => path.resolve(entry.path) === resolved) +} + +async function loadJournalForChild (transaction: FallbackTransactionIdentity): Promise { + const journalPath = fallbackJournalPath(transaction.trackingPath) + const parsed = JSON.parse(await readFile(journalPath, 'utf8')) as FallbackJournal + if (parsed.version !== 2 || JSON.stringify(parsed.manifest) !== JSON.stringify(transaction)) { + throw new FallbackTransactionError('FALLBACK_JOURNAL_CLAIM_FAILED', 'The fallback mutation journal does not match the approved transaction') + } + return parsed +} + +function harnessServerValue (harness: HarnessType, server: BundleDescriptor['mcpServers'][number], credentials: Credentials): Record { + const mcpUrl = credentials.mcpUrl || deriveMcpUrlFromConsoleUrl(credentials.consoleUrl, credentials.organizationId) + if (!mcpUrl) throw new FallbackTransactionError('MCP_RECONCILIATION_REQUIRED', 'MCP URL could not be derived') + const expanded = expandVariables([server as unknown as McpServerRef], { + AUTH_TOKEN: credentials.serviceToken, + AUTH_ORG_ID: credentials.organizationId, + MCP_URL: mcpUrl, + }) + const formatted = applyHarnessWriteFormat(harness, { mcpServers: { [server.name]: expanded[0] } as unknown as Record }) + return formatted.mcpServers[server.name] as unknown as Record +} + +/** + * Render the final bytes of one configuration file from its plan entry. + * Owned-field digests are validated against the live file before the patch is + * generated; foreign records and unowned fields are never touched. + */ +async function renderConfigBytes (planEntry: McpConfigPlanEntry, preferredKey: 'mcp' | 'mcpServers'): Promise { + if (!planHasByteChanges(planEntry)) return undefined + const raw = existsSync(planEntry.configPath) ? await readFile(planEntry.configPath, 'utf8') : '' + for (const owned of planEntry.ownedFieldDigests) { + const current = readMcpServerField(planEntry.configPath, owned.server, owned.field, { preferredKey }) + if (valueDigest(current) !== owned.expectedDigest) { + throw new FallbackTransactionError('FALLBACK_MCP_DRIFT', `Owned MCP field ${owned.server}.${owned.field} changed in ${planEntry.configPath} after planning`) + } + } + for (const name of planEntry.removeServers) { + if (!existsSync(planEntry.configPath)) continue + if (!readMcpServerRecord(planEntry.configPath, name, { preferredKey })) { + throw new FallbackTransactionError('FALLBACK_MCP_DRIFT', `Owned MCP server ${name} disappeared from ${planEntry.configPath} after planning`) + } + } + for (const upsert of planEntry.upsertServers) { + if (existsSync(planEntry.configPath) && readMcpServerRecord(planEntry.configPath, upsert.name, { preferredKey })) { + throw new FallbackTransactionError('MCP_RECONCILIATION_REQUIRED', `A server named ${upsert.name} that is not NodeSource-owned already exists in ${planEntry.configPath}`) + } + } + const ownedFields = new Set(planEntry.ownedFieldDigests.map((owned) => `${owned.server}\0${owned.field}`)) + for (const update of planEntry.updateFields) { + if (ownedFields.has(`${update.server}\0${update.field}`)) continue + const record = existsSync(planEntry.configPath) ? readMcpServerRecord(planEntry.configPath, update.server, { preferredKey }) : undefined + if (record && Object.hasOwn(record, update.field) && valueDigest(record[update.field]) !== valueDigest(update.value)) { + throw new FallbackTransactionError('MCP_RECONCILIATION_REQUIRED', `Field ${update.server}.${update.field} in ${planEntry.configPath} is not NodeSource-owned`) + } } + const upserts = Object.fromEntries(planEntry.upsertServers.map((upsert) => [upsert.name, upsert.value])) + if (planEntry.configPath.endsWith('.toml')) { + // Byte-localized TOML editing: only the owned server/field ranges are + // rewritten, so user comments, CRLF endings, unrelated tables, and + // credentials survive verbatim. Editor ambiguity or parse failure maps to + // the existing non-mutating fallback error contract. + let next: string + try { + next = editMcpTomlBytes(raw, { + upsertServers: upserts, + removeServers: planEntry.removeServers, + setFields: planEntry.updateFields, + removeFields: planEntry.removeFields, + }) + } catch (error) { + if (error instanceof McpTomlEditError) throw new FallbackTransactionError(error.code, error.message) + throw error + } + if (next === raw) return undefined + return Buffer.from(next, 'utf8') + } + // Edit the container that really exists in this file: the harness-preferred + // key when present, the legacy key when it is the only one, and the preferred + // key for brand-new destinations. This keeps foreign content byte-identical + // and never creates a duplicate container. + let next: string + try { + next = editMcpJsonBytes(raw, { + upsertServers: upserts, + removeServers: planEntry.removeServers, + setFields: planEntry.updateFields, + removeFields: planEntry.removeFields, + }, { mcpKey: detectJsonMcpKey(raw, preferredKey) }) + } catch (error) { + // JSON editor failures use the same non-mutating fallback error contract + // as TOML failures: every render error is a FallbackTransactionError. + if (error instanceof McpEditError) throw new FallbackTransactionError(error.code, error.message) + throw error + } + if (next === raw) return undefined + return Buffer.from(next, 'utf8') +} + +function planHasByteChanges (planEntry: McpConfigPlanEntry): boolean { + return planEntry.removeServers.length > 0 || planEntry.upsertServers.length > 0 || planEntry.updateFields.length > 0 || planEntry.removeFields.length > 0 +} + +async function atomicWriteFile (targetPath: string, bytes: Buffer): Promise { + const temporary = `${targetPath}.nsolid-${process.pid}.tmp` + await writeFile(temporary, bytes, { mode: 0o600 }) + await rename(temporary, targetPath) } function validateTransactionIdentity (identity: FallbackTransactionIdentity): UpdateError | undefined { @@ -219,27 +534,41 @@ function validateTransactionIdentity (identity: FallbackTransactionIdentity): Up if (identity.ownedLinkPaths.some((value) => !isCanonicalPath(value))) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains an unsafe link path' } for (const field of identity.ownedMcpFields) { if (!isCanonicalPath(field.configPath) || !existsSync(field.configPath)) return { code: 'FALLBACK_MCP_DRIFT', message: 'Owned MCP configuration changed after planning' } - const current = readMcpField(field.configPath, field.server, field.field) + const current = readMcpServerField(field.configPath, field.server, field.field, { preferredKey: harnessMcpKey(identity.harness) }) if (field.expectedDigest && valueDigest(current) !== field.expectedDigest) return { code: 'FALLBACK_MCP_DRIFT', message: 'Owned MCP field changed after planning' } } + // The canonical MCP path is part of the approved manifest. If the adapter or + // environment resolves a different path between planning and execution, + // that is drift: block before any mutation. + const allowedConfigPaths = new Set(identity.ownedMcpConfigPaths.map((value) => path.resolve(value))) + if (identity.ownedMcpConfigPaths.some((value) => !isCanonicalPath(value))) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains an unsafe MCP config path' } + if (identity.ownedMcpFields.some((field) => !allowedConfigPaths.has(path.resolve(field.configPath)))) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction MCP fields are outside the approved config paths' } + const canonical = getAdapter(identity.harness).getMcpConfigPath() + if (canonical && !allowedConfigPaths.has(path.resolve(canonical))) { + return { code: 'FALLBACK_MCP_DRIFT', message: 'Owned MCP canonical path changed after planning' } + } + // Destination roots are approved at planning time. If the environment now + // resolves a skill or link destination outside those roots, the manifest no + // longer describes this machine: block before any mutation. + const approvedRoots = (identity.approvedDestinationRoots ?? []).map((value) => path.resolve(value)) + if (approvedRoots.length === 0 || approvedRoots.some((value) => !isCanonicalPath(value))) { + return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains an unsafe destination root' } + } + const destination = identity.harness === 'opencode' + ? path.resolve(process.env.NSOLID_OPENCODE_SKILLS_DIR ?? resolveHome('~/.config/opencode/skills')) + : getSkillsDir() + if (!approvedRoots.includes(path.resolve(destination))) { + return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'The harness skill destination is outside the approved destination roots' } + } + if (identity.harness !== 'opencode') { + const linkRoot = path.resolve(getHarnessSkillsPath(identity.harness)) + if (!approvedRoots.includes(linkRoot)) { + return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'The harness link destination is outside the approved destination roots' } + } + } return undefined } -function readMcpField (configPath: string, server: string, field: string): unknown { - try { - const parsed = readMcpConfig(configPath) - const servers = parsed?.mcpServers ?? parsed?.mcp_servers ?? parsed?.mcp - const record = servers && typeof servers === 'object' ? (servers as Record)[server] : undefined - return record && typeof record === 'object' && !Array.isArray(record) ? (record as Record)[field] : undefined - } catch { return undefined } -} - -function readMcpConfig (configPath: string): Record | null { - if (configPath.endsWith('.toml')) return readTomlFile>(configPath) - if (configPath.endsWith('.jsonc')) return readJsoncFile>(configPath) - return readJsonFile>(configPath) -} - function readValidCredentials (): Credentials | null { try { const credentials = readJsonFile(getAuthFilePath()) @@ -248,38 +577,47 @@ function readValidCredentials (): Credentials | null { } catch { return null } } -async function mcpVariables (credentials: Credentials): Promise> { - const mcpUrl = credentials.mcpUrl || deriveMcpUrlFromConsoleUrl(credentials.consoleUrl, credentials.organizationId) - if (!mcpUrl) throw new Error('MCP URL could not be derived') - return { AUTH_TOKEN: credentials.serviceToken, AUTH_ORG_ID: credentials.organizationId, MCP_URL: mcpUrl } -} - async function rollbackFallback (options: { + backupDir: string trackingBackup: string - configBackup?: string - configPath?: string - configExisted: boolean + configBackups: Map skillsBackup: string backupPaths: string[] newPaths: string[] linksBackup: string linkPaths: string[] + journal?: FallbackJournal }): Promise { try { + // A journal-based transaction can only roll back while every live byte is + // still exactly what this transaction last wrote (applied stage digests) + // or what it found (registered originals). Concurrent drift blocks the + // child rollback; the parent's strict recovery takes over and preserves + // the artifacts. + if (options.journal) { + for (const entry of options.journal.entries) { + const kind = await pathKind(entry.path) + const current = kind !== 'missing' ? await pathDigest(entry.path) : null + if (current === undefined) return false + if (entry.applied && entry.stageDigest && current !== entry.stageDigest) return false + if (!entry.applied) { + const expected = entry.expectedCurrentDigest !== undefined ? entry.expectedCurrentDigest : entry.digest ?? null + if (current !== expected) return false + } + } + } for (const newPath of options.newPaths) await rm(newPath, { recursive: true, force: true }) + for (const linkPath of options.linkPaths) await rm(linkPath, { recursive: true, force: true }) for (const oldPath of options.backupPaths) { const backup = path.join(options.skillsBackup, encodeURIComponent(oldPath)) if (existsSync(backup)) await cp(backup, oldPath, { recursive: true, force: true }) } - for (const linkPath of options.linkPaths) await rm(linkPath, { recursive: true, force: true }) for (const linkPath of options.linkPaths) { const backup = path.join(options.linksBackup, encodeURIComponent(linkPath)) if (existsSync(backup)) await cp(backup, linkPath, { recursive: true, force: true }) } - if (options.configPath && options.configBackup && existsSync(options.configBackup)) { - await writeFile(options.configPath, await readFile(options.configBackup), { mode: 0o600 }) - } else if (options.configPath && !options.configExisted) { - await rm(options.configPath, { force: true }) + for (const [configPath, backup] of options.configBackups) { + if (existsSync(backup.backup)) await writeFile(configPath, await readFile(backup.backup), { mode: 0o600 }) } const tracking = JSON.parse(await readFile(options.trackingBackup, 'utf8')) as TrackingData await writeTrackingFile(tracking) @@ -300,17 +638,20 @@ function canRemoveOwnedPath (entry: SkillTrackingEntry, harness: HarnessType): b return !remainingPaths.some((value) => path.resolve(value) === path.resolve(ownedPath)) } -function reconcileTracking ( +function buildTrackingUpdate ( original: TrackingData, harness: HarnessType, destination: string, - skills: BundleDescriptor['skills'], - configPath: string | undefined, + bundle: BundleDescriptor, + plan: { destinations: Readonly> }, mcpServers: BundleDescriptor['mcpServers'], - staleMcpNames: string[] + staleByName: Map, + resolveFieldDigests: (configPath: string, name: string) => Record | undefined = (configPath, name) => readMcpFieldDigests(configPath, name, { preferredKey: harnessMcpKey(harness) }) ): TrackingData { const tracking = JSON.parse(JSON.stringify(original)) as TrackingData + const skills = bundle.skills const newNames = new Set(skills.map((skill) => skill.name)) + const stale = new Set(staleByName.keys()) for (const entry of tracking.skills) { if (!entry.harnesses.includes(harness)) continue @@ -345,32 +686,33 @@ function reconcileTracking ( } } - const stale = new Set(staleMcpNames) tracking.mcpServers = tracking.mcpServers.filter((entry) => !(entry.harness === harness && stale.has(entry.name))) - if (configPath) { - const now = new Date().toISOString() - for (const server of mcpServers) { - const existing = tracking.mcpServers.find((entry) => entry.harness === harness && entry.name === server.name) - if (existing) { - existing.configPath = path.resolve(configPath) - existing.configuredAt = now - existing.fields = readMcpRecord(configPath, server.name) - } else { - tracking.mcpServers.push({ name: server.name, configPath: path.resolve(configPath), harness, configuredAt: now, fields: readMcpRecord(configPath, server.name) }) - } + const now = new Date().toISOString() + for (const server of mcpServers) { + const configPath = plan.destinations[server.name] + if (!configPath) continue + const existing = tracking.mcpServers.find((entry) => entry.harness === harness && entry.name === server.name) + if (existing) { + existing.configPath = path.resolve(configPath) + existing.configuredAt = now + existing.fields = resolveFieldDigests(configPath, server.name) + } else { + tracking.mcpServers.push({ name: server.name, configPath: path.resolve(configPath), harness, configuredAt: now, fields: resolveFieldDigests(configPath, server.name) }) } } + tracking.bundleVersion = bundle.version + tracking.bundleVersions = { ...(tracking.bundleVersions ?? {}), [harness]: bundle.version } return tracking } -function readMcpRecord (configPath: string, name: string): Record | undefined { - try { - const parsed = readMcpConfig(configPath) - const servers = parsed?.mcpServers ?? parsed?.mcp_servers ?? parsed?.mcp - const record = servers && typeof servers === 'object' ? (servers as Record)[name] : undefined - if (!record || typeof record !== 'object' || Array.isArray(record)) return undefined - return Object.fromEntries(Object.entries(record as Record).map(([field, value]) => [field, valueDigest(value)])) - } catch { return undefined } +function mcpRecordIsExclusivelyOwned (configPath: string, name: string, ownedFields: Record | undefined, preferredKey: 'mcp' | 'mcpServers'): boolean { + if (!ownedFields || Object.keys(ownedFields).length === 0) return false + const current = readMcpFieldDigests(configPath, name, { preferredKey }) + if (!current) return false + const ownedNames = Object.keys(ownedFields).sort() + const currentNames = Object.keys(current).sort() + return ownedNames.length === currentNames.length && ownedNames.every((field, index) => + field === currentNames[index] && ownedFields[field] === current[field]) } function failure (code: string, message: string, rollback?: { attempted: boolean; succeeded: boolean }): FallbackRefreshResult { diff --git a/packages/core/src/update/index.ts b/packages/core/src/update/index.ts index 7985103..e05a6fb 100644 --- a/packages/core/src/update/index.ts +++ b/packages/core/src/update/index.ts @@ -28,6 +28,7 @@ export type { UpdateError, UpdateInstallation, UpdateInstallationMetadata, + NativeEvidence, UpdateOptions, UpdateOwnership, UpdatePlan, diff --git a/packages/core/src/update/inventory.ts b/packages/core/src/update/inventory.ts index f6471c5..ba98d0c 100644 --- a/packages/core/src/update/inventory.ts +++ b/packages/core/src/update/inventory.ts @@ -20,6 +20,7 @@ import { readClaudePluginScope } from './claude-record.js' import { detectGlobalPackageOwnership, readPackageVersion as readNamedPackageVersion } from './package-manager.js' import { readCodexPayloadVersion, resolveCodexPluginCachePath } from './codex-transaction.js' import { classifyVersionSet, classifyVersions, isStableVersion, readRunningVersionInfo, resolvePackageRoot } from './version.js' +import { nativePayloadTreeDigest } from './native-payload.js' export interface InventoryOptions { commandRunner: CommandRunner @@ -106,8 +107,9 @@ export async function detectCliInstallation (options: InventoryOptions, probeOwn function detectClaudeInstallations (): UpdateInstallation[] { const installedPath = resolveHome('~/.claude/plugins/installed_plugins.json') + const knownMarketplacesPath = resolveHome('~/.claude/plugins/known_marketplaces.json') const data = safeReadJson(installedPath) - const knownMarketplaces = safeReadJson(resolveHome('~/.claude/plugins/known_marketplaces.json')) ?? {} + const knownMarketplaces = safeReadJson(knownMarketplacesPath) ?? {} const records = extractPluginRecords(data) const output: UpdateInstallation[] = [] @@ -115,7 +117,14 @@ function detectClaudeInstallations (): UpdateInstallation[] { if (!isNsolidPluginId(id)) continue const parsed = PLUGIN_ID.exec(id) const scope = readClaudePluginScope(record) - const metadata = { ...(recordMetadata(record) ?? {}), configPath: installedPath } + const metadata = { + ...(recordMetadata(record) ?? {}), + configPath: installedPath, + nativeEvidence: [ + { path: installedPath, digest: fileDigest(installedPath) }, + { path: knownMarketplacesPath, digest: fileDigest(knownMarketplacesPath) }, + ].filter((entry) => entry.digest.length > 0), + } const marketplaceRecord = parsed && isRecord(knownMarketplaces[parsed[1]]) ? knownMarketplaces[parsed[1]] as Record : {} const enrichedRecord = { ...marketplaceRecord, ...record } const source = parsed && scope @@ -170,7 +179,12 @@ function detectCodexInstallations (): UpdateInstallation[] { const cacheRoot = parsed ? resolveCodexPluginCachePath(configPath, id, parsed[1], recordedMetadata?.packageRoot) : recordedMetadata?.packageRoot - const metadata = { ...(recordedMetadata ?? {}), ...(cacheRoot ? { packageRoot: cacheRoot } : {}), configPath } + const metadata = { + ...(recordedMetadata ?? {}), + ...(cacheRoot ? { packageRoot: cacheRoot } : {}), + configPath, + nativeEvidence: [{ path: configPath, digest: fileDigest(configPath) }].filter((entry) => entry.digest.length > 0), + } const version = readRecordVersion(record, cacheRoot) ?? (cacheRoot ? readCodexPayloadVersion(cacheRoot, id) : undefined) output.push({ installationId: `codex:native:${id}`, @@ -449,12 +463,17 @@ function sourceFromRecord (record: Record, metadata?: UpdateIns const freshness = freshnessValue === 'verified' || freshnessValue === 'stale' || freshnessValue === 'unknown' ? freshnessValue : 'unknown' + // The artifact root is the resolved payload subdirectory that contains + // the manifest; the manifest path narrows to its payload-relative + // basename so planning resolves the same bytes. + const segments = inferredManifest.replace(/\\/g, '/').split('/') + const payloadRoot = path.resolve(root, ...segments.slice(0, -1).filter((segment) => segment && segment !== '.')) return { kind: 'local-snapshot', - root, - manifestPath: inferredManifest, + root: payloadRoot, + manifestPath: segments[segments.length - 1], freshness, - contentDigest: contentDigestForSnapshot(root, inferredManifest), + contentDigest: contentDigestForSnapshot(payloadRoot), } } } @@ -560,8 +579,8 @@ function safeRealpath (filePath: string): string { try { return realpathSync(filePath) } catch { return path.resolve(filePath) } } -function contentDigestForSnapshot (root: string, manifestPath: string): string | undefined { - try { return createHash('sha256').update(readFileSync(path.resolve(root, manifestPath))).digest('hex') } catch { return undefined } +function contentDigestForSnapshot (root: string): string | undefined { + return nativePayloadTreeDigest(root) } function compareInstallations (a: UpdateInstallation, b: UpdateInstallation): number { diff --git a/packages/core/src/update/mcp-edit.ts b/packages/core/src/update/mcp-edit.ts new file mode 100644 index 0000000..b1ef6d0 --- /dev/null +++ b/packages/core/src/update/mcp-edit.ts @@ -0,0 +1,243 @@ +import { applyEdits, findNodeAtLocation, getNodeValue, modify, parseTree, type FormattingOptions, type JSONPath, type ModificationOptions, type Node, type ParseError } from 'jsonc-parser' + +export type JsonMcpKey = 'mcpServers' | 'mcp' + +export interface McpByteEdit { + /** Full server records to create or replace wholesale (new, exclusively owned servers). */ + upsertServers?: Readonly> + /** Server records to delete entirely (proven exclusively owned). */ + removeServers?: readonly string[] + /** Owned-field updates inside an existing server record. */ + setFields?: readonly { server: string; field: string; value: unknown }[] + /** Owned-field removals inside an existing server record. */ + removeFields?: readonly { server: string; field: string }[] + /** Remove the whole MCP container property (no servers remain). */ + removeBlock?: boolean + /** Legacy container keys (for example a pre-migration mcpServers block) removed wholesale. */ + removeKeys?: readonly string[] +} + +export class McpEditError extends Error { + constructor (public readonly code: 'MCP_PARSE_FAILED' | 'MCP_BLOCK_MISSING' | 'MCP_BLOCK_INVALID', message: string) { + super(message) + } +} + +/** Detect the MCP container key already present in the document. */ +export function detectJsonMcpKey (raw: string, preferred: JsonMcpKey = 'mcpServers'): JsonMcpKey { + const tree = parseTree(raw) + if (!tree || tree.type !== 'object') return preferred + if (findNodeAtLocation(tree, [preferred])) return preferred + const alternate: JsonMcpKey = preferred === 'mcpServers' ? 'mcp' : 'mcpServers' + if (findNodeAtLocation(tree, [alternate])) return alternate + return preferred +} + +/** Read the parsed value at an MCP path without altering anything. */ +export function readMcpNodeValue (raw: string, segments: readonly string[]): unknown { + const tree = parseTree(raw) + if (!tree) return undefined + activeRaw = raw + try { + const node = findNodeAtLocation(tree, [...segments] as JSONPath) + if (!node) return undefined + return jsonNodeValue(node) + } finally { + activeRaw = '' + } +} + +function jsonNodeValue (node: Node): unknown { + const text = activeRaw.slice(node.offset, node.offset + node.length) + if (node.type === 'string' || node.type === 'number' || node.type === 'boolean' || node.type === 'null') { + try { return JSON.parse(text) } catch { return text } + } + try { return JSON.parse(text) } catch { return text } +} + +let activeRaw = '' + +/** + * Apply localized AST edits to a JSON/JSONC document, preserving every byte + * outside the edited properties: comments, CRLF line endings, indentation, + * foreign servers, and fields this plugin does not own. The result is a + * complete document string the caller validates and installs atomically. + */ +export function editMcpJsonBytes (raw: string, edit: McpByteEdit, options?: { mcpKey?: JsonMcpKey }): string { + const mcpKey = options?.mcpKey ?? detectJsonMcpKey(raw) + if (raw.trim().length === 0) { + const servers = edit.upsertServers ?? {} + return JSON.stringify({ [mcpKey]: servers }, null, 2) + '\n' + } + activeRaw = raw + try { + const errors: ParseError[] = [] + const tree = parseTree(raw, errors, { allowTrailingComma: true }) + if (!tree || tree.type !== 'object' || errors.length > 0) { + throw new McpEditError('MCP_PARSE_FAILED', 'The MCP configuration is not a valid JSON object') + } + const mcpNode = findNodeAtLocation(tree, [mcpKey]) + const hasStructuralEdits = (edit.removeServers?.length ?? 0) > 0 || (edit.setFields?.length ?? 0) > 0 || (edit.removeFields?.length ?? 0) > 0 + if (mcpNode && mcpNode.type !== 'object') { + // The MCP container exists but is not an object (null, array, string, + // number). Ownership-needing edits cannot be proven: fail closed without + // touching any byte. An install-only edit may replace the container. + if (hasStructuralEdits) { + throw new McpEditError('MCP_BLOCK_INVALID', `The ${mcpKey} container is ${mcpNode.type} and cannot hold owned edits`) + } + let current = raw + const modification: ModificationOptions = { formattingOptions: formattingOptionsFor(raw) } + const replace = (path: JSONPath, value: unknown): void => { + const edits = modify(current, path, value, modification) + if (edits && edits.length > 0) current = applyEdits(current, edits) + } + if (!edit.removeBlock) { + replace([mcpKey], {}) + for (const [name, value] of Object.entries(edit.upsertServers ?? {})) { + replace([mcpKey, name], value) + } + } + for (const legacyKey of edit.removeKeys ?? []) { + current = removeRootProperty(current, legacyKey) + } + return current + } + if (!mcpNode) { + if (hasStructuralEdits) { + throw new McpEditError('MCP_BLOCK_MISSING', `The ${mcpKey} block is absent`) + } + // Only wholesale upserts against a document without the MCP block: + // insert one localized block before the outer closing brace so every + // other byte of the document is preserved. Legacy keys still migrate. + let inserted = insertMcpBlockBeforeClosingBrace(raw, mcpKey, edit.upsertServers ?? {}) + for (const legacyKey of edit.removeKeys ?? []) { + inserted = removeRootProperty(inserted, legacyKey) + } + return inserted + } + let current = raw + const modification: ModificationOptions = { formattingOptions: formattingOptionsFor(raw) } + // jsonc-parser edits from separate modify() calls can overlap, so each + // operation is applied and re-parsed sequentially. + const apply = (path: JSONPath, value: unknown): void => { + const edits = modify(current, path, value, modification) + if (edits && edits.length > 0) current = applyEdits(current, edits) + } + + for (const [name, value] of Object.entries(edit.upsertServers ?? {})) { + apply([mcpKey, name], value) + } + for (const name of edit.removeServers ?? []) { + const liveTree = parseTree(current) + const liveMcp = liveTree ? findNodeAtLocation(liveTree, [mcpKey]) : undefined + if (!liveMcp || !findNodeAtLocation(liveMcp, [name])) throw new McpEditError('MCP_BLOCK_MISSING', `Server ${name} is absent from ${mcpKey}`) + apply([mcpKey, name], undefined) + } + for (const { server, field, value } of edit.setFields ?? []) { + apply([mcpKey, server, field], value) + } + for (const { server, field } of edit.removeFields ?? []) { + const liveTree = parseTree(current) + const liveMcp = liveTree ? findNodeAtLocation(liveTree, [mcpKey]) : undefined + const liveServer = liveMcp ? findNodeAtLocation(liveMcp, [server]) : undefined + if (!liveServer || !findNodeAtLocation(liveServer, [field])) throw new McpEditError('MCP_BLOCK_MISSING', `Field ${server}.${field} is absent`) + apply([mcpKey, server, field], undefined) + } + // Whole-container and legacy-key removals run last: an empty MCP block is + // deleted only after its servers were removed individually, and legacy + // container keys (for example a pre-migration mcpServers block in an + // OpenCode config) are migrated away wholesale. + if (edit.removeBlock) { + current = removeRootProperty(current, mcpKey) + } + for (const legacyKey of edit.removeKeys ?? []) { + current = removeRootProperty(current, legacyKey) + } + return current + } finally { + activeRaw = '' + } +} + +function formattingOptionsFor (raw: string): FormattingOptions { + const match = raw.match(/^[^\S\n]*(?=\S)/m) + const indent = match?.[0] ?? ' ' + return { + tabSize: indent.includes('\t') ? 4 : Math.max(indent.replace(/\t/g, ' ').length, 1), + insertSpaces: !indent.includes('\t'), + eol: raw.includes('\r\n') ? '\r\n' : '\n', + } +} + +/** + * Remove a root-level property (the MCP container or a legacy key) with a + * localized text splice so bytes outside it (comments in particular) survive + * byte-for-byte. Any whitespace-only line left at the splice junction is + * collapsed locally; nothing else in the document is touched. + */ +function removeRootProperty (raw: string, key: string): string { + const tree = parseTree(raw) + if (!tree || tree.type !== 'object') return raw + const property = (tree.children ?? []).find((candidate) => { + const keyNode = candidate.children?.[0] + return keyNode && getNodeValue(keyNode) === key + }) + if (!property) return raw + let start = property.offset + let end = property.offset + property.length + // Drop a trailing comma after the block when one follows it. + let look = end + while (look < raw.length && (raw[look] === ' ' || raw[look] === '\t')) look++ + if (raw[look] === ',') { + end = look + 1 + while (end < raw.length && (raw[end] === ' ' || raw[end] === '\t')) end++ + } else { + // Otherwise drop a preceding comma before the block. + const head = raw.slice(0, start) + const trimmedHead = head.replace(/,\s*$/, '') + if (trimmedHead.length !== head.length) { + start = trimmedHead.length + } + } + let head = raw.slice(0, start) + let tail = raw.slice(end) + // Collapse whitespace left at the splice junction only: the indentation of + // a now-empty last line in head, and a single blank line between head and + // tail. Nothing else in the document is touched. + const lastNewline = head.lastIndexOf('\n') + const lastLine = head.slice(lastNewline + 1) + if (tail.startsWith('\n') && lastLine.length > 0 && lastLine.trim() === '') { + head = head.slice(0, lastNewline + 1) + } + if (head.endsWith('\n') && tail.startsWith('\n')) { + tail = tail.slice(1) + } + return head + tail +} + +function insertMcpBlockBeforeClosingBrace (raw: string, mcpKey: string, servers: Readonly>): string { + // Locate the root object's real closing brace from the parsed tree: braces + // inside comments or strings never participate in the structure. + const errors: ParseError[] = [] + const tree = parseTree(raw, errors, { allowTrailingComma: true }) + if (!tree || tree.type !== 'object' || errors.length > 0) { + return JSON.stringify({ [mcpKey]: servers }, null, 2) + '\n' + } + const lastCloseBrace = tree.offset + tree.length - 1 + const lineStart = raw.lastIndexOf('\n', lastCloseBrace) + const indent = lineStart === -1 ? ' ' : (raw.slice(lineStart + 1, lastCloseBrace).match(/^(\s+)/)?.[1] ?? ' ') + const innerIndent = indent.repeat(2) + const eol = raw.includes('\r\n') ? '\r\n' : '\n' + const innerContent = Object.entries(servers) + .map(([name, value]) => innerIndent + JSON.stringify(name) + ': ' + JSON.stringify(value)) + .join(',' + eol) + const block = JSON.stringify(mcpKey) + ': {' + eol + innerContent + eol + indent + '}' + const before = raw.slice(0, lastCloseBrace) + const after = raw.slice(lastCloseBrace) + if (before.trimEnd().endsWith('{')) { + return before + eol + indent + block + eol + after + } + // A trailing comma before the closing brace must not be duplicated. + const trimmed = before.trimEnd().replace(/,\s*$/, '') + return trimmed + ',' + eol + indent + block + eol + after +} diff --git a/packages/core/src/update/mcp-lookup.ts b/packages/core/src/update/mcp-lookup.ts new file mode 100644 index 0000000..c48e49b --- /dev/null +++ b/packages/core/src/update/mcp-lookup.ts @@ -0,0 +1,103 @@ +import { parseJsonc, readJsoncFile, readTomlFile } from '../utils/config.js' +import { parse as parseToml } from 'smol-toml' +import type { HarnessType } from '../types.js' +import { valueDigest } from './fallback-journal.js' + +export type PreferredMcpKey = 'mcp' | 'mcpServers' + +/** The JSON container key each harness reads and writes. */ +export function harnessMcpKey (harness: HarnessType): PreferredMcpKey { + return harness === 'opencode' ? 'mcp' : 'mcpServers' +} + +/** + * Single source of truth for choosing the MCP container inside a parsed JSON + * document: the harness-preferred key wins; the legacy key is only a fallback + * when the preferred key is absent. + */ +export function selectMcpContainer ( + parsed: Record | null | undefined, + preferredKey: PreferredMcpKey +): Record | undefined { + if (!parsed) return undefined + const isContainer = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + const preferred = parsed[preferredKey] + if (isContainer(preferred)) return preferred + const legacyKey: PreferredMcpKey = preferredKey === 'mcp' ? 'mcpServers' : 'mcp' + const legacy = parsed[legacyKey] + if (isContainer(legacy)) return legacy + return undefined +} + +function serversOf ( + parsed: Record | null, + configPath: string, + preferredKey: PreferredMcpKey +): Record | undefined { + if (!parsed) return undefined + if (configPath.endsWith('.toml')) { + const servers = parsed.mcp_servers + return servers && typeof servers === 'object' && !Array.isArray(servers) ? servers as Record : undefined + } + return selectMcpContainer(parsed, preferredKey) +} + +function parseConfigByPath (configPath: string, raw?: string): Record | null { + if (raw !== undefined) { + return configPath.endsWith('.toml') + ? parseToml(raw) as Record + : parseJsonc(raw) as Record + } + if (configPath.endsWith('.toml')) return readTomlFile>(configPath) + // JSONC parsing is a superset of JSON parsing, so both .json and .jsonc + // configuration files tolerate comments here. + return readJsoncFile>(configPath) +} + +/** + * Single source of truth for reading MCP server records from a harness + * configuration file. The planner, the journal, and the child transaction all + * route through here so ownership rules cannot drift between them. + */ +export function readMcpConfigFile (configPath: string): Record | null { + return parseConfigByPath(configPath) +} + +/** The raw record of one MCP server, or undefined when absent. */ +export function readMcpServerRecord (configPath: string, name: string, options?: { preferredKey?: PreferredMcpKey }): Record | undefined { + try { + const servers = serversOf(readMcpConfigFile(configPath), configPath, options?.preferredKey ?? 'mcpServers') + const record = servers?.[name] + return record && typeof record === 'object' && !Array.isArray(record) ? record as Record : undefined + } catch { return undefined } +} + +/** One field of an MCP server record, or undefined when absent. */ +export function readMcpServerField (configPath: string, server: string, field: string, options?: { preferredKey?: PreferredMcpKey }): unknown { + const record = readMcpServerRecord(configPath, server, options) + return record?.[field] +} + +/** Per-field digests of one MCP server record (the tracking evidence shape). */ +export function readMcpFieldDigests (configPath: string, name: string, options?: { preferredKey?: PreferredMcpKey }): Record | undefined { + const record = readMcpServerRecord(configPath, name, options) + if (!record) return undefined + return Object.fromEntries(Object.entries(record).map(([field, value]) => [field, valueDigest(value)])) +} + +/** + * Per-field digests computed from candidate configuration bytes rather than + * the live file, so tracking evidence can describe the post-swap state. Uses + * the same container selection as live reads. + */ +export function mcpFieldDigestsFromBytes (configPath: string, bytes: Buffer | string, name: string, options?: { preferredKey?: PreferredMcpKey }): Record | undefined { + try { + const text = typeof bytes === 'string' ? bytes : bytes.toString('utf8') + const parsed = parseConfigByPath(configPath, text) + const servers = serversOf(parsed, configPath, options?.preferredKey ?? 'mcpServers') + const record = servers?.[name] + if (!record || typeof record !== 'object' || Array.isArray(record)) return undefined + return Object.fromEntries(Object.entries(record as Record).map(([field, value]) => [field, valueDigest(value)])) + } catch { return undefined } +} diff --git a/packages/core/src/update/mcp-reconciliation.ts b/packages/core/src/update/mcp-reconciliation.ts new file mode 100644 index 0000000..b0cc22e --- /dev/null +++ b/packages/core/src/update/mcp-reconciliation.ts @@ -0,0 +1,142 @@ +import type { McpServerRef } from '../types.js' + +export interface TrackedMcpServer { + name: string + configPath: string + fields?: Record +} + +export interface McpServerValue { + name: string + /** Harness-formatted record ready to be stored. */ + value: Record +} + +export interface McpConfigPlanEntry { + configPath: string + /** Whole-record removals (proven exclusively owned before planning). */ + removeServers: string[] + /** Full records to create (servers with no previous tracking entry). */ + upsertServers: McpServerValue[] + /** Owned-field updates inside existing records (set or add). */ + updateFields: { server: string; field: string; value: unknown }[] + /** Owned-field removals inside existing records. */ + removeFields: { server: string; field: string }[] + /** Owned-field digests validated immediately before generating the patch. */ + ownedFieldDigests: { server: string; field: string; expectedDigest: string }[] +} + +export type McpReconciliationPlan = + | { kind: 'planned'; entries: readonly McpConfigPlanEntry[]; destinations: Readonly> } + | { kind: 'reconciliation-required'; code: 'MCP_RECONCILIATION_REQUIRED'; message: string } + +export interface McpReconciliationInput { + previousServers: readonly TrackedMcpServer[] + desiredServers: readonly McpServerRef[] + /** Harness-formatted record for every desired server, keyed by server name. */ + desiredValues: Readonly>> + canonicalConfigPath?: string +} + +/** + * Compute the per-config-file MCP reconciliation plan for one harness. + * + * Existing servers stay in their registered configuration file; stale servers + * are removed from the file that owns them; new servers go to the single + * pre-existing path, or to the adapter's canonical path. Any ambiguous + * selection (one name spread across several files, or no resolvable + * destination) returns MCP_RECONCILIATION_REQUIRED instead of guessing. + */ +export function planMcpReconciliation (input: McpReconciliationInput): McpReconciliationPlan { + const { previousServers, desiredServers, desiredValues, canonicalConfigPath } = input + const previousByName = new Map() + for (const entry of previousServers) { + const list = previousByName.get(entry.name) ?? [] + list.push(entry) + previousByName.set(entry.name, list) + } + + const ambiguous = desiredServers.find((server) => (previousByName.get(server.name)?.length ?? 0) > 1) + if (ambiguous) { + return { + kind: 'reconciliation-required', + code: 'MCP_RECONCILIATION_REQUIRED', + message: `MCP server ${ambiguous.name} is registered in multiple configuration files and its owner cannot be chosen safely`, + } + } + + const previousPaths = [...new Set(previousServers.map((entry) => entry.configPath))] + const desiredNames = new Set(desiredServers.map((server) => server.name)) + const staleServers = previousServers.filter((entry) => !desiredNames.has(entry.name)) + + let destinationForNew: string | undefined + if (desiredServers.some((server) => (previousByName.get(server.name)?.length ?? 0) === 0)) { + destinationForNew = previousPaths.length === 1 ? previousPaths[0] : canonicalConfigPath + if (!destinationForNew) { + return { + kind: 'reconciliation-required', + code: 'MCP_RECONCILIATION_REQUIRED', + message: 'The destination configuration file for new MCP servers is ambiguous and no canonical path is available', + } + } + } + + const byPath = new Map() + const entryFor = (configPath: string): McpConfigPlanEntry => { + const key = configPath + const existing = byPath.get(key) + if (existing) return existing + const created: McpConfigPlanEntry = { + configPath: key, + removeServers: [], + upsertServers: [], + updateFields: [], + removeFields: [], + ownedFieldDigests: [], + } + byPath.set(key, created) + return created + } + + const destinations: Record = {} + for (const server of desiredServers) { + const owner = previousByName.get(server.name)?.[0] + const desiredValue = desiredValues[server.name] + if (!desiredValue) { + return { + kind: 'reconciliation-required', + code: 'MCP_RECONCILIATION_REQUIRED', + message: `No harness-formatted value was prepared for MCP server ${server.name}`, + } + } + if (!owner) { + const target = entryFor(destinationForNew!) + target.upsertServers.push({ name: server.name, value: desiredValue }) + destinations[server.name] = destinationForNew! + continue + } + const target = entryFor(owner.configPath) + destinations[server.name] = owner.configPath + const trackedFields = owner.fields ?? {} + for (const [field, expectedDigest] of Object.entries(trackedFields)) { + target.ownedFieldDigests.push({ server: server.name, field, expectedDigest }) + if (Object.hasOwn(desiredValue, field)) { + target.updateFields.push({ server: server.name, field, value: desiredValue[field] }) + } else { + target.removeFields.push({ server: server.name, field }) + } + } + for (const [field, value] of Object.entries(desiredValue)) { + if (!Object.hasOwn(trackedFields, field)) { + target.updateFields.push({ server: server.name, field, value }) + } + } + } + + for (const entry of staleServers) { + entryFor(entry.configPath).removeServers.push(entry.name) + } + + const entries = [...byPath.values()].sort((left, right) => left.configPath.localeCompare(right.configPath)) + return { kind: 'planned', entries, destinations } +} diff --git a/packages/core/src/update/mcp-toml-edit.ts b/packages/core/src/update/mcp-toml-edit.ts new file mode 100644 index 0000000..ae97ee3 --- /dev/null +++ b/packages/core/src/update/mcp-toml-edit.ts @@ -0,0 +1,652 @@ +import { parse as parseToml } from 'smol-toml' + +/** + * Byte-localized TOML editor for the NodeSource-owned MCP slice of a user's + * configuration. Every byte outside the exact owned server/field ranges is + * preserved verbatim: comments, CRLF endings, spacing, key spelling, unrelated + * tables, and user credentials. The full document is never re-serialized. + * + * The editor is fail closed: ambiguous constructs inside an owned range + * (standalone comments, dotted partial keys, array-of-tables servers) return a + * structured error instead of guessing, and every generated result is + * re-parsed and deep-compared against an independently computed model before + * it may be installed. + */ +export class McpTomlEditError extends Error { + constructor (public readonly code: 'MCP_PARSE_FAILED' | 'MCP_BLOCK_MISSING' | 'MCP_BLOCK_INVALID' | 'MCP_RECONCILIATION_REQUIRED', message: string) { + super(message) + } +} + +export interface McpTomlEdit { + /** Whole server records to append as new, exclusively owned tables. */ + upsertServers?: Readonly> + /** Server base+descendant tables to delete (proven exclusively owned). */ + removeServers?: readonly string[] + /** Owned-field updates inside an existing server table. */ + setFields?: readonly { server: string; field: string; value: unknown }[] + /** Owned-field removals inside an existing server table. */ + removeFields?: readonly { server: string; field: string }[] +} + +interface AssignmentSpan { + keyPath: string[] + keyStart: number + lineStart: number + lineEnd: number + valueStart: number + valueEnd: number +} + +interface TableSpan { + path: string[] + isArrayTable: boolean + headerLineStart: number + headerEnd: number + /** Line start of the next header in the document, or EOF. */ + nextHeaderLineStart: number + assignments: AssignmentSpan[] + /** A standalone comment line inside the body: ownership is ambiguous. */ + looseComments: boolean +} + +interface TomlIndex { + eol: '\n' | '\r\n' + tables: TableSpan[] +} + +interface SpanEdit { + start: number + end: number + text: string +} + +const SERVERS_KEY = 'mcp_servers' + +/** + * Apply owned MCP edits to raw TOML bytes while preserving every other byte. + * Returns the original string when the requested operations are a semantic + * no-op. + */ +export function editMcpTomlBytes (raw: string, edit: McpTomlEdit): string { + const original = parseTomlSafe(raw, 'MCP_PARSE_FAILED') + const expected = modelAfterOps(original, edit) + // A semantic no-op must not rewrite a single byte. + if (deepEqual(expected, original)) return raw + const index = indexToml(raw) + const edits: SpanEdit[] = [] + for (const name of edit.removeServers ?? []) applyRemoveServer(edits, index, name) + for (const removal of edit.removeFields ?? []) applyRemoveField(edits, index, removal.server, removal.field) + for (const update of edit.setFields ?? []) applySetField(edits, index, raw, update.server, update.field, update.value) + for (const [name, value] of Object.entries(edit.upsertServers ?? {})) applyUpsertServer(edits, index, raw, name, value) + let out = raw + for (const span of edits.sort((left, right) => right.start - left.start)) { + out = out.slice(0, span.start) + span.text + out.slice(span.end) + } + // Independent verification: the result must parse and deep-compare equal to + // the model produced by applying the same operations to the pre-edit parse. + let finalParsed: Record + try { + finalParsed = parseToml(out) as Record + } catch (error) { + throw new McpTomlEditError('MCP_BLOCK_INVALID', `The localized TOML edit produced an invalid document: ${(error as Error).message}`) + } + if (!deepEqual(finalParsed, expected)) { + throw new McpTomlEditError('MCP_BLOCK_INVALID', 'The localized TOML edit did not produce the expected document model; refusing to install it') + } + return out +} + +function parseTomlSafe (raw: string, code: 'MCP_PARSE_FAILED' | 'MCP_BLOCK_INVALID'): Record { + try { + return parseToml(raw) as Record + } catch (error) { + throw new McpTomlEditError(code, `The MCP TOML configuration could not be parsed: ${(error as Error).message}`) + } +} + +// --------------------------------------------------------------------------- +// Lexical index +// --------------------------------------------------------------------------- + +function indexToml (raw: string): TomlIndex { + const eol: '\n' | '\r\n' = raw.includes('\r\n') ? '\r\n' : '\n' + const tables: TableSpan[] = [] + let current: TableSpan | undefined + let lineStart = 0 + let i = 0 + while (i < raw.length) { + const ch = raw[i] + if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') { + if (ch === '\n') lineStart = i + 1 + i++ + continue + } + if (ch === '#') { + // A standalone comment line inside a table body has ambiguous + // ownership: remember it so destructive edits can fail closed. + if (current) current.looseComments = true + i = skipToLineEnd(raw, i) + continue + } + if (ch === '[') { + const header = parseTableHeader(raw, i) + if (!header) { + i = skipToLineEnd(raw, i) + continue + } + if (current) current.nextHeaderLineStart = lineStart + current = { + path: header.path, + isArrayTable: header.isArrayTable, + headerLineStart: lineStart, + headerEnd: header.end, + nextHeaderLineStart: raw.length, + assignments: [], + looseComments: false, + } + tables.push(current) + i = skipToLineEnd(raw, header.end) + continue + } + const key = parseKeySegments(raw, i) + if (!key) { + i = skipToLineEnd(raw, i) + continue + } + let cursor = skipSpaces(raw, key.end) + if (raw[cursor] !== '=') { + i = skipToLineEnd(raw, i) + continue + } + cursor = skipSpaces(raw, cursor + 1) + const value = scanValue(raw, cursor) + if (!value) { + i = skipToLineEnd(raw, i) + continue + } + if (current) { + current.assignments.push({ + keyPath: key.segments, + keyStart: i, + lineStart, + lineEnd: lineEndOf(raw, lineStart), + valueStart: value.start, + valueEnd: value.end, + }) + } + i = lineEndOf(raw, lineStartOfValueLine(raw, value.end)) + // The jump skipped the assignment's newline: keep the walk's line anchor + // in sync so every assignment records its own line bounds. + lineStart = i + } + return { eol, tables } +} + +function lineStartOfValueLine (raw: string, end: number): number { + // The assignment's owning line is the one containing the end of its value + // (multiline values span lines; destructive edits cut whole final lines). + const nl = raw.lastIndexOf('\n', Math.max(end - 1, 0)) + return nl === -1 ? 0 : nl + 1 +} + +function lineEndOf (raw: string, lineStart: number): number { + const nl = raw.indexOf('\n', lineStart) + return nl === -1 ? raw.length : nl + 1 +} + +function skipToLineEnd (raw: string, i: number): number { + while (i < raw.length && raw[i] !== '\n') i++ + return i +} + +function skipSpaces (raw: string, i: number): number { + while (i < raw.length && (raw[i] === ' ' || raw[i] === '\t' || raw[i] === '\r')) i++ + return i +} + +/** Parse `[a.b]` / `[["a".b]]` headers with quote awareness. */ +function parseTableHeader (raw: string, start: number): { path: string[]; isArrayTable: boolean; end: number } | undefined { + const isArrayTable = raw.startsWith('[[', start) + const closing = isArrayTable ? ']]' : ']' + let i = isArrayTable ? start + 2 : start + 1 + const segments: string[] = [] + for (;;) { + i = skipSpaces(raw, i) + const segment = parseKeySegment(raw, i) + if (!segment) return undefined + segments.push(segment.value) + i = skipSpaces(raw, segment.end) + if (raw.startsWith(closing, i)) { + return { path: segments, isArrayTable, end: i + closing.length } + } + if (raw[i] !== '.') return undefined + i++ + } +} + +/** Parse a dotted key: bare or quoted segments joined by dots. */ +function parseKeySegments (raw: string, start: number): { segments: string[]; end: number } | undefined { + const segments: string[] = [] + let i = start + for (;;) { + const segment = parseKeySegment(raw, i) + if (!segment) return undefined + segments.push(segment.value) + i = skipSpaces(raw, segment.end) + if (raw[i] !== '.') return { segments, end: segment.end } + i++ + i = skipSpaces(raw, i) + } +} + +function parseKeySegment (raw: string, start: number): { value: string; end: number } | undefined { + const ch = raw[start] + if (ch === '"') return scanBasicString(raw, start) + if (ch === "'") return scanLiteralString(raw, start) + if (ch === undefined || !/[A-Za-z0-9_-]/.test(ch)) return undefined + let i = start + while (i < raw.length && /[A-Za-z0-9_-]/.test(raw[i])) i++ + return { value: raw.slice(start, i), end: i } +} + +/** Scan one TOML value; returns its exact [start, end) span. */ +function scanValue (raw: string, start: number): { start: number; end: number } | undefined { + const first = skipSpaces(raw, start) + if (first >= raw.length || raw[first] === '\n') return undefined + const ch = raw[first] + let end: number + if (ch === '"') { + const scanned = scanBasicString(raw, first) + if (!scanned) return undefined + end = scanned.end + } else if (ch === "'") { + const scanned = scanLiteralString(raw, first) + if (!scanned) return undefined + end = scanned.end + } else if (ch === '{') { + end = scanBalanced(raw, first, '{', '}') + if (end === -1) return undefined + } else if (ch === '[') { + end = scanBalanced(raw, first, '[', ']') + if (end === -1) return undefined + } else { + end = first + while (end < raw.length && !',}]#\n'.includes(raw[end]) && raw[end] !== '\r') end++ + // Trailing spaces belong to the line, not the value. + while (end > first && (raw[end - 1] === ' ' || raw[end - 1] === '\t')) end-- + if (end === first) return undefined + } + return { start: first, end } +} + +function scanBasicString (raw: string, start: number): { value: string; end: number } | undefined { + const multiline = raw.startsWith('"""', start) + const opener = multiline ? 3 : 1 + let i = start + opener + for (;;) { + if (i >= raw.length) return undefined + if (!multiline && raw[i] === '\n') return undefined + if (raw[i] === '\\') { + // Skip escape sequences; escapes are decoded only for key segments. + i += 2 + continue + } + if (raw.startsWith(multiline ? '"""' : '"', i)) { + const text = raw.slice(start + opener, i) + return { value: decodeBasicEscapes(text), end: i + (multiline ? 3 : 1) } + } + i++ + } +} + +function decodeBasicEscapes (text: string): string { + try { + return JSON.parse('"' + text.replace(/\\e/g, '\\u001b') + '"') + } catch { + return text + } +} + +function scanLiteralString (raw: string, start: number): { value: string; end: number } | undefined { + const multiline = raw.startsWith("'''", start) + const opener = multiline ? 3 : 1 + let i = start + opener + for (;;) { + if (i >= raw.length) return undefined + if (!multiline && raw[i] === '\n') return undefined + if (raw.startsWith(multiline ? "'''" : "'", i)) { + return { value: raw.slice(start + opener, i), end: i + (multiline ? 3 : 1) } + } + i++ + } +} + +function scanBalanced (raw: string, start: number, open: string, close: string): number { + let depth = 0 + let i = start + while (i < raw.length) { + const ch = raw[i] + if (ch === '"' || ch === "'") { + const scanned = ch === '"' ? scanBasicString(raw, i) : scanLiteralString(raw, i) + if (!scanned) return -1 + i = scanned.end + continue + } + if (ch === '#') { + i = skipToLineEnd(raw, i) + continue + } + if (ch === open) depth++ + if (ch === close) { + depth-- + if (depth === 0) return i + 1 + } + i++ + } + return -1 +} + +// --------------------------------------------------------------------------- +// Model verification +// --------------------------------------------------------------------------- + +function isRecord (value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** Structured owned values must be TOML tables; anything else fails closed. */ +function asRecord (value: unknown): Record { + if (!isRecord(value)) { + throw new McpTomlEditError('MCP_BLOCK_INVALID', 'Structured owned values must be rendered as TOML tables') + } + return value +} + +/** + * Apply the requested operations to an independent copy of the pre-edit model + * so the localized result can be verified structurally. Missing targets fail + * closed with the same codes the editor uses for the byte-level path. + */ +function modelAfterOps (model: Record, edit: McpTomlEdit): Record { + const next = structuredClone(model) + const root = next as Record + const ensureServers = (): Record => { + const existing = root[SERVERS_KEY] + if (existing === undefined) { + const created: Record = {} + root[SERVERS_KEY] = created + return created + } + if (isRecord(existing)) return existing + throw new McpTomlEditError('MCP_BLOCK_INVALID', `The ${SERVERS_KEY} entry is not a TOML table`) + } + for (const [name, value] of Object.entries(edit.upsertServers ?? {})) { + const servers = ensureServers() + if (Object.hasOwn(servers, name)) { + throw new McpTomlEditError('MCP_RECONCILIATION_REQUIRED', `A server named ${name} already exists in the TOML MCP configuration`) + } + servers[name] = structuredClone(value) + } + for (const name of edit.removeServers ?? []) { + const servers = root[SERVERS_KEY] + if (!isRecord(servers) || !Object.hasOwn(servers, name)) { + throw new McpTomlEditError('MCP_BLOCK_MISSING', `Server ${name} is absent from the TOML MCP configuration`) + } + if (Array.isArray(servers[name])) { + throw new McpTomlEditError('MCP_BLOCK_INVALID', `Server ${name} is an array of tables and cannot be edited safely`) + } + delete servers[name] + } + for (const { server, field } of edit.removeFields ?? []) { + const servers = root[SERVERS_KEY] + const record = isRecord(servers) ? servers[server] : undefined + if (!isRecord(record) || !Object.hasOwn(record, field)) { + throw new McpTomlEditError('MCP_BLOCK_MISSING', `Field ${server}.${field} is absent from the TOML MCP configuration`) + } + delete record[field] + } + for (const { server, field, value } of edit.setFields ?? []) { + const servers = root[SERVERS_KEY] + const record = isRecord(servers) ? servers[server] : undefined + if (!isRecord(record)) { + throw new McpTomlEditError('MCP_BLOCK_MISSING', `Server ${server} is absent from the TOML MCP configuration`) + } + record[field] = structuredClone(value) + } + return next +} + +function deepEqual (left: unknown, right: unknown): boolean { + if (left === right) return true + if (typeof left === 'number' && typeof right === 'number' && Number.isNaN(left) && Number.isNaN(right)) return true + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length && left.every((value, i) => deepEqual(value, right[i])) + } + if (isRecord(left) && isRecord(right)) { + const leftKeys = Object.keys(left) + return leftKeys.length === Object.keys(right).length && + leftKeys.every((key) => Object.hasOwn(right, key) && deepEqual(left[key], right[key])) + } + return false +} + +// --------------------------------------------------------------------------- +// Byte-localized edit builders +// --------------------------------------------------------------------------- + +function serverTables (index: TomlIndex, name: string): TableSpan[] { + return index.tables.filter((table) => !table.isArrayTable && table.path.length >= 2 && table.path[0] === SERVERS_KEY && table.path[1] === name) +} + +function findTable (index: TomlIndex, path: string[]): TableSpan | undefined { + return index.tables.find((table) => !table.isArrayTable && table.path.length === path.length && table.path.every((segment, i) => segment === path[i])) +} + +function rejectAmbiguousServer (table: TableSpan, name: string): void { + if (table.looseComments) { + throw new McpTomlEditError('MCP_BLOCK_INVALID', `Server ${name} contains standalone comments whose ownership is ambiguous; refusing to rewrite those bytes`) + } +} + +function applyRemoveServer (edits: SpanEdit[], index: TomlIndex, name: string): void { + const tables = serverTables(index, name) + if (tables.length === 0) { + throw new McpTomlEditError('MCP_BLOCK_MISSING', `Server ${name} is absent from the TOML MCP configuration`) + } + for (const table of tables) { + rejectAmbiguousServer(table, name) + edits.push({ start: table.headerLineStart, end: table.nextHeaderLineStart, text: '' }) + } +} + +function applyRemoveField (edits: SpanEdit[], index: TomlIndex, server: string, field: string): void { + const basePath = [SERVERS_KEY, server] + const child = findTable(index, [...basePath, field]) + if (child) { + rejectAmbiguousServer(child, `${server}.${field}`) + edits.push({ start: child.headerLineStart, end: child.nextHeaderLineStart, text: '' }) + return + } + const base = findTable(index, basePath) + if (!base) { + if (index.tables.some((table) => table.path[0] === SERVERS_KEY && table.path[1] === server)) { + throw new McpTomlEditError('MCP_BLOCK_INVALID', `Server ${server} is an array of tables and cannot be edited safely`) + } + throw new McpTomlEditError('MCP_BLOCK_MISSING', `Server ${server} is absent from the TOML MCP configuration`) + } + const assignment = base.assignments.find((candidate) => candidate.keyPath.length === 1 && candidate.keyPath[0] === field) + if (assignment) { + // The whole line goes, including its trailing comment; standalone comments + // above the line are ambiguous and stay untouched. + edits.push({ start: assignment.lineStart, end: assignment.lineEnd, text: '' }) + return + } + if (base.assignments.some((candidate) => candidate.keyPath[0] === field)) { + throw new McpTomlEditError('MCP_BLOCK_INVALID', `Field ${server}.${field} uses dotted-key syntax that cannot be removed safely`) + } + throw new McpTomlEditError('MCP_BLOCK_MISSING', `Field ${server}.${field} is absent from the TOML MCP configuration`) +} + +function applySetField (edits: SpanEdit[], index: TomlIndex, raw: string, server: string, field: string, value: unknown): void { + const eol = index.eol + const basePath = [SERVERS_KEY, server] + const base = findTable(index, basePath) + if (!base) { + if (index.tables.some((table) => table.path[0] === SERVERS_KEY && table.path[1] === server)) { + throw new McpTomlEditError('MCP_BLOCK_INVALID', `Server ${server} is an array of tables and cannot be edited safely`) + } + throw new McpTomlEditError('MCP_BLOCK_MISSING', `Server ${server} is absent from the TOML MCP configuration`) + } + const child = findTable(index, [...basePath, field]) + if (child) { + rejectAmbiguousServer(child, `${server}.${field}`) + // Replace only this exact subtree; the blank lines separating it from the + // following table stay so the surrounding layout is untouched. + const blanks = trailingBlankBytes(raw, child.nextHeaderLineStart) + const replacement = isRecord(value) + ? renderTableHeader([...basePath, field]) + eol + renderAssignments(asRecord(value), [...basePath, field], eol) + blanks + : indentOf(raw, base) + renderKey(field) + ' = ' + inlineValue(value) + eol + blanks + edits.push({ start: child.headerLineStart, end: child.nextHeaderLineStart, text: replacement }) + return + } + const assignment = base.assignments.find((candidate) => candidate.keyPath.length === 1 && candidate.keyPath[0] === field) + if (assignment) { + if (inlineSerializable(value)) { + // Replace only the value span: key spelling, spacing around '=', the + // trailing comment, and the EOL are preserved byte-for-byte. + edits.push({ start: assignment.valueStart, end: assignment.valueEnd, text: inlineValue(value) }) + return + } + // Scalar to structured: drop the line and append the child table at the + // end of this server's subtree. + edits.push({ start: assignment.lineStart, end: assignment.lineEnd, text: '' }) + const subtreeEnd = serverSubtreeEnd(index, server) + const structured = asRecord(value) + edits.push({ + start: subtreeEnd, + end: subtreeEnd, + text: eolPrefix(raw, subtreeEnd, eol) + renderTableHeader([...basePath, field]) + eol + renderAssignments(structured, [...basePath, field], eol), + }) + return + } + if (base.assignments.some((candidate) => candidate.keyPath[0] === field)) { + throw new McpTomlEditError('MCP_BLOCK_INVALID', `Field ${server}.${field} uses dotted-key syntax that cannot be edited safely`) + } + // New field: scalars land inside the base table right after its last + // assignment; structured values become a child table at the end of the + // server's subtree so TOML table scoping stays correct. + if (inlineSerializable(value)) { + const insertPoint = base.assignments.length > 0 + ? base.assignments[base.assignments.length - 1].lineEnd + : lineEndOf(raw, base.headerLineStart) + const line = indentOf(raw, base) + renderKey(field) + ' = ' + inlineValue(value) + eol + edits.push({ start: insertPoint, end: insertPoint, text: eolPrefix(raw, insertPoint, eol) + line }) + return + } + const subtreeEnd = serverSubtreeEnd(index, server) + const structured = asRecord(value) + edits.push({ + start: subtreeEnd, + end: subtreeEnd, + text: eolPrefix(raw, subtreeEnd, eol) + renderTableHeader([...basePath, field]) + eol + renderAssignments(structured, [...basePath, field], eol), + }) +} + +function applyUpsertServer (edits: SpanEdit[], index: TomlIndex, raw: string, name: string, value: unknown): void { + if (serverTables(index, name).length > 0) { + throw new McpTomlEditError('MCP_RECONCILIATION_REQUIRED', `A server named ${name} already exists in the TOML MCP configuration`) + } + const eol = index.eol + const path = [SERVERS_KEY, name] + const fragment = renderTableHeader(path) + eol + renderAssignments(asRecord(value), path, eol) + if (raw.length === 0) { + edits.push({ start: 0, end: 0, text: fragment }) + return + } + edits.push({ start: raw.length, end: raw.length, text: eolPrefix(raw, raw.length, eol) + fragment }) +} + +function serverSubtreeEnd (index: TomlIndex, server: string): number { + const tables = serverTables(index, server) + if (tables.length === 0) { + throw new McpTomlEditError('MCP_BLOCK_MISSING', `Server ${server} is absent from the TOML MCP configuration`) + } + const last = tables.reduce((acc, table) => (table.headerLineStart > acc.headerLineStart ? table : acc), tables[0]) + return last.nextHeaderLineStart +} + +/** Bytes of blank lines immediately before `end`, preserved across replaces. */ +function trailingBlankBytes (raw: string, end: number): string { + let cursor = end + for (;;) { + const nl = raw.lastIndexOf('\n', cursor - 1) + if (nl === -1) break + const lineStart = raw.lastIndexOf('\n', nl - 1) + 1 + const lineEnd = raw.endsWith('\r', nl) ? nl - 1 : nl + if (raw.slice(lineStart, lineEnd).trim() !== '') break + cursor = lineStart + } + return raw.slice(cursor, end) +} + +function eolPrefix (raw: string, insertPoint: number, eol: string): string { + // A final line without a newline needs one before an appended fragment. + return insertPoint >= raw.length && raw.length > 0 && !raw.endsWith('\n') ? eol : '' +} + +function indentOf (raw: string, table: TableSpan): string { + const first = table.assignments[0] + return first ? raw.slice(first.lineStart, first.keyStart) : '' +} + +// --------------------------------------------------------------------------- +// TOML fragment rendering (synthetic owned values only; never a full document) +// --------------------------------------------------------------------------- + +function renderKey (key: string): string { + return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key) +} + +function renderTableHeader (path: readonly string[]): string { + return '[' + path.map(renderKey).join('.') + ']' +} + +function inlineSerializable (value: unknown): boolean { + return !isRecord(value) || Object.keys(value).length === 0 +} + +function inlineValue (value: unknown): string { + if (value === null || value === undefined) { + throw new McpTomlEditError('MCP_BLOCK_INVALID', 'TOML cannot represent a null owned field value') + } + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'number') { + if (Number.isNaN(value)) return 'nan' + if (value === Number.POSITIVE_INFINITY) return 'inf' + if (value === Number.NEGATIVE_INFINITY) return '-inf' + return String(value) + } + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (Array.isArray(value)) return '[' + value.map(inlineValue).join(', ') + ']' + if (Object.keys(value).length === 0) return '{}' + // Non-empty objects never serialize inline: they become child tables via + // renderAssignments so sibling bytes stay untouched. + throw new McpTomlEditError('MCP_BLOCK_INVALID', 'Structured owned values must be rendered as child tables') +} + +function renderAssignments (record: Record, path: readonly string[], eol: string): string { + // Two-pass rendering: every direct scalar/inline assignment of this table + // must be emitted before its child tables, otherwise later scalars would + // be scoped under a child-table header. + let scalars = '' + let children = '' + for (const [key, value] of Object.entries(record)) { + if (inlineSerializable(value)) { + scalars += renderKey(key) + ' = ' + inlineValue(value) + eol + } else { + children += renderTableHeader([...path, key]) + eol + renderAssignments(asRecord(value), [...path, key], eol) + } + } + return scalars + children +} diff --git a/packages/core/src/update/native-evidence.ts b/packages/core/src/update/native-evidence.ts index 39e3bcb..ab3d3d5 100644 --- a/packages/core/src/update/native-evidence.ts +++ b/packages/core/src/update/native-evidence.ts @@ -1,7 +1,8 @@ import { createHash } from 'node:crypto' -import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { readFileSync } from 'node:fs' import path from 'node:path' -import type { ResolvedArtifactIdentity, UpdateSource } from './types.js' +import type { ResolvedArtifactIdentity, UpdateError, UpdateInstallationMetadata, UpdateSource } from './types.js' +import { nativePayloadTreeDigest } from './native-payload.js' const FULL_COMMIT = /^[0-9a-f]{40}$/i @@ -9,16 +10,20 @@ export function nativeSourceHonorsArtifact ( source: UpdateSource, artifact: ResolvedArtifactIdentity | undefined ): boolean { - if (!artifact || (artifact.kind !== 'git' && artifact.kind !== 'local-snapshot')) return true if (source.kind !== 'claude-marketplace' && source.kind !== 'codex-marketplace') return false + // A marketplace mutation without a proven immutable artifact can never be + // authorized: refuse instead of assuming the plan is still pinned. + if (!artifact || (artifact.kind !== 'git' && artifact.kind !== 'local-snapshot')) return false const versionSource = source.versionSource if (artifact.kind === 'git') { if (versionSource.kind !== 'git') return false const pinnedRevision = FULL_COMMIT.test(versionSource.revision ?? '') ? versionSource.revision : undefined const observedCommitMatches = versionSource.commit === undefined || versionSource.commit.toLowerCase() === artifact.commit.toLowerCase() + const payloadMatches = artifact.payloadPath === undefined || + (versionSource.manifestPath !== undefined && payloadDirectory(versionSource.manifestPath) === artifact.payloadPath) return pinnedRevision?.toLowerCase() === artifact.commit.toLowerCase() && observedCommitMatches && - normalizeRepository(versionSource.repository) === normalizeRepository(artifact.repository) + normalizeRepository(versionSource.repository) === normalizeRepository(artifact.repository) && payloadMatches } return versionSource.kind === 'local-snapshot' && @@ -27,37 +32,52 @@ export function nativeSourceHonorsArtifact ( versionSource.contentDigest === artifact.contentDigest } -export function nativePayloadDigest (root: string, manifestPath?: string): string | undefined { - try { - if (manifestPath) { - const base = path.resolve(root) - const manifest = path.resolve(base, manifestPath) - if (manifest.startsWith(`${base}${path.sep}`) && existsSync(manifest)) { - return createHash('sha256').update(readFileSync(manifest)).digest('hex') - } - } - const directBundle = path.join(root, 'bundle.json') - if (existsSync(directBundle)) return createHash('sha256').update(readFileSync(directBundle)).digest('hex') - const files: string[] = [] - collectDigestFiles(root, 0, files) - if (files.length === 0) return undefined - const hash = createHash('sha256') - for (const file of files.sort()) hash.update(file).update(readFileSync(file)) - return hash.digest('hex') - } catch { return undefined } +/** + * Canonical digest of an installed native payload directory. Resolution and + * execution must compare this exact function's output: the marketplace + * resolution digests the payload subtree of the immutable commit and the + * installed plugin directory is digested the same way here. + */ +export function nativePayloadDigest (root: string): string | undefined { + return nativePayloadTreeDigest(root) } -function collectDigestFiles (root: string, depth: number, output: string[]): void { - if (depth > 4 || output.length > 256) return - try { - for (const entry of readdirSync(root, { withFileTypes: true })) { - const file = path.join(root, entry.name) - if (entry.isDirectory()) collectDigestFiles(file, depth + 1, output) - else if (entry.isFile() && ['bundle.json', 'plugin.json', 'package.json', 'manifest.json'].includes(entry.name)) output.push(file) +export function nativeEvidenceMatches (metadata: UpdateInstallationMetadata | undefined): boolean { + const evidence = metadata?.nativeEvidence + if (!evidence || evidence.length === 0) return false + return evidence.every((entry) => { + if (!entry || typeof entry.path !== 'string' || typeof entry.digest !== 'string') return false + try { + return entry.digest.length > 0 && createHash('sha256').update(readFileSync(entry.path)).digest('hex') === entry.digest + } catch { + return false } - } catch { - if (existsSync(root)) output.push(root) + }) +} + +/** + * Shared execution guard for native-plugin strategies (Claude and Codex). + * Returns the structured planning error when the planned bytes can no longer + * be proven, or undefined when the guarded mutation may proceed. + */ +export function nativeExecutionGuard (item: { + metadata?: UpdateInstallationMetadata + source: UpdateSource + artifact?: ResolvedArtifactIdentity +}, label: string): UpdateError | undefined { + if (!nativeEvidenceMatches(item.metadata)) { + return { code: 'NATIVE_SOURCE_DRIFT', message: `${label} marketplace records changed after planning` } } + if (!nativeSourceHonorsArtifact(item.source, item.artifact)) { + return { code: 'NATIVE_SOURCE_NOT_PINNED', message: `${label} marketplace source no longer proves the planned immutable identity` } + } + return undefined +} + +function payloadDirectory (manifestPath: string): string { + const normalized = manifestPath.replace(/\\/g, '/') + const index = normalized.lastIndexOf('/') + return index > 0 ? normalized.slice(0, index) : '' } function normalizeRepository (repository: string): string { diff --git a/packages/core/src/update/native-payload.ts b/packages/core/src/update/native-payload.ts new file mode 100644 index 0000000..77cfeb8 --- /dev/null +++ b/packages/core/src/update/native-payload.ts @@ -0,0 +1,185 @@ +import { createHash } from 'node:crypto' +import { lstatSync, readFileSync, readlinkSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { gunzipSync } from 'node:zlib' + +type PayloadEntry = + | { kind: 'file'; content: Buffer } + | { kind: 'symlink'; target: string } + +const MAX_PAYLOAD_FILES = 4096 +const MAX_PAYLOAD_BYTES = 128 * 1024 * 1024 +const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024 +const MAX_UNPACKED_ARCHIVE_BYTES = 160 * 1024 * 1024 + +export interface ArchivePayloadScope { + /** Repo-relative POSIX directory the installable payload lives in ('' or undefined = repository root). */ + payloadPath?: string + /** Payload-relative POSIX manifest path that must exist inside the subtree. */ + manifestPath?: string +} + +/** Normalize a repo-relative POSIX directory (payload scope). Rejects traversal. */ +export function normalizedPayloadPath (value: string | undefined): string { + const normalized = (value ?? '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '') + if (!normalized) return '' + if (normalized.startsWith('/') || normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..')) { + throw new Error('unsafe payload path') + } + return normalized +} + +export function nativePayloadTreeDigest (root: string): string | undefined { + try { + const resolvedRoot = path.resolve(root) + const entries = new Map() + let totalBytes = 0 + const walk = (directory: string, relativeRoot: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (!relativeRoot && entry.name === '.git') continue + const relative = relativeRoot ? `${relativeRoot}/${entry.name}` : entry.name + assertSafeRelativePath(relative) + const absolute = path.join(directory, entry.name) + const stat = lstatSync(absolute) + if (stat.isDirectory() && !stat.isSymbolicLink()) { + walk(absolute, relative) + } else if (stat.isFile()) { + const content = readFileSync(absolute) + totalBytes += content.length + entries.set(relative, { kind: 'file', content }) + } else if (stat.isSymbolicLink()) { + entries.set(relative, { kind: 'symlink', target: readlinkSync(absolute) }) + } else { + throw new Error('unsupported payload entry') + } + if (entries.size > MAX_PAYLOAD_FILES || totalBytes > MAX_PAYLOAD_BYTES) throw new Error('payload exceeds limits') + } + } + walk(resolvedRoot, '') + return digestEntries(entries) + } catch { + return undefined + } +} + +/** + * Digest only the installable payload subtree of a `git archive` tarball. + * Entries outside `scope.payloadPath` (for example sibling plugins in a + * multi-plugin marketplace repository) are excluded instead of compared. + * The manifest must exist inside the subtree when `scope.manifestPath` is set. + */ +export function gitArchivePayloadDigest (compressedArchive: Buffer, scope: ArchivePayloadScope = {}): string | undefined { + try { + if (compressedArchive.length > MAX_ARCHIVE_BYTES) return undefined + const payloadPath = normalizedPayloadPath(scope.payloadPath) + const manifestPath = normalizedPayloadPath(scope.manifestPath) + const payloadPrefix = payloadPath ? `${payloadPath}/` : '' + const archive = gunzipSync(compressedArchive, { maxOutputLength: MAX_UNPACKED_ARCHIVE_BYTES }) + const entries = new Map() + let offset = 0 + let longPath: string | undefined + let paxPath: string | undefined + let archiveRoot: string | undefined + let totalBytes = 0 + while (offset + 512 <= archive.length) { + const header = archive.subarray(offset, offset + 512) + if (header.every((value) => value === 0)) break + const size = tarNumber(header.subarray(124, 136)) + const bodyStart = offset + 512 + const bodyEnd = bodyStart + size + if (!Number.isSafeInteger(size) || size < 0 || bodyEnd > archive.length) throw new Error('invalid tar size') + const body = archive.subarray(bodyStart, bodyEnd) + const type = String.fromCharCode(header[156] || 48) + const headerPath = [tarString(header.subarray(345, 500)), tarString(header.subarray(0, 100))].filter(Boolean).join('/') + + if (type === 'L') longPath = tarString(body) + else if (type === 'x') paxPath = parsePaxPath(body) + else if (type !== 'g') { + const entryPath = normalizeArchivePath(paxPath ?? longPath ?? headerPath) + paxPath = undefined + longPath = undefined + if (entryPath) { + const segments = entryPath.split('/') + archiveRoot ??= segments[0] + if (segments[0] !== archiveRoot) throw new Error('multiple archive roots') + const repositoryRelative = segments.slice(1).join('/').replace(/\/$/, '') + if (repositoryRelative && !repositoryRelative.startsWith('.git/') && (!payloadPrefix || repositoryRelative.startsWith(payloadPrefix))) { + const relative = payloadPrefix ? repositoryRelative.slice(payloadPrefix.length) : repositoryRelative + if (relative) { + assertSafeRelativePath(relative) + if (entries.has(relative)) throw new Error('duplicate tar entry') + if (type === '0' || type === '\0') { + totalBytes += body.length + entries.set(relative, { kind: 'file', content: Buffer.from(body) }) + } else if (type === '2') { + entries.set(relative, { kind: 'symlink', target: tarString(header.subarray(157, 257)) }) + } else if (type !== '5') throw new Error('unsupported tar entry') + } + } + } + } + if (entries.size > MAX_PAYLOAD_FILES || totalBytes > MAX_PAYLOAD_BYTES) throw new Error('payload exceeds limits') + offset = bodyStart + Math.ceil(size / 512) * 512 + } + if (manifestPath && entries.get(manifestPath)?.kind !== 'file') return undefined + return digestEntries(entries) + } catch { + return undefined + } +} + +function digestEntries (entries: Map): string | undefined { + if (entries.size === 0) return undefined + const hash = createHash('sha256') + for (const [relative, entry] of [...entries].sort(([left], [right]) => left.localeCompare(right))) { + hash.update(entry.kind).update('\0').update(relative).update('\0') + if (entry.kind === 'file') hash.update(String(entry.content.length)).update('\0').update(entry.content) + else hash.update(entry.target) + hash.update('\0') + } + return hash.digest('hex') +} + +function normalizeArchivePath (value: string): string { + return value.replace(/\\/g, '/').replace(/^\.\//, '') +} + +function assertSafeRelativePath (value: string): void { + if (!value || path.posix.isAbsolute(value) || value.split('/').some((segment) => !segment || segment === '.' || segment === '..')) { + throw new Error('unsafe payload path') + } +} + +function parsePaxPath (body: Buffer): string | undefined { + let offset = 0 + let result: string | undefined + while (offset < body.length) { + const space = body.indexOf(0x20, offset) + if (space < 0) throw new Error('invalid pax record') + const length = Number(body.subarray(offset, space).toString('ascii')) + if (!Number.isSafeInteger(length) || length <= 0 || offset + length > body.length) throw new Error('invalid pax length') + const record = body.subarray(space + 1, offset + length - 1).toString('utf8') + const equals = record.indexOf('=') + if (equals > 0 && record.slice(0, equals) === 'path') result = record.slice(equals + 1) + offset += length + } + return result +} + +function tarNumber (value: Buffer): number { + if ((value[0] ?? 0) & 0x80) { + let result = BigInt((value[0] ?? 0) & 0x7f) + for (const byte of value.subarray(1)) result = (result << 8n) | BigInt(byte) + const number = Number(result) + if (!Number.isSafeInteger(number)) throw new Error('tar number overflow') + return number + } + const parsed = Number.parseInt(tarString(value).trim() || '0', 8) + if (!Number.isSafeInteger(parsed)) throw new Error('invalid tar number') + return parsed +} + +function tarString (value: Buffer): string { + const end = value.indexOf(0) + return value.subarray(0, end < 0 ? value.length : end).toString('utf8').replace(/\n$/, '') +} diff --git a/packages/core/src/update/strategies/claude.ts b/packages/core/src/update/strategies/claude.ts index 5c54058..54e783b 100644 --- a/packages/core/src/update/strategies/claude.ts +++ b/packages/core/src/update/strategies/claude.ts @@ -1,11 +1,9 @@ import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' -import { DEFAULT_COMMAND_TIMEOUT_MS, isCommandSuccessful, resolveExecutableIdentity } from '../command-runner.js' +import { DEFAULT_COMMAND_TIMEOUT_MS, resolveExecutableIdentity } from '../command-runner.js' import { managerArgsForIdentity } from '../package-manager.js' -import { nativePayloadDigest, nativeSourceHonorsArtifact } from '../native-evidence.js' -import { readClaudePluginScope } from '../claude-record.js' -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import { commandFailure, failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' +import { nativeExecutionGuard } from '../native-evidence.js' +import { executeClaudeTransaction } from '../claude-transaction.js' +import { failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' export const claudeStrategy: UpdateStrategy = { target: 'claude', @@ -14,9 +12,8 @@ export const claudeStrategy: UpdateStrategy = { async plan (installation: UpdateInstallation): Promise { const source = installation.source if (source.kind !== 'claude-marketplace' || !isMutableVersion(installation)) return planItem(installation) - if (!nativeSourceHonorsArtifact(source, installation.artifact)) { - return planItem(installation, [], [], undefined, { code: 'NATIVE_SOURCE_NOT_PINNED', message: 'Claude marketplace source cannot honor the resolved immutable commit during execution' }) - } + const guard = nativeExecutionGuard(installation, 'Claude') + if (guard) return planItem(installation, [], [], undefined, guard) if (!/^nsolid-plugin@[A-Za-z0-9][A-Za-z0-9._-]*$/.test(source.pluginId)) { return planItem(installation, [], [], undefined, { code: 'INVALID_PLUGIN_ID', message: 'Detected Claude plugin identity is ambiguous' }) } @@ -63,54 +60,26 @@ export const claudeStrategy: UpdateStrategy = { async execute (item: UpdatePlanItem, context: UpdateContext): Promise { if (item.planningError) return failedResult(item, item.planningError) - if (!nativeSourceHonorsArtifact(item.source, item.artifact)) { - return failedResult(item, { code: 'NATIVE_SOURCE_NOT_PINNED', message: 'Claude marketplace source no longer proves the planned immutable identity' }) - } + const guard = nativeExecutionGuard(item, 'Claude') + if (guard) return failedResult(item, guard) if (item.steps.length === 0) return resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)) - const commands = item.steps.filter((step) => step.kind === 'command') - if (commands.length === 0) return failedResult(item, { code: 'INVALID_PLAN', message: 'Claude update plan has no command' }) - for (const step of commands) { - const result = await context.commandRunner.run(step.command) - if (!isCommandSuccessful(result)) return failedResult(item, commandFailure(step.command.executable, result.timedOut, result.spawnErrorCode)) - } - if (item.artifact && (item.artifact.kind === 'git' || item.artifact.kind === 'local-snapshot')) { - const versionSource = item.source.kind === 'claude-marketplace' ? item.source.versionSource : undefined - const manifestPath = versionSource && versionSource.kind !== 'unknown' ? versionSource.manifestPath : undefined - const packageRoot = item.source.kind === 'claude-marketplace' - ? updatedClaudePackageRoot(item.metadata?.configPath, item.source.pluginId, item.source.scope, item.version.latest) - : undefined - const digest = packageRoot ? nativePayloadDigest(packageRoot, manifestPath) : undefined - if (!digest || digest !== item.artifact.contentDigest) return failedResult(item, { code: 'CLAUDE_CONTENT_MISMATCH', message: 'Claude installed payload did not match the planned source identity' }) + const commands = item.steps.flatMap((step) => step.kind === 'command' ? [step.command] : []) + if (commands.length === 0 || item.source.kind !== 'claude-marketplace') return failedResult(item, { code: 'INVALID_PLAN', message: 'Claude update plan has no command' }) + const transaction = await executeClaudeTransaction({ + commands, + registrationPaths: (item.metadata?.nativeEvidence ?? []).map((entry) => entry.path), + configPath: item.metadata?.configPath, + pluginId: item.source.pluginId, + scope: item.source.scope, + expectedVersion: item.version.latest, + artifact: item.artifact, + }, context.commandRunner) + if (!transaction.success) { + return failedResult(item, transaction.error ?? { code: 'CLAUDE_TRANSACTION_FAILED', message: 'Claude replacement failed' }, { + attempted: transaction.rollbackAttempted, + succeeded: transaction.rollbackSucceeded, + }) } - return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest }) + return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest, rollback: { attempted: false } }) }, } - -function updatedClaudePackageRoot ( - configPath: string | undefined, - pluginId: string, - scope: string, - expectedVersion: string | undefined -): string | undefined { - if (!configPath || !path.isAbsolute(configPath)) return undefined - try { - const data = JSON.parse(readFileSync(configPath, 'utf8')) as unknown - if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined - const plugins = (data as Record).plugins - if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return undefined - const value = (plugins as Record)[pluginId] - const records = Array.isArray(value) ? value : [value] - const roots = records.flatMap((record) => { - if (!record || typeof record !== 'object' || Array.isArray(record)) return [] - const entry = record as Record - if (readClaudePluginScope(entry) !== scope) return [] - if (expectedVersion && typeof entry.version === 'string' && entry.version !== expectedVersion) return [] - if (typeof entry.installPath !== 'string' || !path.isAbsolute(entry.installPath)) return [] - const root = path.resolve(entry.installPath) - return existsSync(root) ? [root] : [] - }) - return roots.length === 1 ? roots[0] : undefined - } catch { - return undefined - } -} diff --git a/packages/core/src/update/strategies/codex.ts b/packages/core/src/update/strategies/codex.ts index 28406b0..8f9eaa6 100644 --- a/packages/core/src/update/strategies/codex.ts +++ b/packages/core/src/update/strategies/codex.ts @@ -2,7 +2,7 @@ import path from 'node:path' import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' import { DEFAULT_COMMAND_TIMEOUT_MS, resolveExecutableIdentity } from '../command-runner.js' import { managerArgsForIdentity } from '../package-manager.js' -import { nativeSourceHonorsArtifact } from '../native-evidence.js' +import { nativeExecutionGuard } from '../native-evidence.js' import { executeCodexTransaction, resolveCodexPluginCachePath } from '../codex-transaction.js' import { failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' import { resolveHome } from '../../utils/path.js' @@ -14,9 +14,8 @@ export const codexStrategy: UpdateStrategy = { async plan (installation: UpdateInstallation): Promise { const source = installation.source if (source.kind !== 'codex-marketplace' || !isMutableVersion(installation)) return planItem(installation) - if (!nativeSourceHonorsArtifact(source, installation.artifact)) { - return planItem(installation, [], [], undefined, { code: 'NATIVE_SOURCE_NOT_PINNED', message: 'Codex marketplace source cannot honor the resolved immutable commit during execution' }) - } + const planGuard = nativeExecutionGuard(installation, 'Codex') + if (planGuard) return planItem(installation, [], [], undefined, planGuard) if (!/^nsolid-plugin@[A-Za-z0-9][A-Za-z0-9._-]*$/.test(source.pluginId)) { return planItem(installation, [], [], undefined, { code: 'INVALID_PLUGIN_ID', message: 'Detected Codex plugin identity is ambiguous' }) } @@ -93,9 +92,8 @@ export const codexStrategy: UpdateStrategy = { async execute (item: UpdatePlanItem, context: UpdateContext): Promise { if (item.planningError) return failedResult(item, item.planningError) - if (!nativeSourceHonorsArtifact(item.source, item.artifact)) { - return failedResult(item, { code: 'NATIVE_SOURCE_NOT_PINNED', message: 'Codex marketplace source no longer proves the planned immutable identity' }) - } + const guard = nativeExecutionGuard(item, 'Codex') + if (guard) return failedResult(item, guard) if (item.steps.length === 0) return resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)) const transaction = await executeCodexTransaction(item, context.commandRunner) if (!transaction.success) { diff --git a/packages/core/src/update/strategies/fallback.ts b/packages/core/src/update/strategies/fallback.ts index 78694f8..6fde6a6 100644 --- a/packages/core/src/update/strategies/fallback.ts +++ b/packages/core/src/update/strategies/fallback.ts @@ -1,18 +1,20 @@ import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' +import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' import path from 'node:path' import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' import type { HarnessType } from '../../types.js' import { DEFAULT_COMMAND_TIMEOUT_MS, resolveExecutableIdentity, isCommandSuccessful } from '../command-runner.js' import { failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' -import { getTrackingFilePath } from '../../utils/path.js' +import { getTrackingFilePath, getSkillsDir, resolveHome } from '../../utils/path.js' +import { getAdapter } from '../../harnesses/index.js' import { getHarnessSkillsPath } from '../../skills/skill-linker.js' -import { beginFallbackJournal, captureFallbackJournalState, commitFallbackJournal, markFallbackJournalMutating, recoverFallbackJournal, trackingDigest, valueDigest, restoreFallbackJournal, type FallbackJournal } from '../fallback-journal.js' +import { beginFallbackJournal, captureFallbackJournalState, commitFallbackJournal, markFallbackJournalMutating, recoverFallbackJournal, reloadFallbackJournal, restoreFallbackJournal, trackingDigest, type FallbackJournal } from '../fallback-journal.js' import { cleanupNpmArtifact } from '../version-source.js' import { managerArgsForIdentity, verifyLocalArtifact } from '../package-manager.js' import { readTrackingFile } from '../../skills/skill-tracker.js' -import { readJsonFile, readJsoncFile, readTomlFile } from '../../utils/config.js' +import { harnessMcpKey, readMcpFieldDigests } from '../mcp-lookup.js' export const fallbackStrategy: UpdateStrategy = { target: 'opencode', @@ -46,7 +48,6 @@ export const fallbackStrategy: UpdateStrategy = { } const executor = installation.source.executor ?? detectExecutor() if (!executor) { - const manifestPath = await createManifest(identity) const unsupportedInstallation = { ...installation, source: { @@ -58,8 +59,8 @@ export const fallbackStrategy: UpdateStrategy = { return { ...planItem(unsupportedInstallation), manualCommands: [ - `npm exec --yes --package=nsolid-plugin@${installation.version.latest ?? ''} -- nsolid-plugin-refresh-owned --transaction ${manifestPath}`, - `pnpm --package=nsolid-plugin@${installation.version.latest ?? ''} dlx nsolid-plugin-refresh-owned --transaction ${manifestPath}`, + `npm exec --yes --package=nsolid-plugin@${installation.version.latest ?? ''} -- nsolid-plugin update --harness ${installation.target} --yes`, + `pnpm --package=nsolid-plugin@${installation.version.latest ?? ''} dlx nsolid-plugin update --harness ${installation.target} --yes`, ], } } @@ -141,6 +142,7 @@ export const fallbackStrategy: UpdateStrategy = { }, { attempted: false }) } const rollback = parseRollbackState(`${result.stdout}\n${result.stderr}`) + if (journal) journal = await reloadFallbackJournal(journal) const parentRecovered = journal ? await restoreFallbackJournal(journal) : undefined return failedResult( item, @@ -194,18 +196,31 @@ function createFallbackIdentity (installation: UpdateInstallation) { const names = installation.metadata?.trackedMcpNames ?? [] const trackedFields = installation.metadata?.trackedMcpFields ?? [] if (names.length > 0 && installation.metadata?.trackedMcpOwnershipComplete === false) return undefined + const harness = installation.target as HarnessType + const trackedConfigPaths = [...new Set(trackedFields.map((field) => path.resolve(field.configPath)))] + if (configPath) trackedConfigPaths.push(path.resolve(configPath)) + const canonical = getAdapter(harness).getMcpConfigPath() + const ownedMcpConfigPaths = [...new Set([...trackedConfigPaths, ...(canonical ? [path.resolve(canonical)] : [])])] return { installationId: installation.installationId, - harness: installation.target as HarnessType, + harness, trackingPath, trackingDigest: digest, + nonce: randomUUID(), ownedSkillPaths: skills.map((skill) => path.resolve(skill.path)), - ownedLinkPaths: skills.map((skill) => path.join(getHarnessSkillsPath(installation.target as HarnessType), skill.name)), + ownedLinkPaths: skills.map((skill) => path.join(getHarnessSkillsPath(harness), skill.name)), ownedMcpFields: trackedFields.length > 0 ? trackedFields.map((field) => ({ ...field, configPath: path.resolve(field.configPath) })) : configPath - ? names.flatMap((name) => Object.entries(readMcpRecord(configPath, name) ?? {}).map(([field, value]) => ({ configPath: path.resolve(configPath), server: name, field, expectedDigest: valueDigest(value) }))) + ? names.flatMap((name) => Object.entries(readMcpFieldDigests(configPath, name, { preferredKey: harnessMcpKey(harness) }) ?? {}).map(([field, expectedDigest]) => ({ configPath: path.resolve(configPath), server: name, field, expectedDigest }))) : [], + ownedMcpConfigPaths, + approvedDestinationRoots: [ + harness === 'opencode' + ? path.resolve(process.env.NSOLID_OPENCODE_SKILLS_DIR ?? resolveHome('~/.config/opencode/skills')) + : getSkillsDir(), + ...(harness !== 'opencode' ? [path.resolve(getHarnessSkillsPath(harness))] : []), + ], } as const } @@ -216,19 +231,6 @@ async function createManifest (identity: NonNullable | undefined { - try { - const value = configPath.endsWith('.toml') - ? readTomlFile>(configPath) - : configPath.endsWith('.jsonc') - ? readJsoncFile>(configPath) - : readJsonFile>(configPath) - const servers = value?.mcpServers ?? value?.mcp_servers ?? value?.mcp - const record = servers && typeof servers === 'object' ? (servers as Record)[name] : undefined - return record && typeof record === 'object' && !Array.isArray(record) ? record as Record : undefined - } catch { return undefined } -} - function parseRollbackState (output: string): UpdateResult['rollback'] | undefined { const match = output.match(/(?:^|\n)rollback:\s*(succeeded|failed|not-attempted)\s*(?:\n|$)/i) if (!match) return undefined diff --git a/packages/core/src/update/types.ts b/packages/core/src/update/types.ts index cdf7f56..8a7c065 100644 --- a/packages/core/src/update/types.ts +++ b/packages/core/src/update/types.ts @@ -87,6 +87,8 @@ export interface GitArtifactIdentity { repository: string commit: string contentDigest: string + /** Repo-relative POSIX subdirectory holding the installable payload ('' = repository root). */ + payloadPath?: string } export interface LocalArtifactIdentity { @@ -102,6 +104,8 @@ export interface FallbackTransactionIdentity { harness: HarnessType trackingPath: string trackingDigest: string + /** Shared secret authenticating the child transaction (never authorizing restores). */ + nonce?: string ownedSkillPaths: readonly string[] ownedLinkPaths: readonly string[] ownedMcpFields: readonly { @@ -110,6 +114,10 @@ export interface FallbackTransactionIdentity { field: string expectedDigest: string }[] + /** Union of tracked MCP config paths and the adapter canonical path, fixed at planning. */ + ownedMcpConfigPaths: readonly string[] + /** Canonical roots under which the new bundle's skills/links may be created; the child may only journal new destinations directly inside one of these roots. */ + approvedDestinationRoots: readonly string[] } export type AntigravityLayout = @@ -153,6 +161,12 @@ export type UpdateSource = } | { kind: 'fallback'; bundleVersion?: string; executor?: FallbackPackageExecutor } +/** Exact byte evidence binding a native record to its planned content. */ +export interface NativeEvidence { + path: string + digest: string +} + /** Additional read-only evidence used by strategies. It never reaches CLI output verbatim. */ export interface UpdateInstallationMetadata { /** Exact native configuration path approved during planning. */ @@ -184,6 +198,8 @@ export interface UpdateInstallationMetadata { cacheDigests?: readonly string[] packageEvidencePaths?: readonly string[] packageEvidenceDigests?: readonly string[] + /** Native marketplace records whose exact bytes must still match at execution. */ + nativeEvidence?: readonly NativeEvidence[] } export interface UpdateInstallation { diff --git a/packages/core/src/update/version-source.ts b/packages/core/src/update/version-source.ts index b5f53e6..4dfff5d 100644 --- a/packages/core/src/update/version-source.ts +++ b/packages/core/src/update/version-source.ts @@ -6,6 +6,7 @@ import type { MarketplaceVersionSource, NpmArtifactIdentity, UpdateError, Versio import { isStableVersion } from './version.js' import { bytesMatchIntegrity } from './integrity.js' import { redactSecrets } from './redaction.js' +import { gitArchivePayloadDigest, nativePayloadTreeDigest } from './native-payload.js' export interface VersionSourceOptions { fetchImpl?: typeof fetch @@ -184,9 +185,13 @@ export async function resolveMarketplaceVersion ( const manifestPath = path.resolve(source.root, source.manifestPath) const result = await readManifestVersion(manifestPath) if (result.version) { - const contentDigest = await digestFile(manifestPath) + // The installable payload is the subtree that contains the manifest; + // the artifact root is that resolved subdirectory, never the snapshot + // directory above it. + const payloadRoot = path.resolve(source.root, payloadDirectory(source.manifestPath)) + const contentDigest = nativePayloadTreeDigest(payloadRoot) if (source.contentDigest && contentDigest && source.contentDigest !== contentDigest) return { error: lookupError('SOURCE_CONTENT_MISMATCH', 'Marketplace snapshot content changed after discovery') } - if (contentDigest) result.artifact = { kind: 'local-snapshot', root: path.resolve(source.root), contentDigest } + if (contentDigest) result.artifact = { kind: 'local-snapshot', root: payloadRoot, contentDigest } } return result } @@ -211,10 +216,16 @@ export async function resolveMarketplaceVersion ( const responseCommit = response.headers.get('x-commit-sha') ?? response.headers.get('x-git-commit') ?? undefined const commit = isFullCommit(responseCommit) ? responseCommit : isFullCommit(source.commit) ? source.commit : isFullCommit(revision) ? revision : undefined if (options.requireImmutable && !commit) return { error: lookupError('IMMUTABLE_SOURCE_UNAVAILABLE', 'Marketplace response did not identify an immutable commit') } - const contentDigest = sha256(body) + const manifestDigest = sha256(body) + const payloadPath = payloadDirectory(source.manifestPath) + const payloadManifest = payloadPath ? source.manifestPath.slice(payloadPath.length + 1) : source.manifestPath + const contentDigest = commit && options.requireImmutable + ? await resolveGitPayloadDigest(repository, commit, payloadPath, payloadManifest, options) + : manifestDigest + if (commit && options.requireImmutable && !contentDigest) return { error: lookupError('IMMUTABLE_SOURCE_UNAVAILABLE', 'Marketplace payload could not be captured from the immutable commit') } if (source.contentDigest && source.contentDigest !== contentDigest) return { error: lookupError('SOURCE_CONTENT_MISMATCH', 'Marketplace content changed after discovery') } return commit - ? { version, artifact: { kind: 'git', repository, commit, contentDigest } } + ? { version, artifact: { kind: 'git', repository, commit, contentDigest: contentDigest ?? manifestDigest, payloadPath: payloadPath || undefined } } : { version } } catch (error) { return { error: lookupError('MARKETPLACE_LOOKUP_FAILED', sanitizeLookupMessage(error)) } @@ -242,8 +253,12 @@ export async function resolveFixedGitBundleVersion ( if (!isStableVersion(version)) throw new Error('fixed source version is invalid') const commit = response.headers.get('x-commit-sha') ?? response.headers.get('x-git-commit') ?? effectiveRevision if (options.requireImmutable && !isFullCommit(commit)) return { error: lookupError('IMMUTABLE_SOURCE_UNAVAILABLE', 'Fixed source response did not identify an immutable commit') } + const contentDigest = isFullCommit(commit) && options.requireImmutable + ? await resolveGitPayloadDigest(repository, commit, '', '', options) + : sha256(body) + if (isFullCommit(commit) && options.requireImmutable && !contentDigest) return { error: lookupError('IMMUTABLE_SOURCE_UNAVAILABLE', 'Fixed source payload could not be captured from the immutable commit') } return isFullCommit(commit) - ? { version, artifact: { kind: 'git', repository, commit, contentDigest: sha256(body) } } + ? { version, artifact: { kind: 'git', repository, commit, contentDigest: contentDigest! } } : { version } } catch (error) { return { error: lookupError('FIXED_SOURCE_LOOKUP_FAILED', sanitizeLookupMessage(error)) } @@ -377,12 +392,66 @@ async function resolveGitCommit (repository: string, revision: string, options: } catch { return undefined } } -function sha256 (value: string | Uint8Array): string { - return createHash('sha256').update(value).digest('hex') +/** Hard cap for downloaded archives: 64 MiB. */ +export const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024 + +export interface LimitedBodyResponse { + headers: { get(name: string): string | null } + body: { + getReader(): { + read(): Promise<{ done: boolean, value: Uint8Array | undefined }> + cancel(): Promise + } + } | null +} + +/** + * Stream a response body under a strict size cap. An oversized declared + * Content-Length rejects before the body is consumed; a missing or lying + * header cannot bypass the cap because the reader is cancelled as soon as the + * accumulated bytes exceed the limit. + */ +export async function readArchiveWithLimit (response: LimitedBodyResponse, limit: number = MAX_ARCHIVE_BYTES): Promise { + const declared = Number(response.headers.get('content-length')) + if (Number.isFinite(declared) && declared > limit) throw new Error('downloaded archive exceeds the maximum allowed size') + if (!response.body) return Buffer.alloc(0) + const reader = response.body.getReader() + const chunks: Buffer[] = [] + let total = 0 + for (;;) { + const next = await reader.read() + if (next.done || next.value === undefined) break + total += next.value.byteLength + if (total > limit) { + await reader.cancel().catch(() => {}) + throw new Error('downloaded archive exceeds the maximum allowed size') + } + chunks.push(Buffer.from(next.value)) + } + return Buffer.concat(chunks) +} + +async function resolveGitPayloadDigest (repository: string, commit: string, payloadPath: string, payloadManifest: string, options: VersionSourceOptions): Promise { + try { + const parsed = new URL(repository) + if (parsed.hostname.toLowerCase() !== 'github.com') return undefined + const segments = parsed.pathname.replace(/\.git$/, '').split('/').filter(Boolean) + if (segments.length !== 2 || !isFullCommit(commit)) return undefined + const response = await fetchWithTimeout(`https://codeload.github.com/${segments[0]}/${segments[1]}/tar.gz/${commit}`, options) + if (!response.ok) return undefined + return gitArchivePayloadDigest(await readArchiveWithLimit(response), { payloadPath, manifestPath: payloadManifest }) + } catch { return undefined } +} + +/** Repo-relative POSIX directory of a manifest path ('' when it sits at the root). */ +function payloadDirectory (manifestPath: string): string { + const normalized = manifestPath.replace(/\\/g, '/') + const index = normalized.lastIndexOf('/') + return index > 0 ? normalized.slice(0, index) : '' } -async function digestFile (filePath: string): Promise { - try { return sha256(await readFile(filePath)) } catch { return undefined } +function sha256 (value: string | Uint8Array): string { + return createHash('sha256').update(value).digest('hex') } async function downloadAndVerifyTarball ( @@ -392,7 +461,7 @@ async function downloadAndVerifyTarball ( ): Promise<{ path: string; directory: string; contentDigest: string }> { const response = await fetchWithTimeout(url, options) if (!response.ok) throw new Error(`registry tarball returned ${response.status}`) - const bytes = new Uint8Array(await response.arrayBuffer()) + const bytes = await readArchiveWithLimit(response) if (!bytesMatchIntegrity(bytes, integrity)) throw new Error('registry tarball integrity mismatch') const directory = await mkdtemp(path.join(os.tmpdir(), 'nsolid-plugin-artifact-')) const tarballPath = path.join(directory, 'package.tgz') diff --git a/packages/core/test/unit/mcp/mcp-config-writer.test.ts b/packages/core/test/unit/mcp/mcp-config-writer.test.ts index 137c0bb..5df6c00 100644 --- a/packages/core/test/unit/mcp/mcp-config-writer.test.ts +++ b/packages/core/test/unit/mcp/mcp-config-writer.test.ts @@ -670,6 +670,45 @@ describe('removeMcpConfig', () => { assert.strictEqual(Object.keys(servers).length, 1) }) + it('migrates the legacy mcpServers container away when writing the mcp block', async () => { + const { writeAdapterMcpConfig } = await import('../../../src/mcp/mcp-config-writer.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { mkdirSync } = await import('node:fs') + const { dirname } = await import('node:path') + const { parseJsonc } = await import('../../../src/utils/config.js') + + const configPath = resolveHome('~/.config/opencode/opencode.jsonc') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync(configPath, '{\n // user notes\n "mcpServers": {\n "stale-nsolid": { "url": "https://stale" }\n }\n}\n') + + writeAdapterMcpConfig('opencode', { mcpServers: { 'nsolid-console': { type: 'remote', url: 'https://fresh', enabled: true, headers: {} } } }) + + const content = readFileSync(configPath, 'utf-8') + assert.ok(content.includes('// user notes')) + assert.ok(!content.includes('mcpServers')) + assert.ok(!content.includes('stale-nsolid')) + const parsed = parseJsonc(content) as { mcp: Record } + assert.equal(parsed.mcp['nsolid-console'].url, 'https://fresh') + }) + + it('removes the legacy mcpServers container during uninstall-style cleanup', async () => { + const { removeMcpConfig } = await import('../../../src/mcp/mcp-config-writer.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { mkdirSync } = await import('node:fs') + const { dirname } = await import('node:path') + + const configPath = resolveHome('~/.config/opencode/opencode.jsonc') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync(configPath, '{\n "mcpServers": {\n "stale-nsolid": { "url": "https://stale" }\n },\n "mcp": {\n "ns-benchmark": { "type": "remote", "url": "https://a", "headers": {} }\n }\n}\n') + + await removeMcpConfig('opencode', ['ns-benchmark']) + + const content = readFileSync(configPath, 'utf-8') + assert.ok(!content.includes('mcpServers')) + assert.ok(!content.includes('stale-nsolid')) + assert.ok(!content.includes('ns-benchmark')) + }) + it('handles nonexistent config file', async () => { const { removeMcpConfig } = await import('../../../src/mcp/mcp-config-writer.js') const { resolveHome } = await import('../../../src/utils/path.js') diff --git a/packages/core/test/unit/skills/skill-linker.test.ts b/packages/core/test/unit/skills/skill-linker.test.ts index 7673c5a..c4383f7 100644 --- a/packages/core/test/unit/skills/skill-linker.test.ts +++ b/packages/core/test/unit/skills/skill-linker.test.ts @@ -181,3 +181,107 @@ describe('assertSafeSkillName', () => { assert.strictEqual(assertSafeSkillName('my-skill_v2'), 'my-skill_v2') }) }) + +describe('materializeSkillLink', () => { + interface RecordingFs { + symlinkCalls: Array<{ linkSource: string, target: string, type?: 'dir' | 'junction' }> + copyCalls: Array<{ source: string, destination: string }> + fs: { + symlink: (linkSource: string, target: string, type?: 'dir' | 'junction') => Promise + cp: (source: string, destination: string) => Promise + } + } + + function recordingFs (options: { symlinkError?: Error } = {}): RecordingFs { + const symlinkCalls: RecordingFs['symlinkCalls'] = [] + const copyCalls: RecordingFs['copyCalls'] = [] + return { + symlinkCalls, + copyCalls, + fs: { + symlink: async (linkSource, target, type) => { + symlinkCalls.push({ linkSource, target, type }) + if (options.symlinkError !== undefined) throw options.symlinkError + }, + cp: async (source, destination) => { + copyCalls.push({ source, destination }) + }, + }, + } + } + + it('falls back to copying copySource when junction creation fails on simulated win32', async () => { + const { materializeSkillLink } = await import('../../../src/skills/skill-linker.js') + const { fs, symlinkCalls, copyCalls } = recordingFs({ symlinkError: Object.assign(new Error('EPERM: operation not permitted, symlink'), { code: 'EPERM' }) }) + + await materializeSkillLink({ + linkSource: '/live/shared/nskill', + copySource: '/staged/new/nskill', + target: '/harness/skills/nskill', + platform: 'win32', + fs, + }) + + // The copy comes from the newly prepared staged bytes, not from the link + // source that a Windows junction would have referenced. + assert.deepEqual(copyCalls, [{ source: '/staged/new/nskill', destination: '/harness/skills/nskill' }]) + assert.equal(symlinkCalls.length, 1) + }) + + it('attempts a junction whose source is the final live destination', async () => { + const { materializeSkillLink } = await import('../../../src/skills/skill-linker.js') + const { fs, symlinkCalls } = recordingFs({ symlinkError: Object.assign(new Error('EPERM: operation not permitted, symlink'), { code: 'EPERM' }) }) + + await materializeSkillLink({ + linkSource: '/live/shared/nskill', + copySource: '/staged/new/nskill', + target: '/harness/skills/nskill', + platform: 'win32', + fs, + }) + + assert.deepEqual(symlinkCalls, [{ linkSource: '/live/shared/nskill', target: '/harness/skills/nskill', type: 'junction' }]) + }) + + it('always copies for Pi without attempting a link', async () => { + const { materializeSkillLink } = await import('../../../src/skills/skill-linker.js') + const { fs, symlinkCalls, copyCalls } = recordingFs() + + await materializeSkillLink({ + linkSource: '/live/shared/nskill', + copySource: '/staged/new/nskill', + target: '/harness/skills/nskill', + alwaysCopy: true, + platform: 'win32', + fs, + }) + + assert.deepEqual(copyCalls, [{ source: '/staged/new/nskill', destination: '/harness/skills/nskill' }]) + assert.deepEqual(symlinkCalls, []) + }) + + it('creates a dir symlink on other platforms and does not silently copy on unrelated Unix errors', async () => { + const { materializeSkillLink } = await import('../../../src/skills/skill-linker.js') + const { fs, symlinkCalls, copyCalls } = recordingFs() + + await materializeSkillLink({ + linkSource: '/live/shared/nskill', + target: '/harness/skills/nskill', + platform: 'linux', + fs, + }) + + assert.deepEqual(symlinkCalls, [{ linkSource: '/live/shared/nskill', target: '/harness/skills/nskill', type: 'dir' }]) + assert.deepEqual(copyCalls, []) + + await assert.rejects( + materializeSkillLink({ + linkSource: '/live/shared/nskill', + target: '/harness/skills/nskill', + platform: 'linux', + fs: recordingFs({ symlinkError: Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) }).fs, + }), + (error: NodeJS.ErrnoException) => error.code === 'EACCES' + ) + }) +}) diff --git a/packages/core/test/unit/update/antigravity-transaction.test.ts b/packages/core/test/unit/update/antigravity-transaction.test.ts index 3288196..f281441 100644 --- a/packages/core/test/unit/update/antigravity-transaction.test.ts +++ b/packages/core/test/unit/update/antigravity-transaction.test.ts @@ -1,9 +1,11 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { executeAntigravityTransaction, validateStagedPlugin } from '../../../src/update/antigravity-transaction.js' +import { executeAntigravityTransaction, preservesUnrelatedManifestBytes, validateStagedPlugin } from '../../../src/update/antigravity-transaction.js' +import { nativePayloadDigest } from '../../../src/update/native-evidence.js' +import { readdirSync } from 'node:fs' import type { UpdatePlanItem } from '../../../src/update/types.js' function agyItem (): UpdatePlanItem { @@ -34,14 +36,173 @@ describe('Antigravity staged plugin validation', () => { writeFileSync(path.join(root, 'skills', 'example', 'SKILL.md'), '# example') const manifest = path.join(root, 'import_manifest.json') writeFileSync(manifest, JSON.stringify({ imports: [{ name: 'nsolid-plugin' }] })) + const digest = nativePayloadDigest(root) assert.equal(validateStagedPlugin(root, manifest, '1.0.0'), false) - assert.equal(validateStagedPlugin(root, manifest, '1.0.1'), true) + assert.equal(validateStagedPlugin(root, manifest, '1.0.1', digest), true) + writeFileSync(path.join(root, 'skills', 'example', 'SKILL.md'), '# substituted') + assert.equal(validateStagedPlugin(root, manifest, '1.0.1', digest), false) } finally { rmSync(root, { recursive: true, force: true }) } }) + it('requires unrelated manifest imports to survive plugin replacement byte-for-byte', () => { + // Array form: an in-place splice of only the owned entry keeps every + // outside byte; dropping the sibling import removes foreign bytes. + const before = JSON.stringify({ imports: [{ name: 'unrelated-plugin', path: '/keep' }, { name: 'nsolid-plugin' }] }) + const spliced = before.replace('{"name":"nsolid-plugin"}', '{"name":"nsolid-plugin","path":"/v2"}') + const reordered = JSON.stringify({ imports: [{ name: 'nsolid-plugin' }, { name: 'unrelated-plugin', path: '/keep' }] }) + const dropped = JSON.stringify({ imports: [{ name: 'nsolid-plugin' }] }) + + assert.equal(preservesUnrelatedManifestBytes(before, spliced), true) + // Reordering moves foreign bytes around the owned node: rejected. + assert.equal(preservesUnrelatedManifestBytes(before, reordered), false) + assert.equal(preservesUnrelatedManifestBytes(before, dropped), false) + }) + + it('preserves the my-nsolid-plugin-helper sibling import by exact identity', () => { + const before = JSON.stringify({ + imports: { + 'my-nsolid-plugin-helper': { path: '/keep-helper' }, + 'nsolid-plugin': { name: 'nsolid-plugin', path: '/plugin' }, + }, + }) + const after = JSON.stringify({ + imports: { + 'my-nsolid-plugin-helper': { path: '/keep-helper' }, + 'nsolid-plugin': { name: 'nsolid-plugin', path: '/plugin-v2' }, + }, + }) + assert.equal(preservesUnrelatedManifestBytes(before, after), true) + + const helperRewritten = JSON.stringify({ + imports: { + 'my-nsolid-plugin-helper': { path: '/helper-rewritten' }, + 'nsolid-plugin': { name: 'nsolid-plugin', path: '/plugin-v2' }, + }, + }) + assert.equal(preservesUnrelatedManifestBytes(before, helperRewritten), false) + }) + + it('fails when formatting, CRLF endings, or comments change outside the owned node', () => { + const before = '{\n // user comment\n "imports": {\n "other": {"path": "/keep"},\n "nsolid-plugin": {"name": "nsolid-plugin"}\n }\n}\n' + // Only the owned node's bytes change: comments and formatting survive. + const spliced = '{\n // user comment\n "imports": {\n "other": {"path": "/keep"},\n "nsolid-plugin": {"name": "nsolid-plugin", "path": "/v2"}\n }\n}\n' + assert.equal(preservesUnrelatedManifestBytes(before, spliced), true) + + // agy rewrote the whole file with different formatting: foreign bytes changed. + const reformatted = '{"imports": {"other": {"path": "/keep"}, "nsolid-plugin": {"name": "nsolid-plugin", "path": "/v2"}}}\n' + assert.equal(preservesUnrelatedManifestBytes(before, reformatted), false) + + const crlfBefore = before.replace(/\n/g, '\r\n') + const crlfAfter = spliced.replace(/\n/g, '\r\n') + assert.equal(preservesUnrelatedManifestBytes(crlfBefore, crlfAfter), true) + assert.equal(preservesUnrelatedManifestBytes(crlfBefore, spliced), false) + }) + + it('rejects any mutation when the original manifest has no owned import node', () => { + const before = '{"imports": {"other": {"path": "/keep"}}}\n' + const after = '{"imports": {"other": {"path": "/keep"}, "nsolid-plugin": {"name": "nsolid-plugin"}}}\n' + assert.equal(preservesUnrelatedManifestBytes(before, after), false) + assert.equal(preservesUnrelatedManifestBytes(before, before), true) + }) + + describe('transaction backup preservation', () => { + function setupInstalledFixture (): { home: string; pluginRoot: string; manifestPath: string; previousHome: string | undefined; previousUserProfile: string | undefined } { + const home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-agy-installed-')) + const previousHome = process.env.HOME + const previousUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home + const pluginRoot = path.join(home, '.gemini', 'config', 'plugins', 'nsolid-plugin') + const manifestPath = path.join(home, '.gemini', 'config', 'import_manifest.json') + mkdirSync(path.join(pluginRoot, 'skills', 'example'), { recursive: true }) + writeFileSync(path.join(pluginRoot, 'plugin.json'), JSON.stringify({ name: 'nsolid-plugin' })) + writeFileSync(path.join(pluginRoot, 'bundle.json'), JSON.stringify({ version: '1.0.1', skills: [{ name: 'example', path: 'skills/example' }] })) + writeFileSync(path.join(pluginRoot, 'skills', 'example', 'SKILL.md'), '# v1.0.0\n') + writeFileSync(manifestPath, JSON.stringify({ imports: { 'nsolid-plugin': { name: 'nsolid-plugin' } } })) + return { home, pluginRoot, manifestPath, previousHome, previousUserProfile } + } + + function restoreHome (fixture: { home: string; previousHome: string | undefined; previousUserProfile: string | undefined }): void { + if (fixture.previousHome === undefined) delete process.env.HOME + else process.env.HOME = fixture.previousHome + if (fixture.previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = fixture.previousUserProfile + rmSync(fixture.home, { recursive: true, force: true }) + } + + function backupContainers (configDir: string): { root: string[]; manifest: string[] } { + const pluginsDir = path.join(configDir, 'plugins') + const root = readdirSync(pluginsDir).filter((name) => name.includes('.nsolid-plugin-backup-')) + const manifest = readdirSync(configDir).filter((name) => name.includes('.nsolid-manifest-backup-')) + return { root, manifest } + } + + it('preserves both sibling backups when the guarded restore fails', async () => { + const fixture = setupInstalledFixture() + try { + const item = agyItem() + const digest = nativePayloadDigest(fixture.pluginRoot) + assert.ok(digest, 'the staged payload must be digestible') + const itemWithArtifact = { + ...item, + artifact: { kind: 'git' as const, repository: 'https://github.com/NodeSource/nsolid-plugin.git', commit: 'a'.repeat(40), contentDigest: digest }, + steps: [{ kind: 'command' as const, description: 'agy sync', command: { executable: 'agy', args: ['sync'], timeoutMs: 1_000 } }], + } + let commands = 0 + const result = await executeAntigravityTransaction(itemWithArtifact, { + run: async () => { + commands++ + // The agy replacement corrupts the staged plugin: validation will fail. + rmSync(path.join(fixture.pluginRoot, 'plugin.json')) + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }, { restoreState: async () => false }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'ANTIGRAVITY_VALIDATION_FAILED') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, false) + assert.equal(commands, 1) + // Both backup containers survive for manual recovery. + const containers = backupContainers(path.join(fixture.home, '.gemini', 'config')) + assert.equal(containers.root.length, 1) + assert.equal(containers.manifest.length, 1) + } finally { + restoreHome(fixture) + } + }) + + it('cleans both backups after a successful guarded restore', async () => { + const fixture = setupInstalledFixture() + try { + const item = { + ...agyItem(), + steps: [{ kind: 'command' as const, description: 'agy sync', command: { executable: 'agy', args: ['sync'], timeoutMs: 1_000 } }], + } + const result = await executeAntigravityTransaction(item, { + run: async () => { + rmSync(path.join(fixture.pluginRoot, 'plugin.json')) + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'ANTIGRAVITY_VALIDATION_FAILED') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + const containers = backupContainers(path.join(fixture.home, '.gemini', 'config')) + assert.deepEqual([...containers.root, ...containers.manifest], []) + assert.equal(readFileSync(path.join(fixture.pluginRoot, 'skills/example/SKILL.md'), 'utf8'), '# v1.0.0\n') + assert.equal(existsSync(path.join(fixture.pluginRoot, 'plugin.json')), true) + } finally { + restoreHome(fixture) + } + }) + }) + it('returns a structured backup failure when the plugin root parent directory is missing', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-agy-transaction-')) const previousHome = process.env.HOME diff --git a/packages/core/test/unit/update/claude-transaction.test.ts b/packages/core/test/unit/update/claude-transaction.test.ts new file mode 100644 index 0000000..9998fd7 --- /dev/null +++ b/packages/core/test/unit/update/claude-transaction.test.ts @@ -0,0 +1,635 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { CommandResult, CommandRunner, CommandSpec, ResolvedArtifactIdentity } from '../../../src/update/types.js' +import { executeClaudeTransaction, installedClaudePayloadRoot, restoreClaudeNativeState } from '../../../src/update/claude-transaction.js' +import type { OwnedPathKind } from '../../../src/update/fs-transaction.js' +import { nativePayloadDigest } from '../../../src/update/native-evidence.js' + +interface Fixture { + home: string + registryPath: string + marketplacesPath: string + payloadRoot: string + payloadDigest: string + registryBytes: string +} + +function setupInstallation (): Fixture { + const home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-claude-transaction-')) + const pluginsDir = path.join(home, '.claude', 'plugins') + const payloadRoot = path.join(home, '.claude', 'plugins', 'cache', 'nsolid-plugin', '1.0.0') + mkdirSync(path.join(payloadRoot, 'skills', 'example'), { recursive: true }) + writeFileSync(path.join(payloadRoot, 'bundle.json'), '{"version":"1.0.0"}\n') + writeFileSync(path.join(payloadRoot, 'skills', 'example', 'SKILL.md'), '# v1.0.0\n') + mkdirSync(pluginsDir, { recursive: true }) + const registryPath = path.join(pluginsDir, 'installed_plugins.json') + const registryBytes = JSON.stringify({ + plugins: { + 'nsolid-plugin@nodesource': [{ version: '1.0.0', installPath: payloadRoot, scope: 'user' }], + }, + }) + '\n' + writeFileSync(registryPath, registryBytes) + const marketplacesPath = path.join(pluginsDir, 'known_marketplaces.json') + writeFileSync(marketplacesPath, '{"nodesource":{"source":"github.com/NodeSource/nsolid-plugin"}}\n') + return { home, registryPath, marketplacesPath, payloadRoot, payloadDigest: nativePayloadDigest(payloadRoot)!, registryBytes } +} + +function sha256 (value: string | Buffer): string { + return createHash('sha256').update(value).digest('hex') +} + +const okResult: CommandResult = { exitCode: 0, stdout: '', stderr: '', timedOut: false } +const failedResult: CommandResult = { exitCode: 1, stdout: '', stderr: 'boom', timedOut: false } + +function runner (behavior: (spec: CommandSpec, index: number) => Promise | CommandResult): CommandRunner & { commands: CommandSpec[] } { + const commands: CommandSpec[] = [] + let index = 0 + return { + commands, + async run (spec: CommandSpec) { + commands.push(spec) + return behavior(spec, index++) + }, + } +} + +function recoveryDeps (fixture: Fixture): { recoveryRoot: string, deps: { allocateWorkspace: () => string } } { + const recoveryRoot = path.join(fixture.home, 'recovery-root') + return { recoveryRoot, deps: { allocateWorkspace: () => recoveryRoot } } +} + +function makeArtifact (fixture: Fixture): ResolvedArtifactIdentity { + return { kind: 'git', repository: 'https://github.com/NodeSource/nsolid-plugin', commit: 'a'.repeat(40), contentDigest: fixture.payloadDigest, payloadPath: '' } +} + +describe('Claude native replacement transaction', () => { + it('restores registration records and payload bytes when a command fails', async () => { + const fixture = setupInstallation() + try { + // The first command "installs" a new version; the second one fails. + const newPayload = path.join(fixture.home, '.claude', 'plugins', 'cache', 'nsolid-plugin', '1.0.1') + const runnerStub = runner((_spec, index) => { + if (index === 0) { + mkdirSync(path.join(newPayload, 'skills', 'example'), { recursive: true }) + writeFileSync(path.join(newPayload, 'bundle.json'), '{"version":"1.0.1"}\n') + writeFileSync(path.join(newPayload, 'skills', 'example', 'SKILL.md'), '# v1.0.1\n') + const registry = JSON.parse(readFileSync(fixture.registryPath, 'utf8')) as { plugins: Record>> } + registry.plugins['nsolid-plugin@nodesource'] = [{ version: '1.0.1', installPath: newPayload, scope: 'user' }] + writeFileSync(fixture.registryPath, JSON.stringify(registry) + '\n') + return okResult + } + return failedResult + }) + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['one'], timeoutMs: 1_000 }, { executable: 'claude', args: ['two'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.1', + }, runnerStub) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.equal(result.error?.code, 'CLAUDE_COMMAND_FAILED') + // The new payload directory is gone and the old bytes are back. + assert.equal(existsSync(newPayload), false) + assert.equal(readFileSync(fixture.registryPath, 'utf8'), fixture.registryBytes) + assert.equal(readFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), 'utf8'), '# v1.0.0\n') + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('reports CLAUDE_CONTENT_MISMATCH and rolls back when the installed payload diverges', async () => { + const fixture = setupInstallation() + try { + const artifact = makeArtifact(fixture) + const runnerStub = runner(() => { + // The plugin update "succeeded" but rewrote the payload differently. + writeFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), '# rewritten\n') + return okResult + }) + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['update'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.0', + artifact, + }, runnerStub) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CLAUDE_CONTENT_MISMATCH') + assert.equal(result.rollbackSucceeded, true) + assert.equal(readFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), 'utf8'), '# v1.0.0\n') + assert.equal(nativePayloadDigest(fixture.payloadRoot), fixture.payloadDigest) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('completes successfully when the installed payload matches the planned digest', async () => { + const fixture = setupInstallation() + try { + const runnerStub = runner(() => okResult) + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['marketplace'], timeoutMs: 1_000 }, { executable: 'claude', args: ['update'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.0', + artifact: makeArtifact(fixture), + }, runnerStub) + + assert.equal(result.success, true) + assert.equal(result.rollbackAttempted, false) + assert.equal(nativePayloadDigest(fixture.payloadRoot), fixture.payloadDigest) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('never restores from a partial backup: backup failure aborts before any mutation', async () => { + const fixture = setupInstallation() + try { + const runnerStub = runner(() => okResult) + // A partially completed copy that then explodes: the backup container + // holds content but is not recoverable evidence. + const partialCopy = async (_source: string, destination: string): Promise => { + writeFileSync(path.join(destination, 'bundle.json'), '{"version":"0.0.1"}\n') + throw new Error('EIO: copy failed midway') + } + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['marketplace'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.0', + artifact: makeArtifact(fixture), + }, runnerStub, { copyOwnedPath: partialCopy }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CLAUDE_BACKUP_FAILED') + assert.equal(result.rollbackAttempted, false) + // The command phase never started. + assert.equal(runnerStub.commands.length, 0) + // Live bytes are untouched. + assert.equal(readFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), 'utf8'), '# v1.0.0\n') + assert.equal(readFileSync(fixture.registryPath, 'utf8'), fixture.registryBytes) + // The partial backup container was removed: it is not recoverable evidence. + const siblings = readdirSync(path.dirname(fixture.payloadRoot)).filter((name) => name.includes('.nsolid-payload-backup-')) + assert.deepEqual(siblings, []) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('defers rollback when a timeout leaves descendant termination unconfirmed', async () => { + const fixture = setupInstallation() + try { + // First command installs a new version; the second times out without + // confirmed tree termination. Restoring now would race live writers. + const runnerStub = runner((_spec, index) => { + if (index === 0) { + writeFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), '# mid-flight\n') + return okResult + } + return { exitCode: null, stdout: '', stderr: '', timedOut: true, treeTerminated: false } + }) + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['update'], timeoutMs: 1_000 }, { executable: 'claude', args: ['update'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.1', + }, runnerStub) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CLAUDE_TREE_TERMINATION_UNCONFIRMED') + assert.equal(result.rollbackAttempted, false) + // The live bytes were left alone and the backup remains recoverable. + assert.equal(readFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), 'utf8'), '# mid-flight\n') + const siblings = readdirSync(path.dirname(fixture.payloadRoot)).filter((name) => name.includes('.nsolid-payload-backup-')) + assert.equal(siblings.length, 1) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('refuses to restore when the payload backup changed after its initial verification', async () => { + const fixture = setupInstallation() + try { + const runnerStub = runner((_spec, index) => { + if (index === 0) { + // The failed update rewrites the live payload. + writeFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), '# mid-flight\n') + return okResult + } + // The already-verified backup is tampered before the rollback runs. + // The backup container wraps the copied payload tree one level down. + const cacheDir = path.dirname(fixture.payloadRoot) + const containers = readdirSync(cacheDir).filter((name) => name.includes('.nsolid-payload-backup-')) + if (containers.length !== 1) throw new Error('payload backup sibling missing') + const container = path.join(cacheDir, containers[0]) + const backupSkill = readdirSync(container, { recursive: true }).map(String).find((rel) => rel.endsWith('SKILL.md')) + if (!backupSkill) throw new Error('backup SKILL.md missing') + writeFileSync(path.join(container, backupSkill), '# tampered\n') + return failedResult + }) + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['marketplace'], timeoutMs: 1_000 }, { executable: 'claude', args: ['update'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.1', + }, runnerStub) + + assert.equal(result.success, false) + // The tampered backup must never be restored or reported as success. + assert.equal(result.rollbackSucceeded, false) + assert.equal(result.error?.code, 'CLAUDE_ROLLBACK_FAILED') + // The live payload was NOT overwritten with tampered bytes. + assert.equal(readFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), 'utf8'), '# mid-flight\n') + // The recovery evidence stays preserved for human recovery. + assert.ok(result.recoveryPath) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('restores registration files with mode 0600 even when the live file had wider permissions', async () => { + const fixture = setupInstallation() + try { + chmodSync(fixture.registryPath, 0o644) + const runnerStub = runner(() => failedResult) + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['marketplace'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.0', + }, runnerStub) + + assert.equal(result.success, false) + assert.equal(result.rollbackSucceeded, true) + // The restored registration evidence keeps the private 0600 mode even + // though the live file existed with 0644 before the restore. + assert.equal(statSync(fixture.registryPath).mode & 0o777, 0o600) + assert.equal(readFileSync(fixture.registryPath, 'utf8'), fixture.registryBytes) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('restores registration files with mode 0600 regardless of the process umask', async () => { + const fixture = setupInstallation() + try { + // The on-disk backup is created before the restrictive umask window so + // only the restore write itself runs under it. + const backupPath = `${fixture.registryPath}.rollback-backup` + writeFileSync(backupPath, fixture.registryBytes, { mode: 0o600 }) + const registration = [{ + path: fixture.registryPath, + existed: true, + bytes: Buffer.from(fixture.registryBytes), + digest: sha256(fixture.registryBytes), + backupPath, + postDigest: sha256(readFileSync(fixture.registryPath)), + }] + const previousUmask = process.umask(0o277) + try { + // open(2) creation modes are umask-filtered: the restored file must + // still carry the exact private 0600 mode afterwards. + const restored = await restoreClaudeNativeState({} as Parameters[0], registration) + assert.equal(restored, true) + assert.equal(statSync(fixture.registryPath).mode & 0o777, 0o600) + assert.equal(readFileSync(fixture.registryPath, 'utf8'), fixture.registryBytes) + } finally { + process.umask(previousUmask) + } + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('restores the original payload when the failed update removed the registration entirely', async () => { + const fixture = setupInstallation() + try { + const runnerStub = runner((_spec, index) => { + if (index === 0) { + // A failed native update can unregister the plugin and remove the + // payload directory outright. + rmSync(fixture.payloadRoot, { recursive: true, force: true }) + const registry = JSON.parse(fixture.registryBytes) as { plugins: Record } + delete registry.plugins['nsolid-plugin@nodesource'] + writeFileSync(fixture.registryPath, JSON.stringify(registry) + '\n') + return okResult + } + return failedResult + }) + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['update'], timeoutMs: 1_000 }, { executable: 'claude', args: ['update'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + }, runnerStub) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + // Registration and payload both came back from the backup. + assert.equal(readFileSync(fixture.registryPath, 'utf8'), fixture.registryBytes) + assert.equal(nativePayloadDigest(fixture.payloadRoot), fixture.payloadDigest) + assert.equal(readFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), 'utf8'), '# v1.0.0\n') + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('refuses to restore over concurrent drift and reports CLAUDE_ROLLBACK_FAILED', async () => { + const fixture = setupInstallation() + try { + // The failed transaction left the payload rewritten (authorized state). + writeFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), '# drifted-in-flight\n') + // ...but then a concurrent writer changed it again before the restore. + const registration = [{ + path: fixture.registryPath, + existed: true, + bytes: Buffer.from(fixture.registryBytes), + digest: sha256(fixture.registryBytes), + postDigest: sha256(readFileSync(fixture.registryPath)), + }] + const payload = { + root: fixture.payloadRoot, + kind: 'directory' as const, + postRoot: fixture.payloadRoot, + postDigest: sha256('not-even-the-transaction-state'), + } + const restored = await restoreClaudeNativeState(payload, registration) + assert.equal(restored, false) + // The drifted bytes are untouched. + assert.equal(readFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), 'utf8'), '# drifted-in-flight\n') + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('preserves a complete recovery bundle when tree termination stays unconfirmed', async () => { + const fixture = setupInstallation() + const { recoveryRoot, deps } = recoveryDeps(fixture) + try { + const marketplacesBytes = readFileSync(fixture.marketplacesPath) + const runnerStub = runner((_spec, index) => { + if (index === 0) { + writeFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), '# mid-flight\n') + return okResult + } + return { exitCode: null, stdout: '', stderr: '', timedOut: true, treeTerminated: false } + }) + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['update'], timeoutMs: 1_000 }, { executable: 'claude', args: ['update'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.1', + }, runnerStub, deps) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CLAUDE_TREE_TERMINATION_UNCONFIRMED') + assert.equal(result.rollbackAttempted, false) + assert.equal(result.recoveryPath, recoveryRoot) + const manifestFile = path.join(recoveryRoot, 'recovery.json') + assert.equal(existsSync(manifestFile), true) + const manifest = JSON.parse(readFileSync(manifestFile, 'utf8')) as { + version: number, + complete: boolean, + createdAt: string, + registration: Array<{ path: string, existed: boolean, digest?: string, backup?: string }>, + payload?: { backupPath?: string }, + } + assert.equal(manifest.complete, true) + assert.equal(typeof manifest.createdAt, 'string') + assert.equal(manifest.registration.length, 2) + assert.equal(manifest.registration[0].path, fixture.registryPath) + assert.equal(manifest.registration[0].existed, true) + assert.equal(manifest.registration[0].digest, sha256(fixture.registryBytes)) + assert.equal(manifest.registration[0].backup, 'registration/0000.bin') + const backup0 = path.join(recoveryRoot, 'registration', '0000.bin') + const backup1 = path.join(recoveryRoot, 'registration', '0001.bin') + assert.equal(existsSync(backup0), true) + assert.equal(existsSync(backup1), true) + assert.equal(statSync(backup0).mode & 0o777, 0o600) + assert.equal(readFileSync(backup0).equals(Buffer.from(fixture.registryBytes)), true) + assert.equal(readFileSync(backup1).equals(marketplacesBytes), true) + // The manifest references the separately allocated same-volume payload backup. + const payloadSiblings = readdirSync(path.dirname(fixture.payloadRoot)).filter((name) => name.includes('.nsolid-payload-backup-')) + assert.equal(payloadSiblings.length, 1) + assert.equal(manifest.payload?.backupPath, path.join(path.dirname(fixture.payloadRoot), payloadSiblings[0], path.basename(fixture.payloadRoot))) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('keeps the recovery bundle when the drift gate rejects the rollback', async () => { + const fixture = setupInstallation() + const { recoveryRoot, deps } = recoveryDeps(fixture) + try { + const registryBytesBefore = fixture.registryBytes + const runnerStub = runner((_spec, index) => { + if (index === 0) { + writeFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), '# mutated-by-update\n') + return okResult + } + return failedResult + }) + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['install'], timeoutMs: 1_000 }, { executable: 'claude', args: ['validate'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + }, runnerStub, { ...deps, restoreState: async () => false }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, false) + assert.equal(result.error?.code, 'CLAUDE_ROLLBACK_FAILED') + assert.equal(result.recoveryPath, recoveryRoot) + assert.equal(existsSync(path.join(recoveryRoot, 'recovery.json')), true) + const backup0 = path.join(recoveryRoot, 'registration', '0000.bin') + assert.equal(existsSync(backup0), true) + assert.equal(statSync(backup0).mode & 0o777, 0o600) + assert.equal(sha256(readFileSync(backup0)), sha256(registryBytesBefore)) + const payloadSiblings = readdirSync(path.dirname(fixture.payloadRoot)).filter((name) => name.includes('.nsolid-payload-backup-')) + assert.equal(payloadSiblings.length, 1) + // The drifted live bytes are untouched. + assert.equal(readFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), 'utf8'), '# mutated-by-update\n') + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('removes the recovery bundle after a successful update', async () => { + const fixture = setupInstallation() + const { recoveryRoot, deps } = recoveryDeps(fixture) + try { + const runnerStub = runner(() => okResult) + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['marketplace'], timeoutMs: 1_000 }, { executable: 'claude', args: ['update'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.0', + artifact: makeArtifact(fixture), + }, runnerStub, deps) + + assert.equal(result.success, true) + assert.equal(result.recoveryPath, undefined) + assert.equal(existsSync(recoveryRoot), false) + const payloadSiblings = readdirSync(path.dirname(fixture.payloadRoot)).filter((name) => name.includes('.nsolid-payload-backup-')) + assert.deepEqual(payloadSiblings, []) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('removes the recovery bundle after a verified rollback', async () => { + const fixture = setupInstallation() + const { recoveryRoot, deps } = recoveryDeps(fixture) + try { + const runnerStub = runner((_spec, index) => { + if (index === 0) { + writeFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), '# rewritten\n') + return okResult + } + return failedResult + }) + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['install'], timeoutMs: 1_000 }, { executable: 'claude', args: ['validate'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + }, runnerStub, deps) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.equal(result.recoveryPath, undefined) + assert.equal(existsSync(recoveryRoot), false) + const payloadSiblings = readdirSync(path.dirname(fixture.payloadRoot)).filter((name) => name.includes('.nsolid-payload-backup-')) + assert.deepEqual(payloadSiblings, []) + assert.equal(readFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), 'utf8'), '# v1.0.0\n') + assert.equal(readFileSync(fixture.registryPath, 'utf8'), fixture.registryBytes) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('aborts before any command when the recovery bundle cannot be written', async () => { + const fixture = setupInstallation() + try { + // An allocator pointing inside a regular file makes every bundle write fail. + const blocker = path.join(fixture.home, 'blocker') + writeFileSync(blocker, 'not a directory\n') + const runnerStub = runner(() => okResult) + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['marketplace'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.0', + artifact: makeArtifact(fixture), + }, runnerStub, { allocateWorkspace: () => path.join(blocker, 'recovery') }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CLAUDE_BACKUP_FAILED') + assert.equal(result.rollbackAttempted, false) + assert.equal(result.recoveryPath, undefined) + // The command phase never started. + assert.equal(runnerStub.commands.length, 0) + // Live bytes are untouched. + assert.equal(readFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), 'utf8'), '# v1.0.0\n') + assert.equal(readFileSync(fixture.registryPath, 'utf8'), fixture.registryBytes) + // Every partial recovery artifact was removed. + const payloadSiblings = readdirSync(path.dirname(fixture.payloadRoot)).filter((name) => name.includes('.nsolid-payload-backup-')) + assert.deepEqual(payloadSiblings, []) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('records missing registration paths in the recovery manifest without fabricating backups', async () => { + const fixture = setupInstallation() + const { recoveryRoot, deps } = recoveryDeps(fixture) + try { + const missingPath = path.join(fixture.home, '.claude', 'plugins', 'absent.json') + const runnerStub = runner((_spec, index) => { + if (index === 0) { + writeFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), '# mid-flight\n') + return okResult + } + return { exitCode: null, stdout: '', stderr: '', timedOut: true, treeTerminated: false } + }) + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['update'], timeoutMs: 1_000 }, { executable: 'claude', args: ['update'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath, missingPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.1', + }, runnerStub, deps) + + assert.equal(result.error?.code, 'CLAUDE_TREE_TERMINATION_UNCONFIRMED') + assert.equal(result.recoveryPath, recoveryRoot) + const manifest = JSON.parse(readFileSync(path.join(recoveryRoot, 'recovery.json'), 'utf8')) as { + registration: Array<{ path: string, existed: boolean, digest?: string, backup?: string }>, + } + assert.equal(manifest.registration.length, 3) + assert.deepEqual(manifest.registration[2], { path: missingPath, existed: false }) + assert.deepEqual(readdirSync(path.join(recoveryRoot, 'registration')).sort(), ['0000.bin', '0001.bin']) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('resolves the single installed payload root for a scoped plugin', async () => { + const fixture = setupInstallation() + try { + assert.equal(installedClaudePayloadRoot(fixture.registryPath, 'nsolid-plugin@nodesource', 'user'), fixture.payloadRoot) + assert.equal(installedClaudePayloadRoot(fixture.registryPath, 'nsolid-plugin@nodesource', 'project'), undefined) + assert.equal(installedClaudePayloadRoot(fixture.registryPath, 'other-plugin@x', 'user'), undefined) + assert.equal(installedClaudePayloadRoot(undefined, 'nsolid-plugin@nodesource', 'user'), undefined) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/test/unit/update/codex-transaction.test.ts b/packages/core/test/unit/update/codex-transaction.test.ts index 083e057..105026a 100644 --- a/packages/core/test/unit/update/codex-transaction.test.ts +++ b/packages/core/test/unit/update/codex-transaction.test.ts @@ -1,12 +1,12 @@ import { afterEach, beforeEach, describe, it } from 'node:test' import assert from 'node:assert/strict' -import { createHash } from 'node:crypto' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { writeTomlFileSync } from '../../../src/utils/config.js' import { restoreCodexUserOwnedFields } from '../../../src/update/codex-config.js' import { executeCodexTransaction, readCodexPayloadVersion } from '../../../src/update/codex-transaction.js' +import { nativePayloadDigest } from '../../../src/update/native-evidence.js' import type { UpdatePlanItem } from '../../../src/update/types.js' let home: string @@ -123,12 +123,16 @@ describe('Codex update transaction', () => { mkdirSync(path.dirname(path.join(home, '.codex', 'config.toml')), { recursive: true }) writeTomlFileSync(path.join(home, '.codex', 'config.toml'), { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) writeFileSync(path.join(oldPayload, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + mkdirSync(newPayload, { recursive: true }) + writeFileSync(path.join(newPayload, 'bundle.json'), newBundle) + const plannedDigest = nativePayloadDigest(newPayload)! + rmSync(newPayload, { recursive: true, force: true }) const candidate = item(cachePath) candidate.artifact = { kind: 'git', repository: 'https://github.com/NodeSource/nsolid-plugin.git', commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', - contentDigest: createHash('sha256').update(newBundle).digest('hex'), + contentDigest: plannedDigest, } const result = await executeCodexTransaction(candidate, { diff --git a/packages/core/test/unit/update/command-runner.test.ts b/packages/core/test/unit/update/command-runner.test.ts index dabf40a..9c4cf30 100644 --- a/packages/core/test/unit/update/command-runner.test.ts +++ b/packages/core/test/unit/update/command-runner.test.ts @@ -3,9 +3,15 @@ import assert from 'node:assert/strict' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { deriveShimEntrypoint, resolveExecutableIdentity, runCommand } from '../../../src/update/command-runner.js' +import { deriveShimEntrypoint, resolveExecutableIdentity, runCommand, windowsTaskkillPath } from '../../../src/update/command-runner.js' describe('update command runner', () => { + it('resolves taskkill from an absolute local System32 path', () => { + assert.equal(windowsTaskkillPath('D:\\Windows'), 'D:\\Windows\\System32\\taskkill.exe') + assert.equal(windowsTaskkillPath('\\\\attacker\\share'), 'C:\\Windows\\System32\\taskkill.exe') + assert.equal(path.win32.isAbsolute(windowsTaskkillPath()), true) + }) + it('preserves ENOENT as a structured missing-executable error', async () => { const result = await runCommand({ executable: 'nsolid-plugin-command-that-does-not-exist', diff --git a/packages/core/test/unit/update/fallback-journal.test.ts b/packages/core/test/unit/update/fallback-journal.test.ts index fb33ad1..f38392c 100644 --- a/packages/core/test/unit/update/fallback-journal.test.ts +++ b/packages/core/test/unit/update/fallback-journal.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, it } from 'node:test' import assert from 'node:assert/strict' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' import os from 'node:os' import path from 'node:path' -import { beginFallbackJournal, captureFallbackJournalState, commitFallbackJournal, restoreFallbackJournal, trackingDigest } from '../../../src/update/fallback-journal.js' +import { appendFallbackJournalEntries, applyFallbackEntry, beginFallbackJournal, captureFallbackJournalState, claimFallbackJournalMutation, commitFallbackJournal, markFallbackJournalMutating, pathDigest, recoverFallbackJournal, registerFallbackStage, reloadFallbackJournal, restoreFallbackJournal, trackingDigest } from '../../../src/update/fallback-journal.js' import { getHarnessSkillsPath } from '../../../src/skills/skill-linker.js' -import { getTrackingFilePath } from '../../../src/utils/path.js' +import { getSkillsDir, getTrackingFilePath } from '../../../src/utils/path.js' import type { FallbackTransactionIdentity } from '../../../src/update/types.js' let home: string @@ -46,9 +47,12 @@ describe('fallback journal ownership validation', () => { harness: 'claude', trackingPath, trackingDigest: trackingDigest(trackingPath)!, + nonce: randomUUID(), ownedSkillPaths: [skillPath], ownedLinkPaths: [path.join(getHarnessSkillsPath('claude'), 'tracked')], ownedMcpFields: [], + ownedMcpConfigPaths: [path.join(home, '.claude.json')], + approvedDestinationRoots: [path.join(home, '.agents', 'skills'), getHarnessSkillsPath('claude')], } const { journal } = await beginFallbackJournal(manifest) const victim = path.join(home, 'user-owned.txt') @@ -56,13 +60,40 @@ describe('fallback journal ownership validation', () => { const malicious = { ...journal, manifest: { ...journal.manifest, ownedSkillPaths: [...journal.manifest.ownedSkillPaths, victim] }, - entries: [...journal.entries, { path: victim, backup: path.join(journal.snapshotDirectory, 'attacker'), existed: false }], + entries: [...journal.entries, { path: victim, backup: path.join(journal.snapshotDirectory, 'attacker'), existed: false, kind: 'file' as const }], } assert.equal(await restoreFallbackJournal(malicious), false) assert.equal(readFileSync(victim, 'utf8'), 'keep') }) + it('refuses appended new-destination entries outside the approved destination roots', async () => { + const { manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + const foreign = path.join(home, 'elsewhere', 'added') + mkdirSync(path.dirname(foreign), { recursive: true }) + writeFileSync(foreign, 'payload') + const digest = await pathDigest(foreign) + journal = { + ...journal, + entries: [...journal.entries, { path: foreign, existed: true, kind: 'file', digest: digest!, backup: path.join(journal.snapshotDirectory, 'extra'), expectedCurrentDigest: digest! }], + } + assert.equal(await restoreFallbackJournal(journal), false) + assert.equal(readFileSync(foreign, 'utf8'), 'payload') + }) + + it('refuses appended new-destination entries whose basename is not a safe skill name', async () => { + const { manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + const unsafe = path.join(home, '.agents', 'skills', 'wei..rd') + const digest = await pathDigest(unsafe) + journal = { + ...journal, + entries: [...journal.entries, { path: unsafe, existed: false, kind: 'missing', expectedCurrentDigest: null, digest }], + } + assert.equal(await restoreFallbackJournal(journal), false) + }) + it('restores the snapshotted bytes of owned state after a mutation', async () => { const { trackingPath, skillPath, linkPath, manifest, trackingJson } = setupValidFixture() let { journal } = await beginFallbackJournal(manifest) @@ -77,6 +108,10 @@ describe('fallback journal ownership validation', () => { assert.equal(readFileSync(trackingPath, 'utf8'), trackingJson) assert.equal(existsSync(journal.journalPath), false) assert.equal(existsSync(journal.snapshotDirectory), false) + // No quarantine or stage containers remain beside the owned paths. + const skillSiblings = readdirSync(path.dirname(skillPath)).filter((name) => name.includes('.nsolid-')) + const linkSiblings = readdirSync(path.dirname(linkPath)).filter((name) => name.includes('.nsolid-')) + assert.deepEqual([...skillSiblings, ...linkSiblings], []) }) it('refuses to overwrite state that changed after the authorized mutation snapshot', async () => { @@ -92,6 +127,187 @@ describe('fallback journal ownership validation', () => { assert.equal(existsSync(journal.snapshotDirectory), true) }) + it('recovers a crash after the swap was applied but before commit', async () => { + const { trackingPath, skillPath, manifest, trackingJson } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + journal = await markFallbackJournalMutating(journal) + assert.equal(await claimFallbackJournalMutation(manifest, 2_147_483_647), true) + const stageDir = mkdtempSync(path.join(path.dirname(skillPath), `.${path.basename(skillPath)}.nsolid-stage-`)) + mkdirSync(path.join(stageDir, 'payload'), { recursive: true }) + writeFileSync(path.join(stageDir, 'payload', 'SKILL.md'), '# new bundle\n') + journal = await registerFallbackStage(journal, skillPath, { directory: path.join(stageDir, 'payload') }) + journal = await applyFallbackEntry(journal, skillPath) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# new bundle\n') + + // The mutator PID is gone: the parent recovers the registered state. + assert.deepEqual(await recoverFallbackJournal(trackingPath, true), { pending: true, recovered: true }) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(readFileSync(trackingPath, 'utf8'), trackingJson) + assert.equal(existsSync(journal.journalPath), false) + }) + + it('recovers a crash in the middle of a swap: target missing, stage intact', async () => { + const { trackingPath, skillPath, manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + journal = await markFallbackJournalMutating(journal) + await claimFallbackJournalMutation(manifest, 2_147_483_647) + const stageDir = mkdtempSync(path.join(path.dirname(skillPath), `.${path.basename(skillPath)}.nsolid-stage-`)) + mkdirSync(path.join(stageDir, 'payload'), { recursive: true }) + writeFileSync(path.join(stageDir, 'payload', 'SKILL.md'), '# new bundle\n') + journal = await registerFallbackStage(journal, skillPath, { directory: path.join(stageDir, 'payload') }) + // Crash between the quarantine rename and the stage rename: the target is + // missing while the registered stage is intact. + rmSync(skillPath, { recursive: true, force: true }) + + assert.deepEqual(await recoverFallbackJournal(trackingPath, true), { pending: true, recovered: true }) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(existsSync(journal.journalPath), false) + }) + + it('fails closed and preserves artifacts when live bytes are unregistered drift', async () => { + const { trackingPath, skillPath, manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + journal = await markFallbackJournalMutating(journal) + await claimFallbackJournalMutation(manifest, 2_147_483_647) + // The child mutated a path through unregistered means: unknown digest. + writeFileSync(path.join(skillPath, 'SKILL.md'), '# rogue child write\n') + + assert.deepEqual(await recoverFallbackJournal(trackingPath, true), { pending: true, recovered: false }) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# rogue child write\n') + assert.equal(existsSync(journal.journalPath), true) + assert.equal(existsSync(journal.snapshotDirectory), true) + }) + + it('fails closed when a user edits the target after an applied swap', async () => { + const { trackingPath, skillPath, manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + journal = await markFallbackJournalMutating(journal) + await claimFallbackJournalMutation(manifest, 2_147_483_647) + const stageDir = mkdtempSync(path.join(path.dirname(skillPath), `.${path.basename(skillPath)}.nsolid-stage-`)) + mkdirSync(path.join(stageDir, 'payload'), { recursive: true }) + writeFileSync(path.join(stageDir, 'payload', 'SKILL.md'), '# new bundle\n') + journal = await registerFallbackStage(journal, skillPath, { directory: path.join(stageDir, 'payload') }) + journal = await applyFallbackEntry(journal, skillPath) + // The user touched the freshly-swapped bytes before recovery ran. + writeFileSync(path.join(skillPath, 'SKILL.md'), '# user edit\n') + + assert.deepEqual(await recoverFallbackJournal(trackingPath, true), { pending: true, recovered: false }) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# user edit\n') + assert.equal(existsSync(journal.journalPath), true) + }) + + it('fails closed when the staged payload no longer matches its registered digest', async () => { + const { skillPath, manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + journal = await markFallbackJournalMutating(journal) + await claimFallbackJournalMutation(manifest, 2_147_483_647) + const stageDir = mkdtempSync(path.join(path.dirname(skillPath), `.${path.basename(skillPath)}.nsolid-stage-`)) + mkdirSync(path.join(stageDir, 'payload'), { recursive: true }) + writeFileSync(path.join(stageDir, 'payload', 'SKILL.md'), '# new bundle\n') + journal = await registerFallbackStage(journal, skillPath, { directory: path.join(stageDir, 'payload') }) + const stageEntry = journal.entries.find((entry) => path.resolve(entry.path) === path.resolve(skillPath))! + writeFileSync(path.join(stageEntry.stage!, 'SKILL.md'), '# substituted\n') + + await assert.rejects(applyFallbackEntry(journal, skillPath)) + }) + + it('fails closed when the snapshotted backup no longer matches the registered digest', async () => { + const { trackingPath, manifest, trackingJson } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + journal = await markFallbackJournalMutating(journal) + await claimFallbackJournalMutation(manifest, 2_147_483_647) + // The tracking backup inside the snapshot directory was tampered with. + const trackingEntry = journal.entries.find((entry) => path.resolve(entry.path) === path.resolve(trackingPath))! + writeFileSync(path.join(journal.snapshotDirectory, path.basename(trackingEntry.backup!)), '{}\n') + + assert.deepEqual(await recoverFallbackJournal(trackingPath, true), { pending: true, recovered: false }) + assert.equal(readFileSync(trackingPath, 'utf8'), trackingJson) + assert.equal(existsSync(journal.journalPath), true) + assert.equal(existsSync(journal.snapshotDirectory), true) + }) + + it('fails closed when an applied staged replacement is deleted concurrently', async () => { + const { trackingPath, skillPath, manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + journal = await markFallbackJournalMutating(journal) + await claimFallbackJournalMutation(manifest, 2_147_483_647) + const stageDir = mkdtempSync(path.join(path.dirname(skillPath), `.${path.basename(skillPath)}.nsolid-stage-`)) + mkdirSync(path.join(stageDir, 'payload'), { recursive: true }) + writeFileSync(path.join(stageDir, 'payload', 'SKILL.md'), '# new bundle\n') + journal = await registerFallbackStage(journal, skillPath, { directory: path.join(stageDir, 'payload') }) + journal = await applyFallbackEntry(journal, skillPath) + // Another process deletes the freshly swapped directory afterwards. + rmSync(skillPath, { recursive: true, force: true }) + + assert.deepEqual(await recoverFallbackJournal(trackingPath, true), { pending: true, recovered: false }) + assert.equal(existsSync(skillPath), false) + assert.equal(existsSync(journal.journalPath), true) + assert.equal(existsSync(journal.snapshotDirectory), true) + }) + + it('recovers a deletion crash using the durably persisted quarantine', async () => { + const { trackingPath, skillPath, manifest, trackingJson } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + journal = await markFallbackJournalMutating(journal) + await claimFallbackJournalMutation(manifest, 2_147_483_647) + // Emulate a crash window after the persisted quarantine record but before + // the applied flag: the on-disk journal explains the missing target. + const storage = mkdtempSync(path.join(path.dirname(skillPath), `.${path.basename(skillPath)}.nsolid-quarantine-`)) + const quarantinePath = path.join(storage, path.basename(skillPath)) + renameSync(skillPath, quarantinePath) + const onDisk = JSON.parse(readFileSync(journal.journalPath, 'utf8')) as { entries: Array<{ path: string; quarantine?: string; applied?: boolean }> } + for (const entry of onDisk.entries) { + if (path.resolve(entry.path) === path.resolve(skillPath)) entry.quarantine = quarantinePath + } + writeFileSync(journal.journalPath, JSON.stringify(onDisk)) + journal = await reloadFallbackJournal(journal) + + assert.deepEqual(await recoverFallbackJournal(trackingPath, true), { pending: true, recovered: true }) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(readFileSync(trackingPath, 'utf8'), trackingJson) + assert.equal(existsSync(journal.journalPath), false) + }) + + it('does not recover while the claimed child process is still alive', async () => { + const { trackingPath, manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + journal = await markFallbackJournalMutating(journal) + assert.equal(await claimFallbackJournalMutation(manifest), true) + + assert.deepEqual(await recoverFallbackJournal(trackingPath, true), { pending: true, recovered: false }) + assert.equal(existsSync(journal.journalPath), true) + }) + + it('refuses to claim the mutation without the journal nonce', async () => { + const { manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + journal = await markFallbackJournalMutating(journal) + const impostor = { ...manifest, nonce: randomUUID() } + assert.equal(await claimFallbackJournalMutation(impostor), false) + assert.equal(await claimFallbackJournalMutation({ ...manifest, nonce: undefined }), false) + assert.equal(journal.phase, 'mutating') + }) + + it('fails closed on version 1 journals without cleaning their snapshots', async () => { + const { trackingPath, manifest, skillPath } = setupValidFixture() + const legacySnapshot = mkdtempSync(path.join(path.dirname(trackingPath), '.nsolid-plugin-update-')) + const legacy = { + version: 1, + phase: 'mutating', + manifest: { ...manifest, nonce: undefined }, + journalPath: `${trackingPath}.update-journal.json`, + snapshotDirectory: legacySnapshot, + entries: [{ path: skillPath, backup: path.join(legacySnapshot, '0'), existed: true }], + mutator: { pid: 2_147_483_647, claimedAt: new Date().toISOString() }, + } + writeFileSync(legacy.journalPath, JSON.stringify(legacy)) + + assert.deepEqual(await recoverFallbackJournal(trackingPath, true), { pending: true, recovered: false }) + assert.equal(existsSync(legacy.journalPath), true) + assert.equal(existsSync(legacySnapshot), true) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + }) + it('rejects a nested user-owned link path whose basename matches the expected link', async () => { const { manifest } = setupValidFixture() const expectedLink = path.join(getHarnessSkillsPath('claude'), 'tracked') @@ -103,7 +319,7 @@ describe('fallback journal ownership validation', () => { ...journal, manifest: { ...journal.manifest, ownedLinkPaths: [nested] }, entries: journal.entries.map((entry) => path.resolve(entry.path) === path.resolve(expectedLink) - ? { path: nested, backup: path.join(journal.snapshotDirectory, 'attacker'), existed: false } + ? { path: nested, backup: path.join(journal.snapshotDirectory, 'attacker'), existed: false, kind: 'file' as const } : entry), } @@ -118,6 +334,63 @@ describe('fallback journal ownership validation', () => { await commitFallbackJournal(journal) assert.equal(existsSync(journal.journalPath), false) assert.equal(existsSync(journal.snapshotDirectory), false) + const leftovers = readdirSync(path.dirname(manifest.trackingPath)).filter((name) => name.includes('.nsolid-')) + assert.deepEqual(leftovers, []) + }) + + it('aborts the swap when the live file drifted after registration', async () => { + const { linkPath, manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + journal = await registerFallbackStage(journal, linkPath, { bytes: Buffer.from('# new bundle\n') }) + writeFileSync(linkPath, 'concurrent user edit\n') + + await assert.rejects(applyFallbackEntry(journal, linkPath), /drifted after journaling/) + // The concurrent bytes were never touched and the journal/stage stay recoverable. + assert.equal(readFileSync(linkPath, 'utf8'), 'concurrent user edit\n') + assert.equal(existsSync(journal.journalPath), true) + const reloaded = await reloadFallbackJournal(journal) + const entry = reloaded.entries.find((candidate) => candidate.path === linkPath) + assert.ok(entry?.stage) + assert.equal(readFileSync(entry.stage!, 'utf8'), '# new bundle\n') + }) + + it('aborts the swap when the live directory drifted after registration', async () => { + const { manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + const dirPath = path.join(getSkillsDir(), 'tracked-dir') + mkdirSync(dirPath, { recursive: true }) + writeFileSync(path.join(dirPath, 'SKILL.md'), '# v1\n') + journal = await appendFallbackJournalEntries(journal, [dirPath]) + const stageSource = mkdtempSync(path.join(path.dirname(dirPath), '.stage-src-')) + mkdirSync(path.join(stageSource, 'payload'), { recursive: true }) + writeFileSync(path.join(stageSource, 'payload', 'SKILL.md'), '# v2\n') + journal = await registerFallbackStage(journal, dirPath, { directory: path.join(stageSource, 'payload') }) + writeFileSync(path.join(dirPath, 'user-notes.md'), 'user added a file\n') + + await assert.rejects(applyFallbackEntry(journal, dirPath), /drifted after journaling/) + assert.deepEqual(readdirSync(dirPath).sort(), ['SKILL.md', 'user-notes.md']) + assert.equal(readFileSync(path.join(dirPath, 'SKILL.md'), 'utf8'), '# v1\n') + assert.equal(existsSync(journal.journalPath), true) + rmSync(stageSource, { recursive: true, force: true }) + }) + + it('refuses to swap over a missing destination created concurrently', async () => { + const { manifest } = setupValidFixture() + let { journal } = await beginFallbackJournal(manifest) + const freshPath = path.join(getSkillsDir(), 'fresh-skill') + journal = await appendFallbackJournalEntries(journal, [freshPath]) + const stageSource = mkdtempSync(path.join(path.dirname(freshPath), '.stage-src-')) + mkdirSync(path.join(stageSource, 'payload'), { recursive: true }) + writeFileSync(path.join(stageSource, 'payload', 'SKILL.md'), '# fresh\n') + journal = await registerFallbackStage(journal, freshPath, { directory: path.join(stageSource, 'payload') }) + // A concurrent writer created the destination between journaling and apply. + mkdirSync(freshPath, { recursive: true }) + writeFileSync(path.join(freshPath, 'user-file.txt'), 'precious\n') + + await assert.rejects(applyFallbackEntry(journal, freshPath), /drifted after journaling/) + assert.equal(readFileSync(path.join(freshPath, 'user-file.txt'), 'utf8'), 'precious\n') + assert.equal(existsSync(journal.journalPath), true) + rmSync(stageSource, { recursive: true, force: true }) }) }) @@ -143,9 +416,12 @@ function setupValidFixture (): { trackingPath: string; skillPath: string; linkPa harness: 'claude', trackingPath, trackingDigest: trackingDigest(trackingPath)!, + nonce: randomUUID(), ownedSkillPaths: [skillPath], ownedLinkPaths: [linkPath], ownedMcpFields: [], + ownedMcpConfigPaths: [path.join(home, '.claude.json')], + approvedDestinationRoots: [path.join(home, '.agents', 'skills'), getHarnessSkillsPath('claude')], } return { trackingPath, skillPath, linkPath, manifest, trackingJson } } diff --git a/packages/core/test/unit/update/fallback-ownership.test.ts b/packages/core/test/unit/update/fallback-ownership.test.ts new file mode 100644 index 0000000..86a429e --- /dev/null +++ b/packages/core/test/unit/update/fallback-ownership.test.ts @@ -0,0 +1,12 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { isRemotePath } from '../../../src/update/fallback-ownership.js' + +describe('fallback ownership paths', () => { + it('classifies UNC and Windows device paths as remote destructive targets', () => { + assert.equal(isRemotePath('\\\\server\\share\\skills'), true) + assert.equal(isRemotePath('//server/share/skills'), true) + assert.equal(isRemotePath('\\\\?\\UNC\\server\\share\\skills'), true) + assert.equal(isRemotePath('C:\\Users\\alice\\skills'), false) + }) +}) diff --git a/packages/core/test/unit/update/fallback-strategy.test.ts b/packages/core/test/unit/update/fallback-strategy.test.ts index a33cac8..d531f5a 100644 --- a/packages/core/test/unit/update/fallback-strategy.test.ts +++ b/packages/core/test/unit/update/fallback-strategy.test.ts @@ -65,7 +65,6 @@ describe('fallback update strategy', () => { process.env.PATH = '' process.env.HOME = home process.env.USERPROFILE = home - let manifestDirectory: string | undefined try { const planned = await fallbackStrategy.plan({ ...item(), @@ -75,10 +74,7 @@ describe('fallback update strategy', () => { assert.equal(planned.planningError, undefined) assert.equal(planned.source.kind, 'unsupported') assert.equal(planned.manualCommands?.length, 2) - assert.ok(planned.manualCommands?.every((command) => command.includes(' --transaction ') && !command.includes(' --harness '))) - const manifestPath = planned.manualCommands?.[0]?.split(' --transaction ')[1] - assert.ok(manifestPath && existsSync(manifestPath)) - manifestDirectory = manifestPath ? path.dirname(manifestPath) : undefined + assert.ok(planned.manualCommands?.every((command) => command.includes(' update --harness opencode --yes') && !command.includes(' --transaction '))) } finally { if (previousPath === undefined) delete process.env.PATH else process.env.PATH = previousPath @@ -86,7 +82,6 @@ describe('fallback update strategy', () => { else process.env.HOME = previousHome if (previousUserProfile === undefined) delete process.env.USERPROFILE else process.env.USERPROFILE = previousUserProfile - if (manifestDirectory) rmSync(manifestDirectory, { recursive: true, force: true }) rmSync(home, { recursive: true, force: true }) } }) diff --git a/packages/core/test/unit/update/fallback-transaction.test.ts b/packages/core/test/unit/update/fallback-transaction.test.ts index e8c121c..8d6b65d 100644 --- a/packages/core/test/unit/update/fallback-transaction.test.ts +++ b/packages/core/test/unit/update/fallback-transaction.test.ts @@ -1,10 +1,17 @@ -import { afterEach, beforeEach, describe, it } from 'node:test' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' import assert from 'node:assert/strict' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, rmSync, writeFileSync } from 'node:fs' +import { cp as realFsCp } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { refreshOwnedInstallation } from '../../../src/update/fallback-transaction.js' +import { appendFallbackJournalEntries, applyFallbackEntry, beginFallbackJournal, captureFallbackJournalState, commitFallbackJournal, fallbackJournalPath, markFallbackJournalMutating, reloadFallbackJournal, registerFallbackStage, restoreFallbackJournal, trackingDigest, valueDigest } from '../../../src/update/fallback-journal.js' +import { randomUUID } from 'node:crypto' +import type { FallbackTransactionIdentity } from '../../../src/update/types.js' +import { getHarnessSkillsPath } from '../../../src/skills/skill-linker.js' +import { getSkillsDir, getTrackingFilePath } from '../../../src/utils/path.js' import { readTrackingFile } from '../../../src/skills/skill-tracker.js' +import { parseJsonc } from '../../../src/utils/config.js' let home: string let previousHome: string | undefined @@ -213,6 +220,254 @@ describe('fallback refresh transaction', () => { rmSync(sourceRoot, { recursive: true, force: true }) }) + it('preserves user-owned MCP fields and JSONC bytes during stale cleanup', async () => { + const skillPath = path.join(home, '.config', 'opencode', 'skills', 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'new-server', url: 'https://example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + saasToken: 'saas', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://example.com/mcp', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + const configPath = path.join(home, '.config', 'opencode', 'opencode.jsonc') + mkdirSync(path.dirname(configPath), { recursive: true }) + const originalConfig = '{\n // keep this comment\n "mcp": {\n "old-server": { "url": "https://old.example/mcp", "userSetting": true }\n }\n}\n' + writeFileSync(configPath, originalConfig) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersions: { opencode: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { opencode: skillPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }], + mcpServers: [{ + name: 'old-server', + configPath, + harness: 'opencode', + configuredAt: new Date().toISOString(), + fields: { url: valueDigest('https://old.example/mcp') }, + }], + }) + + const result = await refreshOwnedInstallation({ harness: 'opencode', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'MCP_RECONCILIATION_REQUIRED') + assert.equal(readFileSync(configPath, 'utf8'), originalConfig) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'old') + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('applies owned field updates and removals to an existing codex TOML server', async () => { + const skillPath = path.join(home, '.agents', 'skills', 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'alpha-console', url: 'https://new.example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + saasToken: 'saas', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://new.example.com/mcp', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(path.dirname(configPath), { recursive: true }) + // CRLF document with a comment, an unrelated table, and user credentials: + // the localized editor must preserve every byte outside the owned ranges. + const originalConfig = [ + '# user comment', + '[model]', + 'name = "gpt-5" # keep pick', + '', + '[mcp_servers.alpha-console]', + 'url = "https://old.example/mcp"', + 'note = "keep-note"', + 'user_token = "user-secret"', + ].join('\r\n') + '\r\n' + writeFileSync(configPath, originalConfig) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'codex', + bundleVersions: { codex: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { codex: skillPath }, installedAt: new Date().toISOString(), harnesses: ['codex'] }], + mcpServers: [{ + name: 'alpha-console', + configPath, + harness: 'codex', + configuredAt: new Date().toISOString(), + fields: { url: valueDigest('https://old.example/mcp'), note: valueDigest('keep-note') }, + }], + }) + + const result = await refreshOwnedInstallation({ harness: 'codex', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, true, JSON.stringify(result)) + const final = readFileSync(configPath, 'utf8') + // Byte-localized edit: the comment, CRLF endings, the unrelated [model] + // table, and user credentials are preserved exactly; only the owned url + // value, the removed note line, and the inserted headers line changed. + const expectedConfig = [ + '# user comment', + '[model]', + 'name = "gpt-5" # keep pick', + '', + '[mcp_servers.alpha-console]', + 'url = "https://new.example.com/mcp"', + 'user_token = "user-secret"', + 'headers = {}', + 'name = "alpha-console"', + ].join('\r\n') + '\r\n' + assert.equal(final, expectedConfig) + const tracking = await readTrackingFile() + const tracked = tracking?.mcpServers.find((entry) => entry.name === 'alpha-console') + // Tracking digests must describe the final bytes, never the stale ones. + assert.equal(tracked?.fields?.url, valueDigest('https://new.example.com/mcp')) + assert.equal(tracked?.fields?.note, undefined) + assert.equal(tracked?.fields?.user_token, valueDigest('user-secret')) + assert.equal(tracked?.fields?.headers, valueDigest({})) + assert.equal(tracked?.fields?.name, valueDigest('alpha-console')) + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('fails closed without mutating anything when the codex TOML configuration is malformed', async () => { + const skillPath = path.join(home, '.agents', 'skills', 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'fresh-server', url: 'https://example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + saasToken: 'saas', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://example.com/mcp', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(path.dirname(configPath), { recursive: true }) + const malformedConfig = '# user comment\n[mcp_servers.alpha\nurl = "broken"\n' + writeFileSync(configPath, malformedConfig) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'codex', + bundleVersions: { codex: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { codex: skillPath }, installedAt: new Date().toISOString(), harnesses: ['codex'] }], + mcpServers: [], + }) + + const result = await refreshOwnedInstallation({ harness: 'codex', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, false, JSON.stringify(result)) + assert.equal(result.error?.code, 'MCP_PARSE_FAILED') + // Preflight rejection: the render failure happens before any mutation, so + // no rollback may be attempted. + assert.notEqual(result.rollbackAttempted, true) + // Nothing was mutated: the malformed config, the skill bytes, and the + // tracking record are exactly as they were before the attempt. + assert.equal(readFileSync(configPath, 'utf8'), malformedConfig) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'old') + const tracking = await readTrackingFile() + assert.equal(tracking?.bundleVersions?.codex, '1.0.0') + assert.equal(tracking?.mcpServers.length, 0) + // Zero staging artifacts survive the aborted transaction. + for (const dir of [path.join(home, '.agents'), path.join(home, '.codex')]) { + const leftovers = existsSync(dir) + ? readdirSync(dir).filter((name) => name.includes('.nsolid-stage-')) + : [] + assert.equal(leftovers.length, 0, `${dir}: ${leftovers.join(', ')}`) + } + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('edits the legacy mcpServers container when the opencode config has no preferred key', async () => { + const skillPath = path.join(home, '.config', 'opencode', 'skills', 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'alpha-console', url: 'https://new.example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + saasToken: 'saas', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://new.example.com/mcp', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + const configPath = path.join(home, '.config', 'opencode', 'opencode.jsonc') + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(configPath, '{\n "mcpServers": {\n "alpha-console": { "url": "https://old.example/mcp", "note": "keep-note" },\n "user-own": { "url": "https://user.example/mcp" }\n }\n}\n') + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersions: { opencode: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { opencode: skillPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }], + mcpServers: [{ + name: 'alpha-console', + configPath, + harness: 'opencode', + configuredAt: new Date().toISOString(), + fields: { url: valueDigest('https://old.example/mcp'), note: valueDigest('keep-note') }, + }], + }) + + const result = await refreshOwnedInstallation({ harness: 'opencode', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, true, JSON.stringify(result)) + const final = JSON.parse(readFileSync(configPath, 'utf8')) as { mcp?: Record, mcpServers?: Record } + // No duplicate container: the legacy key is the only container and it was + // edited in place. + assert.equal(final.mcp, undefined) + assert.equal(final.mcpServers?.['alpha-console']?.url, 'https://new.example.com/mcp') + assert.equal(final.mcpServers?.['alpha-console']?.note, undefined) + assert.deepEqual(final.mcpServers?.['user-own'], { url: 'https://user.example/mcp' }) + const tracking = await readTrackingFile() + const tracked = tracking?.mcpServers.find((entry) => entry.name === 'alpha-console') + assert.equal(tracked?.fields?.url, valueDigest('https://new.example.com/mcp')) + assert.equal(tracked?.fields?.note, undefined) + rmSync(sourceRoot, { recursive: true, force: true }) + }) + it('repoints the legacy path when the referenced harness drops a shared skill', async () => { const sharedDir = path.join(home, '.agents', 'skills') const claudeDroppedPath = path.join(sharedDir, 'dropped') @@ -295,3 +550,670 @@ describe('fallback refresh transaction', () => { rmSync(sourceRoot, { recursive: true, force: true }) }) }) + +describe('fallback refresh journal-backed canonical MCP path', () => { + interface JournalFixture { + identity: FallbackTransactionIdentity + home: string + skillPath: string + linkPath: string + canonicalPath: string + trackedConfigPath?: string + sourceRoot: string + bundlePath: string + } + + async function setupJournalFixture (options: { harness: 'claude' | 'pi' | 'codex'; trackedMcp?: boolean }): Promise { + const harness = options.harness + const trackedConfigPath = path.join(home, 'custom', `${harness}-tracked.json`) + const canonicalPath = harness === 'claude' + ? path.join(home, '.claude.json') + : harness === 'codex' + ? path.join(home, '.codex', 'config.toml') + : path.join(home, '.pi', 'agent', 'mcp.json') + const skillPath = path.join(home, '.agents', 'skills', 'tracked') + const linkPath = path.join(getHarnessSkillsPath(harness), 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old tracked') + mkdirSync(path.dirname(linkPath), { recursive: true }) + if (harness === 'pi') mkdirSync(linkPath, { recursive: true }) + else symlinkSync(skillPath, linkPath, 'dir') + + const alphaRecord = { url: 'https://old.example.com/mcp', headers: { AUTH: 'x' } } + const mcpServers: unknown[] = [] + const ownedMcpFields: Array = [] + const ownedMcpConfigPaths = [canonicalPath] + if (options.trackedMcp) { + mkdirSync(path.dirname(trackedConfigPath), { recursive: true }) + writeFileSync(trackedConfigPath, JSON.stringify({ mcpServers: { 'alpha-console': alphaRecord } }, null, 2)) + mcpServers.push({ name: 'alpha-console', harness, configPath: trackedConfigPath, configuredAt: new Date().toISOString(), fields: { url: valueDigest(alphaRecord.url), headers: valueDigest(alphaRecord.headers) } }) + ownedMcpFields.push({ configPath: trackedConfigPath, server: 'alpha-console', field: 'url', expectedDigest: valueDigest(alphaRecord.url) }) + ownedMcpFields.push({ configPath: trackedConfigPath, server: 'alpha-console', field: 'headers', expectedDigest: valueDigest(alphaRecord.headers) }) + ownedMcpConfigPaths.push(trackedConfigPath) + } + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness, + ...(harness === 'codex' ? { bundleVersions: { codex: '1.0.0' } } : {}), + skills: [{ name: 'tracked', path: skillPath, paths: { [harness]: skillPath }, installedAt: new Date().toISOString(), harnesses: [harness] }], + mcpServers, + }) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://example.com/mcp', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + const skillSource = path.join(sourceRoot, 'skills', 'tracked') + mkdirSync(skillSource, { recursive: true }) + writeFileSync(path.join(skillSource, 'SKILL.md'), 'new tracked') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'nsolid-console', url: 'https://new.example.com/mcp', headers: {} }], + }) + + const trackingPath = getTrackingFilePath() + const identity: FallbackTransactionIdentity = { + installationId: `${harness}:fallback`, + harness, + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + nonce: randomUUID(), + ownedSkillPaths: [skillPath], + ownedLinkPaths: [linkPath], + ownedMcpFields, + ownedMcpConfigPaths: ownedMcpConfigPaths.map((value) => path.resolve(value)), + approvedDestinationRoots: [getSkillsDir(), getHarnessSkillsPath(harness)].map((value) => path.resolve(value)), + } + return { identity, home, skillPath, linkPath, canonicalPath, trackedConfigPath: options.trackedMcp ? trackedConfigPath : undefined, sourceRoot, bundlePath } + } + + it('journals the missing canonical MCP path and installs the first server into it', async () => { + const fixture = await setupJournalFixture({ harness: 'claude' }) + try { + let { journal } = await beginFallbackJournal(fixture.identity) + journal = await markFallbackJournalMutating(journal) + // The canonical path does not exist yet but is journaled as missing state. + const canonicalEntry = journal.entries.find((entry) => path.resolve(entry.path) === path.resolve(fixture.canonicalPath)) + assert.ok(canonicalEntry, 'the canonical MCP path must have a journal entry') + assert.equal(canonicalEntry!.existed, false) + + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: fixture.identity }) + assert.equal(result.success, true) + assert.equal(result.error, undefined) + // The first server landed in the previously nonexistent canonical config. + const written = JSON.parse(readFileSync(fixture.canonicalPath, 'utf8')) as { mcpServers: Record } + assert.equal(written.mcpServers['nsolid-console'].url, 'https://new.example.com/mcp') + + journal = await captureFallbackJournalState(journal) + await commitFallbackJournal(journal) + const tracking = await readTrackingFile() + const entry = tracking?.mcpServers.find((server) => server.name === 'nsolid-console') + assert.equal(entry?.configPath, path.resolve(fixture.canonicalPath)) + assert.equal(entry?.fields?.url, valueDigest('https://new.example.com/mcp')) + + const trackingDir = path.dirname(getTrackingFilePath()) + const leftovers = readdirSync(trackingDir).filter((name) => name.includes('.nsolid-')) + assert.deepEqual(leftovers, []) + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } finally { + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } + }) + + it('blocks with drift when the canonical MCP path changes after planning', async () => { + const fixture = await setupJournalFixture({ harness: 'claude' }) + try { + const { journal } = await beginFallbackJournal(fixture.identity) + await markFallbackJournalMutating(journal) + // The environment resolves a different canonical path after planning. + const movedHome = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-moved-')) + const previousHome = process.env.HOME + process.env.HOME = movedHome + try { + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: fixture.identity }) + assert.equal(result.success, false) + assert.equal(result.error?.code, 'FALLBACK_MCP_DRIFT') + } finally { + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + rmSync(movedHome, { recursive: true, force: true }) + } + // Neither the planned nor the moved canonical path was created. + assert.equal(existsSync(fixture.canonicalPath), false) + assert.equal(existsSync(path.join(movedHome, '.claude.json')), false) + } finally { + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } + }) + + it('removes the links staging temp after failed child runs while journal stages survive', async () => { + const realLinker = await import('../../../src/skills/skill-linker.js') + mock.module('../../../src/skills/skill-linker.js', { + namedExports: { + ...(realLinker as unknown as Record), + materializeSkillLink: async () => { + // The render preflight already passed and the journal was claimed: + // this failure happens after the skill staging so the transaction- + // owned links temp must be cleaned by the finally block while the + // journal-owned stages survive for parent recovery. + throw new Error('simulated link materialization failure') + }, + }, + }) + // @ts-expect-error query-suffixed specifier re-evaluates the module under test + const { refreshOwnedInstallation: refreshWithFailingLinks } = await import('../../../src/update/fallback-transaction.js?failing-links-staging') + + const fixture = await setupJournalFixture({ harness: 'claude' }) + try { + const { journal } = await beginFallbackJournal(fixture.identity) + await markFallbackJournalMutating(journal) + + const result = await refreshWithFailingLinks({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: fixture.identity }) + assert.equal(result.success, false) + assert.equal(result.error?.code, 'FALLBACK_REFRESH_FAILED') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + + // The transaction-owned links staging temp container is gone. + const harnessDir = path.dirname(fixture.linkPath) + const harnessDirParent = path.dirname(harnessDir) + assert.equal(readdirSync(harnessDirParent).some((name) => name.startsWith(`.${path.basename(harnessDir)}.nsolid-stage-`)), false, 'the links staging temp must be removed') + // The journal-owned stage for the skill survives for parent recovery. + assert.ok(readdirSync(path.dirname(fixture.skillPath)).some((name) => name.startsWith('.tracked.nsolid-stage-')), 'the journal-owned skill stage must survive') + } finally { + mock.reset() + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } + }) + + it('rejects foreign server-name collisions in the render preflight without claiming the journal', async () => { + for (const harness of ['claude', 'pi'] as const) { + const fixture = await setupJournalFixture({ harness, trackedMcp: true }) + try { + let { journal } = await beginFallbackJournal(fixture.identity) + journal = await markFallbackJournalMutating(journal) + // A foreign server already occupies the new name inside the tracked + // config: the render preflight must reject the run before the journal + // is claimed, so nothing is staged and nothing rolls back. + const tracked = JSON.parse(readFileSync(fixture.trackedConfigPath!, 'utf8')) as { mcpServers: Record> } + tracked.mcpServers['nsolid-console'] = { url: 'https://foreign.example.com/mcp' } + writeFileSync(fixture.trackedConfigPath!, JSON.stringify(tracked, null, 2)) + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + const journalBefore = readFileSync(journalPath) + + const result = await refreshOwnedInstallation({ harness, bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: fixture.identity }) + assert.equal(result.success, false) + assert.equal(result.error?.code, 'MCP_RECONCILIATION_REQUIRED') + assert.notEqual(result.rollbackAttempted, true) + // The journal was never claimed or rewritten. + assert.deepEqual(readFileSync(journalPath), journalBefore) + // No live byte moved and no staging artifact was created. + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + const harnessDir = path.dirname(fixture.linkPath) + const harnessDirParent = path.dirname(harnessDir) + assert.equal(readdirSync(harnessDirParent).some((name) => name.startsWith(`.${path.basename(harnessDir)}.nsolid-stage-`)), false) + assert.equal(readdirSync(path.dirname(fixture.skillPath)).some((name) => name.includes('.nsolid-stage-')), false) + } finally { + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } + } + }) + + it('stages linked skills through the Windows junction/copy policy when junction creation fails', async () => { + const realLinker = await import('../../../src/skills/skill-linker.js') + const materializations: Array<{ linkSource: string, target: string, copySource: string }> = [] + mock.module('../../../src/skills/skill-linker.js', { + namedExports: { + ...(realLinker as unknown as Record), + materializeSkillLink: async (options: { linkSource: string, target: string, copySource?: string }) => { + materializations.push({ linkSource: options.linkSource, target: options.target, copySource: options.copySource ?? options.linkSource }) + return realLinker.materializeSkillLink({ + ...options, + // Simulate Windows without mutating process.platform: junction + // creation fails with EPERM, so the staged copy must come from the + // newly prepared staged bytes instead of the live path. + platform: 'win32', + fs: { + symlink: async () => { throw Object.assign(new Error('EPERM: operation not permitted, symlink'), { code: 'EPERM' }) }, + cp: (source: string, destination: string, opts?: { recursive?: boolean, force?: boolean }) => realFsCp(source, destination, opts), + }, + }) + }, + }, + }) + // Re-import the transaction so its static binding to skill-linker picks + // up the mocked materializeSkillLink. The query string forces a fresh + // module evaluation under the active module mock. + // @ts-expect-error query-suffixed specifier re-evaluates the module under test + const { refreshOwnedInstallation: refreshWithSimulatedWindows } = await import('../../../src/update/fallback-transaction.js?win32-junction-fallback') + + const fixture = await setupJournalFixture({ harness: 'claude' }) + try { + const { journal } = await beginFallbackJournal(fixture.identity) + await markFallbackJournalMutating(journal) + + const result = await refreshWithSimulatedWindows({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: fixture.identity }) + assert.equal(result.success, true) + + // The staging policy ran with the final live shared skill path as the + // junction source and the newly prepared staged bytes as copy source. + assert.equal(materializations.length, 1, 'fallback staging must materialize staged links through the Windows-safe policy instead of a direct symlink') + assert.equal(materializations[0].linkSource, path.join(getSkillsDir(), 'tracked')) + assert.equal(path.basename(materializations[0].copySource), 'tracked') + assert.match(path.dirname(materializations[0].copySource), /\.nsolid-stage-/) + + // The staged directory (not a symlink) was applied to the live harness + // path and contains the new bytes. + assert.equal(lstatSync(fixture.linkPath).isSymbolicLink(), false) + assert.equal(readFileSync(path.join(fixture.linkPath, 'SKILL.md'), 'utf8'), 'new tracked') + + // The temporary links-stage directory is cleaned after the run. + const harnessDir = path.dirname(fixture.linkPath) + const harnessDirParent = path.dirname(harnessDir) + assert.equal(readdirSync(harnessDirParent).some((name) => name.startsWith(`.${path.basename(harnessDir)}.nsolid-stage-`)), false) + } finally { + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } + }) + + it('reconciles into the preferred container when both MCP containers exist with different values', async () => { + const previousOpencodeDir = process.env.NSOLID_OPENCODE_SKILLS_DIR + process.env.NSOLID_OPENCODE_SKILLS_DIR = path.join(home, 'opencode-skills') + let sourceRoot = '' + try { + const destination = path.join(home, 'opencode-skills') + const skillPath = path.join(destination, 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old tracked') + const canonicalPath = path.join(home, '.config', 'opencode', 'opencode.jsonc') + const legacyUrl = 'https://legacy.example.com/mcp' + const preferredUrl = 'https://preferred.example.com/mcp' + const preferredRecord = { url: preferredUrl, headers: { AUTH: 'x' } } + mkdirSync(path.dirname(canonicalPath), { recursive: true }) + writeFileSync(canonicalPath, JSON.stringify({ mcp: { 'alpha-console': preferredRecord }, mcpServers: { 'alpha-console': { url: legacyUrl } } }, null, 2)) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + skills: [{ name: 'tracked', path: skillPath, paths: { opencode: skillPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }], + mcpServers: [{ name: 'alpha-console', harness: 'opencode', configPath: canonicalPath, configuredAt: new Date().toISOString(), fields: { url: valueDigest(preferredUrl), headers: valueDigest(preferredRecord.headers) } }], + }) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://example.com/mcp', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new tracked') + const bundlePath = path.join(sourceRoot, 'bundle.json') + const newUrl = 'https://new.example.com/mcp' + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'alpha-console', url: newUrl, headers: {} }], + }) + const trackingPath = getTrackingFilePath() + const identity: FallbackTransactionIdentity = { + installationId: 'opencode:fallback', + harness: 'opencode', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + nonce: randomUUID(), + ownedSkillPaths: [skillPath], + ownedLinkPaths: [path.join(getHarnessSkillsPath('opencode'), 'tracked')], + ownedMcpFields: [ + { configPath: canonicalPath, server: 'alpha-console', field: 'url', expectedDigest: valueDigest(preferredUrl) }, + { configPath: canonicalPath, server: 'alpha-console', field: 'headers', expectedDigest: valueDigest(preferredRecord.headers) }, + ], + ownedMcpConfigPaths: [path.resolve(canonicalPath)], + approvedDestinationRoots: [path.resolve(destination)], + } + let { journal } = await beginFallbackJournal(identity) + journal = await markFallbackJournalMutating(journal) + const result = await refreshOwnedInstallation({ harness: 'opencode', bundlePath, skillsSource: sourceRoot, transaction: identity }) + assert.equal(result.success, true) + assert.equal(result.error, undefined) + journal = await captureFallbackJournalState(journal) + await commitFallbackJournal(journal) + // The preferred container was reconciled in place; the legacy container + // is a foreign structure and must survive byte-for-byte. + const written = parseJsonc(readFileSync(canonicalPath, 'utf8')) as { mcp: Record, mcpServers: Record } + assert.equal(written.mcp['alpha-console'].url, newUrl) + assert.equal(written.mcpServers['alpha-console'].url, legacyUrl) + // Tracking evidence describes the preferred container's post-commit value. + const tracking = await readTrackingFile() + const entry = tracking?.mcpServers.find((server) => server.name === 'alpha-console') + assert.equal(entry?.configPath, path.resolve(canonicalPath)) + assert.equal(entry?.fields?.url, valueDigest(newUrl)) + } finally { + if (previousOpencodeDir === undefined) delete process.env.NSOLID_OPENCODE_SKILLS_DIR + else process.env.NSOLID_OPENCODE_SKILLS_DIR = previousOpencodeDir + if (sourceRoot) rmSync(sourceRoot, { recursive: true, force: true }) + } + }) + + it('blocks a transaction whose approved destination roots are not canonical', async () => { + const fixture = await setupJournalFixture({ harness: 'claude' }) + try { + const badIdentity: FallbackTransactionIdentity = { + ...fixture.identity, + approvedDestinationRoots: [path.join(home, 'escape', '..')], + } + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: badIdentity }) + assert.equal(result.success, false) + assert.equal(result.error?.code, 'INVALID_TRANSACTION_MANIFEST') + assert.notEqual(result.rollbackAttempted, true) + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + } finally { + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } + }) + + it('blocks when the environment resolves a skill destination outside the approved roots', async () => { + const fixture = await setupJournalFixture({ harness: 'claude' }) + try { + // Canonical but foreign roots: the environment's destinations are no + // longer covered by the approved manifest. + const foreignIdentity: FallbackTransactionIdentity = { + ...fixture.identity, + approvedDestinationRoots: [path.join(home, 'other-root')], + } + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: foreignIdentity }) + assert.equal(result.success, false) + assert.equal(result.error?.code, 'INVALID_TRANSACTION_MANIFEST') + assert.notEqual(result.rollbackAttempted, true) + // Nothing was created or touched. + assert.equal(existsSync(path.join(getHarnessSkillsPath('claude'), 'nsolid-console')), false) + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + } finally { + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } + }) + + it('aborts before claiming the journal when an MCP configuration cannot be parsed', async () => { + const fixture = await setupJournalFixture({ harness: 'claude' }) + try { + // The canonical config is where the new bundle server will be planned; + // corrupt it so the render preflight must fail before any mutation. + writeFileSync(path.join(home, '.claude.json'), '{ mcpServers: broken') + const { journal } = await beginFallbackJournal(fixture.identity) + await markFallbackJournalMutating(journal) + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + const journalBefore = readFileSync(journalPath) + + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: fixture.identity }) + + assert.equal(result.success, false) + assert.notEqual(result.rollbackAttempted, true) + // The journal was never claimed or rewritten. + assert.deepEqual(readFileSync(journalPath), journalBefore) + // No live byte moved. + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + // Zero staging artifacts survive the aborted transaction. + const leftovers = readdirSync(path.join(home, '.agents')).filter((name) => name.includes('.nsolid-stage-')) + assert.equal(leftovers.length, 0, leftovers.join(', ')) + } finally { + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } + }) + + it('aborts before claiming the journal when the codex TOML configuration is malformed', async () => { + const fixture = await setupJournalFixture({ harness: 'codex' }) + const malformedConfig = '# user comment\n[mcp_servers.alpha\nurl = "broken"\n' + try { + // The canonical codex config is where the new bundle server will be + // planned; corrupt it so the render preflight must fail before any + // mutation. + const configPath = path.join(home, '.codex', 'config.toml') + writeFileSync(configPath, malformedConfig) + const { journal } = await beginFallbackJournal(fixture.identity) + await markFallbackJournalMutating(journal) + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + const journalBefore = readFileSync(journalPath) + + const result = await refreshOwnedInstallation({ harness: 'codex', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: fixture.identity }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'MCP_PARSE_FAILED') + assert.notEqual(result.rollbackAttempted, true) + // The journal was never claimed or rewritten. + assert.deepEqual(readFileSync(journalPath), journalBefore) + // No live byte moved. + assert.equal(readFileSync(configPath, 'utf8'), malformedConfig) + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + // Zero staging artifacts survive the aborted transaction. + for (const dir of [path.join(home, '.agents'), path.join(home, '.codex')]) { + const dirLeftovers = existsSync(dir) + ? readdirSync(dir).filter((name) => name.includes('.nsolid-stage-')) + : [] + assert.equal(dirLeftovers.length, 0, `${dir}: ${dirLeftovers.join(', ')}`) + } + } finally { + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } + }) + + it('removes journaled new destinations on recovery and the next update installs cleanly', async () => { + const fixture = await setupJournalFixture({ harness: 'claude' }) + const sourceRoot = fixture.sourceRoot + try { + // Extend the planned bundle with a brand-new skill and link destination. + const addedSource = path.join(sourceRoot, 'skills', 'added') + mkdirSync(addedSource, { recursive: true }) + writeFileSync(path.join(addedSource, 'SKILL.md'), 'new skill') + writeJson(fixture.bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [ + { name: 'tracked', path: 'skills/tracked', description: 'tracked' }, + { name: 'added', path: 'skills/added', description: 'added' }, + ], + mcpServers: [{ name: 'nsolid-console', url: 'https://new.example.com/mcp', headers: {} }], + }) + const addedSkill = path.join(getSkillsDir(), 'added') + const addedLink = path.join(getHarnessSkillsPath('claude'), 'added') + assert.equal(existsSync(addedSkill), false) + + let { journal } = await beginFallbackJournal(fixture.identity) + journal = await markFallbackJournalMutating(journal) + // The verified child durably appends the new destinations before staging. + journal = await appendFallbackJournalEntries(journal, [addedSkill, addedLink]) + // Child staging + apply, exactly as the transaction performs it. + const stagedSkillRoot = mkdtempSync(path.join(path.dirname(addedSkill), `.${path.basename(addedSkill)}.nsolid-stage-`)) + writeFileSync(path.join(stagedSkillRoot, 'SKILL.md'), 'new skill') + journal = await registerFallbackStage(journal, addedSkill, { directory: stagedSkillRoot }) + journal = await applyFallbackEntry(journal, addedSkill) + const stagedLinksRoot = mkdtempSync(path.join(path.dirname(addedLink), `.${path.basename(addedLink)}.nsolid-stage-`)) + symlinkSync(addedSkill, path.join(stagedLinksRoot, 'added'), 'dir') + journal = await registerFallbackStage(journal, addedLink, { directory: path.join(stagedLinksRoot, 'added') }) + journal = await applyFallbackEntry(journal, addedLink) + // CRASH: the tracking commit never happened but the destinations exist. + assert.equal(existsSync(addedSkill), true) + assert.equal(existsSync(addedLink), true) + + // Parent recovery removes the orphan destinations and restores state. + journal = await reloadFallbackJournal(journal) + assert.equal(await restoreFallbackJournal(journal), true) + assert.equal(existsSync(addedSkill), false) + assert.equal(existsSync(addedLink), false) + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + + // The next update no longer sees an untracked destination. + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: sourceRoot }) + assert.equal(result.success, true) + assert.equal(existsSync(addedSkill), true) + assert.equal(readFileSync(path.join(addedSkill, 'SKILL.md'), 'utf8'), 'new skill') + } finally { + rmSync(sourceRoot, { recursive: true, force: true }) + } + }) +}) + +describe('fallback refresh multi-config MCP reconciliation', () => { + it('fails and rolls back when an owned MCP field drifts between planning and apply', async () => { + const configA = path.join(home, 'custom', 'claude-a.json') + const alphaRecord = { url: 'https://old.example.com/mcp', headers: { AUTH: 'x' } } + mkdirSync(path.dirname(configA), { recursive: true }) + writeFileSync(configA, [ + '{', + ' "mcpServers": {', + ' "alpha-console": ' + JSON.stringify(alphaRecord), + ' }', + '}', + '', + ].join('\n')) + + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://example.com/mcp', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + const skillSource = path.join(sourceRoot, 'skills', 'tracked') + mkdirSync(skillSource, { recursive: true }) + writeFileSync(path.join(skillSource, 'SKILL.md'), 'tracked') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'alpha-console', url: 'https://new.example.com/mcp', headers: {} }], + }) + const skillPath = path.join(home, '.agents', 'skills', 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old tracked') + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + skills: [{ name: 'tracked', path: skillPath, paths: { claude: skillPath }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [ + { name: 'alpha-console', harness: 'claude', configPath: configA, configuredAt: new Date().toISOString(), fields: { url: valueDigest(alphaRecord.url), headers: valueDigest(alphaRecord.headers) } }, + ], + }) + + try { + // Concurrent drift between planning and the transaction: the owned url + // was rewritten under our feet. + const drifted = { url: 'https://evil.example.com/mcp', headers: { AUTH: 'x' } } + const before = readFileSync(configA, 'utf8') + const driftedText = before.replace(JSON.stringify(alphaRecord), JSON.stringify(drifted)) + writeFileSync(configA, driftedText) + + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource: sourceRoot }) + assert.equal(result.success, false) + assert.equal(result.error?.code, 'FALLBACK_MCP_DRIFT') + // The drifted bytes are preserved; no owned update was applied. + assert.equal(readFileSync(configA, 'utf8'), driftedText) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + + // Sanity: with the pristine bytes the same refresh succeeds. + writeFileSync(configA, before) + const retry = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource: sourceRoot }) + assert.equal(retry.success, true) + rmSync(sourceRoot, { recursive: true, force: true }) + } finally { + rmSync(sourceRoot, { recursive: true, force: true }) + } + }) + + it('updates and removes each server in its owning file and routes new servers to the canonical path', async () => { + const configA = path.join(home, 'custom', 'claude-a.json') + const configB = path.join(home, 'custom', 'claude-b.json') + const alphaRecord = { url: 'https://old.example.com/mcp', headers: { AUTH: 'x' } } + const legacyRecord = { url: 'https://legacy.example.com/mcp', headers: {} } + // Config A: foreign server with comments plus the owned alpha-console. + mkdirSync(path.dirname(configA), { recursive: true }) + writeFileSync(configA, [ + '{', + ' // Foreign configuration comments must survive.', + ' "mcpServers": {', + ' "user-server": {"command": "/usr/bin/user-thing"},', + ' "alpha-console": ' + JSON.stringify(alphaRecord), + ' }', + '}', + '', + ].join('\n')) + // Config B: owns the stale legacy-console plus unrelated keys. + writeJson(configB, { version: 2, mcpServers: { 'legacy-console': legacyRecord } }) + + const writeJsonc = (filePath: string, value: unknown): void => { + mkdirSync(path.dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(value, null, 2)) + } + writeJsonc(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://example.com/mcp', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + const skillSource = path.join(sourceRoot, 'skills', 'tracked') + mkdirSync(skillSource, { recursive: true }) + writeFileSync(path.join(skillSource, 'SKILL.md'), 'tracked') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'alpha-console', url: 'https://new.example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + skills: [{ name: 'tracked', path: path.join(home, '.agents', 'skills', 'tracked'), paths: { claude: path.join(home, '.agents', 'skills', 'tracked') }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [ + { name: 'alpha-console', harness: 'claude', configPath: configA, configuredAt: new Date().toISOString(), fields: { url: valueDigest(alphaRecord.url), headers: valueDigest(alphaRecord.headers) } }, + { name: 'legacy-console', harness: 'claude', configPath: configB, configuredAt: new Date().toISOString(), fields: { url: valueDigest(legacyRecord.url), headers: valueDigest(legacyRecord.headers) } }, + ], + }) + mkdirSync(path.join(home, '.agents', 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(home, '.agents', 'skills', 'tracked', 'SKILL.md'), 'old tracked') + + try { + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource: sourceRoot }) + assert.equal(result.success, true) + + // Config A: alpha-console updated in place; foreign bytes untouched. + const afterA = readFileSync(configA, 'utf8') + assert.ok(afterA.includes('// Foreign configuration comments must survive.')) + assert.ok(afterA.includes('"user-server": {"command": "/usr/bin/user-thing"}')) + const parsedA = parseJsonc(afterA) as { mcpServers: Record> } + assert.equal(parsedA.mcpServers['alpha-console'].url, 'https://new.example.com/mcp') + + // Config B: only the stale server was removed there. + const parsedB = JSON.parse(readFileSync(configB, 'utf8')) as { version: number; mcpServers: Record } + assert.equal(parsedB.version, 2) + assert.deepEqual(parsedB.mcpServers, {}) + + // New nsolid-console is absent from the bundle: alpha kept in A, no new server. + const tracking = await readTrackingFile() + const alpha = tracking?.mcpServers.find((entry) => entry.name === 'alpha-console') + assert.equal(alpha?.configPath, path.resolve(configA)) + // Field evidence describes the post-swap bytes, not the pre-update file. + assert.equal(alpha?.fields?.url, valueDigest('https://new.example.com/mcp')) + assert.ok(Object.keys(alpha?.fields ?? {}).length > 0) + + rmSync(sourceRoot, { recursive: true, force: true }) + } finally { + rmSync(sourceRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/test/unit/update/inventory.test.ts b/packages/core/test/unit/update/inventory.test.ts index 221c3bb..205bdf3 100644 --- a/packages/core/test/unit/update/inventory.test.ts +++ b/packages/core/test/unit/update/inventory.test.ts @@ -4,6 +4,8 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node: import os from 'node:os' import path from 'node:path' import { detectInstallations } from '../../../src/update/inventory.js' +import { resolveMarketplaceVersion } from '../../../src/update/version-source.js' +import { nativePayloadDigest } from '../../../src/update/native-evidence.js' import { checkUpdates, planUpdates, update } from '../../../src/update/coordinator.js' let home: string @@ -238,6 +240,45 @@ describe('update installation inventory', () => { assert.equal(codex?.metadata?.packageRoot, cacheRoot) }) + it('narrows a nested local-snapshot manifest to its payload root and basename', async () => { + const snapshotRoot = path.join(home, 'marketplace-repo') + const payloadRoot = path.join(snapshotRoot, 'plugins', 'nsolid') + mkdirSync(path.join(payloadRoot, 'skills', 'example'), { recursive: true }) + writeFileSync(path.join(payloadRoot, 'plugin.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1' })) + writeFileSync(path.join(payloadRoot, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1' })) + writeFileSync(path.join(payloadRoot, 'skills', 'example', 'SKILL.md'), '# example\n') + writeJson(path.join(home, '.claude', 'plugins', 'installed_plugins.json'), { + plugins: { + 'nsolid-plugin@nodesource': [{ + scope: 'user', + version: '1.0.1', + installPath: snapshotRoot, + relativeManifestPath: 'plugins/nsolid/plugin.json', + freshness: 'verified', + }], + }, + }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const claude = detected.find((installation) => installation.target === 'claude') + const versionSource = claude?.source.kind === 'claude-marketplace' ? claude.source.versionSource : undefined + assert.equal(versionSource?.kind, 'local-snapshot') + if (versionSource?.kind !== 'local-snapshot') return + // The root is the payload subdirectory and the manifest is relative to it. + assert.equal(versionSource.root, payloadRoot) + assert.equal(versionSource.manifestPath, 'plugin.json') + assert.equal(versionSource.contentDigest, nativePayloadDigest(payloadRoot)) + + // The narrowed source resolves: root and digest describe the same bytes. + const resolved = await resolveMarketplaceVersion({ ...versionSource }, { requireImmutable: false }) + assert.equal(resolved.version, '1.0.1') + assert.equal(resolved.artifact?.kind, 'local-snapshot') + if (resolved.artifact?.kind === 'local-snapshot') { + assert.equal(resolved.artifact.root, payloadRoot) + assert.equal(resolved.artifact.contentDigest, versionSource.contentDigest) + } + }) + it('classifies a Claude registration without marketplace metadata as unsupported', async () => { writeJson(path.join(home, '.claude', 'plugins', 'installed_plugins.json'), { plugins: { 'nsolid-plugin@nodesource': [{ scope: 'user' }] }, diff --git a/packages/core/test/unit/update/mcp-edit.test.ts b/packages/core/test/unit/update/mcp-edit.test.ts new file mode 100644 index 0000000..f30b862 --- /dev/null +++ b/packages/core/test/unit/update/mcp-edit.test.ts @@ -0,0 +1,182 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { editMcpJsonBytes, McpEditError, readMcpNodeValue } from '../../../src/update/mcp-edit.js' +import { harnessMcpKey, mcpFieldDigestsFromBytes, readMcpFieldDigests, readMcpServerRecord } from '../../../src/update/mcp-lookup.js' +import { parseJsonc } from '../../../src/utils/config.js' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { valueDigest } from '../../../src/update/fallback-journal.js' + +describe('MCP byte-preserving AST edits', () => { + it('rewrites only the owned server and preserves comments, foreign servers, and formatting', () => { + const raw = [ + '{', + ' // User comments belong to the user.', + ' "otherKey": true,', + ' "mcpServers": {', + ' // A foreign server with its own comment.', + ' "foreign": {"command": "/usr/bin/foreign"},', + ' "nsolid-console": {"url": "https://old.example.com/mcp", "headers": {"AUTH": "x"}}', + ' }', + '}', + '', + ].join('\n') + const next = editMcpJsonBytes(raw, { + upsertServers: { 'nsolid-console': { url: 'https://new.example.com/mcp', headers: { AUTH: 'y' } } }, + }) + + // The owned server changed. + assert.ok(next.includes('https://new.example.com/mcp')) + // Every foreign byte survived. + assert.ok(next.includes('// User comments belong to the user.')) + assert.ok(next.includes('// A foreign server with its own comment.')) + assert.ok(next.includes('"foreign": {"command": "/usr/bin/foreign"}')) + assert.ok(next.includes('"otherKey": true')) + assert.ok(!next.includes('https://old.example.com/mcp')) + const reparsed = parseJsonc(next) as { mcpServers: Record } + assert.equal(reparsed.mcpServers['nsolid-console'].url, 'https://new.example.com/mcp') + }) + + it('keeps CRLF line endings outside the edited bytes and uses CRLF for inserted lines', () => { + const raw = '{\r\n "keep": true,\r\n "mcpServers": {\r\n "old": {"url": "https://old"}\r\n }\r\n}\r\n' + const next = editMcpJsonBytes(raw, { upsertServers: { fresh: { url: 'https://fresh' } }, removeServers: ['old'] }) + assert.ok(next.includes('\r\n')) + assert.ok(next.includes('"keep": true')) + assert.ok(next.includes('"fresh"')) + assert.ok(!next.includes('"old"')) + }) + + it('updates a single owned field inside an existing server without touching sibling fields', () => { + const raw = '{\n "mcpServers": {\n "nsolid-console": {"url": "https://old", "headers": {"A": "b"}, "custom": "user-value"}\n }\n}\n' + const next = editMcpJsonBytes(raw, { setFields: [{ server: 'nsolid-console', field: 'url', value: 'https://new' }] }) + const parsed = JSON.parse(next) as { mcpServers: Record> } + assert.equal(parsed.mcpServers['nsolid-console'].url, 'https://new') + assert.equal(parsed.mcpServers['nsolid-console'].custom, 'user-value') + assert.ok(next.includes('"custom": "user-value"')) + }) + + it('removes owned servers without disturbing unrelated content', () => { + const raw = '{\n "mcpServers": {\n "keep-me": {"url": "https://keep"},\n "stale-nsolid": {"url": "https://stale"}\n },\n "note": "mine"\n}\n' + const next = editMcpJsonBytes(raw, { removeServers: ['stale-nsolid'] }) + const parsed = JSON.parse(next) as { mcpServers: Record; note: string } + assert.deepEqual(Object.keys(parsed.mcpServers), ['keep-me']) + assert.equal(parsed.note, 'mine') + }) + + it('inserts the MCP block into a document without one, preserving existing bytes', () => { + const raw = '{\n "unrelated": {"a": 1}\n}\n' + const next = editMcpJsonBytes(raw, { upsertServers: { 'nsolid-console': { url: 'https://x' } } }) + const parsed = JSON.parse(next) as { unrelated: unknown; mcpServers: Record } + assert.ok(next.includes('"unrelated": {"a": 1}')) + assert.equal(parsed.mcpServers['nsolid-console'].url, 'https://x') + }) + + it('replaces a scalar, null, or array MCP container on install and migrates legacy keys', () => { + for (const scalar of ['null', '[]', '"applied"', '3']) { + const raw = `{\n "keep": true,\n "mcp": ${scalar}\n}\n` + const next = editMcpJsonBytes(raw, { upsertServers: { fresh: { url: 'https://fresh' } } }, { mcpKey: 'mcp' }) + const parsed = parseJsonc(next) as { keep: boolean; mcp: Record } + assert.equal(parsed.keep, true) + assert.equal(parsed.mcp.fresh.url, 'https://fresh', `scalar ${scalar} must be replaced by the server object`) + } + // A legacy mcpServers container is still migrated away on install. + const withLegacy = '{\n "mcpServers": {"old": {"url": "https://old"}},\n "mcp": "text"\n}\n' + const migrated = editMcpJsonBytes(withLegacy, { upsertServers: { fresh: { url: 'https://fresh' } }, removeKeys: ['mcpServers'] }, { mcpKey: 'mcp' }) + const migratedParsed = parseJsonc(migrated) as { mcp: Record } + assert.ok(!migrated.includes('mcpServers')) + assert.equal(migratedParsed.mcp.fresh.url, 'https://fresh') + }) + + it('fails closed with MCP_BLOCK_INVALID for ownership edits over a scalar container', () => { + const raw = '{\n "mcp": "user data",\n "other": true\n}\n' + for (const edit of [ + { removeServers: ['nsolid-console'] }, + { setFields: [{ server: 'nsolid-console', field: 'url', value: 'https://x' }] }, + { removeFields: [{ server: 'nsolid-console', field: 'url' }] }, + ]) { + let thrown: McpEditError | undefined + try { + editMcpJsonBytes(raw, edit, { mcpKey: 'mcp' }) + } catch (error) { + thrown = error as McpEditError + } + assert.ok(thrown instanceof McpEditError, `edit ${JSON.stringify(edit)} must throw`) + assert.equal(thrown!.code, 'MCP_BLOCK_INVALID') + } + // The document was not modified by any failed attempt. + assert.equal(raw, '{\n "mcp": "user data",\n "other": true\n}\n') + }) + + it('selects the harness-preferred container and keeps live and staged digests identical', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-mcp-container-')) + try { + assert.equal(harnessMcpKey('opencode'), 'mcp') + assert.equal(harnessMcpKey('claude'), 'mcpServers') + const configPath = path.join(root, 'opencode.jsonc') + const raw = '{\n "mcpServers": {"server": {"url": "https://legacy"}},\n "mcp": {"server": {"url": "https://preferred"}}\n}\n' + writeFileSync(configPath, raw) + // Preferred key wins over the legacy container. + const record = readMcpServerRecord(configPath, 'server', { preferredKey: 'mcp' }) + assert.deepEqual(record, { url: 'https://preferred' }) + const liveDigests = readMcpFieldDigests(configPath, 'server', { preferredKey: 'mcp' }) + assert.equal(liveDigests?.url, valueDigest('https://preferred')) + // Staged bytes produce identical evidence. + const stagedDigests = mcpFieldDigestsFromBytes(configPath, raw, 'server', { preferredKey: 'mcp' }) + assert.deepEqual(stagedDigests, liveDigests) + // Default precedence for non-OpenCode harnesses keeps mcpServers first. + const defaultRecord = readMcpServerRecord(configPath, 'server') + assert.deepEqual(defaultRecord, { url: 'https://legacy' }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('leaves blank lines inside unrelated nested objects untouched when removing the block', () => { + // The unrelated nested object deliberately contains a blank line; the + // splice collapse must only touch whitespace at the removal junction. + const raw = '{\n "outer": {\n "a": 1,\n\n "b": 2\n },\n "mcp": {\n "stale": {"url": "https://stale"}\n }\n}\n' + const next = editMcpJsonBytes(raw, { removeServers: ['stale'], removeBlock: true }, { mcpKey: 'mcp' }) + const parsed = parseJsonc(next) as { outer: Record } + assert.deepEqual(parsed.outer, { a: 1, b: 2 }) + assert.ok(next.includes('"a": 1,\n\n "b": 2'), 'the unrelated nested blank line must survive') + assert.ok(!next.includes('mcp')) + }) + + it('inserts the block via the parsed root when comments or strings contain braces', () => { + const lineComment = '{\n // a foreign } brace lives here\n "keep": true\n}\n' + const fromLine = editMcpJsonBytes(lineComment, { upsertServers: { fresh: { url: 'https://fresh' } } }, { mcpKey: 'mcpServers' }) + assert.deepEqual(parseJsonc(fromLine), { keep: true, mcpServers: { fresh: { url: 'https://fresh' } } }) + assert.ok(fromLine.includes('// a foreign } brace lives here'), 'the foreign line comment survives') + + const blockComment = '{\n /* unbalanced } brace */\n "keep": true\n}\n' + const fromBlock = editMcpJsonBytes(blockComment, { upsertServers: { fresh: { url: 'https://fresh' } } }, { mcpKey: 'mcpServers' }) + assert.deepEqual(parseJsonc(fromBlock), { keep: true, mcpServers: { fresh: { url: 'https://fresh' } } }) + assert.ok(fromBlock.includes('/* unbalanced } brace */'), 'the foreign block comment survives') + + const withString = '{\n "note": "brace } here",\n "keep": true\n}\n' + const fromString = editMcpJsonBytes(withString, { upsertServers: { fresh: { url: 'https://fresh' } } }, { mcpKey: 'mcpServers' }) + assert.deepEqual(parseJsonc(fromString), { note: 'brace } here', keep: true, mcpServers: { fresh: { url: 'https://fresh' } } }) + + const crlfComment = '{\r\n // closing } in comment\r\n "keep": true\r\n}\r\n' + const fromCrlf = editMcpJsonBytes(crlfComment, { upsertServers: { fresh: { url: 'https://fresh' } } }, { mcpKey: 'mcpServers' }) + assert.deepEqual(parseJsonc(fromCrlf), { keep: true, mcpServers: { fresh: { url: 'https://fresh' } } }) + assert.ok(fromCrlf.includes('\r\n'), 'CRLF endings survive') + }) + + it('rejects structurally invalid documents and impossible structural edits', () => { + assert.throws(() => editMcpJsonBytes('{ not json', { removeBlock: true }), McpEditError) + assert.throws(() => editMcpJsonBytes('{"unrelated": 1}', { removeServers: ['ghost'] }), (error: unknown) => { + return error instanceof McpEditError && error.code === 'MCP_BLOCK_MISSING' + }) + }) + + it('reads node values without mutating the document', () => { + const raw = '{\n "mcpServers": {"s": {"url": "https://x", "n": 3, "b": true, "z": null}}\n}\n' + assert.equal(readMcpNodeValue(raw, ['mcpServers', 's', 'url']), 'https://x') + assert.equal(readMcpNodeValue(raw, ['mcpServers', 's', 'n']), 3) + assert.equal(readMcpNodeValue(raw, ['mcpServers', 's', 'b']), true) + assert.equal(readMcpNodeValue(raw, ['mcpServers', 's', 'z']), null) + assert.equal(readMcpNodeValue(raw, ['mcpServers', 's', 'missing']), undefined) + }) +}) diff --git a/packages/core/test/unit/update/mcp-reconciliation.test.ts b/packages/core/test/unit/update/mcp-reconciliation.test.ts new file mode 100644 index 0000000..7f96d20 --- /dev/null +++ b/packages/core/test/unit/update/mcp-reconciliation.test.ts @@ -0,0 +1,129 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { planMcpReconciliation } from '../../../src/update/mcp-reconciliation.js' + +const desired = [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }] + +function values (servers: readonly { name: string }[]): Record> { + return Object.fromEntries(servers.map((server) => [server.name, { url: 'https://example.com/mcp', headers: {} }])) +} + +describe('MCP multi-config reconciliation planning', () => { + it('keeps an existing server in its registered configuration file', () => { + const plan = planMcpReconciliation({ + previousServers: [{ name: 'nsolid-console', configPath: '/home/user/.config/opencode/other.jsonc', fields: { url: 'a' } }], + desiredServers: desired, + desiredValues: values(desired), + canonicalConfigPath: '/home/user/.config/opencode/opencode.jsonc', + }) + assert.equal(plan.kind, 'planned') + if (plan.kind !== 'planned') return + assert.deepEqual(plan.destinations, { 'nsolid-console': '/home/user/.config/opencode/other.jsonc' }) + const entry = plan.entries.find((candidate) => candidate.configPath === '/home/user/.config/opencode/other.jsonc') + assert.ok(entry) + assert.deepEqual(entry.updateFields.map((field) => field.field), ['url', 'headers']) + assert.deepEqual(entry.ownedFieldDigests.map((field) => field.field), ['url']) + }) + + it('plans stale-server removal only in the file that owns the record', () => { + const plan = planMcpReconciliation({ + previousServers: [ + { name: 'stale-server', configPath: '/configs/first.json', fields: { url: 'a' } }, + { name: 'stale-server', configPath: '/configs/second.json', fields: { url: 'b' } }, + ].slice(0, 1), + desiredServers: [], + desiredValues: {}, + canonicalConfigPath: '/configs/canonical.json', + }) + assert.equal(plan.kind, 'planned') + if (plan.kind !== 'planned') return + assert.deepEqual(plan.entries, [{ + configPath: '/configs/first.json', + removeServers: ['stale-server'], + upsertServers: [], + updateFields: [], + removeFields: [], + ownedFieldDigests: [], + }]) + }) + + it('sends new servers to the single pre-existing configuration path', () => { + const plan = planMcpReconciliation({ + previousServers: [{ name: 'old-server', configPath: '/configs/custom.json', fields: { url: 'a' } }], + desiredServers: [...desired, { name: 'brand-new', url: 'https://example.com/mcp', headers: {} }], + desiredValues: values([...desired, { name: 'brand-new', url: 'https://example.com/mcp', headers: {} }]), + canonicalConfigPath: '/configs/canonical.json', + }) + assert.equal(plan.kind, 'planned') + if (plan.kind !== 'planned') return + assert.equal(plan.destinations['brand-new'], '/configs/custom.json') + assert.equal(plan.destinations['nsolid-console'], '/configs/custom.json') + }) + + it('falls back to the canonical adapter path when previous paths are split', () => { + const plan = planMcpReconciliation({ + previousServers: [ + { name: 'one', configPath: '/configs/a.json', fields: { url: 'a' } }, + { name: 'two', configPath: '/configs/b.json', fields: { url: 'b' } }, + ], + desiredServers: [...desired, { name: 'brand-new', url: 'https://example.com/mcp', headers: {} }], + desiredValues: values([...desired, { name: 'brand-new', url: 'https://example.com/mcp', headers: {} }]), + canonicalConfigPath: '/configs/canonical.json', + }) + assert.equal(plan.kind, 'planned') + if (plan.kind !== 'planned') return + assert.equal(plan.destinations['brand-new'], '/configs/canonical.json') + assert.equal(plan.destinations['nsolid-console'], '/configs/canonical.json') + // Stale previous servers are removals in their own files. + const removals = plan.entries.flatMap((entry) => entry.removeServers) + assert.deepEqual(removals.sort(), ['one', 'two']) + }) + + it('returns MCP_RECONCILIATION_REQUIRED for a server registered in multiple files', () => { + const plan = planMcpReconciliation({ + previousServers: [ + { name: 'nsolid-console', configPath: '/configs/a.json', fields: { url: 'a' } }, + { name: 'nsolid-console', configPath: '/configs/b.json', fields: { url: 'b' } }, + ], + desiredServers: desired, + desiredValues: values(desired), + canonicalConfigPath: '/configs/canonical.json', + }) + assert.equal(plan.kind, 'reconciliation-required') + if (plan.kind !== 'reconciliation-required') return + assert.equal(plan.code, 'MCP_RECONCILIATION_REQUIRED') + }) + + it('returns MCP_RECONCILIATION_REQUIRED when a new server has no resolvable destination', () => { + const plan = planMcpReconciliation({ + previousServers: [], + desiredServers: desired, + desiredValues: values(desired), + }) + assert.equal(plan.kind, 'reconciliation-required') + if (plan.kind !== 'reconciliation-required') return + assert.equal(plan.code, 'MCP_RECONCILIATION_REQUIRED') + }) + + it('tracks owned-field digests for drift validation before patching', () => { + const plan = planMcpReconciliation({ + previousServers: [{ name: 'nsolid-console', configPath: '/configs/a.json', fields: { url: 'digest-url', headers: 'digest-headers' } }], + desiredServers: desired, + desiredValues: { 'nsolid-console': { url: 'https://new', headers: {} } }, + canonicalConfigPath: '/configs/canonical.json', + }) + assert.equal(plan.kind, 'planned') + if (plan.kind !== 'planned') return + const entry = plan.entries[0] + assert.deepEqual(entry.ownedFieldDigests, [ + { server: 'nsolid-console', field: 'url', expectedDigest: 'digest-url' }, + { server: 'nsolid-console', field: 'headers', expectedDigest: 'digest-headers' }, + ]) + // Both tracked fields exist in the desired value, so both are updates. + assert.deepEqual(entry.updateFields, [ + { server: 'nsolid-console', field: 'url', value: 'https://new' }, + { server: 'nsolid-console', field: 'headers', value: {} }, + ]) + assert.deepEqual(entry.removeFields, []) + }) +}) diff --git a/packages/core/test/unit/update/mcp-toml-edit.test.ts b/packages/core/test/unit/update/mcp-toml-edit.test.ts new file mode 100644 index 0000000..1cf7b26 --- /dev/null +++ b/packages/core/test/unit/update/mcp-toml-edit.test.ts @@ -0,0 +1,217 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { editMcpTomlBytes, McpTomlEditError } from '../../../src/update/mcp-toml-edit.js' + +describe('editMcpTomlBytes', () => { + it('rewrites only owned value ranges in a CRLF document with comments, foreign tables, and inline comments', () => { + const original = [ + '# top-level user comment', + '[model]', + 'name = "gpt-5"', + '', + '[mcp_servers.alpha-console]', + 'url = "https://old.example/mcp" # owned endpoint', + 'note = "keep-note"', + 'user_token = "user-secret"', + '', + '[mcp_servers.other]', + 'url = "https://other.example/mcp"', + ].join('\r\n') + '\r\n' + const expected = [ + '# top-level user comment', + '[model]', + 'name = "gpt-5"', + '', + '[mcp_servers.alpha-console]', + 'url = "https://new.example.com/mcp" # owned endpoint', + 'user_token = "user-secret"', + '', + '[mcp_servers.other]', + 'url = "https://other.example/mcp"', + ].join('\r\n') + '\r\n' + + const next = editMcpTomlBytes(original, { + setFields: [{ server: 'alpha-console', field: 'url', value: 'https://new.example.com/mcp' }], + removeFields: [{ server: 'alpha-console', field: 'note' }], + }) + + assert.equal(next, expected) + // The user-owned field survives byte-for-byte, including its CRLF ending. + assert.ok(next.includes('user_token = "user-secret"\r\n'), next) + }) + + it('updates an owned structured value represented by a child table without rewriting siblings', () => { + const original = [ + '[mcp_servers.alpha]', + 'url = "https://old.example/mcp"', + 'token = "t"', + '', + '[mcp_servers.alpha.headers]', + 'X-Old = "1"', + '', + '[mcp_servers.beta]', + 'url = "https://beta.example/mcp"', + ].join('\n') + const expected = [ + '[mcp_servers.alpha]', + 'url = "https://old.example/mcp"', + 'token = "t"', + '', + '[mcp_servers.alpha.headers]', + 'X-New = "2"', + '', + '[mcp_servers.beta]', + 'url = "https://beta.example/mcp"', + ].join('\n') + + const next = editMcpTomlBytes(original, { + setFields: [{ server: 'alpha', field: 'headers', value: { 'X-New': '2' } }], + }) + + assert.equal(next, expected) + }) + + it('removes an exclusively owned server including descendant tables and preserves ambiguous leading comments', () => { + const original = [ + '# before alpha', + '[mcp_servers.alpha]', + 'url = "https://a.example/mcp"', + '', + '[mcp_servers.alpha.cache]', + 'ttl = 30', + '', + '[model]', + 'name = "m"', + '', + '[mcp_servers.beta]', + 'url = "https://b.example/mcp"', + ].join('\n') + const expected = [ + '# before alpha', + '[model]', + 'name = "m"', + '', + '[mcp_servers.beta]', + 'url = "https://b.example/mcp"', + ].join('\n') + + const next = editMcpTomlBytes(original, { removeServers: ['alpha'] }) + + assert.equal(next, expected) + }) + + it('fails closed when a removed server body contains an ambiguous standalone comment', () => { + const original = '[mcp_servers.alpha]\n# why is this here\nurl = "https://a.example/mcp"\n' + + assert.throws(() => editMcpTomlBytes(original, { removeServers: ['alpha'] }), (error: unknown) => { + assert.ok(error instanceof McpTomlEditError) + assert.equal(error.code, 'MCP_BLOCK_INVALID') + return true + }) + }) + + it('inserts a new exclusively owned server at the end using the document EOL without rewriting the prefix', () => { + const original = '# user config\n[model]\nname = "m"\n' + const expected = '# user config\n[model]\nname = "m"\n[mcp_servers.nsolid-console]\nurl = "https://n.example/mcp"\nheaders = {}\n' + + const next = editMcpTomlBytes(original, { + upsertServers: { 'nsolid-console': { url: 'https://n.example/mcp', headers: {} } }, + }) + + assert.equal(next, expected) + assert.ok(next.startsWith(original), next) + }) + + it('inserts a new mixed server with structured-first key order into an empty document', () => { + const next = editMcpTomlBytes('', { + upsertServers: { alpha: { headers: { Authorization: 'x' }, url: 'https://example/mcp' } }, + }) + + // Direct scalars must land under the server table; child tables come + // after them regardless of source key order. + assert.equal(next, '[mcp_servers.alpha]\nurl = "https://example/mcp"\n[mcp_servers.alpha.headers]\nAuthorization = "x"\n') + }) + + it('inserts a new mixed server with scalar-first key order into an empty document', () => { + const next = editMcpTomlBytes('', { + upsertServers: { alpha: { url: 'https://example/mcp', headers: { Authorization: 'x' } } }, + }) + + assert.equal(next, '[mcp_servers.alpha]\nurl = "https://example/mcp"\n[mcp_servers.alpha.headers]\nAuthorization = "x"\n') + }) + + it('inserts a new mixed server into a populated CRLF document without rewriting the prefix', () => { + const original = '# user config\r\n[owned-by-user]\r\nkey = 1\r\n' + const next = editMcpTomlBytes(original, { + upsertServers: { alpha: { headers: { Authorization: 'x' }, url: 'https://example/mcp' } }, + }) + + assert.equal(next, original + '[mcp_servers.alpha]\r\nurl = "https://example/mcp"\r\n[mcp_servers.alpha.headers]\r\nAuthorization = "x"\r\n') + }) + + it('inserts a new mixed server with scalar-first key order into a populated LF document', () => { + const original = '[owned-by-user]\nkey = 1\n' + const next = editMcpTomlBytes(original, { + upsertServers: { alpha: { url: 'https://example/mcp', headers: { Authorization: 'x' } } }, + }) + + assert.equal(next, original + '[mcp_servers.alpha]\nurl = "https://example/mcp"\n[mcp_servers.alpha.headers]\nAuthorization = "x"\n') + }) + + it('rejects malformed TOML before any mutation', () => { + const original = '[mcp_servers.alpha\nurl = "https://a.example/mcp"\n' + + assert.throws(() => editMcpTomlBytes(original, { removeServers: ['alpha'] }), (error: unknown) => { + assert.ok(error instanceof McpTomlEditError) + assert.equal(error.code, 'MCP_PARSE_FAILED') + return true + }) + }) + + it('returns the original document when the requested operations are a semantic no-op', () => { + const original = '[mcp_servers.alpha]\nurl = "https://a.example/mcp"\n' + + const next = editMcpTomlBytes(original, { + setFields: [{ server: 'alpha', field: 'url', value: 'https://a.example/mcp' }], + }) + + assert.equal(next, original) + }) + + it('matches quoted server and field names by decoded value', () => { + const original = '[mcp_servers."alpha-console"]\nurl = "https://old.example/mcp"\n' + const expected = '[mcp_servers."alpha-console"]\nurl = "https://new.example.com/mcp"\n' + + const next = editMcpTomlBytes(original, { + setFields: [{ server: 'alpha-console', field: 'url', value: 'https://new.example.com/mcp' }], + }) + + assert.equal(next, expected) + }) + + it('removes an owned field represented by a descendant table and keeps following tables intact', () => { + const original = [ + '[mcp_servers.alpha]', + 'url = "https://a.example/mcp"', + '', + '[mcp_servers.alpha.headers]', + 'X-Old = "1"', + '', + '[mcp_servers.beta]', + 'url = "https://beta.example/mcp"', + ].join('\n') + const expected = [ + '[mcp_servers.alpha]', + 'url = "https://a.example/mcp"', + '', + '[mcp_servers.beta]', + 'url = "https://beta.example/mcp"', + ].join('\n') + + const next = editMcpTomlBytes(original, { + removeFields: [{ server: 'alpha', field: 'headers' }], + }) + + assert.equal(next, expected) + }) +}) diff --git a/packages/core/test/unit/update/native-evidence.test.ts b/packages/core/test/unit/update/native-evidence.test.ts new file mode 100644 index 0000000..9fa735e --- /dev/null +++ b/packages/core/test/unit/update/native-evidence.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import { describe, it } from 'node:test' +import { nativeExecutionGuard, nativeSourceHonorsArtifact } from '../../../src/update/native-evidence.js' +import type { ResolvedArtifactIdentity, UpdateInstallationMetadata, UpdateSource } from '../../../src/update/types.js' + +function marketplaceSource (): UpdateSource { + return { + kind: 'claude-marketplace', + pluginId: 'nsolid-plugin@nodesource', + marketplace: 'nodesource', + scope: 'user', + versionSource: { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + revision: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + manifestPath: 'bundle.json', + }, + } +} + +function gitArtifact (): ResolvedArtifactIdentity { + return { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: 'planned-content', + } +} + +function localSnapshotArtifact (root: string, digest: string): ResolvedArtifactIdentity { + return { kind: 'local-snapshot', root, contentDigest: digest } +} + +function metadataWithEvidence (): UpdateInstallationMetadata { + const evidencePath = path.join(mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-evidence-')), 'evidence.json') + const evidence = '{"target":"claude"}' + writeFileSync(evidencePath, evidence) + return { + nativeEvidence: [{ path: evidencePath, digest: createHash('sha256').update(evidence).digest('hex') }], + } as UpdateInstallationMetadata +} + +describe('native marketplace sources must prove the planned immutable identity', () => { + it('honors a matching git artifact', () => { + assert.equal(nativeSourceHonorsArtifact(marketplaceSource(), gitArtifact()), true) + }) + + it('honors a matching local-snapshot artifact', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-snapshot-')) + const source = { + ...marketplaceSource(), + versionSource: { kind: 'local-snapshot', freshness: 'verified', root, contentDigest: 'snapshot-digest' }, + } as UpdateSource + assert.equal(nativeSourceHonorsArtifact(source, localSnapshotArtifact(root, 'snapshot-digest')), true) + }) + + it('refuses a marketplace source with no artifact at all', () => { + assert.equal(nativeSourceHonorsArtifact(marketplaceSource(), undefined), false) + }) + + it('refuses an artifact of an unsupported class', () => { + const unsupported = { kind: 'tarball', url: 'https://example.com/x.tgz' } as unknown as ResolvedArtifactIdentity + assert.equal(nativeSourceHonorsArtifact(marketplaceSource(), unsupported), false) + }) + + it('refuses non-marketplace sources', () => { + const source = { kind: 'npm', packageName: 'nsolid-plugin' } as unknown as UpdateSource + assert.equal(nativeSourceHonorsArtifact(source, gitArtifact()), false) + }) + + it('nativeExecutionGuard rejects the mutation when the artifact is missing', () => { + const error = nativeExecutionGuard({ metadata: metadataWithEvidence(), source: marketplaceSource(), artifact: undefined }, 'Claude') + assert.equal(error?.code, 'NATIVE_SOURCE_NOT_PINNED') + }) + + it('nativeExecutionGuard rejects the mutation for an unsupported artifact class', () => { + const unsupported = { kind: 'tarball', url: 'https://example.com/x.tgz' } as unknown as ResolvedArtifactIdentity + const error = nativeExecutionGuard({ metadata: metadataWithEvidence(), source: marketplaceSource(), artifact: unsupported }, 'Claude') + assert.equal(error?.code, 'NATIVE_SOURCE_NOT_PINNED') + }) + + it('nativeExecutionGuard allows a properly pinned git source', () => { + const error = nativeExecutionGuard({ metadata: metadataWithEvidence(), source: marketplaceSource(), artifact: gitArtifact() }, 'Claude') + assert.equal(error, undefined) + }) + + it('nativeExecutionGuard allows a verified local-snapshot source', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-snapshot-guard-')) + const source = { + ...marketplaceSource(), + versionSource: { kind: 'local-snapshot', freshness: 'verified', root, contentDigest: 'snapshot-digest' }, + } as UpdateSource + const error = nativeExecutionGuard({ metadata: metadataWithEvidence(), source, artifact: localSnapshotArtifact(root, 'snapshot-digest') }, 'Claude') + assert.equal(error, undefined) + }) +}) diff --git a/packages/core/test/unit/update/native-payload.test.ts b/packages/core/test/unit/update/native-payload.test.ts new file mode 100644 index 0000000..efc5cef --- /dev/null +++ b/packages/core/test/unit/update/native-payload.test.ts @@ -0,0 +1,116 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { gzipSync } from 'node:zlib' +import { gitArchivePayloadDigest, nativePayloadTreeDigest } from '../../../src/update/native-payload.js' + +describe('native payload identity', () => { + it('uses the same complete-tree digest for an immutable Git archive and its installed payload', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-tree-')) + try { + const files = new Map([ + ['bundle.json', Buffer.from('{"version":"1.0.1"}\n')], + ['skills/example/SKILL.md', Buffer.from('# example\n')], + ]) + for (const [relative, content] of files) { + const target = path.join(root, relative) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, content) + } + + const archive = gzipSync(makeTar(files)) + assert.equal(gitArchivePayloadDigest(archive), nativePayloadTreeDigest(root)) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('digests only the payload subtree of a multi-plugin marketplace repository', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-subtree-')) + try { + const payloadFiles = new Map([ + ['plugins/nsolid-plugin/bundle.json', Buffer.from('{"version":"1.0.1"}\n')], + ['plugins/nsolid-plugin/skills/example/SKILL.md', Buffer.from('# example\n')], + ]) + const siblingFiles = new Map([ + ['plugins/other-plugin/bundle.json', Buffer.from('{"version":"9.9.9"}\n')], + ['plugins/other-plugin/skills/other/SKILL.md', Buffer.from('# other\n')], + ]) + const archiveFiles = new Map([...payloadFiles, ...siblingFiles]) + for (const [relative, content] of archiveFiles) { + const target = path.join(root, relative) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, content) + } + + const payloadRoot = path.join(root, 'plugins', 'nsolid-plugin') + const scope = { payloadPath: 'plugins/nsolid-plugin', manifestPath: 'bundle.json' } + assert.equal(gitArchivePayloadDigest(gzipSync(makeTar(archiveFiles)), scope), nativePayloadTreeDigest(payloadRoot)) + // Sibling bytes are excluded: changing them does not change the digest. + writeFileSync(path.join(root, 'plugins/other-plugin/bundle.json'), '{"version":"9.9.10"}\n') + assert.equal(gitArchivePayloadDigest(gzipSync(makeTar(archiveFiles)), scope), nativePayloadTreeDigest(payloadRoot)) + // A nested payload change does change the digest. + writeFileSync(path.join(root, 'plugins/nsolid-plugin/skills/example/SKILL.md'), '# substituted\n') + assert.notEqual(gitArchivePayloadDigest(gzipSync(makeTar(archiveFiles)), scope), nativePayloadTreeDigest(payloadRoot)) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects subtree scopes with unsafe paths and archives missing the manifest', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-scope-')) + try { + const files = new Map([ + ['plugins/nsolid-plugin/skills/example/SKILL.md', Buffer.from('# example\n')], + ]) + for (const [relative, content] of files) { + const target = path.join(root, relative) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, content) + } + const archive = gzipSync(makeTar(files)) + + // The manifest is absent from the subtree: reject. + assert.equal(gitArchivePayloadDigest(archive, { payloadPath: 'plugins/nsolid-plugin', manifestPath: 'bundle.json' }), undefined) + // Traversal in the payload scope: reject. + assert.equal(gitArchivePayloadDigest(archive, { payloadPath: 'plugins/../..', manifestPath: 'SKILL.md' }), undefined) + // A valid manifest elsewhere in the archive does not satisfy a scoped manifest. + const withManifest = new Map([...files, ['plugins/other/bundle.json', Buffer.from('{}')]]) + assert.equal(gitArchivePayloadDigest(gzipSync(makeTar(withManifest)), { payloadPath: 'plugins/nsolid-plugin', manifestPath: 'bundle.json' }), undefined) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) + +function makeTar (files: Map): Buffer { + const output: Buffer[] = [] + for (const [relative, content] of files) { + const header = Buffer.alloc(512) + header.write(`repository-commit/${relative}`, 0, 100, 'utf8') + writeOctal(header, 100, 8, 0o644) + writeOctal(header, 108, 8, 0) + writeOctal(header, 116, 8, 0) + writeOctal(header, 124, 12, content.length) + writeOctal(header, 136, 12, 0) + header.fill(0x20, 148, 156) + header[156] = '0'.charCodeAt(0) + header.write('ustar\0', 257, 6, 'ascii') + header.write('00', 263, 2, 'ascii') + const checksum = header.reduce((sum, byte) => sum + byte, 0) + const checksumText = checksum.toString(8).padStart(6, '0') + header.write(checksumText, 148, 6, 'ascii') + header[154] = 0 + header[155] = 0x20 + output.push(header, content, Buffer.alloc((512 - (content.length % 512)) % 512)) + } + output.push(Buffer.alloc(1024)) + return Buffer.concat(output) +} + +function writeOctal (target: Buffer, offset: number, length: number, value: number): void { + const encoded = value.toString(8).padStart(length - 1, '0') + '\0' + target.write(encoded, offset, length, 'ascii') +} diff --git a/packages/core/test/unit/update/strategies.test.ts b/packages/core/test/unit/update/strategies.test.ts index b5299fb..056fa3b 100644 --- a/packages/core/test/unit/update/strategies.test.ts +++ b/packages/core/test/unit/update/strategies.test.ts @@ -9,20 +9,25 @@ import { codexStrategy } from '../../../src/update/strategies/codex.js' import { piStrategy } from '../../../src/update/strategies/pi.js' import { antigravityStrategy } from '../../../src/update/strategies/antigravity.js' import type { UpdateInstallation, UpdateSource } from '../../../src/update/types.js' +import { nativePayloadDigest } from '../../../src/update/native-evidence.js' let previousPath: string | undefined let previousPathExt: string | undefined let previousHome: string | undefined let previousUserProfile: string | undefined +let evidenceRoot: string +let evidenceSequence = 0 beforeEach(() => { previousPath = process.env.PATH previousPathExt = process.env.PATHEXT previousHome = process.env.HOME previousUserProfile = process.env.USERPROFILE + evidenceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-evidence-')) }) afterEach(() => { + rmSync(evidenceRoot, { recursive: true, force: true }) if (previousPath === undefined) delete process.env.PATH else process.env.PATH = previousPath if (previousPathExt === undefined) delete process.env.PATHEXT @@ -34,6 +39,9 @@ afterEach(() => { }) function installation (target: UpdateInstallation['target'], source: UpdateSource): UpdateInstallation { + const evidencePath = path.join(evidenceRoot, `${target}-${evidenceSequence++}.json`) + const evidence = JSON.stringify({ target, source }) + writeFileSync(evidencePath, evidence) return { installationId: `${target}:native:nsolid-plugin@nodesource`, target, @@ -41,6 +49,9 @@ function installation (target: UpdateInstallation['target'], source: UpdateSourc installed: true, source, version: { current: undefined, latest: '1.0.1', status: 'update-available' }, + metadata: { + nativeEvidence: [{ path: evidencePath, digest: createHash('sha256').update(evidence).digest('hex') }], + }, } } @@ -130,7 +141,7 @@ function piInstallation (root: string, source: UpdateSource): UpdateInstallation }, }, })) - candidate.metadata = { packageRoots: [packageRoot], packageEvidencePaths: [evidencePath] } + candidate.metadata = { ...candidate.metadata, packageRoots: [packageRoot], packageEvidencePaths: [evidencePath] } return candidate } @@ -170,12 +181,116 @@ describe('harness strategies degrade unsupported launchers at plan time', () => } }) + it('native strategies refuse to plan when no immutable artifact pins the source', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-no-artifact-')) + writeVerifiedLauncher(root, 'claude') + writeVerifiedLauncher(root, 'codex') + process.env.PATH = root + try { + for (const [strategy, target, source] of [ + [claudeStrategy, 'claude', claudeSource()], + [codexStrategy, 'codex', codexSource()], + ] as const) { + const candidate = installation(target, source) + // No resolved artifact at all: a marketplace mutation must never be + // authorized without one. + const item = await strategy.plan(candidate, context()) + assert.equal(item.steps.length, 0, target) + assert.equal(item.planningError?.code, 'NATIVE_SOURCE_NOT_PINNED', target) + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('native strategies refuse to plan for an unsupported artifact class', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-bad-artifact-')) + writeVerifiedLauncher(root, 'claude') + process.env.PATH = root + try { + const candidate = installation('claude', claudeSource()) + candidate.artifact = { kind: 'tarball', url: 'https://example.com/x.tgz' } as never + const item = await claudeStrategy.plan(candidate, context()) + assert.equal(item.steps.length, 0) + assert.equal(item.planningError?.code, 'NATIVE_SOURCE_NOT_PINNED') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('refuses native execution when marketplace records changed after planning', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-drift-')) + writeVerifiedLauncher(root, 'claude') + process.env.PATH = root + try { + const candidate = installation('claude', claudeSource()) + // The source is legitimately pinned: only the evidence drifts below. + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: 'planned-content', + } + const item = await claudeStrategy.plan(candidate, context()) + writeFileSync(candidate.metadata!.nativeEvidence![0].path, '{"revision":"main"}') + let commands = 0 + const result = await claudeStrategy.execute(item, { + options: {}, + commandRunner: { run: async () => { commands++; return { exitCode: 0, stdout: '', stderr: '', timedOut: false } } }, + }) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'NATIVE_SOURCE_DRIFT') + assert.equal(commands, 0) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('native execution refuses to run commands when the planned item lost its artifact', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-execute-unpinned-')) + writeVerifiedLauncher(root, 'claude') + process.env.PATH = root + try { + const candidate = installation('claude', claudeSource()) + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: 'planned-content', + } + const item = await claudeStrategy.plan(candidate, context()) + assert.equal(item.planningError, undefined) + // Simulate an item reaching execution without its immutable identity. + const unpinned = { ...item, artifact: undefined } + let commands = 0 + const result = await claudeStrategy.execute(unpinned, { + options: {}, + commandRunner: { run: async () => { commands++; return { exitCode: 0, stdout: '', stderr: '', timedOut: false } } }, + }) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'NATIVE_SOURCE_NOT_PINNED') + assert.equal(commands, 0) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('claude: plans a spawn-safe command with embedded identity for a verified launcher', async () => { const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-strategy-claude-')) const exe = writeVerifiedLauncher(root, 'claude') process.env.PATH = root try { - const item = await claudeStrategy.plan(installation('claude', claudeSource()), context()) + const candidate = installation('claude', claudeSource()) + // A pinned marketplace plan carries the resolved immutable artifact. + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: 'planned-content', + } + const item = await claudeStrategy.plan(candidate, context()) assert.equal(item.planningError, undefined) const commands = item.steps.filter((step) => step.kind === 'command') assert.equal(commands.length, 2) @@ -198,7 +313,14 @@ describe('harness strategies degrade unsupported launchers at plan time', () => writeFileSync(path.join(root, 'claude'), 'not executable\n', { mode: 0o644 }) process.env.PATH = root try { - const item = await claudeStrategy.plan(installation('claude', claudeSource()), context()) + const candidate = installation('claude', claudeSource()) + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: 'planned-content', + } + const item = await claudeStrategy.plan(candidate, context()) assert.equal(item.steps.length, 0) assert.equal(item.planningError?.code, 'UNSAFE_HARNESS_LAUNCHER') assert.deepEqual(item.manualCommands, [ @@ -231,12 +353,12 @@ describe('harness strategies degrade unsupported launchers at plan time', () => process.env.USERPROFILE = root try { const candidate = installation('claude', claudeSource()) - candidate.metadata = { packageRoot: oldPayload, configPath: installedPath } + candidate.metadata = { ...candidate.metadata, packageRoot: oldPayload, configPath: installedPath } candidate.artifact = { kind: 'git', repository: 'https://github.com/NodeSource/nsolid-plugin.git', commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', - contentDigest: createHash('sha256').update(newBundle).digest('hex'), + contentDigest: nativePayloadDigest(newPayload)!, } const item = await claudeStrategy.plan(candidate, context()) const result = await claudeStrategy.execute(item, { @@ -279,12 +401,12 @@ describe('harness strategies degrade unsupported launchers at plan time', () => process.env.USERPROFILE = root try { const candidate = installation('claude', claudeSource()) - candidate.metadata = { configPath: installedPath } + candidate.metadata = { ...candidate.metadata, configPath: installedPath } candidate.artifact = { kind: 'git', repository: 'https://github.com/NodeSource/nsolid-plugin.git', commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', - contentDigest: createHash('sha256').update(newBundle).digest('hex'), + contentDigest: nativePayloadDigest(newPayload)!, } const item = await claudeStrategy.plan(candidate, context()) const result = await claudeStrategy.execute(item, { @@ -321,7 +443,13 @@ describe('harness strategies degrade unsupported launchers at plan time', () => const cachePath = path.join(root, 'plugins', 'cache', 'nodesource', 'nsolid-plugin') mkdirSync(cachePath, { recursive: true }) writeFileSync(configPath, '') - candidate.metadata = { configPath, packageRoot: cachePath } + candidate.metadata = { ...candidate.metadata, configPath, packageRoot: cachePath } + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: 'planned-content', + } const item = await codexStrategy.plan(candidate, context()) assert.equal(item.planningError, undefined) const commands = item.steps.filter((step) => step.kind === 'command') @@ -346,7 +474,14 @@ describe('harness strategies degrade unsupported launchers at plan time', () => writeFileSync(path.join(root, 'codex'), 'not executable\n', { mode: 0o644 }) process.env.PATH = root try { - const item = await codexStrategy.plan(installation('codex', codexSource()), context()) + const candidate = installation('codex', codexSource()) + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: 'planned-content', + } + const item = await codexStrategy.plan(candidate, context()) assert.equal(item.steps.length, 0) assert.equal(item.planningError?.code, 'UNSAFE_HARNESS_LAUNCHER') assert.deepEqual(item.manualCommands, [ diff --git a/packages/core/test/unit/update/version-source.test.ts b/packages/core/test/unit/update/version-source.test.ts index dacabb6..0156727 100644 --- a/packages/core/test/unit/update/version-source.test.ts +++ b/packages/core/test/unit/update/version-source.test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { isSafeManifestPath, resolveMarketplaceVersion, resolveRegistryVersion, sanitizeRepository } from '../../../src/update/version-source.js' +import { isSafeManifestPath, readArchiveWithLimit, resolveMarketplaceVersion, resolveRegistryVersion, sanitizeRepository } from '../../../src/update/version-source.js' describe('update version sources', () => { it('redacts repository credentials and rejects traversal paths', () => { @@ -201,3 +201,54 @@ describe('update version sources', () => { assert.ok(!(marketplace.error?.message ?? '').includes(secretBody)) }) }) + +describe('archive download limit', () => { + const encode = (text: string): Uint8Array => new TextEncoder().encode(text) + + function syntheticBody (chunks: Uint8Array[]): { body: unknown, state: () => { pulls: number, cancelled: boolean } } { + let pulls = 0 + let cancelled = false + const body = { + getReader: () => ({ + read: async (): Promise<{ done: boolean, value: Uint8Array | undefined }> => { + pulls++ + if (pulls <= chunks.length) return { done: false, value: chunks[pulls - 1] } + return { done: true, value: undefined } + }, + cancel: async (): Promise => { cancelled = true }, + }), + } + return { body, state: () => ({ pulls, cancelled }) } + } + + it('rejects an oversized declared length before reading the body', async () => { + const { body, state } = syntheticBody([encode('never')]) + await assert.rejects( + readArchiveWithLimit({ headers: new Headers({ 'content-length': '65' }), body } as unknown as Parameters[0], 64), + /exceeds the maximum allowed size/ + ) + assert.equal(state().pulls, 0, 'the body must not be consumed when the header already exceeds the limit') + }) + + it('cancels the stream as soon as the accumulated bytes cross the limit', async () => { + const { body, state } = syntheticBody([encode('a'.repeat(6)), encode('b'.repeat(6))]) + await assert.rejects( + readArchiveWithLimit({ headers: new Headers(), body } as unknown as Parameters[0], 10), + /exceeds the maximum allowed size/ + ) + assert.equal(state().cancelled, true, 'the reader must be cancelled when the limit is exceeded') + }) + + it('accepts a stream that exactly reaches the limit', async () => { + const { body } = syntheticBody([encode('a'.repeat(5)), encode('b'.repeat(5))]) + const bytes = await readArchiveWithLimit({ headers: new Headers(), body } as unknown as Parameters[0], 10) + assert.equal(bytes.length, 10) + assert.equal(bytes.toString('utf8'), 'a'.repeat(5) + 'b'.repeat(5)) + }) + + it('accepts a small valid archive with an unparseable content-length header', async () => { + const { body } = syntheticBody([encode('tiny')]) + const bytes = await readArchiveWithLimit({ headers: new Headers({ 'content-length': 'not-a-number' }), body } as unknown as Parameters[0], 64) + assert.equal(bytes.toString('utf8'), 'tiny') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d39287a..59f5cf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: packages/core: dependencies: + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 semver: specifier: 7.8.5 version: 7.8.5 @@ -1023,6 +1026,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -2638,6 +2644,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonc-parser@3.3.1: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 From d5454916f781387c795d80f344dc29fadc047c88 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Mon, 31 Aug 2026 16:41:41 +0200 Subject: [PATCH 09/12] fix(update): respect Windows chmod, separator, and homedir semantics - claude-transaction restore(): keep the exact 0600 gate on POSIX; on Windows accept a writable file, since chmod there only toggles the read-only bit and 0600 is not observable (every restore was rejected) - claude-transaction tests: platform-aware private-mode expectations and a separator-portable manifest backup-path assertion - fallback-transaction drift test: redirect USERPROFILE alongside HOME because os.homedir() follows USERPROFILE on Windows --- .../core/src/update/claude-transaction.ts | 9 ++++++++- .../unit/update/claude-transaction.test.ts | 19 ++++++++++++------- .../unit/update/fallback-transaction.test.ts | 6 ++++++ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/core/src/update/claude-transaction.ts b/packages/core/src/update/claude-transaction.ts index ea9bd21..044b765 100644 --- a/packages/core/src/update/claude-transaction.ts +++ b/packages/core/src/update/claude-transaction.ts @@ -347,7 +347,14 @@ async function restore (payload: PayloadSnapshot, registration: readonly Registr writeDurableFile(entry.path, restoreBytes) // A private mode is part of the restored contract: the final file is // verified explicitly because creation modes are umask-filtered. - if ((statSync(entry.path).mode & 0o777) !== 0o600) return false + // Windows chmod only toggles the read-only bit, so the strongest mode + // contract it can express is "writable, not read-only" (files report + // 0o666); POSIX keeps the exact 0600 requirement. + const restoredMode = statSync(entry.path).mode & 0o777 + const privateModeOk = process.platform === 'win32' + ? (restoredMode & 0o222) !== 0 + : restoredMode === 0o600 + if (!privateModeOk) return false } for (const entry of registration) { const restored = existsSync(entry.path) ? stateDigest(entry.path) : null diff --git a/packages/core/test/unit/update/claude-transaction.test.ts b/packages/core/test/unit/update/claude-transaction.test.ts index 9998fd7..29f4934 100644 --- a/packages/core/test/unit/update/claude-transaction.test.ts +++ b/packages/core/test/unit/update/claude-transaction.test.ts @@ -9,6 +9,11 @@ import { executeClaudeTransaction, installedClaudePayloadRoot, restoreClaudeNati import type { OwnedPathKind } from '../../../src/update/fs-transaction.js' import { nativePayloadDigest } from '../../../src/update/native-evidence.js' +// Windows chmod only toggles the read-only bit: a writable file reports mode +// 0o666, so 0600 is not observable there. Assert the strongest mode contract +// each platform can express. +const privateFileMode = process.platform === 'win32' ? 0o666 : 0o600 + interface Fixture { home: string registryPath: string @@ -290,9 +295,9 @@ describe('Claude native replacement transaction', () => { assert.equal(result.success, false) assert.equal(result.rollbackSucceeded, true) - // The restored registration evidence keeps the private 0600 mode even + // The restored registration evidence keeps the private mode even // though the live file existed with 0644 before the restore. - assert.equal(statSync(fixture.registryPath).mode & 0o777, 0o600) + assert.equal(statSync(fixture.registryPath).mode & 0o777, privateFileMode) assert.equal(readFileSync(fixture.registryPath, 'utf8'), fixture.registryBytes) } finally { rmSync(fixture.home, { recursive: true, force: true }) @@ -317,10 +322,10 @@ describe('Claude native replacement transaction', () => { const previousUmask = process.umask(0o277) try { // open(2) creation modes are umask-filtered: the restored file must - // still carry the exact private 0600 mode afterwards. + // still carry the exact private mode afterwards. const restored = await restoreClaudeNativeState({} as Parameters[0], registration) assert.equal(restored, true) - assert.equal(statSync(fixture.registryPath).mode & 0o777, 0o600) + assert.equal(statSync(fixture.registryPath).mode & 0o777, privateFileMode) assert.equal(readFileSync(fixture.registryPath, 'utf8'), fixture.registryBytes) } finally { process.umask(previousUmask) @@ -435,12 +440,12 @@ describe('Claude native replacement transaction', () => { assert.equal(manifest.registration[0].path, fixture.registryPath) assert.equal(manifest.registration[0].existed, true) assert.equal(manifest.registration[0].digest, sha256(fixture.registryBytes)) - assert.equal(manifest.registration[0].backup, 'registration/0000.bin') + assert.equal(manifest.registration[0].backup, path.join('registration', '0000.bin')) const backup0 = path.join(recoveryRoot, 'registration', '0000.bin') const backup1 = path.join(recoveryRoot, 'registration', '0001.bin') assert.equal(existsSync(backup0), true) assert.equal(existsSync(backup1), true) - assert.equal(statSync(backup0).mode & 0o777, 0o600) + assert.equal(statSync(backup0).mode & 0o777, privateFileMode) assert.equal(readFileSync(backup0).equals(Buffer.from(fixture.registryBytes)), true) assert.equal(readFileSync(backup1).equals(marketplacesBytes), true) // The manifest references the separately allocated same-volume payload backup. @@ -481,7 +486,7 @@ describe('Claude native replacement transaction', () => { assert.equal(existsSync(path.join(recoveryRoot, 'recovery.json')), true) const backup0 = path.join(recoveryRoot, 'registration', '0000.bin') assert.equal(existsSync(backup0), true) - assert.equal(statSync(backup0).mode & 0o777, 0o600) + assert.equal(statSync(backup0).mode & 0o777, privateFileMode) assert.equal(sha256(readFileSync(backup0)), sha256(registryBytesBefore)) const payloadSiblings = readdirSync(path.dirname(fixture.payloadRoot)).filter((name) => name.includes('.nsolid-payload-backup-')) assert.equal(payloadSiblings.length, 1) diff --git a/packages/core/test/unit/update/fallback-transaction.test.ts b/packages/core/test/unit/update/fallback-transaction.test.ts index 8d6b65d..c0da7aa 100644 --- a/packages/core/test/unit/update/fallback-transaction.test.ts +++ b/packages/core/test/unit/update/fallback-transaction.test.ts @@ -676,7 +676,11 @@ describe('fallback refresh journal-backed canonical MCP path', () => { // The environment resolves a different canonical path after planning. const movedHome = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-moved-')) const previousHome = process.env.HOME + const previousUserProfile = process.env.USERPROFILE process.env.HOME = movedHome + // os.homedir() follows USERPROFILE on Windows; redirect both so the + // canonical path resolution actually moves on every platform. + process.env.USERPROFILE = movedHome try { const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: fixture.identity }) assert.equal(result.success, false) @@ -684,6 +688,8 @@ describe('fallback refresh journal-backed canonical MCP path', () => { } finally { if (previousHome === undefined) delete process.env.HOME else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile rmSync(movedHome, { recursive: true, force: true }) } // Neither the planned nor the moved canonical path was created. From 4e94454b955f3b1c9bf3c7afb36e859054fffb31 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Mon, 31 Aug 2026 18:25:09 +0200 Subject: [PATCH 10/12] fix(update): close CodeRabbit review findings on PR #56 - pin marketplace versionSource revision+commit to the resolved artifact commit at planning (native guard no longer false-rejects mutable refs) - fail closed on structural edits to an empty MCP JSON document - record fallback manifest temp dirs on the plan item; never derive recursive deletes from command arguments - include preserved backup locations in the Codex timeout error - compare TOML datetime values by getTime() before the record branch - make the moved-home drift assertion non-vacuous --- packages/core/src/update/codex-transaction.ts | 5 +- packages/core/src/update/coordinator.ts | 50 ++++- packages/core/src/update/mcp-edit.ts | 7 +- packages/core/src/update/mcp-toml-edit.ts | 7 + .../core/src/update/strategies/fallback.ts | 17 +- packages/core/src/update/types.ts | 2 + .../unit/update/codex-transaction.test.ts | 21 +++ .../core/test/unit/update/coordinator.test.ts | 177 +++++++++++++++++- .../unit/update/fallback-strategy.test.ts | 21 +++ .../unit/update/fallback-transaction.test.ts | 6 +- .../core/test/unit/update/mcp-edit.test.ts | 23 +++ .../test/unit/update/mcp-toml-edit.test.ts | 27 +++ 12 files changed, 348 insertions(+), 15 deletions(-) diff --git a/packages/core/src/update/codex-transaction.ts b/packages/core/src/update/codex-transaction.ts index 177789c..7cead51 100644 --- a/packages/core/src/update/codex-transaction.ts +++ b/packages/core/src/update/codex-transaction.ts @@ -124,7 +124,10 @@ export async function executeCodexTransaction ( return { success: false, rollbackAttempted: false, - error: { code: 'CODEX_TREE_TERMINATION_UNCONFIRMED', message: 'Codex timed out and descendant termination could not be confirmed; the backup was preserved' }, + error: { + code: 'CODEX_TREE_TERMINATION_UNCONFIRMED', + message: `Codex timed out and descendant termination could not be confirmed; backups were preserved at ${configBackupStorage.directory} and ${cacheBackupStorage.directory}`, + }, } } rollbackAttempted = commandResult.completed.some((completed) => completed.args.includes('remove')) || command.args.includes('remove') diff --git a/packages/core/src/update/coordinator.ts b/packages/core/src/update/coordinator.ts index 329a73c..7a60cdb 100644 --- a/packages/core/src/update/coordinator.ts +++ b/packages/core/src/update/coordinator.ts @@ -1,17 +1,18 @@ import type { HarnessType } from '../types.js' import { rm } from 'node:fs/promises' -import path from 'node:path' import { createCommandRunner } from './command-runner.js' import { detectCliInstallation, detectInstallations } from './inventory.js' import { cleanupNpmArtifact, downloadNpmArtifact, resolveFixedGitBundleVersion, resolveMarketplaceVersion, resolveRegistryVersion } from './version-source.js' import { classifyVersionSet, classifyVersions } from './version.js' import type { + ResolvedArtifactIdentity, UpdateContext, UpdateInstallation, UpdateOptions, UpdatePlan, UpdatePlanItem, UpdateResult, + UpdateSource, UpdateStatus, UpdateStrategy, UpdateSummary, @@ -105,7 +106,15 @@ export async function planUpdates (options: UpdateOptions = {}): Promise { await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) - const command = item.steps.find((step) => step.kind === 'command') - const transactionIndex = command?.kind === 'command' ? command.command.args.indexOf('--transaction') : -1 - const manifestPath = transactionIndex >= 0 && command?.kind === 'command' ? command.command.args[transactionIndex + 1] : undefined - if (manifestPath) await rm(path.dirname(manifestPath), { recursive: true, force: true }).catch(() => {}) + // Only directories recorded at planning time (created by this process) are + // removed; never derive a delete target from command arguments. + for (const directory of item.temporaryDirectories ?? []) { + await rm(directory, { recursive: true, force: true }).catch(() => {}) + } } function mustPreservePlanState (result: UpdateResult): boolean { @@ -374,3 +384,31 @@ function validateScope (options: UpdateOptions): void { function isMutationUnavailableLookup (code: string): boolean { return code === 'IMMUTABLE_SOURCE_UNAVAILABLE' || code === 'SOURCE_CONTENT_MISMATCH' || code === 'INVALID_MARKETPLACE_SOURCE' } + +/** + * Carry the immutable identity resolved during planning into the planned + * source. A marketplace source planned with a mutable ref (for example + * `revision: 'main'`) is only ever authorized against the exact commit the + * immutable lookup resolved, so the execution guard sees a pinned revision + * instead of the original mutable ref. + */ +export function withPinnedMarketplaceCommit (source: UpdateSource, artifact: ResolvedArtifactIdentity | undefined): UpdateSource { + if (artifact?.kind !== 'git') return source + if (source.kind !== 'claude-marketplace' && source.kind !== 'codex-marketplace') return source + const versionSource = source.versionSource + if (versionSource.kind !== 'git') return source + // Both the revision and the commit must already be the resolved artifact + // commit: a source pinned only by `commit` but still carrying a mutable + // `revision` (for example `revision: 'main'`) would fail the execution + // guard, which reads the revision as the authoritative pinned identity. + const alreadyPinned = versionSource.revision === artifact.commit && versionSource.commit === artifact.commit + if (alreadyPinned) return source + return { + ...source, + versionSource: { + ...versionSource, + revision: artifact.commit, + commit: artifact.commit, + }, + } +} diff --git a/packages/core/src/update/mcp-edit.ts b/packages/core/src/update/mcp-edit.ts index b1ef6d0..599f2d2 100644 --- a/packages/core/src/update/mcp-edit.ts +++ b/packages/core/src/update/mcp-edit.ts @@ -65,7 +65,13 @@ let activeRaw = '' */ export function editMcpJsonBytes (raw: string, edit: McpByteEdit, options?: { mcpKey?: JsonMcpKey }): string { const mcpKey = options?.mcpKey ?? detectJsonMcpKey(raw) + const hasStructuralEdits = (edit.removeServers?.length ?? 0) > 0 || (edit.setFields?.length ?? 0) > 0 || (edit.removeFields?.length ?? 0) > 0 if (raw.trim().length === 0) { + // Fail closed like the TOML editor: an empty document has no MCP block to + // own, so requested removals/field edits must never be dropped silently. + if (hasStructuralEdits) { + throw new McpEditError('MCP_BLOCK_MISSING', `The ${mcpKey} block is absent`) + } const servers = edit.upsertServers ?? {} return JSON.stringify({ [mcpKey]: servers }, null, 2) + '\n' } @@ -77,7 +83,6 @@ export function editMcpJsonBytes (raw: string, edit: McpByteEdit, options?: { mc throw new McpEditError('MCP_PARSE_FAILED', 'The MCP configuration is not a valid JSON object') } const mcpNode = findNodeAtLocation(tree, [mcpKey]) - const hasStructuralEdits = (edit.removeServers?.length ?? 0) > 0 || (edit.setFields?.length ?? 0) > 0 || (edit.removeFields?.length ?? 0) > 0 if (mcpNode && mcpNode.type !== 'object') { // The MCP container exists but is not an object (null, array, string, // number). Ownership-needing edits cannot be proven: fail closed without diff --git a/packages/core/src/update/mcp-toml-edit.ts b/packages/core/src/update/mcp-toml-edit.ts index ae97ee3..bddd8a8 100644 --- a/packages/core/src/update/mcp-toml-edit.ts +++ b/packages/core/src/update/mcp-toml-edit.ts @@ -420,6 +420,13 @@ function modelAfterOps (model: Record, edit: McpTomlEdit): Reco function deepEqual (left: unknown, right: unknown): boolean { if (left === right) return true if (typeof left === 'number' && typeof right === 'number' && Number.isNaN(left) && Number.isNaN(right)) return true + // smol-toml parses TOML datetimes as TomlDate, a Date subclass: a Date is a + // record-shaped object with no own enumerable keys, so the record branch + // below would treat every Date as an empty object and distinct datetimes + // would compare equal. Compare instants explicitly before the record branch. + if (left instanceof Date || right instanceof Date) { + return left instanceof Date && right instanceof Date && left.getTime() === right.getTime() + } if (Array.isArray(left) && Array.isArray(right)) { return left.length === right.length && left.every((value, i) => deepEqual(value, right[i])) } diff --git a/packages/core/src/update/strategies/fallback.ts b/packages/core/src/update/strategies/fallback.ts index 6fde6a6..b7e7e3d 100644 --- a/packages/core/src/update/strategies/fallback.ts +++ b/packages/core/src/update/strategies/fallback.ts @@ -80,7 +80,7 @@ export const fallbackStrategy: UpdateStrategy = { const command = { executable: spawn.executable, executableIdentity, args: spawn.args, timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS } const paths = installation.metadata?.trackedSkills?.map((skill) => skill.path) ?? [] if (installation.metadata?.trackedMcpConfigPath) paths.push(installation.metadata.trackedMcpConfigPath) - return planItem( + const planned = planItem( { ...installation, source: { ...installation.source, executor }, fallbackTransaction: identity }, [ { kind: 'filesystem', description: 'Back up tracked NodeSource-owned fallback assets', operation: 'backup', paths }, @@ -91,6 +91,13 @@ export const fallbackStrategy: UpdateStrategy = { [{ kind: 'filesystem', description: 'Restore tracked fallback assets and tracking state', operation: 'restore', paths }], installation.target === 'opencode' ? 'Restart OpenCode to load refreshed skills' : undefined ) + // The manifest staging directory is owned by this process from creation: + // record it on the plan item so cleanup (whether execute() runs or not) + // removes exactly the directory this process created. + return { + ...planned, + temporaryDirectories: [path.dirname(manifestPath)], + } }, async execute (item: UpdatePlanItem, context: UpdateContext): Promise { @@ -180,9 +187,11 @@ export const fallbackStrategy: UpdateStrategy = { return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest, rollback: { attempted: false } }) } finally { if (!preserveRecoveryArtifacts) await rm(workspace, { recursive: true, force: true }).catch(() => {}) - const transactionIndex = step.command.args.indexOf('--transaction') - const manifestPath = transactionIndex >= 0 ? step.command.args[transactionIndex + 1] : undefined - if (manifestPath && !preserveRecoveryArtifacts) await rm(path.dirname(manifestPath), { recursive: true, force: true }).catch(() => {}) + if (!preserveRecoveryArtifacts) { + for (const directory of item.temporaryDirectories ?? []) { + await rm(directory, { recursive: true, force: true }).catch(() => {}) + } + } } }, } diff --git a/packages/core/src/update/types.ts b/packages/core/src/update/types.ts index 8a7c065..3913e6c 100644 --- a/packages/core/src/update/types.ts +++ b/packages/core/src/update/types.ts @@ -322,6 +322,8 @@ export interface UpdatePlanItem { metadata?: UpdateInstallationMetadata artifact?: ResolvedArtifactIdentity fallbackTransaction?: FallbackTransactionIdentity + /** Temporary directories this plan item's process created (for example a manifest staging dir); removal must only ever target these. */ + temporaryDirectories?: readonly string[] } export interface UpdatePlan { diff --git a/packages/core/test/unit/update/codex-transaction.test.ts b/packages/core/test/unit/update/codex-transaction.test.ts index 105026a..3565e86 100644 --- a/packages/core/test/unit/update/codex-transaction.test.ts +++ b/packages/core/test/unit/update/codex-transaction.test.ts @@ -339,4 +339,25 @@ describe('Codex update transaction', () => { assert.equal(result.error?.code, 'CODEX_BACKUP_FAILED') assert.equal(existsSync(path.join(home, '.codex')), false) }) + + it('reports the preserved backup locations in the tree-termination timeout error', async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + mkdirSync(cachePath, { recursive: true }) + mkdirSync(path.dirname(path.join(home, '.codex', 'config.toml')), { recursive: true }) + writeTomlFileSync(path.join(home, '.codex', 'config.toml'), { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + + const result = await executeCodexTransaction(item(cachePath), { + run: async () => ({ exitCode: null, stdout: '', stderr: '', timedOut: true, treeTerminated: false }), + }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, false) + assert.equal(result.error?.code, 'CODEX_TREE_TERMINATION_UNCONFIRMED') + // The randomly named sibling backup directories must be discoverable from + // the error so the user can locate or remove the preserved evidence. + assert.match(result.error?.message ?? '', /config-backup/) + assert.match(result.error?.message ?? '', /cache-backup/) + assert.equal(result.error?.message?.includes(path.dirname(cachePath)), true) + }) }) diff --git a/packages/core/test/unit/update/coordinator.test.ts b/packages/core/test/unit/update/coordinator.test.ts index 17a608f..d0c5614 100644 --- a/packages/core/test/unit/update/coordinator.test.ts +++ b/packages/core/test/unit/update/coordinator.test.ts @@ -2,12 +2,13 @@ import { afterEach, beforeEach, describe, it } from 'node:test' import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { gzipSync } from 'node:zlib' import os from 'node:os' import path from 'node:path' -import { executeUpdatePlan, planUpdates, update } from '../../../src/update/coordinator.js' +import { executeUpdatePlan, planUpdates, update, withPinnedMarketplaceCommit } from '../../../src/update/coordinator.js' import { fallbackJournalPath } from '../../../src/update/fallback-journal.js' import { getTrackingFilePath } from '../../../src/utils/path.js' -import type { UpdatePlanItem } from '../../../src/update/types.js' +import type { ResolvedArtifactIdentity, UpdatePlanItem, UpdateSource } from '../../../src/update/types.js' let home: string let previousHome: string | undefined @@ -171,6 +172,7 @@ describe('update coordinator recovery gate', () => { }], rollbackSteps: [], requiresConfirmation: true, + temporaryDirectories: [transactionDirectory], } const summary = await executeUpdatePlan({ checkOnly: false, items: [item] }, { @@ -208,6 +210,7 @@ describe('update coordinator recovery gate', () => { steps: [{ kind: 'command', description: 'refresh', command: { executable: process.execPath, args: ['--transaction', manifestPath], timeoutMs: 1000 } }], rollbackSteps: [], requiresConfirmation: true, + temporaryDirectories: [transactionDirectory], } const summary = await executeUpdatePlan({ checkOnly: false, items: [item] }, { @@ -239,3 +242,173 @@ describe('update coordinator recovery gate', () => { rmSync(plannedArtifact.tempDirectory, { recursive: true, force: true }) }) }) + +describe('withPinnedMarketplaceCommit', () => { + const gitArtifact = (commit = 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c'): ResolvedArtifactIdentity => ({ + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit, + contentDigest: 'planned-content', + }) + + const marketplaceSource = (): UpdateSource => ({ + kind: 'claude-marketplace', + pluginId: 'nsolid-plugin@nodesource', + marketplace: 'nodesource', + scope: 'user', + versionSource: { kind: 'git', repository: 'https://github.com/NodeSource/nsolid-plugin.git', revision: 'main', manifestPath: 'bundle.json' } as const, + }) + + it('pins a mutable marketplace ref to the resolved commit', () => { + const pinned = withPinnedMarketplaceCommit(marketplaceSource(), gitArtifact()) + if (pinned.kind !== 'claude-marketplace') { assert.fail('source kind changed') } + if (pinned.versionSource.kind !== 'git') { assert.fail('version source kind changed') } + assert.equal(pinned.versionSource.revision, 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c') + assert.equal(pinned.versionSource.commit, 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c') + }) + + it('leaves a source already pinned to the resolved commit untouched', () => { + const source = { + ...marketplaceSource(), + versionSource: { kind: 'git', repository: 'https://github.com/NodeSource/nsolid-plugin.git', revision: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', manifestPath: 'bundle.json' } as const, + } + assert.equal(withPinnedMarketplaceCommit(source, gitArtifact()), source) + }) + + it('rewrites a source whose commit matches but whose revision is still a mutable ref', () => { + // A source carrying the resolved commit but a branch revision would pass + // through a commit-only pin check and then fail the execution guard + // (NATIVE_SOURCE_NOT_PINNED): the revision must also be rewritten to the + // resolved artifact commit. + const source = { + ...marketplaceSource(), + versionSource: { kind: 'git', repository: 'https://github.com/NodeSource/nsolid-plugin.git', revision: 'main', commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', manifestPath: 'bundle.json' } as const, + } + const pinned = withPinnedMarketplaceCommit(source, gitArtifact()) + if (pinned.kind !== 'claude-marketplace') { assert.fail('source kind changed') } + if (pinned.versionSource.kind !== 'git') { assert.fail('version source kind changed') } + assert.equal(pinned.versionSource.revision, 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c') + assert.equal(pinned.versionSource.commit, 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c') + // A rewritten source must not be the same object identity. + assert.notEqual(pinned, source) + }) + + it('returns the source unchanged for non-git artifacts', () => { + const source = marketplaceSource() + const snapshotArtifact: ResolvedArtifactIdentity = { kind: 'local-snapshot', root: '/tmp/snapshot', contentDigest: 'snapshot-digest' } + assert.equal(withPinnedMarketplaceCommit(source, snapshotArtifact), source) + assert.equal(withPinnedMarketplaceCommit(source, undefined), source) + }) + + it('returns the source unchanged for non-marketplace sources', () => { + const cliSource: UpdateSource = { kind: 'global-package', packageManager: 'npm', packageName: 'nsolid-plugin' } + assert.equal(withPinnedMarketplaceCommit(cliSource, gitArtifact()), cliSource) + }) + + it('pins codex marketplace sources too', () => { + const codexSource: UpdateSource = { + kind: 'codex-marketplace', + pluginId: 'nsolid-plugin@nodesource', + marketplace: 'nodesource', + versionSource: { kind: 'git', repository: 'https://github.com/NodeSource/nsolid-plugin.git', revision: 'main', manifestPath: 'bundle.json' } as const, + } + const pinned = withPinnedMarketplaceCommit(codexSource, gitArtifact()) + if (pinned.kind !== 'codex-marketplace') { assert.fail('source kind changed') } + if (pinned.versionSource.kind !== 'git') { assert.fail('version source kind changed') } + assert.equal(pinned.versionSource.commit, 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c') + }) +}) + +describe('planUpdates pins the resolved marketplace commit into the planned source', () => { + const resolvedCommit = 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c' + + function tarEntry (name: string, body: Buffer | undefined, type: string): Buffer { + const header = Buffer.alloc(512) + header.write(name, 0, 'utf8') + const size = body ? body.length : 0 + header.write(size.toString(8).padStart(11, '0') + ' ', 124, 'ascii') + header[156] = type.charCodeAt(0) + header.write('ustar', 257, 'ascii') + header.write('00', 263, 'ascii') + const blocks = Math.ceil(size / 512) + const padded = Buffer.concat([body ?? Buffer.alloc(0), Buffer.alloc(blocks * 512 - size)]) + return Buffer.concat([header, padded]) + } + + function marketplaceArchive (): Buffer { + const root = `nsolid-plugin-${resolvedCommit}` + const dir = tarEntry(`${root}/`, undefined, '5') + const file = tarEntry(`${root}/bundle.json`, Buffer.from('{"version":"1.0.1"}\n'), '0') + return gzipSync(Buffer.concat([dir, file, Buffer.alloc(1024)])) + } + + it('resolves a mutable main ref and plans the native strategy with the pinned commit', async () => { + const claudeDir = mkdtempSync(path.join(home, 'claude-bin-')) + const claudeExe = path.join(claudeDir, process.platform === 'win32' ? 'claude.exe' : 'claude') + writeFileSync(claudeExe, process.platform === 'win32' ? '' : '#!/bin/sh\nexit 0\n', { mode: 0o755 }) + // A mutable-ref marketplace installation: the record pins no commit, only + // a branch ref, so the resolved commit must come from the lookup. + const payloadRoot = path.join(home, '.claude', 'plugins', 'cache', 'nsolid-plugin', '1.0.0') + mkdirSync(payloadRoot, { recursive: true }) + writeFileSync(path.join(payloadRoot, 'bundle.json'), '{"version":"1.0.0"}\n') + const pluginsDir = path.join(home, '.claude', 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const registry = { + plugins: { + 'nsolid-plugin@nodesource': [{ + version: '1.0.0', + installPath: payloadRoot, + scope: 'user', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + revision: 'main', + }], + }, + } + const installedPath = path.join(pluginsDir, 'installed_plugins.json') + writeFileSync(installedPath, JSON.stringify(registry)) + writeFileSync(path.join(pluginsDir, 'known_marketplaces.json'), '{"nodesource":{"source":"github.com/NodeSource/nsolid-plugin"}}\n') + const previousPath = process.env.PATH + const previousPathExt = process.env.PATHEXT + process.env.PATH = claudeDir + if (process.platform === 'win32') process.env.PATHEXT = '.EXE;.COM;.CMD;.BAT' + + const archive = marketplaceArchive() + try { + const plan = await planUpdates({ + harness: 'claude', + fetchImpl: async (url: RequestInfo | URL) => { + const text = String(url) + if (text.includes('api.github.com')) { + return new Response(JSON.stringify({ sha: resolvedCommit }), { status: 200 }) + } + if (text.includes('raw.githubusercontent.com')) { + return new Response(JSON.stringify({ version: '1.0.1' }), { + status: 200, + headers: { 'x-commit-sha': resolvedCommit }, + }) + } + if (text.includes('codeload.github.com')) { + return new Response(new Uint8Array(archive), { status: 200 }) + } + throw new Error(`unexpected fetch ${text}`) + }, + commandRunner: { run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }) }, + }) + + const item = plan.items.find((candidate) => candidate.target === 'claude') + assert.ok(item, 'a claude plan item must exist') + assert.equal(item.planningError, undefined, JSON.stringify(item.planningError)) + assert.ok(item.steps.length > 0) + const source = item.source + if (source.kind !== 'claude-marketplace') { assert.fail('source kind changed') } + if (source.versionSource.kind !== 'git') { assert.fail('version source kind changed') } + assert.equal(source.versionSource.revision, resolvedCommit) + assert.equal(source.versionSource.commit, resolvedCommit) + } finally { + if (previousPath === undefined) delete process.env.PATH + else process.env.PATH = previousPath + if (previousPathExt === undefined) delete process.env.PATHEXT + else process.env.PATHEXT = previousPathExt + } + }) +}) diff --git a/packages/core/test/unit/update/fallback-strategy.test.ts b/packages/core/test/unit/update/fallback-strategy.test.ts index d531f5a..c3615b3 100644 --- a/packages/core/test/unit/update/fallback-strategy.test.ts +++ b/packages/core/test/unit/update/fallback-strategy.test.ts @@ -47,6 +47,27 @@ describe('fallback update strategy', () => { assert.equal(existsSync(path.resolve(observedCwd)), false) }) + it('removes only the recorded manifest directory, never a path derived from command args', async () => { + const recorded = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-recorded-')) + const foreign = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-foreign-')) + // The command references a foreign directory that this process did not + // create; only the recorded temporary directory may be removed. + const candidate = { + ...item(), + steps: [{ kind: 'command' as const, description: 'refresh', command: { executable: 'npm', args: ['--transaction', path.join(foreign, 'transaction.json')], cwd: tmpdir(), timeoutMs: 1000 } }], + temporaryDirectories: [recorded], + } + + const result = await fallbackStrategy.execute(candidate, { + options: {}, + commandRunner: { run: async () => ({ exitCode: 1, stdout: '', stderr: 'refresh failed\n', timedOut: false, treeTerminated: true }) }, + }) + + assert.equal(result.status, 'failed') + assert.equal(existsSync(recorded), false) + assert.equal(existsSync(foreign), true, 'a directory not created by this process must never be deleted') + }) + it('reports a missing package executor as unsupported instead of failed planning', async () => { const previousPath = process.env.PATH const previousHome = process.env.HOME diff --git a/packages/core/test/unit/update/fallback-transaction.test.ts b/packages/core/test/unit/update/fallback-transaction.test.ts index c0da7aa..ade59ad 100644 --- a/packages/core/test/unit/update/fallback-transaction.test.ts +++ b/packages/core/test/unit/update/fallback-transaction.test.ts @@ -681,11 +681,15 @@ describe('fallback refresh journal-backed canonical MCP path', () => { // os.homedir() follows USERPROFILE on Windows; redirect both so the // canonical path resolution actually moves on every platform. process.env.USERPROFILE = movedHome + let movedCanonicalExists = true try { const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: fixture.identity }) assert.equal(result.success, false) assert.equal(result.error?.code, 'FALLBACK_MCP_DRIFT') } finally { + // Capture the moved-location existence before the cleanup deletes it, + // otherwise the assertion below would be vacuous. + movedCanonicalExists = existsSync(path.join(movedHome, '.claude.json')) if (previousHome === undefined) delete process.env.HOME else process.env.HOME = previousHome if (previousUserProfile === undefined) delete process.env.USERPROFILE @@ -694,7 +698,7 @@ describe('fallback refresh journal-backed canonical MCP path', () => { } // Neither the planned nor the moved canonical path was created. assert.equal(existsSync(fixture.canonicalPath), false) - assert.equal(existsSync(path.join(movedHome, '.claude.json')), false) + assert.equal(movedCanonicalExists, false) } finally { rmSync(fixture.sourceRoot, { recursive: true, force: true }) } diff --git a/packages/core/test/unit/update/mcp-edit.test.ts b/packages/core/test/unit/update/mcp-edit.test.ts index f30b862..ad595d9 100644 --- a/packages/core/test/unit/update/mcp-edit.test.ts +++ b/packages/core/test/unit/update/mcp-edit.test.ts @@ -171,6 +171,29 @@ describe('MCP byte-preserving AST edits', () => { }) }) + it('fails closed with MCP_BLOCK_MISSING when an empty document receives structural edits', () => { + for (const edit of [ + { removeServers: ['nsolid-console'] }, + { setFields: [{ server: 'nsolid-console', field: 'url', value: 'https://x' }] }, + { removeFields: [{ server: 'nsolid-console', field: 'url' }] }, + ]) { + assert.throws(() => editMcpJsonBytes('', edit), (error: unknown) => { + assert.ok(error instanceof McpEditError) + assert.equal(error.code, 'MCP_BLOCK_MISSING') + return true + }) + // Whitespace-only documents behave the same as empty ones. + assert.throws(() => editMcpJsonBytes(' \n\t ', edit), (error: unknown) => { + assert.ok(error instanceof McpEditError) + assert.equal(error.code, 'MCP_BLOCK_MISSING') + return true + }) + } + // A pure upsert against an empty document still creates the container. + const created = editMcpJsonBytes('', { upsertServers: { 'nsolid-console': { url: 'https://fresh' } } }) + assert.deepEqual(JSON.parse(created), { mcpServers: { 'nsolid-console': { url: 'https://fresh' } } }) + }) + it('reads node values without mutating the document', () => { const raw = '{\n "mcpServers": {"s": {"url": "https://x", "n": 3, "b": true, "z": null}}\n}\n' assert.equal(readMcpNodeValue(raw, ['mcpServers', 's', 'url']), 'https://x') diff --git a/packages/core/test/unit/update/mcp-toml-edit.test.ts b/packages/core/test/unit/update/mcp-toml-edit.test.ts index 1cf7b26..f023a14 100644 --- a/packages/core/test/unit/update/mcp-toml-edit.test.ts +++ b/packages/core/test/unit/update/mcp-toml-edit.test.ts @@ -178,6 +178,33 @@ describe('editMcpTomlBytes', () => { assert.equal(next, original) }) + it('distinguishes TOML datetimes by instant instead of comparing them as empty records', () => { + // smol-toml parses TOML datetimes as TomlDate, a Date subclass with no own + // enumerable keys: without an explicit Date branch the deep comparison + // would treat every datetime as an empty object and distinct datetimes as + // equal. The editor must therefore refuse to install a changed datetime + // value it cannot render byte-exactly, instead of silently accepting it. + const original = '[mcp_servers.alpha]\nurl = "https://a.example/mcp"\nupdated_at = 2024-01-01T00:00:00Z\n' + + assert.throws( + () => editMcpTomlBytes(original, { + setFields: [{ server: 'alpha', field: 'updated_at', value: new Date('2024-01-02T00:00:00Z') }], + }), + (error: unknown) => { + assert.ok(error instanceof McpTomlEditError) + assert.equal(error.code, 'MCP_BLOCK_INVALID') + return true + } + ) + + // The identical instant is a semantic no-op: the document is returned + // byte-for-byte, proving datetimes are compared by instant. + const noOp = editMcpTomlBytes(original, { + setFields: [{ server: 'alpha', field: 'updated_at', value: new Date('2024-01-01T00:00:00Z') }], + }) + assert.equal(noOp, original) + }) + it('matches quoted server and field names by decoded value', () => { const original = '[mcp_servers."alpha-console"]\nurl = "https://old.example/mcp"\n' const expected = '[mcp_servers."alpha-console"]\nurl = "https://new.example.com/mcp"\n' From 3b8a1c90e4943f96f70859c708748f6bed35d571 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Tue, 1 Sep 2026 14:25:09 +0200 Subject: [PATCH 11/12] fix(update): close batch-3 review findings on PR #56 Track only NodeSource-rendered MCP fields in refresh and install so user-added fields survive repeated runs. Gate fallback journal snapshot cleanup on the exact mkdtemp shape and realpath containment, and preflight every backup digest before any destructive restore. Roll back failed Codex commands whenever mutation started, authenticating backups against digests captured at backup time with drift-gated, digest-verified restore. Require exact nsolid-plugin identity in Antigravity staged and restored state, and authenticate Antigravity backups against persisted original digests. Prove the fallback child's claimed state from journal digests before committing. --- packages/core/src/index.ts | 4 +- packages/core/src/mcp/index.ts | 2 +- packages/core/src/mcp/mcp-config-writer.ts | 18 ++ packages/core/src/mcp/mcp-tracker.ts | 51 ++--- .../src/update/antigravity-transaction.ts | 50 +++-- packages/core/src/update/codex-transaction.ts | 79 +++++-- packages/core/src/update/fallback-journal.ts | 74 +++++-- .../core/src/update/fallback-ownership.ts | 20 ++ .../core/src/update/fallback-transaction.ts | 45 ++-- packages/core/src/update/index.ts | 3 +- packages/core/src/update/mcp-lookup.ts | 20 +- packages/core/src/update/native-payload.ts | 5 + .../core/src/update/strategies/fallback.ts | 59 +++++- .../core/test/integration/installer.test.ts | 48 +++++ .../core/test/unit/mcp/mcp-tracker.test.ts | 116 ++++++++++ .../update/antigravity-transaction.test.ts | 146 +++++++++++++ .../unit/update/codex-transaction.test.ts | 139 +++++++++++- .../test/unit/update/fallback-journal.test.ts | 74 ++++++- .../unit/update/fallback-ownership.test.ts | 96 ++++++++- .../unit/update/fallback-strategy.test.ts | 198 +++++++++++++++++- .../unit/update/fallback-transaction.test.ts | 158 +++++++++++++- .../core/test/unit/update/mcp-edit.test.ts | 3 +- 22 files changed, 1289 insertions(+), 119 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a6c9162..b950fd2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,7 @@ import { } from './skills/skill-tracker.js' import { writeMcpConfig, + renderedMcpFieldNames, removeMcpConfig, addTrackedMcps, removeTrackedMcps, @@ -375,7 +376,8 @@ export async function install (options: InstallOptions): Promise } if (mcpConfigPath && result.mcpServersConfigured.length > 0) { - const mcpEntries = bundle.mcpServers.map((s) => ({ name: s.name, configPath: mcpConfigPath })) + const rendered = renderedMcpFieldNames(options.harness, bundle.mcpServers, variables) + const mcpEntries = bundle.mcpServers.map((s) => ({ name: s.name, configPath: mcpConfigPath, ownedFields: rendered[s.name] })) await addTrackedMcps(mcpEntries, options.harness, logger) } } catch (err) { diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 3353285..080ec00 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -1,6 +1,6 @@ export { mergeMcpConfig, removeMcpServers, expandVariables } from './mcp-config-merger.js' export type { McpServerConfig, NormalizedMcpConfig } from './mcp-config-merger.js' -export { writeMcpConfig, removeMcpConfig } from './mcp-config-writer.js' +export { writeMcpConfig, removeMcpConfig, renderedMcpFieldNames } from './mcp-config-writer.js' export { addTrackedMcps, removeTrackedMcps, listTrackedMcps } from './mcp-tracker.js' export type { McpTrackingEntry } from './mcp-tracker.js' export { MCP_REMOTE_VERSION, getMcpRemoteRuntimeRoot, inspectMcpRemoteRuntime, ensureMcpRemoteRuntime, resolveNpmCommand } from './mcp-remote-runtime.js' diff --git a/packages/core/src/mcp/mcp-config-writer.ts b/packages/core/src/mcp/mcp-config-writer.ts index cf40332..9631ba1 100644 --- a/packages/core/src/mcp/mcp-config-writer.ts +++ b/packages/core/src/mcp/mcp-config-writer.ts @@ -176,6 +176,24 @@ function writeTomlConfig (configPath: string, config: NormalizedMcpConfig): void writeTomlFileSync(configPath, tomlData) } +/** + * The field names NodeSource renders for each server, computed through the + * same pipeline `writeMcpConfig` applies before bytes reach disk (variable + * expansion plus the harness write format, which may add or rename fields). + * Field names do not depend on variable values, so this is safe to use as + * ownership evidence: the tracking snapshot must never describe fields that + * exist only because the user put them in the config. + */ +export function renderedMcpFieldNames ( + harness: HarnessType, + servers: McpServerRef[], + variables?: Record +): Record { + const resolved = variables !== undefined ? expandVariables(servers, variables) : servers + const rendered = applyHarnessWriteFormat(harness, { mcpServers: Object.fromEntries(resolved.map((server) => [server.name, { ...server }])) }) + return Object.fromEntries(Object.entries(rendered.mcpServers).map(([name, server]) => [name, Object.keys(server)])) +} + /** * Apply harness-specific MCP server schema before writing to disk. * diff --git a/packages/core/src/mcp/mcp-tracker.ts b/packages/core/src/mcp/mcp-tracker.ts index d29e7e3..e53cdb5 100644 --- a/packages/core/src/mcp/mcp-tracker.ts +++ b/packages/core/src/mcp/mcp-tracker.ts @@ -1,11 +1,10 @@ import path from 'node:path' import { existsSync, unlinkSync } from 'node:fs' -import { createHash } from 'node:crypto' import type { HarnessType, Logger } from '../types.js' import type { McpTrackingEntry, TrackingData } from '../skills/skill-tracker.js' import { readTrackingFile, writeTrackingFile } from '../skills/skill-tracker.js' import { getTrackingFilePath, resolveHome } from '../utils/path.js' -import { readJsonFile, readJsoncFile, readTomlFile } from '../utils/config.js' +import { harnessMcpKey, readMcpFieldDigests } from '../update/mcp-lookup.js' export type { McpTrackingEntry } from '../skills/skill-tracker.js' @@ -20,7 +19,7 @@ function createEmptyTracking (harness: HarnessType): TrackingData { } export async function addTrackedMcps ( - entries: { name: string; configPath: string }[], + entries: { name: string; configPath: string; ownedFields?: readonly string[] }[], harness: HarnessType, logger?: Logger ): Promise { @@ -32,17 +31,27 @@ export async function addTrackedMcps ( (m) => m.name === entry.name && m.harness === harness ) + const configPath = path.resolve(resolveHome(entry.configPath)) + // Tracking evidence describes the same container the transaction reads and + // writes for this harness: the field-digests module is the single source + // of truth for both the container selection and the digest computation. + // Ownership evidence must never describe fields the user added: tracked + // fields absent from the desired render get removed on the next refresh. + const digests = readMcpFieldDigests(configPath, entry.name, { preferredKey: harnessMcpKey(harness) }) + const fields = entry.ownedFields === undefined || digests === undefined + ? digests + : Object.fromEntries(Object.entries(digests).filter(([name]) => entry.ownedFields!.includes(name))) if (existing) { - existing.configPath = path.resolve(resolveHome(entry.configPath)) + existing.configPath = configPath existing.configuredAt = now - existing.fields = readOwnedFieldDigests(existing.configPath, existing.name) + existing.fields = fields } else { tracking.mcpServers.push({ name: entry.name, - configPath: path.resolve(resolveHome(entry.configPath)), + configPath, harness, configuredAt: now, - fields: readOwnedFieldDigests(path.resolve(resolveHome(entry.configPath)), entry.name), + fields, }) } } @@ -50,34 +59,6 @@ export async function addTrackedMcps ( await writeTrackingFile(tracking, logger) } -function readOwnedFieldDigests (configPath: string, name: string): Record | undefined { - try { - const raw = configPath.endsWith('.toml') - ? readTomlFile>(configPath) - : configPath.endsWith('.jsonc') - ? readJsoncFile>(configPath) - : readJsonFile>(configPath) - if (!raw) return undefined - const servers = (raw.mcpServers ?? raw.mcp_servers ?? raw.mcp) as unknown - if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return undefined - const server = (servers as Record)[name] - if (!server || typeof server !== 'object' || Array.isArray(server)) return undefined - return Object.fromEntries(Object.entries(server as Record).map(([field, value]) => [field, digest(value)])) - } catch { return undefined } -} - -function digest (value: unknown): string { - return createHash('sha256').update(JSON.stringify(stableValue(value)) ?? 'undefined').digest('hex') -} - -function stableValue (value: unknown): unknown { - if (Array.isArray(value)) return value.map(stableValue) - if (value && typeof value === 'object') { - return Object.fromEntries(Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, stableValue(child)])) - } - return value -} - export async function removeTrackedMcps ( serverNames: string[], harness?: HarnessType, diff --git a/packages/core/src/update/antigravity-transaction.ts b/packages/core/src/update/antigravity-transaction.ts index caa9d0e..f07bf63 100644 --- a/packages/core/src/update/antigravity-transaction.ts +++ b/packages/core/src/update/antigravity-transaction.ts @@ -1,6 +1,5 @@ import { readFile, writeFile } from 'node:fs/promises' import { existsSync, readFileSync } from 'node:fs' -import { createHash } from 'node:crypto' import path from 'node:path' import { findNodeAtLocation, getNodeValue, parseTree, type Node } from 'jsonc-parser' import { resolveHome } from '../utils/path.js' @@ -9,7 +8,7 @@ import { isStableVersion } from './version.js' import { copyOwnedPath, createSiblingBackupPath, ownedPathKind, removeOwnedPath } from './fs-transaction.js' import type { SiblingBackupPath } from './fs-transaction.js' import { runTransactionCommands } from './transaction-commands.js' -import { nativePayloadTreeDigest } from './native-payload.js' +import { nativePayloadTreeDigest, sha256Hex } from './native-payload.js' export interface AntigravityTransactionResult { success: boolean @@ -19,8 +18,8 @@ export interface AntigravityTransactionResult { } interface AntigravityBackupSnapshot { - root: { target: string; backup: string; existed: boolean; complete: boolean } - manifest: { target: string; backup: string; existed: boolean; complete: boolean } + root: { target: string; backup: string; existed: boolean; complete: boolean; originalDigest?: string } + manifest: { target: string; backup: string; existed: boolean; complete: boolean; originalDigest?: string } } /** Injectable dependencies for deterministic tests. */ @@ -75,13 +74,15 @@ export async function executeAntigravityTransaction ( let rollbackAttempted = false let preserveBackup = false let originalManifestText: string | undefined + let originalRootDigest: string | undefined + let originalManifestDigest: string | undefined // Exact post-mutation state this transaction is authorized to replace // during rollback. let authorizedRootDigest: string | null | undefined let authorizedManifestDigest: string | null | undefined const backupSnapshot = (): AntigravityBackupSnapshot => ({ - root: { target: pluginRoot, backup: rootBackup, existed: rootExisted, complete: rootBackupComplete }, - manifest: { target: manifestPath, backup: manifestBackup, existed: manifestExisted, complete: manifestBackupComplete }, + root: { target: pluginRoot, backup: rootBackup, existed: rootExisted, complete: rootBackupComplete, originalDigest: originalRootDigest }, + manifest: { target: manifestPath, backup: manifestBackup, existed: manifestExisted, complete: manifestBackupComplete, originalDigest: originalManifestDigest }, }) // Single guarded post-mutation rollback path: a failed restore always // preserves both sibling backup containers for manual recovery. @@ -104,11 +105,15 @@ export async function executeAntigravityTransaction ( try { if (rootExisted) { await copyOwnedPath(pluginRoot, rootBackup) + // Persist the original digests before any mutation; the backup must + // be authenticated against these, never against itself. + originalRootDigest = treeDigest(rootBackup) rootBackupComplete = true } if (manifestExisted) { const originalManifest = await readFile(manifestPath) originalManifestText = originalManifest.toString('utf8') + originalManifestDigest = sha256Hex(originalManifest) await writeFile(manifestBackup, originalManifest, { mode: 0o600 }) manifestBackupComplete = true } @@ -189,8 +194,11 @@ export function validateStagedPlugin (pluginRoot: string, manifestPath: string, if (!existsSync(path.join(pluginRoot, 'skills'))) return false try { const plugin = JSON.parse(readFileSync(path.join(pluginRoot, 'plugin.json'), 'utf8')) as unknown - if (!plugin || typeof plugin !== 'object') return false - const bundle = JSON.parse(readFileSync(path.join(pluginRoot, 'bundle.json'), 'utf8')) as { version?: unknown; skills?: Array<{ name?: unknown; path?: unknown }> } + if (!isPluginIdentity(plugin)) return false + const bundle = JSON.parse(readFileSync(path.join(pluginRoot, 'bundle.json'), 'utf8')) as { name?: unknown; version?: unknown; skills?: Array<{ name?: unknown; path?: unknown }> } + // bundle.json must never claim a foreign identity or disagree on version. + if (bundle.name !== undefined && bundle.name !== 'nsolid-plugin') return false + if (plugin.version !== undefined && plugin.version !== bundle.version) return false if (expectedVersion !== undefined && (!isStableVersion(bundle.version) || bundle.version !== expectedVersion)) return false if (expectedDigest && nativePayloadTreeDigest(pluginRoot) !== expectedDigest) return false if (!Array.isArray(bundle.skills) || bundle.skills.length === 0) return false @@ -203,7 +211,7 @@ export function validateStagedPlugin (pluginRoot: string, manifestPath: string, if (Array.isArray(manifest.imports)) return manifest.imports.some((entry) => isPluginImport(entry)) if (manifest.imports && typeof manifest.imports === 'object') { return Object.entries(manifest.imports as Record).some(([key, value]) => - key.includes('nsolid-plugin') || isPluginImport(value)) + key === 'nsolid-plugin' || isPluginImport(value)) } return false } catch { @@ -211,6 +219,11 @@ export function validateStagedPlugin (pluginRoot: string, manifestPath: string, } } +/** plugin.json must carry the canonical plugin identity, never a lookalike. */ +function isPluginIdentity (value: unknown): value is { name: 'nsolid-plugin'; version?: unknown } { + return !!value && typeof value === 'object' && !Array.isArray(value) && (value as { name?: unknown }).name === 'nsolid-plugin' +} + /** * Byte-level preservation check for the Antigravity import manifest. * @@ -284,18 +297,25 @@ function treeDigest (target: string): string | undefined { return nativePayloadTreeDigest(target) } -function sha256Hex (value: Buffer): string { - return createHash('sha256').update(value).digest('hex') -} - async function restore ( snapshot: AntigravityBackupSnapshot, authorized: { rootDigest?: string | null; manifestDigest?: string | null } ): Promise { try { - const rootOriginalDigest = snapshot.root.existed ? treeDigest(snapshot.root.backup) : null - const manifestOriginalDigest = snapshot.manifest.existed ? sha256Hex(readFileSync(snapshot.manifest.backup)) : null if (!snapshot.root.complete || !snapshot.manifest.complete) return false + // Authenticate the backup bytes against the digests persisted at backup + // time, before any live path is touched; a backup can never pass by + // matching itself. + if (snapshot.root.existed) { + if (snapshot.root.originalDigest === undefined || !existsSync(snapshot.root.backup)) return false + if (treeDigest(snapshot.root.backup) !== snapshot.root.originalDigest) return false + } + if (snapshot.manifest.existed) { + if (snapshot.manifest.originalDigest === undefined || !existsSync(snapshot.manifest.backup)) return false + if (sha256Hex(readFileSync(snapshot.manifest.backup)) !== snapshot.manifest.originalDigest) return false + } + const rootOriginalDigest = snapshot.root.existed ? snapshot.root.originalDigest : null + const manifestOriginalDigest = snapshot.manifest.existed ? snapshot.manifest.originalDigest : null // Only restore while the live bytes are still exactly the state this // transaction produced (or its original state). Concurrent drift is never // overwritten. diff --git a/packages/core/src/update/codex-transaction.ts b/packages/core/src/update/codex-transaction.ts index 7cead51..95d2466 100644 --- a/packages/core/src/update/codex-transaction.ts +++ b/packages/core/src/update/codex-transaction.ts @@ -7,6 +7,7 @@ import { compareVersions, isStableVersion } from './version.js' import { copyOwnedPath, createSiblingBackupPath, ownedPathKind, removeOwnedPath } from './fs-transaction.js' import type { SiblingBackupPath } from './fs-transaction.js' import { nativePayloadDigest } from './native-evidence.js' +import { nativePayloadTreeDigest, sha256Hex } from './native-payload.js' import { runTransactionCommands } from './transaction-commands.js' import { codexUserOwnedFieldsMatch, readCodexPlugin, restoreCodexUserOwnedFields } from './codex-config.js' @@ -18,8 +19,10 @@ export interface CodexTransactionResult { } interface CodexBackupSnapshot { - config: { target: string; backup: string; existed: boolean; complete: boolean } - cache: { target: string; backup: string; existed: boolean; complete: boolean } + config: { target: string; backup: string; existed: boolean; complete: boolean; originalDigest?: string } + cache: { target: string; backup: string; existed: boolean; complete: boolean; originalDigest?: string } + /** Digest-or-missing of the exact post-command live state rollback may replace. */ + authorized: { config: string | null; cache: string | null } } export async function executeCodexTransaction ( @@ -83,12 +86,22 @@ export async function executeCodexTransaction ( let cacheBackupComplete = !cacheExisted let backupsComplete = false let originalConfigText: string | undefined + let configOriginalDigest: string | undefined + let cacheOriginalDigest: string | undefined let mutationStarted = false let rollbackAttempted = false + let rollbackSucceeded: boolean | undefined let preserveBackup = false + // Exact post-command state captured once, before any validation or rollback + // logic; drift after this point is never overwritten. `undefined` means the + // command phase never returned an observable result, in which case + // backupSnapshot falls back to a fresh live read. + let authorizedConfigDigest: string | null | undefined + let authorizedCacheDigest: string | null | undefined const backupSnapshot = (): CodexBackupSnapshot => ({ - config: { target: configPath, backup: backupPath, existed: configExisted, complete: configBackupComplete }, - cache: { target: cachePath, backup: cacheBackup, existed: cacheExisted, complete: cacheBackupComplete }, + config: { target: configPath, backup: backupPath, existed: configExisted, complete: configBackupComplete, originalDigest: configOriginalDigest }, + cache: { target: cachePath, backup: cacheBackup, existed: cacheExisted, complete: cacheBackupComplete, originalDigest: cacheOriginalDigest }, + authorized: { config: authorizedConfigDigest ?? liveConfigDigestAt(configPath), cache: authorizedCacheDigest ?? ownedTreeDigest(cachePath) }, }) try { @@ -99,11 +112,13 @@ export async function executeCodexTransaction ( if (configExisted) { const original = await readFile(configPath) originalConfigText = original.toString('utf8') + configOriginalDigest = sha256Hex(original) await writeFile(backupPath, original, { mode: 0o600 }) configBackupComplete = true } if (cacheExisted) { await copyOwnedPath(cachePath, cacheBackup) + cacheOriginalDigest = ownedTreeDigest(cacheBackup) ?? undefined cacheBackupComplete = true } backupsComplete = configBackupComplete && cacheBackupComplete @@ -117,6 +132,10 @@ export async function executeCodexTransaction ( mutationStarted = true const commandResult = await runTransactionCommands(item.steps, commandRunner) + // Capture the exact post-command state once, before any validation or + // rollback logic runs; only this state may be replaced during rollback. + authorizedConfigDigest = liveConfigDigestAt(configPath) + authorizedCacheDigest = ownedTreeDigest(cachePath) if (!commandResult.success) { const { command, result } = commandResult if (result.timedOut && result.treeTerminated !== true) { @@ -130,8 +149,11 @@ export async function executeCodexTransaction ( }, } } - rollbackAttempted = commandResult.completed.some((completed) => completed.args.includes('remove')) || command.args.includes('remove') - const rollbackSucceeded = rollbackAttempted + // Any command failure after a complete backup leaves a partially + // mutated cache/config; rollback is gated only on backup completeness, + // never on the failed command's arguments. + rollbackAttempted = mutationStarted && backupsComplete + rollbackSucceeded = rollbackAttempted ? await restoreFiles(backupSnapshot()) : undefined return { @@ -148,7 +170,7 @@ export async function executeCodexTransaction ( const refreshedPlugin = pluginId ? readCodexPlugin(configPath, pluginId) : undefined if (pluginId && !refreshedPlugin) { rollbackAttempted = true - const rollbackSucceeded = await restoreFiles(backupSnapshot()) + rollbackSucceeded = await restoreFiles(backupSnapshot()) return { success: false, rollbackAttempted, @@ -172,7 +194,7 @@ export async function executeCodexTransaction ( : readCodexPayloadVersion(cachePath, pluginId) if (cachedVersion !== item.version.latest) { rollbackAttempted = true - const rollbackSucceeded = await restoreFiles(backupSnapshot()) + rollbackSucceeded = await restoreFiles(backupSnapshot()) return { success: false, rollbackAttempted, @@ -184,7 +206,7 @@ export async function executeCodexTransaction ( const digest = selectedPayload ? nativePayloadDigest(selectedPayload) : undefined if (!selectedPayload || !digest || digest !== item.artifact.contentDigest) { rollbackAttempted = true - const rollbackSucceeded = await restoreFiles(backupSnapshot()) + rollbackSucceeded = await restoreFiles(backupSnapshot()) return { success: false, rollbackAttempted, @@ -202,7 +224,7 @@ export async function executeCodexTransaction ( const restoredPlugin = readCodexPlugin(configPath, pluginId) if (!restoredPlugin || (originalPlugin !== undefined && !codexUserOwnedFieldsMatch(restoredPlugin, originalPlugin))) { rollbackAttempted = true - const rollbackSucceeded = await restoreFiles(backupSnapshot()) + rollbackSucceeded = await restoreFiles(backupSnapshot()) return { success: false, rollbackAttempted, @@ -212,7 +234,7 @@ export async function executeCodexTransaction ( } if (!restoredUserFields) { rollbackAttempted = true - const rollbackSucceeded = await restoreFiles(backupSnapshot()) + rollbackSucceeded = await restoreFiles(backupSnapshot()) return { success: false, rollbackAttempted, @@ -225,7 +247,7 @@ export async function executeCodexTransaction ( const validation = item.steps.find((step) => step.kind === 'validation') if (validation && (!existsSync(configPath) || (pluginId !== undefined && !readCodexPlugin(configPath, pluginId)))) { rollbackAttempted = true - const rollbackSucceeded = await restoreFiles(backupSnapshot()) + rollbackSucceeded = await restoreFiles(backupSnapshot()) return { success: false, rollbackAttempted, @@ -236,7 +258,7 @@ export async function executeCodexTransaction ( return { success: true, rollbackAttempted: false } } catch { rollbackAttempted = mutationStarted && backupsComplete - const rollbackSucceeded = rollbackAttempted + rollbackSucceeded = rollbackAttempted ? await restoreFiles(backupSnapshot()) : undefined return { @@ -249,6 +271,8 @@ export async function executeCodexTransaction ( }, } } finally { + // A failed restore keeps both backup containers for manual recovery. + if (rollbackSucceeded === false) preserveBackup = true if (!preserveBackup) { await Promise.all([ removeOwnedPath(configBackupStorage.directory).catch(() => {}), @@ -366,13 +390,27 @@ function isDirectory (filePath: string): boolean { try { return readdirSync(filePath).length >= 0 } catch { return false } } +/** Digest-or-missing for either a payload tree or a plain file cache. */ +function ownedTreeDigest (target: string): string | null { + return nativePayloadTreeDigest(target) ?? (existsSync(target) && !isDirectory(target) ? sha256Hex(readFileSync(target)) : null) +} + async function restoreFiles ( snapshot: CodexBackupSnapshot ): Promise { try { if (snapshot.config.existed && !snapshot.config.complete) return false if (snapshot.cache.existed && !snapshot.cache.complete) return false - if (snapshot.config.complete && snapshot.config.existed) await writeFile(snapshot.config.target, await readFile(snapshot.config.backup), { mode: 0o600 }) + // Authenticate backup bytes against the digests captured before any + // mutation; a missing or tampered backup must never reach the live paths. + const configBackupBytes = snapshot.config.existed ? await readFile(snapshot.config.backup) : undefined + if (snapshot.config.existed && (configBackupBytes === undefined || sha256Hex(configBackupBytes) !== snapshot.config.originalDigest)) return false + if (snapshot.cache.existed && ownedTreeDigest(snapshot.cache.backup) !== snapshot.cache.originalDigest) return false + // Drift gate: only replace live bytes that are exactly the post-command + // state this transaction produced. Concurrent edits are never overwritten. + if (liveConfigDigestAt(snapshot.config.target) !== snapshot.authorized.config) return false + if (ownedTreeDigest(snapshot.cache.target) !== snapshot.authorized.cache) return false + if (configBackupBytes !== undefined) await writeFile(snapshot.config.target, configBackupBytes, { mode: 0o600 }) else if (!snapshot.config.existed) await removeOwnedPath(snapshot.config.target) if (snapshot.cache.complete && snapshot.cache.existed) { await removeOwnedPath(snapshot.cache.target) @@ -380,14 +418,23 @@ async function restoreFiles ( } else if (!snapshot.cache.existed) { await removeOwnedPath(snapshot.cache.target) } - const configRestored = snapshot.config.existed ? snapshot.config.complete && existsSync(snapshot.config.backup) && existsSync(snapshot.config.target) : !existsSync(snapshot.config.target) - const cacheRestored = snapshot.cache.existed ? snapshot.cache.complete && existsSync(snapshot.cache.backup) && existsSync(snapshot.cache.target) : !existsSync(snapshot.cache.target) + // Restored bytes must match the captured originals, not merely exist. + const configRestored = snapshot.config.existed + ? snapshot.config.complete && existsSync(snapshot.config.backup) && existsSync(snapshot.config.target) && sha256Hex(readFileSync(snapshot.config.target)) === snapshot.config.originalDigest + : !existsSync(snapshot.config.target) + const cacheRestored = snapshot.cache.existed + ? snapshot.cache.complete && existsSync(snapshot.cache.backup) && existsSync(snapshot.cache.target) && ownedTreeDigest(snapshot.cache.target) === snapshot.cache.originalDigest + : !existsSync(snapshot.cache.target) return configRestored && cacheRestored } catch { return false } } +function liveConfigDigestAt (target: string): string | null { + return existsSync(target) ? sha256Hex(readFileSync(target)) : null +} + export function resolveCodexPluginCachePath ( configPath: string, pluginId: string, diff --git a/packages/core/src/update/fallback-journal.ts b/packages/core/src/update/fallback-journal.ts index 5b0388e..61054fc 100644 --- a/packages/core/src/update/fallback-journal.ts +++ b/packages/core/src/update/fallback-journal.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from 'node:crypto' -import { cp, lstat, mkdtemp, open, readFile, readlink, readdir, rename, rm, writeFile } from 'node:fs/promises' +import { cp, lstat, mkdtemp, open, readFile, realpath, readlink, readdir, rename, rm, writeFile } from 'node:fs/promises' import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import type { FallbackTransactionIdentity } from './types.js' @@ -50,10 +50,6 @@ export function trackingDigest (trackingPath: string): string | undefined { try { return createHash('sha256').update(readFileSync(trackingPath)).digest('hex') } catch { return undefined } } -export function valueDigest (value: unknown): string { - return createHash('sha256').update(JSON.stringify(stableValue(value)) ?? 'undefined').digest('hex') -} - export function fallbackJournalPath (trackingPath: string): string { return `${path.resolve(trackingPath)}.update-journal.json` } @@ -260,6 +256,7 @@ export async function captureFallbackJournalState (journal: FallbackJournal): Pr export async function commitFallbackJournal (journal: FallbackJournal): Promise { journal = await reloadFallbackJournal(journal) if (!isSafeJournal(journal) || !await journalOwnershipIsValid(journal)) throw new Error('Invalid fallback journal') + if (!await snapshotArtifactsAreSafe(journal)) throw new Error('Invalid fallback journal') await writeDurable(journal.journalPath, { ...journal, phase: 'committed' }) await rm(journal.journalPath, { force: true }) await rm(journal.snapshotDirectory, { recursive: true, force: true }).catch(() => {}) @@ -279,6 +276,17 @@ export async function restoreFallbackJournal (journal: FallbackJournal): Promise for (const entry of journal.entries) { if (!await entryStateIsAuthorized(entry)) return false } + // Preflight before the first destructive byte moves: the snapshot must be + // provably real, and every backup that will be restored must still hold + // the journaled original. A tampered or rotted backup aborts with the + // live paths untouched and the artifacts preserved. + if (!await snapshotArtifactsAreSafe(journal)) return false + for (const entry of journal.entries) { + if (!entry.existed) continue + if (!entry.backup) return false + if (await pathKind(entry.backup) !== entry.kind) return false + if (await pathDigest(entry.backup) !== entry.digest) return false + } for (const entry of journal.entries) { const kind = await pathKind(entry.path) if (kind !== 'missing') await rm(entry.path, { recursive: true, force: true }) @@ -293,6 +301,7 @@ export async function restoreFallbackJournal (journal: FallbackJournal): Promise return await pathDigest(entry.path) === entry.digest })).then((values) => values.every(Boolean)) if (valid) { + if (!await snapshotArtifactsAreSafe(journal)) return false await rm(journal.journalPath, { force: true }) await rm(journal.snapshotDirectory, { recursive: true, force: true }).catch(() => {}) await removeEntryArtifacts(journal.entries) @@ -345,6 +354,9 @@ export async function recoverFallbackJournal (trackingPath: string, mutate: bool if (!isSafeJournal(journal) || journal.journalPath !== journalPath || !await journalOwnershipIsValid(journal)) return { pending: true, recovered: false } if (!mutate) return { pending: true, recovered: false } if (journal.phase === 'committed') { + // The cleanup rm is destructive: gate it on filesystem reality so a + // forged snapshot location can never widen the deletion. + if (!await snapshotArtifactsAreSafe(journal)) return { pending: true, recovered: false } await rm(journal.journalPath, { force: true }).catch(() => {}) await rm(journal.snapshotDirectory, { recursive: true, force: true }).catch(() => {}) await removeEntryArtifacts(journal.entries) @@ -365,6 +377,34 @@ async function removeEntryArtifacts (entries: readonly FallbackJournalEntry[]): } } +/** + * Filesystem-aware gate run immediately before every destructive use of the + * snapshot (the commit, restore, and committed-phase recovery cleanups): the + * journal threat model is a forged file in a user-writable location, and + * lexical containment cannot see symlinked path components. Fail closed + * whenever reality cannot be proven; artifacts then survive untouched. + */ +async function snapshotArtifactsAreSafe (journal: FallbackJournal): Promise { + const snapshot = path.resolve(journal.snapshotDirectory) + try { + const stat = await lstat(snapshot) + if (!stat.isDirectory()) return false + const realParent = await realpath(path.dirname(path.resolve(journal.manifest.trackingPath))) + const realSnapshot = await realpath(snapshot) + if (realSnapshot === realParent || !realSnapshot.startsWith(realParent + path.sep)) return false + for (const entry of journal.entries) { + if (entry.backup === undefined) continue + // A verbatim-copied symlink backup legitimately resolves to a target + // outside the snapshot, so only its containing directory is required + // to be the real snapshot itself. + if (await realpath(path.dirname(entry.backup)) !== realSnapshot) return false + } + return true + } catch { + return false + } +} + async function writeDurable (filePath: string, value: unknown): Promise { const temporary = `${filePath}.${process.pid}.tmp` await writeFile(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }) @@ -434,7 +474,14 @@ function isSafeJournal (journal: FallbackJournal): boolean { if (journal.mutator !== undefined && (!Number.isSafeInteger(journal.mutator.pid) || journal.mutator.pid <= 0 || typeof journal.mutator.nonce !== 'string' || typeof journal.mutator.claimedAt !== 'string' || !Number.isFinite(Date.parse(journal.mutator.claimedAt)))) return false const trackingPath = path.resolve(journal.manifest.trackingPath) if (journal.journalPath !== fallbackJournalPath(trackingPath)) return false - if (!isSameOrContained(path.resolve(journal.snapshotDirectory), path.dirname(trackingPath))) return false + // The snapshot must be exactly what beginFallbackJournal creates: a strict + // direct child of the tracking directory carrying the mkdtemp suffix shape. + // Lexical containment alone would let a forged journal point the snapshot + // at the tracking directory itself (equality passes) and the cleanup rm + // would delete user state. + const snapshot = path.resolve(journal.snapshotDirectory) + if (path.dirname(snapshot) !== path.dirname(trackingPath)) return false + if (!/^\.nsolid-plugin-update-[A-Za-z0-9_]{6}$/.test(path.basename(snapshot))) return false if (!journal.manifest.installationId || journal.manifest.installationId !== `${journal.manifest.harness}:fallback`) return false const expectedPaths = new Set([ trackingPath, @@ -468,7 +515,12 @@ function isSafeJournal (journal: FallbackJournal): boolean { } flexibleEntries.add(target) } - if (entry.backup !== undefined && (!isSameOrContained(path.resolve(entry.backup), path.resolve(journal.snapshotDirectory)) || !isCanonicalPath(path.resolve(entry.backup)))) return false + if (entry.backup !== undefined) { + const backup = path.resolve(entry.backup) + // A backup must live strictly inside the snapshot; equality would let a + // forged entry alias the snapshot container itself. + if (backup === snapshot || !isSameOrContained(backup, snapshot) || !isCanonicalPath(backup)) return false + } if (entry.stage !== undefined) { const stageDir = path.resolve(path.dirname(entry.stage)) if (!isSameOrContained(stageDir, path.dirname(target)) || stageDir === path.dirname(target) || !isCanonicalPath(stageDir)) return false @@ -528,11 +580,3 @@ async function manifestMatchesTrackingFile (manifest: FallbackTransactionIdentit return false } } - -function stableValue (value: unknown): unknown { - if (Array.isArray(value)) return value.map(stableValue) - if (value && typeof value === 'object') { - return Object.fromEntries(Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, stableValue(child)])) - } - return value -} diff --git a/packages/core/src/update/fallback-ownership.ts b/packages/core/src/update/fallback-ownership.ts index 3294b0d..97d78cf 100644 --- a/packages/core/src/update/fallback-ownership.ts +++ b/packages/core/src/update/fallback-ownership.ts @@ -1,5 +1,6 @@ import path from 'node:path' import { getAdapter } from '../harnesses/index.js' +import { readMcpFieldDigests, type PreferredMcpKey } from './mcp-lookup.js' import { getHarnessSkillsPath } from '../skills/skill-linker.js' import type { TrackingData } from '../skills/skill-tracker.js' import type { FallbackTransactionIdentity } from './types.js' @@ -55,3 +56,22 @@ export function isSameOrContained (candidate: string, parent: string): boolean { if (path.isAbsolute(relative)) return false return relative !== '..' && !relative.startsWith(`..${path.sep}`) } + +/** + * Exclusive-ownership gate for MCP server records (one of two MCP ownership + * invariants; the postcondition check in strategies/fallback.ts is the other, + * and is a subset match by design). A record may be REMOVED only when the + * live per-field digests match the owned evidence exactly — same field set, + * same digests — because a foreign field inside the record means the user + * co-owns it. Routes through the field-digests module so the ownership rule + * and the tracking evidence can never drift apart. + */ +export function mcpRecordIsExclusivelyOwned (configPath: string, name: string, ownedFields: Record | undefined, preferredKey: PreferredMcpKey): boolean { + if (!ownedFields || Object.keys(ownedFields).length === 0) return false + const current = readMcpFieldDigests(configPath, name, { preferredKey }) + if (!current) return false + const ownedNames = Object.keys(ownedFields).sort() + const currentNames = Object.keys(current).sort() + return ownedNames.length === currentNames.length && ownedNames.every((field, index) => + field === currentNames[index] && ownedFields[field] === current[field]) +} diff --git a/packages/core/src/update/fallback-transaction.ts b/packages/core/src/update/fallback-transaction.ts index 2b4ffe2..b15f55a 100644 --- a/packages/core/src/update/fallback-transaction.ts +++ b/packages/core/src/update/fallback-transaction.ts @@ -15,14 +15,14 @@ import { getHarnessSkillsPath, linkSkillsToHarness, materializeSkillLink, unlink import { assertSafeSkillName } from '../utils/skill-name.js' import { getAdapter } from '../harnesses/index.js' import type { FallbackTransactionIdentity, UpdateError } from './types.js' -import { appendFallbackJournalEntries, applyFallbackEntry, claimFallbackJournalMutation, fallbackJournalPath, registerFallbackStage, trackingDigest, valueDigest, pathDigest, pathKind, type FallbackJournal } from './fallback-journal.js' +import { appendFallbackJournalEntries, applyFallbackEntry, claimFallbackJournalMutation, fallbackJournalPath, registerFallbackStage, trackingDigest, pathDigest, pathKind, type FallbackJournal } from './fallback-journal.js' import { planMcpReconciliation, type McpConfigPlanEntry } from './mcp-reconciliation.js' import { detectJsonMcpKey, editMcpJsonBytes, McpEditError } from './mcp-edit.js' import { editMcpTomlBytes, McpTomlEditError } from './mcp-toml-edit.js' -import { harnessMcpKey, mcpFieldDigestsFromBytes, readMcpFieldDigests, readMcpServerField, readMcpServerRecord } from './mcp-lookup.js' +import { harnessMcpKey, mcpFieldDigestsFromBytes, readMcpFieldDigests, readMcpServerField, readMcpServerRecord, valueDigest } from './mcp-lookup.js' import { readPackageVersion } from './package-manager.js' import { isStableVersion } from './version.js' -import { isCanonicalPath, matchesTrackedOwnership } from './fallback-ownership.js' +import { isCanonicalPath, matchesTrackedOwnership, mcpRecordIsExclusivelyOwned } from './fallback-ownership.js' export interface FallbackRefreshOptions { harness: HarnessType @@ -179,13 +179,19 @@ export async function refreshOwnedInstallation (options: FallbackRefreshOptions) } // Plan every MCP change grouped by owning file before anything is - // staged or written. + // staged or written. The desired harness-formatted values are shared + // with the tracking update: their keys are exactly the fields + // NodeSource renders, so tracking evidence can be filtered to owned + // keys instead of every field that survived in the config bytes. const configuredMcpServers = canReconcileMcp ? bundle.mcpServers : [] + const desiredMcpValues = canReconcileMcp && credentials + ? Object.fromEntries(bundle.mcpServers.map((server) => [server.name, harnessServerValue(options.harness, server, credentials)])) + : {} const plan = canReconcileMcp && credentials ? planMcpReconciliation({ previousServers: previousMcps.map((entry) => ({ name: entry.name, configPath: path.resolve(entry.configPath), fields: entry.fields })), desiredServers: bundle.mcpServers, - desiredValues: Object.fromEntries(bundle.mcpServers.map((server) => [server.name, harnessServerValue(options.harness, server, credentials)])), + desiredValues: desiredMcpValues, canonicalConfigPath: canonicalConfigPath ?? undefined, }) : { kind: 'planned' as const, entries: [] as McpConfigPlanEntry[], destinations: {} } @@ -298,7 +304,7 @@ export async function refreshOwnedInstallation (options: FallbackRefreshOptions) if (staged !== undefined) return mcpFieldDigestsFromBytes(configPath, staged, name, { preferredKey }) return readMcpFieldDigests(configPath, name, { preferredKey }) } - const updatedTracking = buildTrackingUpdate(tracking, options.harness, destination, bundle, plan, configuredMcpServers, staleByName, resolveFieldDigests) + const updatedTracking = buildTrackingUpdate(tracking, options.harness, destination, bundle, plan, configuredMcpServers, staleByName, desiredMcpValues, resolveFieldDigests) journal = await registerFallbackStage(journal, options.transaction!.trackingPath, { bytes: Buffer.from(JSON.stringify(updatedTracking, null, 2) + '\n', 'utf8') }) } } @@ -382,7 +388,7 @@ export async function refreshOwnedInstallation (options: FallbackRefreshOptions) // swap installs exactly those. journal = await applyFallbackEntry(journal, options.transaction!.trackingPath) } else { - const updatedTracking = buildTrackingUpdate(tracking, options.harness, destination, bundle, plan, configuredMcpServers, staleByName) + const updatedTracking = buildTrackingUpdate(tracking, options.harness, destination, bundle, plan, configuredMcpServers, staleByName, desiredMcpValues) await writeTrackingFile(updatedTracking) } return { success: true } @@ -646,6 +652,7 @@ function buildTrackingUpdate ( plan: { destinations: Readonly> }, mcpServers: BundleDescriptor['mcpServers'], staleByName: Map, + desiredValues: Readonly>>, resolveFieldDigests: (configPath: string, name: string) => Record | undefined = (configPath, name) => readMcpFieldDigests(configPath, name, { preferredKey: harnessMcpKey(harness) }) ): TrackingData { const tracking = JSON.parse(JSON.stringify(original)) as TrackingData @@ -691,13 +698,23 @@ function buildTrackingUpdate ( for (const server of mcpServers) { const configPath = plan.destinations[server.name] if (!configPath) continue + // Tracking evidence describes ONLY the fields NodeSource renders: the + // keys of the desired harness-formatted value. Foreign fields that merely + // survived in the config bytes must never enter tracking, or the next + // refresh would treat them as owned and delete them (reconciliation + // removes tracked fields absent from the desired render). + const digests = resolveFieldDigests(configPath, server.name) + const ownedNames = Object.keys(desiredValues[server.name] ?? {}) + const fields = digests === undefined + ? undefined + : Object.fromEntries(ownedNames.filter((name) => Object.hasOwn(digests, name)).map((name) => [name, digests[name]])) const existing = tracking.mcpServers.find((entry) => entry.harness === harness && entry.name === server.name) if (existing) { existing.configPath = path.resolve(configPath) existing.configuredAt = now - existing.fields = resolveFieldDigests(configPath, server.name) + existing.fields = fields } else { - tracking.mcpServers.push({ name: server.name, configPath: path.resolve(configPath), harness, configuredAt: now, fields: resolveFieldDigests(configPath, server.name) }) + tracking.mcpServers.push({ name: server.name, configPath: path.resolve(configPath), harness, configuredAt: now, fields }) } } tracking.bundleVersion = bundle.version @@ -705,16 +722,6 @@ function buildTrackingUpdate ( return tracking } -function mcpRecordIsExclusivelyOwned (configPath: string, name: string, ownedFields: Record | undefined, preferredKey: 'mcp' | 'mcpServers'): boolean { - if (!ownedFields || Object.keys(ownedFields).length === 0) return false - const current = readMcpFieldDigests(configPath, name, { preferredKey }) - if (!current) return false - const ownedNames = Object.keys(ownedFields).sort() - const currentNames = Object.keys(current).sort() - return ownedNames.length === currentNames.length && ownedNames.every((field, index) => - field === currentNames[index] && ownedFields[field] === current[field]) -} - function failure (code: string, message: string, rollback?: { attempted: boolean; succeeded: boolean }): FallbackRefreshResult { return { success: false, rollbackAttempted: rollback?.attempted, rollbackSucceeded: rollback?.succeeded, error: { code, message } } } diff --git a/packages/core/src/update/index.ts b/packages/core/src/update/index.ts index e05a6fb..591ccd6 100644 --- a/packages/core/src/update/index.ts +++ b/packages/core/src/update/index.ts @@ -1,7 +1,8 @@ export { checkUpdates, executeUpdatePlan, planUpdates, summarizePlan, summarizeResults, update } from './coordinator.js' export { createCommandRunner, findExecutable, isCommandSuccessful, runCommand, sanitizeOutput } from './command-runner.js' export { detectAntigravityLayout, detectInstallations, detectCliInstallation } from './inventory.js' -export { commitFallbackJournal, fallbackJournalPath, recoverFallbackJournal, trackingDigest, valueDigest } from './fallback-journal.js' +export { commitFallbackJournal, fallbackJournalPath, recoverFallbackJournal, trackingDigest } from './fallback-journal.js' +export { valueDigest } from './mcp-lookup.js' export { refreshOwnedInstallation } from './fallback-transaction.js' export type { FallbackJournal, FallbackJournalPhase } from './fallback-journal.js' export type { FallbackRefreshOptions, FallbackRefreshResult } from './fallback-transaction.js' diff --git a/packages/core/src/update/mcp-lookup.ts b/packages/core/src/update/mcp-lookup.ts index c48e49b..2763931 100644 --- a/packages/core/src/update/mcp-lookup.ts +++ b/packages/core/src/update/mcp-lookup.ts @@ -1,7 +1,7 @@ +import { createHash } from 'node:crypto' import { parseJsonc, readJsoncFile, readTomlFile } from '../utils/config.js' import { parse as parseToml } from 'smol-toml' import type { HarnessType } from '../types.js' -import { valueDigest } from './fallback-journal.js' export type PreferredMcpKey = 'mcp' | 'mcpServers' @@ -101,3 +101,21 @@ export function mcpFieldDigestsFromBytes (configPath: string, bytes: Buffer | st return Object.fromEntries(Object.entries(record as Record).map(([field, value]) => [field, valueDigest(value)])) } catch { return undefined } } + +/** + * Single source of truth for the per-field digest evidence stored in tracking + * entries and verified by the ownership gate. Field digests are computed from + * a stable serialization so nested objects compare equal regardless of key + * order, exactly as `JSON.stringify` produced them when planning. + */ +export function valueDigest (value: unknown): string { + return createHash('sha256').update(JSON.stringify(stableValue(value)) ?? 'undefined').digest('hex') +} + +function stableValue (value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue) + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, stableValue(child)])) + } + return value +} diff --git a/packages/core/src/update/native-payload.ts b/packages/core/src/update/native-payload.ts index 77cfeb8..2e12ca8 100644 --- a/packages/core/src/update/native-payload.ts +++ b/packages/core/src/update/native-payload.ts @@ -29,6 +29,11 @@ export function normalizedPayloadPath (value: string | undefined): string { return normalized } +/** Hex sha256 of a byte buffer, shared by the native transactions. */ +export function sha256Hex (value: Buffer): string { + return createHash('sha256').update(value).digest('hex') +} + export function nativePayloadTreeDigest (root: string): string | undefined { try { const resolvedRoot = path.resolve(root) diff --git a/packages/core/src/update/strategies/fallback.ts b/packages/core/src/update/strategies/fallback.ts index b7e7e3d..7490e6a 100644 --- a/packages/core/src/update/strategies/fallback.ts +++ b/packages/core/src/update/strategies/fallback.ts @@ -10,7 +10,7 @@ import { failedResult, isMutableVersion, noMutationStatus, planItem, resultFromP import { getTrackingFilePath, getSkillsDir, resolveHome } from '../../utils/path.js' import { getAdapter } from '../../harnesses/index.js' import { getHarnessSkillsPath } from '../../skills/skill-linker.js' -import { beginFallbackJournal, captureFallbackJournalState, commitFallbackJournal, markFallbackJournalMutating, recoverFallbackJournal, reloadFallbackJournal, restoreFallbackJournal, trackingDigest, type FallbackJournal } from '../fallback-journal.js' +import { beginFallbackJournal, captureFallbackJournalState, commitFallbackJournal, markFallbackJournalMutating, pathDigest, pathKind, recoverFallbackJournal, reloadFallbackJournal, restoreFallbackJournal, trackingDigest, type FallbackJournal } from '../fallback-journal.js' import { cleanupNpmArtifact } from '../version-source.js' import { managerArgsForIdentity, verifyLocalArtifact } from '../package-manager.js' import { readTrackingFile } from '../../skills/skill-tracker.js' @@ -175,6 +175,15 @@ export const fallbackStrategy: UpdateStrategy = { preserveRecoveryArtifacts = true return failedResult(item, { code: 'FALLBACK_STATE_UNPROVEN', message: 'Fallback child completed but the resulting owned state could not be captured safely' }, { attempted: false }) } + // The child's journal is trusted only when its live state independently + // proves what it claims: applied stages match their registered digest, + // deletions are gone, and untouched entries are byte-identical to the + // journaled original. Comparing against expectedCurrentDigest is + // meaningless here — capture overwrote it with the current live state. + if (!await journalProvesAppliedState(journal)) { + const recovered = await restoreFallbackJournal(journal) + return failedResult(item, { code: recovered ? 'FALLBACK_VALIDATION_FAILED' : 'FALLBACK_ROLLBACK_FAILED', message: recovered ? 'Fallback child completed without proving the planned owned-state mutation' : 'Fallback validation failed and parent recovery was incomplete' }, { attempted: true, succeeded: recovered }) + } const tracking = await readTrackingFile() const bundleEvidence = tracking?.bundleVersions?.[item.target as keyof typeof tracking.bundleVersions] ?? tracking?.bundleVersion if (!tracking || bundleEvidence !== item.version.latest || !validateFallbackPostconditions(tracking, item.target)) { @@ -253,7 +262,9 @@ function detectExecutor (): 'npm-exec' | 'pnpm-dlx' | undefined { return undefined } -function validateFallbackPostconditions (tracking: Awaited>, harness: UpdatePlanItem['target']): boolean { +type TrackingDataOrNull = Awaited> + +function validateFallbackPostconditions (tracking: TrackingDataOrNull, harness: UpdatePlanItem['target']): boolean { if (!tracking || harness === 'cli') return false const scopedSkills = tracking.skills.filter((entry) => entry.harnesses.includes(harness)) if (scopedSkills.some((entry) => { @@ -261,5 +272,47 @@ function validateFallbackPostconditions (tracking: Awaited entry.harness === harness) - return scopedMcp.every((entry) => path.isAbsolute(entry.configPath) && existsSync(entry.configPath)) + if (!scopedMcp.every((entry) => path.isAbsolute(entry.configPath) && existsSync(entry.configPath))) return false + // Postcondition: tracked-ownership evidence consistency — a SUBSET check by + // design. Every tracked field must still match the live configuration + // re-read through the field-digests module (a lying tracking file, a drift + // in an owned field, or a write into the wrong container fails the gate), + // but preserved user fields in the live record are tolerated here. This is + // deliberately NOT the exclusive-ownership gate in fallback-ownership.ts, + // which requires an exact field set before removal decisions. + const preferredKey = harnessMcpKey(harness as HarnessType) + return scopedMcp.every((entry) => { + if (!entry.fields || Object.keys(entry.fields).length === 0) return false + const live = readMcpFieldDigests(entry.configPath, entry.name, { preferredKey }) + if (!live) return false + return Object.entries(entry.fields).every(([name, expectedDigest]) => live[name] === expectedDigest) + }) +} + +/** + * Independently prove the child's claimed mutation from the journal and the + * live filesystem: applied staged entries must carry exactly the registered + * stage digest, applied deletions must be gone, and entries the child never + * staged must still be byte-identical to the journaled original. + */ +async function journalProvesAppliedState (journal: FallbackJournal): Promise { + for (const entry of journal.entries) { + const target = path.resolve(entry.path) + if (entry.stageDigest !== undefined) { + if (entry.applied !== true) return false + if (await pathDigest(target) !== entry.stageDigest) return false + continue + } + if (entry.applied === true) { + if (await pathKind(target) !== 'missing') return false + continue + } + const kind = await pathKind(target) + if (entry.existed === true) { + if (kind === 'missing' || entry.digest === undefined || await pathDigest(target) !== entry.digest) return false + } else if (kind !== 'missing') { + return false + } + } + return true } diff --git a/packages/core/test/integration/installer.test.ts b/packages/core/test/integration/installer.test.ts index 653bf7e..10a7eb3 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -685,6 +685,54 @@ describe('install()', () => { assert.strictEqual(servers['nsolid-console'].url, 'https://custom-mcp.example.com/entry') }) + it('re-running install does not swallow user-added MCP fields into ownership', async () => { + const { install } = await import('../../src/index.js') + const { readJsonFile } = await import('../../src/utils/config.js') + const bundle = createBundle({ + mcpServers: [ + { name: 'nsolid-console', url: '$' + '{MCP_URL}', headers: { 'X-Nsolid-Service-Token': '$' + '{AUTH_TOKEN}' } }, + ], + auth: { + type: 'oauth', + provider: 'nodesource', + accountsUrl: 'https://accounts.nodesource.com', + }, + }) + const bundlePath = writeBundle(bundle) + const skillsSource = createSkillSource('ns-test-skill') + seedCredentials({ + consoleUrl: 'https://test-org.saas.nodesource.io', + mcpUrl: 'https://custom-mcp.example.com/entry', + }) + + const first = await install({ harness: 'claude', bundlePath, skillsSource }) + assert.strictEqual(first.success, true) + + // The user adds a field to OUR server record between installs. + const claudeConfigPath = join(tmpDir, '.claude.json') + const claudeConfig = readJsonFile<{ mcpServers: Record> }>(claudeConfigPath)! + claudeConfig.mcpServers['nsolid-console'].user_token = 'user-secret' + writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2)) + + const second = await install({ harness: 'claude', bundlePath, skillsSource }) + assert.strictEqual(second.success, true) + + // The user field survives the reinstall merge... + const afterSecond = readJsonFile<{ mcpServers: Record> }>(claudeConfigPath)! + assert.strictEqual(afterSecond.mcpServers['nsolid-console'].user_token, 'user-secret') + assert.strictEqual(afterSecond.mcpServers['nsolid-console'].url, 'https://custom-mcp.example.com/entry') + + // ...and tracking records only the rendered owned fields, so a later + // refresh can never treat user_token as owned and delete it. + const tracking = readJsonFile<{ mcpServers: Array<{ name: string; fields?: Record }> }>( + join(tmpDir, '.agents', '.nodesource-installed.json') + )! + const tracked = tracking.mcpServers.find((entry) => entry.name === 'nsolid-console') + assert.ok(tracked, 'server tracked') + assert.ok(!Object.hasOwn(tracked.fields ?? {}, 'user_token'), 'user_token must not be tracked as owned') + assert.ok(Object.hasOwn(tracked.fields ?? {}, 'url'), 'the rendered owned fields stay tracked') + }) + it('derives console MCP URL without appending /mcp when no explicit MCP URL is stored', async () => { const { install } = await import('../../src/index.js') const { readJsonFile } = await import('../../src/utils/config.js') diff --git a/packages/core/test/unit/mcp/mcp-tracker.test.ts b/packages/core/test/unit/mcp/mcp-tracker.test.ts index 3f5779f..db952a0 100644 --- a/packages/core/test/unit/mcp/mcp-tracker.test.ts +++ b/packages/core/test/unit/mcp/mcp-tracker.test.ts @@ -99,6 +99,49 @@ describe('addTrackedMcps', () => { }) }) +describe('addTrackedMcps ownedFields', () => { + it('excludes fields outside the owned set from the digest snapshot', async () => { + const { addTrackedMcps } = await import('../../../src/mcp/mcp-tracker.js') + const { readTrackingFile } = await import('../../../src/skills/skill-tracker.js') + const { valueDigest } = await import('../../../src/update/mcp-lookup.js') + + const configPath = join(tmpDir, '.claude.json') + // The user added user_token to our server record: it survives in the + // bytes but must never become tracking evidence. + writeFileSync(configPath, JSON.stringify({ + mcpServers: { + 'ns-benchmark': { name: 'ns-benchmark', type: 'http', url: 'https://example.com/mcp', user_token: 'user-secret' }, + }, + })) + + await addTrackedMcps([{ name: 'ns-benchmark', configPath, ownedFields: ['name', 'type', 'url'] }], 'claude') + + const tracking = await readTrackingFile() + const entry = tracking!.mcpServers[0] + assert.deepEqual(Object.keys(entry.fields ?? {}).sort(), ['name', 'type', 'url']) + assert.equal(entry.fields?.url, valueDigest('https://example.com/mcp')) + assert.equal(entry.fields?.user_token, undefined) + }) + + it('does not record owned fields that are absent from the record', async () => { + const { addTrackedMcps } = await import('../../../src/mcp/mcp-tracker.js') + const { readTrackingFile } = await import('../../../src/skills/skill-tracker.js') + + const configPath = join(tmpDir, '.claude.json') + writeFileSync(configPath, JSON.stringify({ + mcpServers: { + 'ns-benchmark': { url: 'https://example.com/mcp' }, + }, + })) + + await addTrackedMcps([{ name: 'ns-benchmark', configPath, ownedFields: ['name', 'type', 'url'] }], 'claude') + + const tracking = await readTrackingFile() + const entry = tracking!.mcpServers[0] + assert.deepEqual(Object.keys(entry.fields ?? {}).sort(), ['url']) + }) +}) + describe('removeTrackedMcps', () => { it('removes MCP entry for specific harness', async () => { const { addTrackedMcps, removeTrackedMcps, listTrackedMcps } = await import('../../../src/mcp/mcp-tracker.js') @@ -193,3 +236,76 @@ describe('listTrackedMcps', () => { assert.strictEqual(codexEntries[0].name, 'nsolid-console') }) }) + +describe('addTrackedMcps field digests', () => { + it('routes field digests through the field-digests module using the harness-preferred container', async () => { + const { addTrackedMcps } = await import('../../../src/mcp/mcp-tracker.js') + const { readTrackingFile } = await import('../../../src/skills/skill-tracker.js') + const { harnessMcpKey, readMcpFieldDigests, valueDigest } = await import('../../../src/update/mcp-lookup.js') + + // A Claude stdio server lives under "mcpServers"; an OpenCode remote + // server lives under "mcp". Tracking must describe the same container + // the transaction reads and writes for the harness. + const configPath = join(tmpDir, '.claude.json') + writeFileSync(configPath, JSON.stringify({ + mcpServers: { + 'ns-benchmark': { type: 'http', url: 'https://example.com/mcp', headers: { 'x-a': '1', 'x-b': '2' } }, + }, + })) + + await addTrackedMcps([{ name: 'ns-benchmark', configPath }], 'claude') + + const tracking = await readTrackingFile() + const entry = tracking!.mcpServers[0] + const expected = readMcpFieldDigests(configPath, 'ns-benchmark', { preferredKey: harnessMcpKey('claude') }) + assert.deepStrictEqual(entry.fields, expected) + assert.strictEqual(entry.fields?.url, valueDigest('https://example.com/mcp')) + assert.strictEqual(Object.hasOwn(entry.fields ?? {}, 'headers'), true) + }) + + it('reads the opencode "mcp" container, not a legacy "mcpServers" sibling', async () => { + const { addTrackedMcps } = await import('../../../src/mcp/mcp-tracker.js') + const { readTrackingFile } = await import('../../../src/skills/skill-tracker.js') + const { valueDigest } = await import('../../../src/update/mcp-lookup.js') + + const configPath = join(tmpDir, 'opencode.jsonc') + writeFileSync(configPath, JSON.stringify({ + mcpServers: { 'ns-benchmark': { url: 'https://legacy.example/mcp' } }, + mcp: { 'ns-benchmark': { type: 'remote', url: 'https://current.example/mcp' } }, + })) + + await addTrackedMcps([{ name: 'ns-benchmark', configPath }], 'opencode') + + const tracking = await readTrackingFile() + const entry = tracking!.mcpServers[0] + assert.strictEqual(entry.fields?.url, valueDigest('https://current.example/mcp')) + }) + + it('stores no fields for a server missing from the config', async () => { + const { addTrackedMcps } = await import('../../../src/mcp/mcp-tracker.js') + const { readTrackingFile } = await import('../../../src/skills/skill-tracker.js') + + const configPath = join(tmpDir, '.claude.json') + writeFileSync(configPath, JSON.stringify({ mcpServers: {} })) + + await addTrackedMcps([{ name: 'ns-benchmark', configPath }], 'claude') + + const tracking = await readTrackingFile() + assert.strictEqual(tracking!.mcpServers[0].fields, undefined) + }) + + it('supports toml configs via the field-digests module', async () => { + const { addTrackedMcps } = await import('../../../src/mcp/mcp-tracker.js') + const { readTrackingFile } = await import('../../../src/skills/skill-tracker.js') + const { valueDigest } = await import('../../../src/update/mcp-lookup.js') + + const configPath = join(tmpDir, 'config.toml') + writeFileSync(configPath, '[mcp_servers.ns-benchmark]\nurl = "https://codex.example/mcp"\n') + + await addTrackedMcps([{ name: 'ns-benchmark', configPath }], 'codex') + + const tracking = await readTrackingFile() + const entry = tracking!.mcpServers[0] + assert.strictEqual(entry.fields?.url, valueDigest('https://codex.example/mcp')) + }) +}) diff --git a/packages/core/test/unit/update/antigravity-transaction.test.ts b/packages/core/test/unit/update/antigravity-transaction.test.ts index f281441..b3bfbdd 100644 --- a/packages/core/test/unit/update/antigravity-transaction.test.ts +++ b/packages/core/test/unit/update/antigravity-transaction.test.ts @@ -47,6 +47,39 @@ describe('Antigravity staged plugin validation', () => { } }) + it('rejects identity-less plugin.json, foreign identities, and substring import keys', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-agy-identity-')) + try { + mkdirSync(path.join(root, 'skills', 'example'), { recursive: true }) + const pluginJson = path.join(root, 'plugin.json') + const bundleJson = path.join(root, 'bundle.json') + writeFileSync(bundleJson, JSON.stringify({ version: '1.0.1', skills: [{ name: 'example', path: 'skills/example' }] })) + writeFileSync(path.join(root, 'skills', 'example', 'SKILL.md'), '# example') + const manifest = path.join(root, 'import_manifest.json') + writeFileSync(manifest, JSON.stringify({ imports: { 'nsolid-plugin': { name: 'nsolid-plugin' } } })) + + // plugin.json without the canonical identity. + writeFileSync(pluginJson, JSON.stringify({ description: 'no name' })) + assert.equal(validateStagedPlugin(root, manifest), false) + // A lookalike name. + writeFileSync(pluginJson, JSON.stringify({ name: 'not-nsolid-plugin' })) + assert.equal(validateStagedPlugin(root, manifest), false) + // A version that disagrees with bundle.json. + writeFileSync(pluginJson, JSON.stringify({ name: 'nsolid-plugin', version: '0.0.1' })) + assert.equal(validateStagedPlugin(root, manifest), false) + // bundle.json claiming a foreign identity. + writeFileSync(pluginJson, JSON.stringify({ name: 'nsolid-plugin' })) + writeFileSync(bundleJson, JSON.stringify({ name: 'other-plugin', version: '1.0.1', skills: [{ name: 'example', path: 'skills/example' }] })) + assert.equal(validateStagedPlugin(root, manifest), false) + writeFileSync(bundleJson, JSON.stringify({ version: '1.0.1', skills: [{ name: 'example', path: 'skills/example' }] })) + // A substring import key whose value is not a plugin import. + writeFileSync(manifest, JSON.stringify({ imports: { 'my-nsolid-plugin-helper': { path: '/keep' } } })) + assert.equal(validateStagedPlugin(root, manifest), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('requires unrelated manifest imports to survive plugin replacement byte-for-byte', () => { // Array form: an in-place splice of only the owned entry keeps every // outside byte; dropping the sibling import removes foreign bytes. @@ -201,6 +234,119 @@ describe('Antigravity staged plugin validation', () => { restoreHome(fixture) } }) + + function failingSyncItem (): UpdatePlanItem { + return { + ...agyItem(), + steps: [{ kind: 'command' as const, description: 'agy sync', command: { executable: 'agy', args: ['sync'], timeoutMs: 1_000 } }], + } + } + + function findBackupPath (fixture: { home: string; pluginRoot: string; manifestPath: string }): { rootBackup: string; manifestBackup: string } { + const configDir = path.join(fixture.home, '.gemini', 'config') + const containers = backupContainers(configDir) + assert.equal(containers.root.length, 1) + assert.equal(containers.manifest.length, 1) + return { + rootBackup: path.join(configDir, 'plugins', containers.root[0], path.basename(fixture.pluginRoot)), + manifestBackup: path.join(configDir, containers.manifest[0], path.basename(fixture.manifestPath)), + } + } + + it('refuses to restore a tampered root backup and preserves both backups', async () => { + const fixture = setupInstalledFixture() + try { + const result = await executeAntigravityTransaction(failingSyncItem(), { + run: async () => { + rmSync(path.join(fixture.pluginRoot, 'plugin.json')) + const { rootBackup } = findBackupPath(fixture) + writeFileSync(path.join(rootBackup, 'plugin.json'), '{"name":"tampered"}') + return { exitCode: 1, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, false) + // The live state was never replaced: the post-command damage remains. + assert.equal(existsSync(path.join(fixture.pluginRoot, 'plugin.json')), false) + const containers = backupContainers(path.join(fixture.home, '.gemini', 'config')) + assert.equal(containers.root.length, 1) + assert.equal(containers.manifest.length, 1) + } finally { + restoreHome(fixture) + } + }) + + it('refuses to restore a tampered manifest backup and preserves both backups', async () => { + const fixture = setupInstalledFixture() + try { + const result = await executeAntigravityTransaction(failingSyncItem(), { + run: async () => { + rmSync(path.join(fixture.pluginRoot, 'plugin.json')) + const { manifestBackup } = findBackupPath(fixture) + writeFileSync(manifestBackup, '{"imports":{}}') + return { exitCode: 1, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, false) + // The live manifest was never replaced with the tampered backup bytes. + assert.match(readFileSync(fixture.manifestPath, 'utf8'), /nsolid-plugin/) + const containers = backupContainers(path.join(fixture.home, '.gemini', 'config')) + assert.equal(containers.root.length, 1) + assert.equal(containers.manifest.length, 1) + } finally { + restoreHome(fixture) + } + }) + + it('reports failure when the restored state fails strict identity validation', async () => { + const fixture = setupInstalledFixture() + try { + // The pre-update identity-less plugin can be restored byte-exactly, + // yet must never be reported as a successful rollback. + writeFileSync(path.join(fixture.pluginRoot, 'plugin.json'), JSON.stringify({ description: 'identity-less' })) + const result = await executeAntigravityTransaction(failingSyncItem(), { + run: async () => { + rmSync(path.join(fixture.pluginRoot, 'plugin.json')) + return { exitCode: 1, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, false) + const containers = backupContainers(path.join(fixture.home, '.gemini', 'config')) + assert.equal(containers.root.length, 1) + assert.equal(containers.manifest.length, 1) + } finally { + restoreHome(fixture) + } + }) + + it('restores the original plugin and cleans both backups when a command fails', async () => { + const fixture = setupInstalledFixture() + try { + const result = await executeAntigravityTransaction(failingSyncItem(), { + run: async () => { + writeFileSync(path.join(fixture.pluginRoot, 'skills/example/SKILL.md'), '# substituted\n') + return { exitCode: 1, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.equal(readFileSync(path.join(fixture.pluginRoot, 'skills/example/SKILL.md'), 'utf8'), '# v1.0.0\n') + const containers = backupContainers(path.join(fixture.home, '.gemini', 'config')) + assert.deepEqual([...containers.root, ...containers.manifest], []) + } finally { + restoreHome(fixture) + } + }) }) it('returns a structured backup failure when the plugin root parent directory is missing', async () => { diff --git a/packages/core/test/unit/update/codex-transaction.test.ts b/packages/core/test/unit/update/codex-transaction.test.ts index 3565e86..fa472d1 100644 --- a/packages/core/test/unit/update/codex-transaction.test.ts +++ b/packages/core/test/unit/update/codex-transaction.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, it } from 'node:test' import assert from 'node:assert/strict' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { writeTomlFileSync } from '../../../src/utils/config.js' @@ -360,4 +360,141 @@ describe('Codex update transaction', () => { assert.match(result.error?.message ?? '', /cache-backup/) assert.equal(result.error?.message?.includes(path.dirname(cachePath)), true) }) + + describe('rollback gating and verified restore', () => { + function backupContainers (baseDir: string, marker: string): string[] { + return existsSync(baseDir) ? readdirSync(baseDir).filter((name) => name.includes(marker)) : [] + } + + function failedUpgradeItem (cachePath: string): UpdatePlanItem { + // The reviewer's exact scenario: a failed install/upgrade command whose + // args contain no `remove` at all. + return { + ...item(cachePath), + steps: [ + { kind: 'command', description: 'upgrade', command: { executable: 'codex', args: ['plugin', 'marketplace', 'upgrade', 'NodeSource/nsolid-plugin'], timeoutMs: 1000 } }, + ], + } + } + + function setupFixture (): { cachePath: string; configPath: string; originalConfig: string; configMarker: string } { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + mkdirSync(cachePath, { recursive: true }) + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(configPath, [ + '# user comment must survive', + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true', + '', + ].join('\n')) + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + return { cachePath, configPath, originalConfig: readFileSync(configPath, 'utf8'), configMarker: '.nsolid-config-backup-' } + } + + function tamperConfigBackup (configPath: string, marker: string, tampered: string): void { + const container = backupContainers(path.dirname(configPath), marker)[0] + assert.ok(container, 'expected the config backup container to exist') + writeFileSync(path.join(path.dirname(configPath), container, path.basename(configPath)), tampered) + } + + it('rolls back a failed upgrade command whose args contain no remove', async () => { + const fixture = setupFixture() + const result = await executeCodexTransaction(failedUpgradeItem(fixture.cachePath), { + run: async (command) => { + // Simulate the partially mutated state the failed command leaves. + writeFileSync(path.join(fixture.cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '9.9.9', skills: [] })) + assert.equal(command.args.includes('remove'), false) + return { exitCode: 1, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_COMMAND_FAILED') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.equal(readFileSync(fixture.configPath, 'utf8'), fixture.originalConfig) + assert.match(readFileSync(path.join(fixture.cachePath, 'bundle.json'), 'utf8'), /1\.0\.0/) + assert.equal(backupContainers(path.dirname(fixture.configPath), fixture.configMarker).length, 0) + }) + + it('refuses to restore a tampered config backup', async () => { + const fixture = setupFixture() + const result = await executeCodexTransaction(failedUpgradeItem(fixture.cachePath), { + run: async () => { + const mutatedConfig = fixture.originalConfig.replace('enabled = true', 'enabled = false') + writeFileSync(fixture.configPath, mutatedConfig) + tamperConfigBackup(fixture.configPath, fixture.configMarker, '# tampered bytes') + return { exitCode: 1, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, false) + // The live config must not be overwritten with the tampered backup + // bytes, and it must not have been restored to the original either. + assert.equal(readFileSync(fixture.configPath, 'utf8'), fixture.originalConfig.replace('enabled = true', 'enabled = false')) + // Backups are preserved for manual recovery. + assert.equal(backupContainers(path.dirname(fixture.configPath), fixture.configMarker).length, 1) + }) + + it('refuses to overwrite a concurrently edited live config after command failure', async () => { + const fixture = setupFixture() + const drifted = `${fixture.originalConfig}# concurrent user edit\n` + const result = await executeCodexTransaction(failedUpgradeItem(fixture.cachePath), { + // The concurrent edit must land after the transaction captures the + // post-command state but before the restore reads the live bytes, so + // it is queued two microtask ticks behind the failure resolution. + run: () => new Promise((resolve) => { + queueMicrotask(() => { + resolve({ exitCode: 1, stdout: '', stderr: '', timedOut: false }) + queueMicrotask(() => queueMicrotask(() => writeFileSync(fixture.configPath, drifted))) + }) + }), + }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, false) + assert.equal(readFileSync(fixture.configPath, 'utf8'), drifted) + assert.equal(backupContainers(path.dirname(fixture.configPath), fixture.configMarker).length, 1) + }) + + it('restores exact original digests, not mere existence', async () => { + const fixture = setupFixture() + const digestBefore = nativePayloadDigest(fixture.cachePath) + assert.ok(digestBefore, 'the cache must be digestible') + const result = await executeCodexTransaction(failedUpgradeItem(fixture.cachePath), { + run: async () => { + writeFileSync(path.join(fixture.cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '9.9.9', skills: [] })) + writeFileSync(path.join(fixture.cachePath, 'stray.json'), '{}') + return { exitCode: 1, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.equal(readFileSync(fixture.configPath, 'utf8'), fixture.originalConfig) + assert.equal(existsSync(path.join(fixture.cachePath, 'stray.json')), false) + assert.equal(nativePayloadDigest(fixture.cachePath), digestBefore) + }) + + it('restores an originally empty config file after a failed command', async () => { + const fixture = setupFixture() + writeFileSync(fixture.configPath, '') + const result = await executeCodexTransaction(failedUpgradeItem(fixture.cachePath), { + run: async () => { + writeFileSync(fixture.configPath, 'codex rewrote the empty config\n') + return { exitCode: 1, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.equal(readFileSync(fixture.configPath, 'utf8'), '') + assert.equal(backupContainers(path.dirname(fixture.configPath), fixture.configMarker).length, 0) + }) + }) }) diff --git a/packages/core/test/unit/update/fallback-journal.test.ts b/packages/core/test/unit/update/fallback-journal.test.ts index f38392c..c6c91da 100644 --- a/packages/core/test/unit/update/fallback-journal.test.ts +++ b/packages/core/test/unit/update/fallback-journal.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, it } from 'node:test' import assert from 'node:assert/strict' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' import os from 'node:os' import path from 'node:path' @@ -392,6 +392,78 @@ describe('fallback journal ownership validation', () => { assert.equal(existsSync(journal.journalPath), true) rmSync(stageSource, { recursive: true, force: true }) }) + + it('fails closed when the snapshot directory points at the tracking directory itself', async () => { + const { trackingPath, manifest, trackingJson } = setupValidFixture() + const { journal } = await beginFallbackJournal(manifest) + const trackingDir = path.dirname(trackingPath) + const sibling = path.join(trackingDir, 'sibling.txt') + writeFileSync(sibling, 'keep') + const tampered = { ...journal, snapshotDirectory: trackingDir } + + assert.equal(await restoreFallbackJournal(tampered), false) + const committed = { ...tampered, phase: 'committed' as const } + writeFileSync(journal.journalPath, JSON.stringify(committed)) + await assert.rejects(commitFallbackJournal(committed)) + assert.deepEqual(await recoverFallbackJournal(trackingPath, true), { pending: true, recovered: false }) + + // The tracking directory and its siblings survived every cleanup attempt. + assert.equal(readFileSync(trackingPath, 'utf8'), trackingJson) + assert.equal(readFileSync(sibling, 'utf8'), 'keep') + assert.equal(existsSync(journal.snapshotDirectory), true) + assert.equal(existsSync(journal.journalPath), true) + }) + + it('fails closed when the snapshot directory is a symlink escaping the tracking directory', { skip: process.platform === 'win32' }, async () => { + const { trackingPath, manifest } = setupValidFixture() + const { journal } = await beginFallbackJournal(manifest) + const victim = path.join(home, 'victim-dir') + mkdirSync(victim) + writeFileSync(path.join(victim, 'keep.txt'), 'keep') + rmSync(journal.snapshotDirectory, { recursive: true, force: true }) + symlinkSync(victim, journal.snapshotDirectory, 'dir') + + assert.equal(await restoreFallbackJournal(journal), false) + const committed = { ...journal, phase: 'committed' as const } + writeFileSync(journal.journalPath, JSON.stringify(committed)) + assert.deepEqual(await recoverFallbackJournal(trackingPath, true), { pending: true, recovered: false }) + await assert.rejects(commitFallbackJournal(journal)) + + // Nothing outside the journal was deleted and the journal survives. + assert.equal(readFileSync(path.join(victim, 'keep.txt'), 'utf8'), 'keep') + assert.equal(existsSync(journal.journalPath), true) + }) + + it('fails closed when the snapshot directory name does not match the mkdtemp shape', async () => { + const { trackingPath, manifest } = setupValidFixture() + const { journal } = await beginFallbackJournal(manifest) + const trackingDir = path.dirname(trackingPath) + const suffixless = { ...journal, snapshotDirectory: path.join(trackingDir, '.nsolid-plugin-update-') } + const foreign = { ...journal, snapshotDirectory: path.join(trackingDir, '.other-update-abc123') } + + assert.equal(await restoreFallbackJournal(suffixless), false) + assert.equal(await restoreFallbackJournal(foreign), false) + writeFileSync(journal.journalPath, JSON.stringify({ ...foreign, phase: 'committed' as const })) + await assert.rejects(commitFallbackJournal(foreign)) + assert.deepEqual(await recoverFallbackJournal(trackingPath, true), { pending: true, recovered: false }) + + // The real snapshot was never cleaned up by the forged journals. + assert.equal(existsSync(journal.snapshotDirectory), true) + assert.equal(existsSync(journal.journalPath), true) + }) + + it('aborts the restore without touching live paths when a non-tracking backup was tampered with', async () => { + const { skillPath, manifest } = setupValidFixture() + const { journal } = await beginFallbackJournal(manifest) + const skillEntry = journal.entries.find((entry) => path.resolve(entry.path) === path.resolve(skillPath))! + writeFileSync(path.join(skillEntry.backup!, 'SKILL.md'), '# tampered\n') + + assert.equal(await restoreFallbackJournal(journal), false) + // The live skill still holds its pre-restore bytes, not the tampered backup. + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(existsSync(journal.journalPath), true) + assert.equal(existsSync(journal.snapshotDirectory), true) + }) }) function setupValidFixture (): { trackingPath: string; skillPath: string; linkPath: string; manifest: FallbackTransactionIdentity; trackingJson: string } { diff --git a/packages/core/test/unit/update/fallback-ownership.test.ts b/packages/core/test/unit/update/fallback-ownership.test.ts index 86a429e..c7223e6 100644 --- a/packages/core/test/unit/update/fallback-ownership.test.ts +++ b/packages/core/test/unit/update/fallback-ownership.test.ts @@ -1,6 +1,10 @@ -import { describe, it } from 'node:test' +import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' -import { isRemotePath } from '../../../src/update/fallback-ownership.js' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { isRemotePath, mcpRecordIsExclusivelyOwned } from '../../../src/update/fallback-ownership.js' +import { readMcpFieldDigests, valueDigest } from '../../../src/update/mcp-lookup.js' describe('fallback ownership paths', () => { it('classifies UNC and Windows device paths as remote destructive targets', () => { @@ -10,3 +14,91 @@ describe('fallback ownership paths', () => { assert.equal(isRemotePath('C:\\Users\\alice\\skills'), false) }) }) + +describe('mcpRecordIsExclusivelyOwned', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'nsolid-ownership-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function writeConfig (content: Record): string { + const configPath = join(tmpDir, '.claude.json') + writeFileSync(configPath, JSON.stringify(content)) + return configPath + } + + it('passes when live digests match the owned evidence exactly', () => { + const configPath = writeConfig({ + mcpServers: { 'ns-benchmark': { url: 'https://example.com/mcp' } }, + }) + const owned = { url: valueDigest('https://example.com/mcp') } + + assert.equal(mcpRecordIsExclusivelyOwned(configPath, 'ns-benchmark', owned, 'mcpServers'), true) + }) + + it('fails when a foreign field was added to the owned record', () => { + const configPath = writeConfig({ + mcpServers: { 'ns-benchmark': { url: 'https://example.com/mcp', headers: { 'x-a': '1' } } }, + }) + const owned = { url: valueDigest('https://example.com/mcp') } + + assert.equal(mcpRecordIsExclusivelyOwned(configPath, 'ns-benchmark', owned, 'mcpServers'), false) + }) + + it('fails when an owned field drifted', () => { + const configPath = writeConfig({ + mcpServers: { 'ns-benchmark': { url: 'https://drifted.example/mcp' } }, + }) + const owned = { url: valueDigest('https://example.com/mcp') } + + assert.equal(mcpRecordIsExclusivelyOwned(configPath, 'ns-benchmark', owned, 'mcpServers'), false) + }) + + it('fails closed over missing records, foreign fields, and absent owned evidence', () => { + const configPath = writeConfig({ + mcpServers: { 'ns-benchmark': { url: 'https://example.com/mcp' } }, + }) + + // Missing record, foreign fields, and absent owned evidence all fail closed. + assert.equal(mcpRecordIsExclusivelyOwned(configPath, 'foreign-server', { url: valueDigest('x') }, 'mcpServers'), false) + assert.equal(mcpRecordIsExclusivelyOwned(configPath, 'ns-benchmark', { url: valueDigest('x') }, 'mcpServers'), false) + assert.equal(mcpRecordIsExclusivelyOwned(configPath, 'ns-benchmark', undefined, 'mcpServers'), false) + assert.equal(mcpRecordIsExclusivelyOwned(configPath, 'ns-benchmark', {}, 'mcpServers'), false) + }) + + it('requires every owned field to match, not merely the field set', () => { + const configPath = writeConfig({ + mcpServers: { 'ns-benchmark': { url: 'https://example.com/mcp', note: 'kept' } }, + }) + const owned = { url: valueDigest('https://example.com/mcp'), note: valueDigest('drifted') } + + assert.equal(mcpRecordIsExclusivelyOwned(configPath, 'ns-benchmark', owned, 'mcpServers'), false) + }) + + it('describes the same container the harness prefers', () => { + // The opencode container is "mcp"; a legacy "mcpServers" sibling must not + // be digested. Verify the preferredKey is honored end to end. + const configPath = writeConfig({ + mcpServers: { 'ns-benchmark': { url: 'https://legacy.example/mcp' } }, + mcp: { 'ns-benchmark': { type: 'remote', url: 'https://current.example/mcp' } }, + }) + const owned = { type: valueDigest('remote'), url: valueDigest('https://current.example/mcp') } + + assert.equal(mcpRecordIsExclusivelyOwned(configPath, 'ns-benchmark', owned, 'mcp'), true) + assert.equal(mcpRecordIsExclusivelyOwned(configPath, 'ns-benchmark', { url: valueDigest('https://legacy.example/mcp') }, 'mcp'), false) + }) + + it('uses the same field digests the field-digests module computes', () => { + const configPath = writeConfig({ + mcpServers: { 'ns-benchmark': { url: 'https://example.com/mcp' } }, + }) + const owned = readMcpFieldDigests(configPath, 'ns-benchmark', { preferredKey: 'mcpServers' }) + + assert.equal(mcpRecordIsExclusivelyOwned(configPath, 'ns-benchmark', owned, 'mcpServers'), true) + }) +}) diff --git a/packages/core/test/unit/update/fallback-strategy.test.ts b/packages/core/test/unit/update/fallback-strategy.test.ts index c3615b3..5c01510 100644 --- a/packages/core/test/unit/update/fallback-strategy.test.ts +++ b/packages/core/test/unit/update/fallback-strategy.test.ts @@ -1,10 +1,16 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' import path from 'node:path' +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' import { fallbackStrategy } from '../../../src/update/strategies/fallback.js' -import type { UpdatePlanItem } from '../../../src/update/types.js' +import { applyFallbackEntry, fallbackJournalPath, pathDigest, registerFallbackStage, trackingDigest } from '../../../src/update/fallback-journal.js' +import { valueDigest } from '../../../src/update/mcp-lookup.js' +import { readTrackingFile, writeTrackingFile } from '../../../src/skills/skill-tracker.js' +import { getHarnessSkillsPath } from '../../../src/skills/skill-linker.js' +import { getSkillsDir, resolveHome } from '../../../src/utils/path.js' +import type { FallbackTransactionIdentity, UpdatePlanItem } from '../../../src/update/types.js' function item (): UpdatePlanItem { return { @@ -107,3 +113,187 @@ describe('fallback update strategy', () => { } }) }) + +describe('fallback strategy parent gate', () => { + let home: string + let previousHome: string | undefined + let previousUserProfile: string | undefined + + beforeEach(() => { + home = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-fallback-strategy-')) + previousHome = process.env.HOME + previousUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home + }) + + afterEach(() => { + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile + rmSync(home, { recursive: true, force: true }) + }) + + interface GateFixture { + identity: FallbackTransactionIdentity + trackingPath: string + skillPath: string + trackedConfigPath?: string + item: UpdatePlanItem + } + + async function setupGateFixture (options: { trackedMcp?: boolean } = {}): Promise { + const skillPath = path.join(getSkillsDir(), 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old tracked') + const trackedConfigPath = path.join(home, 'custom', 'claude-tracked.json') + const configPath = options.trackedMcp === true ? trackedConfigPath : resolveHome('~/.claude.json') + if (options.trackedMcp === true) { + mkdirSync(path.dirname(trackedConfigPath), { recursive: true }) + writeFileSync(trackedConfigPath, JSON.stringify({ + mcpServers: { 'alpha-console': { url: 'https://old.example.com/mcp', headers: { AUTH: 'x' } } }, + }, null, 2) + '\n') + } + const trackingPath = path.join(home, '.agents', '.nodesource-installed.json') + mkdirSync(path.dirname(trackingPath), { recursive: true }) + await writeTrackingFile({ + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + bundleVersions: { claude: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { claude: skillPath }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: options.trackedMcp === true + ? [{ name: 'alpha-console', configPath, harness: 'claude', configuredAt: new Date().toISOString(), fields: { url: valueDigest('https://old.example.com/mcp'), headers: valueDigest({ AUTH: 'x' }) } }] + : [], + }) + const identity: FallbackTransactionIdentity = { + installationId: 'claude:fallback', + harness: 'claude', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + nonce: randomUUID(), + ownedSkillPaths: [skillPath], + ownedLinkPaths: [path.join(getHarnessSkillsPath('claude'), 'tracked')], + ownedMcpFields: options.trackedMcp === true + ? [ + { configPath, server: 'alpha-console', field: 'url', expectedDigest: valueDigest('https://old.example.com/mcp') }, + { configPath, server: 'alpha-console', field: 'headers', expectedDigest: valueDigest({ AUTH: 'x' }) }, + ] + : [], + // The union of tracked MCP config paths and the adapter's canonical path, + // exactly as the ownership matcher recomputes it. + ownedMcpConfigPaths: [...new Set([configPath, resolveHome('~/.claude.json')].map((value) => path.resolve(value)))], + approvedDestinationRoots: [getSkillsDir(), getHarnessSkillsPath('claude')].map((value) => path.resolve(value)), + } + const gateItem: UpdatePlanItem = { + installationId: 'claude:fallback', + target: 'claude', + ownership: 'fallback', + installed: true, + source: { kind: 'fallback', bundleVersion: '1.0.0' }, + version: { current: '1.0.0', latest: '1.0.1', status: 'update-available' }, + steps: [{ kind: 'command', description: 'refresh', command: { executable: 'npm', args: ['exec'], cwd: tmpdir(), timeoutMs: 1000 } }], + rollbackSteps: [], + requiresConfirmation: true, + fallbackTransaction: identity, + } + return { identity, trackingPath, skillPath, trackedConfigPath: options.trackedMcp === true ? trackedConfigPath : undefined, item: gateItem } + } + + /** Simulate the verified child: it legitimately holds the nonce, so it may stage and apply through the journal API. */ + async function childStagesAndApplies (fixture: GateFixture, target: string, bytes: Buffer): Promise { + const journal = JSON.parse(readFileSync(fallbackJournalPath(fixture.identity.trackingPath), 'utf8')) + const staged = await registerFallbackStage(journal, target, { bytes }) + await applyFallbackEntry(staged, target) + } + + /** Simulate a lying child: it registers a stage for new bytes and claims the swap, but the live path keeps the old bytes. */ + async function childClaimsSwapWithoutApplying (fixture: GateFixture, target: string, bytes: Buffer): Promise { + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + const stageDir = mkdtempSync(path.join(path.dirname(target), `.${path.basename(target)}.nsolid-stage-`)) + const stagePath = path.join(stageDir, 'payload') + writeFileSync(stagePath, bytes) + const stageDigest = await pathDigest(stagePath) + const journal = JSON.parse(readFileSync(journalPath, 'utf8')) + const entries = journal.entries.map((entry: { path: string }) => path.resolve(entry.path) === path.resolve(target) + ? { ...entry, stage: stagePath, stageDigest, applied: true } + : entry) + writeFileSync(journalPath, JSON.stringify({ ...journal, entries }, null, 2) + '\n') + } + + it('fails a no-op child with a parent rollback instead of reporting updated', async () => { + const fixture = await setupGateFixture() + const skillBytes = readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8') + + const result = await fallbackStrategy.execute(fixture.item, { + options: {}, + commandRunner: { run: async () => ({ exitCode: 0, stdout: 'refresh done\n', stderr: '', timedOut: false }) }, + }) + + assert.equal(result.status, 'failed') + assert.notEqual(result.status, 'updated') + assert.deepEqual(result.rollback, { attempted: true, succeeded: true }) + assert.equal(result.error?.code, 'FALLBACK_VALIDATION_FAILED') + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), skillBytes) + const tracking = await readTrackingFile() + assert.equal(tracking?.bundleVersions?.claude, '1.0.0') + }) + + it('fails a lying child whose claimed swap left the owned skill bytes stale', async () => { + const fixture = await setupGateFixture() + const newTracking = { ...(await readTrackingFile())!, bundleVersions: { claude: '1.0.1' } } + + const result = await fallbackStrategy.execute(fixture.item, { + options: {}, + commandRunner: { + run: async () => { + // The child stages and applies the tracking update properly: the + // bundle evidence check alone would trust it. + await childStagesAndApplies(fixture, fixture.trackingPath, Buffer.from(JSON.stringify(newTracking, null, 2) + '\n')) + // But the skill swap is only claimed: the journal records new bytes + // while the live path still carries the old ones. + await childClaimsSwapWithoutApplying(fixture, fixture.skillPath, Buffer.from('new tracked')) + return { exitCode: 0, stdout: 'refresh done\n', stderr: '', timedOut: false } + }, + }, + }) + + assert.equal(result.status, 'failed') + assert.deepEqual(result.rollback, { attempted: true, succeeded: true }) + assert.equal(result.error?.code, 'FALLBACK_VALIDATION_FAILED') + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + const tracking = await readTrackingFile() + assert.equal(tracking?.bundleVersions?.claude, '1.0.0') + }) + + it('fails when tracked field evidence no longer matches the live MCP configuration', async () => { + const fixture = await setupGateFixture({ trackedMcp: true }) + const originalConfig = readFileSync(fixture.trackedConfigPath!, 'utf8') + const newTracking = { ...(await readTrackingFile())!, bundleVersions: { claude: '1.0.1' } } + // A wrong record value inside the owned server: the child stages and + // applies it together with the tracking evidence, so every journal-level + // check passes and only the tracked-digest proof can catch it. + const tamperedConfig = JSON.stringify({ + mcpServers: { 'alpha-console': { url: 'https://tampered.example.com/mcp', headers: { AUTH: 'x' } } }, + }, null, 2) + '\n' + + const result = await fallbackStrategy.execute(fixture.item, { + options: {}, + commandRunner: { + run: async () => { + await childStagesAndApplies(fixture, fixture.trackedConfigPath!, Buffer.from(tamperedConfig)) + await childStagesAndApplies(fixture, fixture.trackingPath, Buffer.from(JSON.stringify(newTracking, null, 2) + '\n')) + return { exitCode: 0, stdout: 'refresh done\n', stderr: '', timedOut: false } + }, + }, + }) + + assert.equal(result.status, 'failed') + assert.deepEqual(result.rollback, { attempted: true, succeeded: true }) + assert.equal(result.error?.code, 'FALLBACK_VALIDATION_FAILED') + assert.equal(readFileSync(fixture.trackedConfigPath!, 'utf8'), originalConfig) + const tracking = await readTrackingFile() + assert.equal(tracking?.bundleVersions?.claude, '1.0.0') + }) +}) diff --git a/packages/core/test/unit/update/fallback-transaction.test.ts b/packages/core/test/unit/update/fallback-transaction.test.ts index ade59ad..9283540 100644 --- a/packages/core/test/unit/update/fallback-transaction.test.ts +++ b/packages/core/test/unit/update/fallback-transaction.test.ts @@ -5,7 +5,8 @@ import { cp as realFsCp } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { refreshOwnedInstallation } from '../../../src/update/fallback-transaction.js' -import { appendFallbackJournalEntries, applyFallbackEntry, beginFallbackJournal, captureFallbackJournalState, commitFallbackJournal, fallbackJournalPath, markFallbackJournalMutating, reloadFallbackJournal, registerFallbackStage, restoreFallbackJournal, trackingDigest, valueDigest } from '../../../src/update/fallback-journal.js' +import { appendFallbackJournalEntries, applyFallbackEntry, beginFallbackJournal, captureFallbackJournalState, commitFallbackJournal, fallbackJournalPath, markFallbackJournalMutating, reloadFallbackJournal, registerFallbackStage, restoreFallbackJournal, trackingDigest } from '../../../src/update/fallback-journal.js' +import { valueDigest } from '../../../src/update/mcp-lookup.js' import { randomUUID } from 'node:crypto' import type { FallbackTransactionIdentity } from '../../../src/update/types.js' import { getHarnessSkillsPath } from '../../../src/skills/skill-linker.js' @@ -346,12 +347,165 @@ describe('fallback refresh transaction', () => { // Tracking digests must describe the final bytes, never the stale ones. assert.equal(tracked?.fields?.url, valueDigest('https://new.example.com/mcp')) assert.equal(tracked?.fields?.note, undefined) - assert.equal(tracked?.fields?.user_token, valueDigest('user-secret')) + // user_token survived refresh #1 in the config bytes; tracking must not + // record it as owned, or refresh #2 would delete it. + assert.equal(tracked?.fields?.user_token, undefined) assert.equal(tracked?.fields?.headers, valueDigest({})) assert.equal(tracked?.fields?.name, valueDigest('alpha-console')) rmSync(sourceRoot, { recursive: true, force: true }) }) + it('never tracks foreign MCP fields and preserves them across two refreshes of the same bundle', async () => { + // Reviewer scenario: refresh #1 records digests of every field present in + // the staged bytes (including a user-added user_token); refresh #2 then + // deletes it because reconciliation removes tracked fields absent from + // the desired render. Tracking must only ever describe desired-render + // fields so a foreign field survives both refreshes. + const skillPath = path.join(home, '.agents', 'skills', 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'alpha-console', url: 'https://new.example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + saasToken: 'saas', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://new.example.com/mcp', + expiresAt: new Date(Date.now() + 120_000).toISOString(), + }) + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(path.dirname(configPath), { recursive: true }) + const originalConfig = [ + '[mcp_servers.alpha-console]', + 'url = "https://old.example.com/mcp"', + 'user_token = "user-secret"', + ].join('\r\n') + '\r\n' + writeFileSync(configPath, originalConfig) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'codex', + bundleVersions: { codex: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { codex: skillPath }, installedAt: new Date().toISOString(), harnesses: ['codex'] }], + mcpServers: [{ + name: 'alpha-console', + configPath, + harness: 'codex', + configuredAt: new Date().toISOString(), + fields: { url: valueDigest('https://old.example.com/mcp') }, + }], + }) + + const first = await refreshOwnedInstallation({ harness: 'codex', bundlePath, skillsSource: sourceRoot }) + assert.equal(first.success, true, JSON.stringify(first)) + + const configAfterFirst = readFileSync(configPath, 'utf8') + assert.equal(configAfterFirst.includes('user_token = "user-secret"'), true, 'refresh #1 must leave the foreign field in the config bytes') + const trackingAfterFirst = await readTrackingFile() + const trackedAfterFirst = trackingAfterFirst?.mcpServers.find((entry) => entry.name === 'alpha-console') + // Only the desired-render fields enter tracking; user_token is foreign. + assert.deepEqual(Object.keys(trackedAfterFirst?.fields ?? {}).sort(), ['headers', 'name', 'url']) + assert.equal(trackedAfterFirst?.fields?.url, valueDigest('https://new.example.com/mcp')) + + const second = await refreshOwnedInstallation({ harness: 'codex', bundlePath, skillsSource: sourceRoot }) + assert.equal(second.success, true, JSON.stringify(second)) + + // Reviewer's exact regression: refresh #2 of the same bundle must not + // delete the user-owned field. + const configAfterSecond = readFileSync(configPath, 'utf8') + assert.equal(configAfterSecond.includes('user_token = "user-secret"'), true, 'refresh #2 must not delete the foreign field it never owned') + const trackingAfterSecond = await readTrackingFile() + const trackedAfterSecond = trackingAfterSecond?.mcpServers.find((entry) => entry.name === 'alpha-console') + assert.deepEqual(Object.keys(trackedAfterSecond?.fields ?? {}).sort(), ['headers', 'name', 'url']) + assert.equal(trackedAfterSecond?.fields?.url, valueDigest('https://new.example.com/mcp')) + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('keeps tracking desired-field digests updated when a desired value changes between refreshes', async () => { + const skillPath = path.join(home, '.agents', 'skills', 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + const bundleVersion = (url: string): unknown => ({ + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'alpha-console', url, headers: {} }], + }) + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, bundleVersion('https://one.example.com/mcp')) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + saasToken: 'saas', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://one.example.com/mcp', + expiresAt: new Date(Date.now() + 120_000).toISOString(), + }) + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(path.dirname(configPath), { recursive: true }) + const originalConfig = [ + '[mcp_servers.alpha-console]', + 'url = "https://old.example.com/mcp"', + 'user_token = "user-secret"', + ].join('\r\n') + '\r\n' + writeFileSync(configPath, originalConfig) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'codex', + bundleVersions: { codex: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { codex: skillPath }, installedAt: new Date().toISOString(), harnesses: ['codex'] }], + mcpServers: [{ + name: 'alpha-console', + configPath, + harness: 'codex', + configuredAt: new Date().toISOString(), + fields: { url: valueDigest('https://old.example.com/mcp') }, + }], + }) + + const first = await refreshOwnedInstallation({ harness: 'codex', bundlePath, skillsSource: sourceRoot }) + assert.equal(first.success, true, JSON.stringify(first)) + const trackingAfterFirst = await readTrackingFile() + const trackedAfterFirst = trackingAfterFirst?.mcpServers.find((entry) => entry.name === 'alpha-console') + assert.equal(trackedAfterFirst?.fields?.url, valueDigest('https://one.example.com/mcp')) + + // Second refresh with a changed desired value: owned fields keep their + // digests updated while the foreign field survives untouched. + writeJson(bundlePath, bundleVersion('https://two.example.com/mcp')) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + saasToken: 'saas', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://two.example.com/mcp', + expiresAt: new Date(Date.now() + 120_000).toISOString(), + }) + const second = await refreshOwnedInstallation({ harness: 'codex', bundlePath, skillsSource: sourceRoot }) + assert.equal(second.success, true, JSON.stringify(second)) + + const configAfterSecond = readFileSync(configPath, 'utf8') + assert.equal(configAfterSecond.includes('url = "https://two.example.com/mcp"'), true) + assert.equal(configAfterSecond.includes('user_token = "user-secret"'), true, 'the foreign field survives a desired-value change it does not own') + const trackingAfterSecond = await readTrackingFile() + const trackedAfterSecond = trackingAfterSecond?.mcpServers.find((entry) => entry.name === 'alpha-console') + assert.equal(trackedAfterSecond?.fields?.url, valueDigest('https://two.example.com/mcp')) + assert.deepEqual(Object.keys(trackedAfterSecond?.fields ?? {}).sort(), ['headers', 'name', 'url']) + rmSync(sourceRoot, { recursive: true, force: true }) + }) + it('fails closed without mutating anything when the codex TOML configuration is malformed', async () => { const skillPath = path.join(home, '.agents', 'skills', 'tracked') mkdirSync(skillPath, { recursive: true }) diff --git a/packages/core/test/unit/update/mcp-edit.test.ts b/packages/core/test/unit/update/mcp-edit.test.ts index ad595d9..bc8daf1 100644 --- a/packages/core/test/unit/update/mcp-edit.test.ts +++ b/packages/core/test/unit/update/mcp-edit.test.ts @@ -1,12 +1,11 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { editMcpJsonBytes, McpEditError, readMcpNodeValue } from '../../../src/update/mcp-edit.js' -import { harnessMcpKey, mcpFieldDigestsFromBytes, readMcpFieldDigests, readMcpServerRecord } from '../../../src/update/mcp-lookup.js' +import { harnessMcpKey, mcpFieldDigestsFromBytes, readMcpFieldDigests, readMcpServerRecord, valueDigest } from '../../../src/update/mcp-lookup.js' import { parseJsonc } from '../../../src/utils/config.js' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { valueDigest } from '../../../src/update/fallback-journal.js' describe('MCP byte-preserving AST edits', () => { it('rewrites only the owned server and preserves comments, foreign servers, and formatting', () => { From cdfe58e8991f294c363b2a33a1f9f56b68f180f7 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Tue, 1 Sep 2026 17:31:34 +0200 Subject: [PATCH 12/12] fix(update): close coderabbit follow-up findings on PR #56 Never render or claim ownership of the McpServerRef name metadata: the install writer's ownership evidence and the refresh reconciliation values both strip the ref's name before harness formatting, so a user-authored name inside a server entry survives reinstall and refresh byte-for-byte and install-created entries never gain that key. Skip setFields edits whose current value already equals the desired one so identical-value refreshes cannot re-serialize and cosmetically drift entry bytes. Treat an empty, oversized, unsupported, or otherwise undigestible native plugin tree as a backup failure: Antigravity and Codex now abort before mutation because no authenticated rollback could be guaranteed, matching the Claude transaction precedent. Accept the full portable mkdtemp suffix character set (dot and dash) when validating fallback journal snapshots. --- packages/core/src/mcp/mcp-config-writer.ts | 4 +- .../src/update/antigravity-transaction.ts | 6 +- packages/core/src/update/codex-transaction.ts | 7 ++- packages/core/src/update/fallback-journal.ts | 2 +- .../core/src/update/fallback-transaction.ts | 6 +- packages/core/src/update/mcp-edit.ts | 8 +++ .../core/test/integration/installer.test.ts | 57 ++++++++++++++++++- .../test/unit/mcp/mcp-config-writer.test.ts | 26 +++++++++ .../update/antigravity-transaction.test.ts | 45 +++++++++++++++ .../unit/update/codex-transaction.test.ts | 29 ++++++++++ .../test/unit/update/fallback-journal.test.ts | 31 ++++++++++ .../unit/update/fallback-transaction.test.ts | 11 ++-- 12 files changed, 221 insertions(+), 11 deletions(-) diff --git a/packages/core/src/mcp/mcp-config-writer.ts b/packages/core/src/mcp/mcp-config-writer.ts index 9631ba1..e75fa10 100644 --- a/packages/core/src/mcp/mcp-config-writer.ts +++ b/packages/core/src/mcp/mcp-config-writer.ts @@ -190,7 +190,9 @@ export function renderedMcpFieldNames ( variables?: Record ): Record { const resolved = variables !== undefined ? expandVariables(servers, variables) : servers - const rendered = applyHarnessWriteFormat(harness, { mcpServers: Object.fromEntries(resolved.map((server) => [server.name, { ...server }])) }) + // The ref's `name` is only the map key metadata: it is never rendered as a + // field inside the entry, so it must never enter ownership evidence either. + const rendered = applyHarnessWriteFormat(harness, { mcpServers: Object.fromEntries(resolved.map(({ name, ...entry }) => [name, entry])) }) return Object.fromEntries(Object.entries(rendered.mcpServers).map(([name, server]) => [name, Object.keys(server)])) } diff --git a/packages/core/src/update/antigravity-transaction.ts b/packages/core/src/update/antigravity-transaction.ts index f07bf63..eef3dc5 100644 --- a/packages/core/src/update/antigravity-transaction.ts +++ b/packages/core/src/update/antigravity-transaction.ts @@ -106,8 +106,12 @@ export async function executeAntigravityTransaction ( if (rootExisted) { await copyOwnedPath(pluginRoot, rootBackup) // Persist the original digests before any mutation; the backup must - // be authenticated against these, never against itself. + // be authenticated against these, never against itself. An empty, + // oversized, or otherwise undigestible tree has no provable backup: + // fail here, before mutation, instead of entering a transaction whose + // rollback can never be authenticated. originalRootDigest = treeDigest(rootBackup) + if (originalRootDigest === undefined) throw new Error('backup tree could not be digested') rootBackupComplete = true } if (manifestExisted) { diff --git a/packages/core/src/update/codex-transaction.ts b/packages/core/src/update/codex-transaction.ts index 95d2466..c0e2313 100644 --- a/packages/core/src/update/codex-transaction.ts +++ b/packages/core/src/update/codex-transaction.ts @@ -118,7 +118,12 @@ export async function executeCodexTransaction ( } if (cacheExisted) { await copyOwnedPath(cachePath, cacheBackup) - cacheOriginalDigest = ownedTreeDigest(cacheBackup) ?? undefined + // An empty, oversized, or otherwise undigestible tree has no provable + // backup digest: fail here, before mutation, instead of entering a + // transaction whose rollback can never be authenticated. + const digest = ownedTreeDigest(cacheBackup) + if (digest === null) throw new Error('cache backup tree could not be digested') + cacheOriginalDigest = digest cacheBackupComplete = true } backupsComplete = configBackupComplete && cacheBackupComplete diff --git a/packages/core/src/update/fallback-journal.ts b/packages/core/src/update/fallback-journal.ts index 61054fc..7022ae1 100644 --- a/packages/core/src/update/fallback-journal.ts +++ b/packages/core/src/update/fallback-journal.ts @@ -481,7 +481,7 @@ function isSafeJournal (journal: FallbackJournal): boolean { // would delete user state. const snapshot = path.resolve(journal.snapshotDirectory) if (path.dirname(snapshot) !== path.dirname(trackingPath)) return false - if (!/^\.nsolid-plugin-update-[A-Za-z0-9_]{6}$/.test(path.basename(snapshot))) return false + if (!/^\.nsolid-plugin-update-[A-Za-z0-9._-]{6}$/.test(path.basename(snapshot))) return false if (!journal.manifest.installationId || journal.manifest.installationId !== `${journal.manifest.harness}:fallback`) return false const expectedPaths = new Set([ trackingPath, diff --git a/packages/core/src/update/fallback-transaction.ts b/packages/core/src/update/fallback-transaction.ts index b15f55a..c8baff3 100644 --- a/packages/core/src/update/fallback-transaction.ts +++ b/packages/core/src/update/fallback-transaction.ts @@ -442,7 +442,11 @@ function harnessServerValue (harness: HarnessType, server: BundleDescriptor['mcp AUTH_ORG_ID: credentials.organizationId, MCP_URL: mcpUrl, }) - const formatted = applyHarnessWriteFormat(harness, { mcpServers: { [server.name]: expanded[0] } as unknown as Record }) + // The ref's `name` is only the map key metadata: reconciliation values, + // inserted records, and tracking ownership must use only renderable entry + // fields, never the key that holds the server's own name. + const { name: _name, ...entry } = expanded[0] + const formatted = applyHarnessWriteFormat(harness, { mcpServers: { [server.name]: entry } as unknown as Record }) return formatted.mcpServers[server.name] as unknown as Record } diff --git a/packages/core/src/update/mcp-edit.ts b/packages/core/src/update/mcp-edit.ts index 599f2d2..b738ab8 100644 --- a/packages/core/src/update/mcp-edit.ts +++ b/packages/core/src/update/mcp-edit.ts @@ -139,6 +139,14 @@ export function editMcpJsonBytes (raw: string, edit: McpByteEdit, options?: { mc apply([mcpKey, name], undefined) } for (const { server, field, value } of edit.setFields ?? []) { + // A field already holding the desired value is not rewritten: a no-op + // AST edit still re-serializes the node and would cosmetically drift + // bytes the transaction does not need to touch. + const liveTree = parseTree(current) + const liveMcp = liveTree ? findNodeAtLocation(liveTree, [mcpKey]) : undefined + const liveServer = liveMcp ? findNodeAtLocation(liveMcp, [server]) : undefined + const liveField = liveServer ? findNodeAtLocation(liveServer, [field]) : undefined + if (liveField && JSON.stringify(getNodeValue(liveField)) === JSON.stringify(value)) continue apply([mcpKey, server, field], value) } for (const { server, field } of edit.removeFields ?? []) { diff --git a/packages/core/test/integration/installer.test.ts b/packages/core/test/integration/installer.test.ts index 10a7eb3..7935929 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -712,14 +712,18 @@ describe('install()', () => { const claudeConfigPath = join(tmpDir, '.claude.json') const claudeConfig = readJsonFile<{ mcpServers: Record> }>(claudeConfigPath)! claudeConfig.mcpServers['nsolid-console'].user_token = 'user-secret' + // A user-authored `name` inside the entry is harness metadata, not a field + // the install writer renders; it must never become ownership evidence. + claudeConfig.mcpServers['nsolid-console'].name = 'my-friendly-name' writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2)) const second = await install({ harness: 'claude', bundlePath, skillsSource }) assert.strictEqual(second.success, true) - // The user field survives the reinstall merge... + // The user field AND the user-authored name survive the reinstall merge... const afterSecond = readJsonFile<{ mcpServers: Record> }>(claudeConfigPath)! assert.strictEqual(afterSecond.mcpServers['nsolid-console'].user_token, 'user-secret') + assert.strictEqual(afterSecond.mcpServers['nsolid-console'].name, 'my-friendly-name') assert.strictEqual(afterSecond.mcpServers['nsolid-console'].url, 'https://custom-mcp.example.com/entry') // ...and tracking records only the rendered owned fields, so a later @@ -730,7 +734,58 @@ describe('install()', () => { const tracked = tracking.mcpServers.find((entry) => entry.name === 'nsolid-console') assert.ok(tracked, 'server tracked') assert.ok(!Object.hasOwn(tracked.fields ?? {}, 'user_token'), 'user_token must not be tracked as owned') + assert.ok(!Object.hasOwn(tracked.fields ?? {}, 'name'), 'a user-authored name must not be tracked as owned') assert.ok(Object.hasOwn(tracked.fields ?? {}, 'url'), 'the rendered owned fields stay tracked') + + // A subsequent refresh of the same bundle preserves the user-authored + // name and leaves the entry bytes unchanged. + const bytesBeforeRefresh = readFileSync(claudeConfigPath, 'utf8') + const { refreshOwnedInstallation } = await import('../../src/update/fallback-transaction.js') + const refreshed = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource }) + assert.strictEqual(refreshed.success, true, JSON.stringify(refreshed)) + assert.strictEqual(readFileSync(claudeConfigPath, 'utf8'), bytesBeforeRefresh) + const afterRefresh = readJsonFile<{ mcpServers: Record> }>(claudeConfigPath)! + assert.strictEqual(afterRefresh.mcpServers['nsolid-console'].name, 'my-friendly-name') + assert.strictEqual(afterRefresh.mcpServers['nsolid-console'].user_token, 'user-secret') + }) + + it('keeps install-created MCP entries free of a name field across a refresh', async () => { + const { install } = await import('../../src/index.js') + const { readJsonFile } = await import('../../src/utils/config.js') + const bundle = createBundle({ + mcpServers: [ + { name: 'nsolid-console', url: '$' + '{MCP_URL}', headers: { 'X-Nsolid-Service-Token': '$' + '{AUTH_TOKEN}' } }, + ], + auth: { + type: 'oauth', + provider: 'nodesource', + accountsUrl: 'https://accounts.nodesource.com', + }, + }) + const bundlePath = writeBundle(bundle) + const skillsSource = createSkillSource('ns-test-skill') + seedCredentials({ + consoleUrl: 'https://test-org.saas.nodesource.io', + mcpUrl: 'https://custom-mcp.example.com/entry', + }) + + const first = await install({ harness: 'claude', bundlePath, skillsSource }) + assert.strictEqual(first.success, true) + + const claudeConfigPath = join(tmpDir, '.claude.json') + const installed = readJsonFile<{ mcpServers: Record> }>(claudeConfigPath)! + assert.strictEqual(Object.hasOwn(installed.mcpServers['nsolid-console'], 'name'), false, 'the install writer never renders a name field inside the entry') + + const { refreshOwnedInstallation } = await import('../../src/update/fallback-transaction.js') + const bytesBeforeRefresh = readFileSync(claudeConfigPath, 'utf8') + const refreshed = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource }) + assert.strictEqual(refreshed.success, true, JSON.stringify(refreshed)) + + // The refresh reconciliation values must not carry the server's own name + // into the entry: an install-created entry stays without that key. + const afterRefresh = readJsonFile<{ mcpServers: Record> }>(claudeConfigPath)! + assert.strictEqual(Object.hasOwn(afterRefresh.mcpServers['nsolid-console'], 'name'), false, 'the refresh must not add a name field to install-created entries') + assert.strictEqual(readFileSync(claudeConfigPath, 'utf8'), bytesBeforeRefresh) }) it('derives console MCP URL without appending /mcp when no explicit MCP URL is stored', async () => { diff --git a/packages/core/test/unit/mcp/mcp-config-writer.test.ts b/packages/core/test/unit/mcp/mcp-config-writer.test.ts index 5df6c00..26864aa 100644 --- a/packages/core/test/unit/mcp/mcp-config-writer.test.ts +++ b/packages/core/test/unit/mcp/mcp-config-writer.test.ts @@ -797,3 +797,29 @@ describe('removeMcpConfig', () => { assert.strictEqual(existsSync(configPath), false) }) }) + +describe('renderedMcpFieldNames', () => { + it('never reports the metadata name as a rendered entry field', async () => { + const { renderedMcpFieldNames } = await import('../../../src/mcp/mcp-config-writer.js') + + // The `name` on a McpServerRef is the map key metadata, never a field the + // install writer renders inside an entry; ownership evidence computed from + // it must not claim the key that holds the server's own name either. + const claude = renderedMcpFieldNames('claude', [serverA]) + assert.deepEqual([...claude['ns-benchmark']].sort(), ['headers', 'type', 'url']) + + const antigravity = renderedMcpFieldNames('antigravity', [serverA]) + assert.deepEqual([...antigravity['ns-benchmark']].sort(), ['headers', 'serverUrl']) + }) + + it('keeps the rendered field names stable when variables are expanded', async () => { + const { renderedMcpFieldNames } = await import('../../../src/mcp/mcp-config-writer.js') + + const claude = renderedMcpFieldNames('claude', [serverA], { + MCP_URL: 'https://benchmark.mcp.saas.nodesource.io/mcp', + AUTH_TOKEN: 'token', + AUTH_ORG_ID: 'org', + }) + assert.deepEqual([...claude['ns-benchmark']].sort(), ['headers', 'type', 'url']) + }) +}) diff --git a/packages/core/test/unit/update/antigravity-transaction.test.ts b/packages/core/test/unit/update/antigravity-transaction.test.ts index b3bfbdd..0a079af 100644 --- a/packages/core/test/unit/update/antigravity-transaction.test.ts +++ b/packages/core/test/unit/update/antigravity-transaction.test.ts @@ -374,4 +374,49 @@ describe('Antigravity staged plugin validation', () => { rmSync(home, { recursive: true, force: true }) } }) + + it('fails closed before mutation when the existing plugin root has no digestible tree', async () => { + // An empty existing plugin root cannot produce an authenticated backup + // digest, so rollback could never be proven: the transaction must abort + // in the backup phase, before any command runs. + const home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-agy-empty-root-')) + const previousHome = process.env.HOME + const previousUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home + try { + const pluginRoot = path.join(home, '.gemini', 'config', 'plugins', 'nsolid-plugin') + const manifestPath = path.join(home, '.gemini', 'config', 'import_manifest.json') + mkdirSync(pluginRoot, { recursive: true }) + writeFileSync(manifestPath, JSON.stringify({ imports: { 'nsolid-plugin': { name: 'nsolid-plugin' } } })) + const item = { + ...agyItem(), + steps: [{ kind: 'command' as const, description: 'agy sync', command: { executable: 'agy', args: ['sync'], timeoutMs: 1_000 } }], + } + let commands = 0 + const result = await executeAntigravityTransaction(item, { + run: async () => { + commands++ + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'ANTIGRAVITY_BACKUP_FAILED') + assert.equal(result.rollbackAttempted, false) + assert.equal(commands, 0, 'no mutation may run without a provable backup digest') + // The live (empty) root and manifest were never touched, and the + // incomplete backup containers were cleaned up. + assert.equal(existsSync(pluginRoot), true) + assert.equal(readdirSync(pluginRoot).length, 0) + assert.equal(readdirSync(path.dirname(manifestPath)).filter((name) => name.includes('.nsolid-manifest-backup-')).length, 0) + assert.equal(readdirSync(path.dirname(pluginRoot)).filter((name) => name.includes('.nsolid-plugin-backup-')).length, 0) + } finally { + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile + rmSync(home, { recursive: true, force: true }) + } + }) }) diff --git a/packages/core/test/unit/update/codex-transaction.test.ts b/packages/core/test/unit/update/codex-transaction.test.ts index fa472d1..6c3a0a2 100644 --- a/packages/core/test/unit/update/codex-transaction.test.ts +++ b/packages/core/test/unit/update/codex-transaction.test.ts @@ -340,6 +340,35 @@ describe('Codex update transaction', () => { assert.equal(existsSync(path.join(home, '.codex')), false) }) + it('fails closed before mutation when the existing cache has no digestible tree', async () => { + // An empty existing cache directory cannot produce an authenticated + // backup digest, so rollback could never be proven: the transaction must + // abort in the backup phase, before any command runs. + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + mkdirSync(cachePath, { recursive: true }) + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(path.dirname(configPath), { recursive: true }) + writeTomlFileSync(configPath, { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) + const originalConfig = readFileSync(configPath, 'utf8') + + let commands = 0 + const result = await executeCodexTransaction(item(cachePath), { + run: async () => { + commands++ + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_BACKUP_FAILED') + assert.equal(result.rollbackAttempted, false) + assert.equal(commands, 0, 'no mutation may run without a provable backup digest') + assert.equal(readFileSync(configPath, 'utf8'), originalConfig) + // The incomplete backup containers were cleaned up. + assert.equal(readdirSync(path.dirname(configPath)).filter((name) => name.includes('.nsolid-config-backup-')).length, 0) + assert.equal(readdirSync(path.dirname(cachePath)).filter((name) => name.includes('.nsolid-cache-backup-')).length, 0) + }) + it('reports the preserved backup locations in the tree-termination timeout error', async () => { const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') mkdirSync(cachePath, { recursive: true }) diff --git a/packages/core/test/unit/update/fallback-journal.test.ts b/packages/core/test/unit/update/fallback-journal.test.ts index c6c91da..2a99a25 100644 --- a/packages/core/test/unit/update/fallback-journal.test.ts +++ b/packages/core/test/unit/update/fallback-journal.test.ts @@ -452,6 +452,37 @@ describe('fallback journal ownership validation', () => { assert.equal(existsSync(journal.journalPath), true) }) + it('accepts a portable mkdtemp suffix containing dot and dash characters', async () => { + // POSIX mkdtemp only promises suffix characters from the portable filename + // set, which includes `.` and `-`: a valid snapshot produced by such a + // libc must still pass validation and restore. + const { trackingPath, skillPath, linkPath, manifest, trackingJson } = setupValidFixture() + const { journal } = await beginFallbackJournal(manifest) + const renamed = path.join(path.dirname(journal.snapshotDirectory), '.nsolid-plugin-update-a.c-01') + renameSync(journal.snapshotDirectory, renamed) + const rebased = { + ...journal, + snapshotDirectory: renamed, + entries: journal.entries.map((entry) => entry.backup === undefined + ? entry + : { ...entry, backup: path.join(renamed, path.basename(entry.backup)) }), + } + // Persist the rebased journal so later reloads keep the renamed snapshot. + writeFileSync(journal.journalPath, JSON.stringify(rebased, null, 2) + '\n') + // The live owned bytes were mutated like a crashed child would leave them. + writeFileSync(path.join(skillPath, 'SKILL.md'), '# mutated\n') + writeFileSync(linkPath, 'mutated\n') + writeFileSync(trackingPath, JSON.stringify({ ...JSON.parse(trackingJson), installedAt: 'mutated' })) + const captured = await captureFallbackJournalState(rebased) + + assert.equal(await restoreFallbackJournal(captured), true) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(readFileSync(linkPath, 'utf8'), 'link\n') + assert.equal(readFileSync(trackingPath, 'utf8'), trackingJson) + assert.equal(existsSync(rebased.journalPath), false) + assert.equal(existsSync(renamed), false) + }) + it('aborts the restore without touching live paths when a non-tracking backup was tampered with', async () => { const { skillPath, manifest } = setupValidFixture() const { journal } = await beginFallbackJournal(manifest) diff --git a/packages/core/test/unit/update/fallback-transaction.test.ts b/packages/core/test/unit/update/fallback-transaction.test.ts index 9283540..0e98db0 100644 --- a/packages/core/test/unit/update/fallback-transaction.test.ts +++ b/packages/core/test/unit/update/fallback-transaction.test.ts @@ -339,7 +339,6 @@ describe('fallback refresh transaction', () => { 'url = "https://new.example.com/mcp"', 'user_token = "user-secret"', 'headers = {}', - 'name = "alpha-console"', ].join('\r\n') + '\r\n' assert.equal(final, expectedConfig) const tracking = await readTrackingFile() @@ -351,7 +350,9 @@ describe('fallback refresh transaction', () => { // record it as owned, or refresh #2 would delete it. assert.equal(tracked?.fields?.user_token, undefined) assert.equal(tracked?.fields?.headers, valueDigest({})) - assert.equal(tracked?.fields?.name, valueDigest('alpha-console')) + // The server's name is entry metadata, not a rendered field: it must never + // be written into the entry or claimed as ownership evidence. + assert.equal(tracked?.fields?.name, undefined) rmSync(sourceRoot, { recursive: true, force: true }) }) @@ -413,7 +414,7 @@ describe('fallback refresh transaction', () => { const trackingAfterFirst = await readTrackingFile() const trackedAfterFirst = trackingAfterFirst?.mcpServers.find((entry) => entry.name === 'alpha-console') // Only the desired-render fields enter tracking; user_token is foreign. - assert.deepEqual(Object.keys(trackedAfterFirst?.fields ?? {}).sort(), ['headers', 'name', 'url']) + assert.deepEqual(Object.keys(trackedAfterFirst?.fields ?? {}).sort(), ['headers', 'url']) assert.equal(trackedAfterFirst?.fields?.url, valueDigest('https://new.example.com/mcp')) const second = await refreshOwnedInstallation({ harness: 'codex', bundlePath, skillsSource: sourceRoot }) @@ -425,7 +426,7 @@ describe('fallback refresh transaction', () => { assert.equal(configAfterSecond.includes('user_token = "user-secret"'), true, 'refresh #2 must not delete the foreign field it never owned') const trackingAfterSecond = await readTrackingFile() const trackedAfterSecond = trackingAfterSecond?.mcpServers.find((entry) => entry.name === 'alpha-console') - assert.deepEqual(Object.keys(trackedAfterSecond?.fields ?? {}).sort(), ['headers', 'name', 'url']) + assert.deepEqual(Object.keys(trackedAfterSecond?.fields ?? {}).sort(), ['headers', 'url']) assert.equal(trackedAfterSecond?.fields?.url, valueDigest('https://new.example.com/mcp')) rmSync(sourceRoot, { recursive: true, force: true }) }) @@ -502,7 +503,7 @@ describe('fallback refresh transaction', () => { const trackingAfterSecond = await readTrackingFile() const trackedAfterSecond = trackingAfterSecond?.mcpServers.find((entry) => entry.name === 'alpha-console') assert.equal(trackedAfterSecond?.fields?.url, valueDigest('https://two.example.com/mcp')) - assert.deepEqual(Object.keys(trackedAfterSecond?.fields ?? {}).sort(), ['headers', 'name', 'url']) + assert.deepEqual(Object.keys(trackedAfterSecond?.fields ?? {}).sort(), ['headers', 'url']) rmSync(sourceRoot, { recursive: true, force: true }) })