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/.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..65eebfe --- /dev/null +++ b/openspec/changes/add-update-flow/design.md @@ -0,0 +1,695 @@ +# 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 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: + +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 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. +- Owns overall exit/result semantics, not target-specific commands. + +`packages/core/src/update/inventory.ts` + +- 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 (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 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. + +`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. +- Returns unsupported for workspaces, `npx`, local checkouts, Volta/Yarn/Bun ownership, mismatched global roots, and ambiguous launchers. + +`packages/core/src/update/command-runner.ts` + +- 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 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` + +- 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/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 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 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 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 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. 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. + +### 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/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 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/` + +- 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 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 + +`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 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: + +```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 +import type { HarnessType } from '../types.js' + +export type UpdateTarget = + | 'cli' + | 'claude' + | 'codex' + | 'opencode' + | 'antigravity' + | 'pi' + +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 +} + +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 + manifestPath: string + contentDigest: 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 +} + +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 } + +export interface UpdateInstallation { + installationId: string + target: UpdateTarget + ownership: UpdateOwnership + installed: boolean + source: UpdateSource + version: VersionInfo +} + +export interface UpdateOptions { + harness?: HarnessType + all?: boolean + check?: boolean + yes?: boolean + json?: boolean + verbose?: boolean + noColor?: boolean + commandRunner?: CommandRunner + 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 + ownership: UpdateOwnership + installed: boolean + source: UpdateSource + version: VersionInfo + artifact?: ResolvedArtifactIdentity + fallbackTransaction?: FallbackTransactionIdentity + steps: readonly UpdatePlanStep[] + rollbackSteps: readonly UpdatePlanStep[] + planningError?: UpdateError + requiresConfirmation: boolean + 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?: UpdateError +} + +export interface UpdateSummary { + checkOnly: boolean + results: UpdateResult[] + counts: Record + success: boolean + exitCode: 0 | 1 | 2 +} + +export interface CommandSpec { + executable: string + args: readonly string[] + cwd?: string + env?: Readonly> + 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 + readonly ownership: UpdateOwnership + plan(installation: UpdateInstallation, context: UpdateContext): Promise + execute(item: UpdatePlanItem, context: UpdateContext): Promise +} +``` + +Rules enforced by these contracts: + +- `check` stops after planning/version resolution and never calls `execute`. +- 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. +- 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 `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/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 | +| 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 | 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 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 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. + +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 + +### 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 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) + 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 FS + 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 installation, sequentially + Coordinator->>Strategy: execute(planItem) + 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 + 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. + +### 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 +sequenceDiagram + participant Updater + participant FS + participant AGY + + 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 + Updater->>FS: remove temporary backup after root + registration validation + else install or validation fails + Updater->>FS: restore staged root and import registration + 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. 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. +- 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 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. 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 + +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. 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. + +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/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 new file mode 100644 index 0000000..4e73daf --- /dev/null +++ b/openspec/changes/add-update-flow/proposal.md @@ -0,0 +1,96 @@ +# 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 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 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 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. + +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 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. +- 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. + +## 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, package-manager detection, and the package-internal owned-asset refresh entrypoint. +- `packages/core/src/harnesses/` — harness-owned update strategies and native/fallback installation detection. +- `~/.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 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. +- `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, 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 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. +- 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. + +Acceptance tests: + +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 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 new file mode 100644 index 0000000..3505b70 --- /dev/null +++ b/openspec/changes/add-update-flow/specs/release-versioning/spec.md @@ -0,0 +1,171 @@ +# 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 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` +- `.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, 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. + +#### 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..72d67be --- /dev/null +++ b/openspec/changes/add-update-flow/specs/update-flow/spec.md @@ -0,0 +1,538 @@ +# 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 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` + +#### 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 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` + +#### Scenario: Registry lookup fails + +**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 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 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, 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 + +**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** 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, 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 with code `1` + +#### 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 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 + +#### 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`, 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 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 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. + +#### 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: 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 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, 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** 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 + +#### Scenario: Update Claude native plugin + +**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** 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 + +#### Scenario: Update Codex native plugin + +**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 ` +**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 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 + +#### 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 with code `1` +**And** provides the exact detected plugin remove/add commands for manual recovery + +#### Scenario: Update Pi package-owned skills + +**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** 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** 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 +**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, 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** 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 `nsolid-plugin` registry, tarball, stable version, and integrity in the plan +**And** requires an available supported package executor +**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 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 +**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 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 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 +**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 + +**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 +**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 +**And** a mutating invocation exits with code `2`, while a read-only check exits with code `0` + +#### 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 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 + +**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** 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** 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` + +#### 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 entry in the matching detected import manifest while preserving unrelated imports +**And** reports whether rollback succeeded +**And** exits with code `1` +**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 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` + +#### 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 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` +**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** 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. + +#### 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** source identity is preserved for every supported native/package-owned update +**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 + +**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 new file mode 100644 index 0000000..9de6507 --- /dev/null +++ b/openspec/changes/add-update-flow/tasks.md @@ -0,0 +1,106 @@ +# Tasks + +## Task 1: Define update contracts and semantic-version behavior + +- [ ] **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, 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 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/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. 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, 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 + +- [ ] **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, 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 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.” + +## Task 6: Implement Claude and Codex native strategies + +- [ ] **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`, `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 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/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 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.” + +## Task 9: Build the coordinator and programmatic API + +- [ ] **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 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 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 + +- [ ] **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, 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 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 + +- [ ] **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. 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 + +- [ ] **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.” + +## 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, the packaged internal refresh binary, unchanged public install entrypoints, and same-version Pi/core dependency resolution. 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..2736eb7 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": { ".": { @@ -31,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/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..b950fd2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,9 +27,11 @@ import { readTrackingFile, addTrackedSkills, removeTrackedSkills, + setTrackingBundleVersion, } from './skills/skill-tracker.js' import { writeMcpConfig, + renderedMcpFieldNames, removeMcpConfig, addTrackedMcps, removeTrackedMcps, @@ -374,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) { @@ -383,6 +386,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 +840,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/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 64c5976..e75fa10 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,173 +176,24 @@ 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 +/** + * 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 + // 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)])) } /** @@ -347,7 +220,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/mcp/mcp-tracker.ts b/packages/core/src/mcp/mcp-tracker.ts index 5c30067..e53cdb5 100644 --- a/packages/core/src/mcp/mcp-tracker.ts +++ b/packages/core/src/mcp/mcp-tracker.ts @@ -4,6 +4,7 @@ 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 { harnessMcpKey, readMcpFieldDigests } from '../update/mcp-lookup.js' export type { McpTrackingEntry } from '../skills/skill-tracker.js' @@ -18,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 { @@ -30,15 +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 = fields } else { tracking.mcpServers.push({ name: entry.name, - configPath: path.resolve(resolveHome(entry.configPath)), + configPath, harness, configuredAt: now, + fields, }) } } diff --git a/packages/core/src/skills/skill-linker.ts b/packages/core/src/skills/skill-linker.ts index 18715eb..a0b51fe 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' @@ -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 @@ -60,7 +108,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) { @@ -89,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/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..eef3dc5 --- /dev/null +++ b/packages/core/src/update/antigravity-transaction.ts @@ -0,0 +1,349 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync } from 'node:fs' +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 { copyOwnedPath, createSiblingBackupPath, ownedPathKind, removeOwnedPath } from './fs-transaction.js' +import type { SiblingBackupPath } from './fs-transaction.js' +import { runTransactionCommands } from './transaction-commands.js' +import { nativePayloadTreeDigest, sha256Hex } from './native-payload.js' + +export interface AntigravityTransactionResult { + success: boolean + rollbackAttempted: boolean + rollbackSucceeded?: boolean + error?: UpdateError +} + +interface AntigravityBackupSnapshot { + 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. */ +export interface AntigravityTransactionDependencies { + restoreState?: () => Promise +} + +export async function executeAntigravityTransaction ( + item: UpdatePlanItem, + 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' } } + } + 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 + 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, 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. + 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 + // 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) + // Persist the original digests before any mutation; the backup must + // 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) { + const originalManifest = await readFile(manifestPath) + originalManifestText = originalManifest.toString('utf8') + originalManifestDigest = sha256Hex(originalManifest) + await writeFile(manifestBackup, originalManifest, { 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) + // 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) { + 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}`, + }, + } + } + await attemptRollback() + 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) || + (originalManifestText !== undefined && !preservesUnrelatedManifestBytes(originalManifestText, readFileSync(manifestPath, 'utf8')))) { + await attemptRollback() + 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 { + if (backupsComplete && mutationStarted) await attemptRollback() + 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 (!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 + 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 === 'nsolid-plugin' || isPluginImport(value)) + } + return false + } catch { + return false + } +} + +/** 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. + * + * 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' || 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) +} + +async function restore ( + snapshot: AntigravityBackupSnapshot, + authorized: { rootDigest?: string | null; manifestDigest?: string | null } +): Promise { + try { + 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. + 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) + } 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 + 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-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/claude-transaction.ts b/packages/core/src/update/claude-transaction.ts new file mode 100644 index 0000000..044b765 --- /dev/null +++ b/packages/core/src/update/claude-transaction.ts @@ -0,0 +1,446 @@ +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. + // 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 + 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-config.ts b/packages/core/src/update/codex-config.ts new file mode 100644 index 0000000..a501e42 --- /dev/null +++ b/packages/core/src/update/codex-config.ts @@ -0,0 +1,198 @@ +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 matchingHeaders = lines.filter((line) => isMatchingPluginTableHeader(line.text, pluginId)) + 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) +} + +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[] { + 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..c0e2313 --- /dev/null +++ b/packages/core/src/update/codex-transaction.ts @@ -0,0 +1,513 @@ +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 { nativePayloadTreeDigest, sha256Hex } from './native-payload.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; 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 ( + 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 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, originalDigest: configOriginalDigest }, + cache: { target: cachePath, backup: cacheBackup, existed: cacheExisted, complete: cacheBackupComplete, originalDigest: cacheOriginalDigest }, + authorized: { config: authorizedConfigDigest ?? liveConfigDigestAt(configPath), cache: authorizedCacheDigest ?? ownedTreeDigest(cachePath) }, + }) + + 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') + configOriginalDigest = sha256Hex(original) + await writeFile(backupPath, original, { mode: 0o600 }) + configBackupComplete = true + } + if (cacheExisted) { + await copyOwnedPath(cachePath, cacheBackup) + // 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 + } 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) + // 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) { + preserveBackup = true + return { + success: false, + rollbackAttempted: false, + 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}`, + }, + } + } + // 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 { + 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 + 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 + 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 digest = selectedPayload ? nativePayloadDigest(selectedPayload) : undefined + if (!selectedPayload || !digest || digest !== item.artifact.contentDigest) { + rollbackAttempted = true + 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 + 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 + 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 + 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 + 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 { + // A failed restore keeps both backup containers for manual recovery. + if (rollbackSucceeded === false) preserveBackup = true + 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 } +} + +/** 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 + // 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) + await copyOwnedPath(snapshot.cache.backup, snapshot.cache.target) + } else if (!snapshot.cache.existed) { + await removeOwnedPath(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, + 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..5a496cb --- /dev/null +++ b/packages/core/src/update/command-runner.ts @@ -0,0 +1,508 @@ +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(windowsTaskkillPath(process.env.SystemRoot), ['/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) +} + +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) +} + +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..7a60cdb --- /dev/null +++ b/packages/core/src/update/coordinator.ts @@ -0,0 +1,414 @@ +import type { HarnessType } from '../types.js' +import { rm } from 'node:fs/promises' +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, + 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, + // The immutable identity the lookup proved (a resolved commit or a + // verified snapshot) becomes part of the planned source so the + // execution guard validates the pinned identity, not a mutable ref. + source: withPinnedMarketplaceCommit(installation.source, lookup.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) + // 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 { + 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' +} + +/** + * 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/fallback-journal.ts b/packages/core/src/update/fallback-journal.ts new file mode 100644 index 0000000..7022ae1 --- /dev/null +++ b/packages/core/src/update/fallback-journal.ts @@ -0,0 +1,582 @@ +import { createHash, randomUUID } from 'node:crypto' +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' +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: 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[] +} + +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 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.ownedMcpConfigPaths, + ].map((value) => path.resolve(value)))] + const entries: FallbackJournalEntry[] = [] + try { + for (const [index, target] of paths.entries()) { + const kind = await pathKind(target) + const backup = path.join(snapshotDirectory, String(index)) + 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, + } + await writeDurable(journalPath, journal) + return { journal } + } catch (error) { + await rm(snapshotDirectory, { recursive: true, force: true }).catch(() => {}) + throw new Error('FALLBACK_BACKUP_FAILED', { cause: error }) + } +} + +/** + * 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 = 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, 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') + 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(() => {}) + 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 { + 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 }) + 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) => { + 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) { + 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) + } + return valid + } catch { + return false + } +} + +/** + * 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') { + // 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) + 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(() => {}) + } +} + +/** + * 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 }) + 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 */ } +} + +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') + 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 !== 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 + // 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, + ...journal.manifest.ownedSkillPaths, + ...journal.manifest.ownedLinkPaths, + ...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.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)) 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) { + 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 + } + 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 + if (expectedPaths.has(target)) entries.add(target) + } + return [...expectedPaths].every((target) => entries.has(target)) +} + +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 + } +} + +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 || !trackingEntry.backup) 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 + } +} diff --git a/packages/core/src/update/fallback-ownership.ts b/packages/core/src/update/fallback-ownership.ts new file mode 100644 index 0000000..97d78cf --- /dev/null +++ b/packages/core/src/update/fallback-ownership.ts @@ -0,0 +1,77 @@ +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' + +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 + // 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 + })) +} + +export function isCanonicalPath (value: string): boolean { + 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)) + // 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}`) +} + +/** + * 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 new file mode 100644 index 0000000..c8baff3 --- /dev/null +++ b/packages/core/src/update/fallback-transaction.ts @@ -0,0 +1,752 @@ +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, McpServerRef } from '../types.js' +import { validateBundle } from '../validate.js' +import { readJsonFile } from '../utils/config.js' +import { resolveHome, getSkillsDir, getAuthFilePath } from '../utils/path.js' +import { deriveMcpUrlFromConsoleUrl } from '../auth/mcp-url.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, 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 { 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, valueDigest } from './mcp-lookup.js' +import { readPackageVersion } from './package-manager.js' +import { isStableVersion } from './version.js' +import { isCanonicalPath, matchesTrackedOwnership, mcpRecordIsExclusivelyOwned } 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 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 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 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 + // 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 }) + 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 }) + } + } + 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 }) + } + } + 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 { + success: false, + rollbackAttempted: false, + error: { code: 'FALLBACK_BACKUP_FAILED', message: 'Owned fallback backup could not be completed' }, + } + } + + 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. 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: desiredMcpValues, + 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, desiredMcpValues, 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)) + .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]) { + if (journal && isJournalEntry(journal, ownedPath)) { + journal = await applyFallbackEntry(journal, ownedPath) + } else { + await rm(ownedPath, { recursive: true, force: true }) + } + } + // 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 (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]) + } + } + } + + 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) + } + } + + 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, desiredMcpValues) + 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({ 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 }) + } + 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 { + 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, + }) + // 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 +} + +/** + * 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 { + 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 = 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 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 rollbackFallback (options: { + backupDir: string + trackingBackup: string + 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) { + const backup = path.join(options.linksBackup, encodeURIComponent(linkPath)) + if (existsSync(backup)) await cp(backup, linkPath, { recursive: true, 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) + 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 buildTrackingUpdate ( + original: TrackingData, + harness: HarnessType, + destination: string, + bundle: BundleDescriptor, + 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 + 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 + 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], + }) + } + } + + tracking.mcpServers = tracking.mcpServers.filter((entry) => !(entry.harness === harness && stale.has(entry.name))) + const now = new Date().toISOString() + 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 = fields + } else { + tracking.mcpServers.push({ name: server.name, configPath: path.resolve(configPath), harness, configuredAt: now, fields }) + } + } + tracking.bundleVersion = bundle.version + tracking.bundleVersions = { ...(tracking.bundleVersions ?? {}), [harness]: bundle.version } + return tracking +} + +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..591ccd6 --- /dev/null +++ b/packages/core/src/update/index.ts @@ -0,0 +1,47 @@ +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 } 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' +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, + NativeEvidence, + 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..ba98d0c --- /dev/null +++ b/packages/core/src/update/inventory.ts @@ -0,0 +1,697 @@ +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' +import { nativePayloadTreeDigest } from './native-payload.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 knownMarketplacesPath = resolveHome('~/.claude/plugins/known_marketplaces.json') + const data = safeReadJson(installedPath) + const knownMarketplaces = safeReadJson(knownMarketplacesPath) ?? {} + 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, + 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 + ? 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, + 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}`, + 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 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') + 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), + }, + }, ...unsupportedInstallations] +} + +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' + // 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: payloadRoot, + manifestPath: segments[segments.length - 1], + freshness, + contentDigest: contentDigestForSnapshot(payloadRoot), + } + } + } + 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): string | undefined { + return nativePayloadTreeDigest(root) +} + +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/mcp-edit.ts b/packages/core/src/update/mcp-edit.ts new file mode 100644 index 0000000..b738ab8 --- /dev/null +++ b/packages/core/src/update/mcp-edit.ts @@ -0,0 +1,256 @@ +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) + 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' + } + 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]) + 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 ?? []) { + // 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 ?? []) { + 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..2763931 --- /dev/null +++ b/packages/core/src/update/mcp-lookup.ts @@ -0,0 +1,121 @@ +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' + +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 } +} + +/** + * 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/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..bddd8a8 --- /dev/null +++ b/packages/core/src/update/mcp-toml-edit.ts @@ -0,0 +1,659 @@ +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 + // 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])) + } + 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 new file mode 100644 index 0000000..ab3d3d5 --- /dev/null +++ b/packages/core/src/update/native-evidence.ts @@ -0,0 +1,85 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import type { ResolvedArtifactIdentity, UpdateError, UpdateInstallationMetadata, UpdateSource } from './types.js' +import { nativePayloadTreeDigest } from './native-payload.js' + +const FULL_COMMIT = /^[0-9a-f]{40}$/i + +export function nativeSourceHonorsArtifact ( + source: UpdateSource, + artifact: ResolvedArtifactIdentity | undefined +): boolean { + 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) && payloadMatches + } + + return versionSource.kind === 'local-snapshot' && + versionSource.freshness === 'verified' && + path.resolve(versionSource.root) === path.resolve(artifact.root) && + versionSource.contentDigest === artifact.contentDigest +} + +/** + * 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) +} + +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 + } + }) +} + +/** + * 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 { + return repository.trim().replace(/\.git\/?$/i, '').replace(/\/$/, '').toLowerCase() +} diff --git a/packages/core/src/update/native-payload.ts b/packages/core/src/update/native-payload.ts new file mode 100644 index 0000000..2e12ca8 --- /dev/null +++ b/packages/core/src/update/native-payload.ts @@ -0,0 +1,190 @@ +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 +} + +/** 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) + 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/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..54e783b --- /dev/null +++ b/packages/core/src/update/strategies/claude.ts @@ -0,0 +1,85 @@ +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 { 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', + ownership: 'native-plugin', + + async plan (installation: UpdateInstallation): Promise { + const source = installation.source + if (source.kind !== 'claude-marketplace' || !isMutableVersion(installation)) return planItem(installation) + 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' }) + } + 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) + 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.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, rollback: { attempted: false } }) + }, +} 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..8f9eaa6 --- /dev/null +++ b/packages/core/src/update/strategies/codex.ts @@ -0,0 +1,107 @@ +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 { 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' + +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) + 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' }) + } + 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) + 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) { + 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..7490e6a --- /dev/null +++ b/packages/core/src/update/strategies/fallback.ts @@ -0,0 +1,318 @@ +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, getSkillsDir, resolveHome } from '../../utils/path.js' +import { getAdapter } from '../../harnesses/index.js' +import { getHarnessSkillsPath } from '../../skills/skill-linker.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' +import { harnessMcpKey, readMcpFieldDigests } from '../mcp-lookup.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 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 update --harness ${installation.target} --yes`, + `pnpm --package=nsolid-plugin@${installation.version.latest ?? ''} dlx nsolid-plugin update --harness ${installation.target} --yes`, + ], + } + } + 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) + const planned = 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 + ) + // 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 { + 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}`) + if (journal) journal = await reloadFallbackJournal(journal) + 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 }) + } + // 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)) { + 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(() => {}) + if (!preserveRecoveryArtifacts) { + for (const directory of item.temporaryDirectories ?? []) { + await rm(directory, { 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 + 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, + trackingPath, + trackingDigest: digest, + nonce: randomUUID(), + ownedSkillPaths: skills.map((skill) => path.resolve(skill.path)), + 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(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 +} + +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 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 +} + +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) => { + const skillPath = entry.paths?.[harness] ?? entry.path + return !path.isAbsolute(skillPath) || !existsSync(skillPath) + })) return false + const scopedMcp = tracking.mcpServers.filter((entry) => entry.harness === harness) + 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/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..3913e6c --- /dev/null +++ b/packages/core/src/update/types.ts @@ -0,0 +1,385 @@ +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 + /** Repo-relative POSIX subdirectory holding the installable payload ('' = repository root). */ + payloadPath?: 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 + /** Shared secret authenticating the child transaction (never authorizing restores). */ + nonce?: string + ownedSkillPaths: readonly string[] + ownedLinkPaths: readonly string[] + ownedMcpFields: readonly { + configPath: string + server: string + 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 = + | { + 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 } + +/** 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. */ + 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[] + /** Native marketplace records whose exact bytes must still match at execution. */ + nativeEvidence?: readonly NativeEvidence[] +} + +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 + /** 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 { + 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..4dfff5d --- /dev/null +++ b/packages/core/src/update/version-source.ts @@ -0,0 +1,484 @@ +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' +import { gitArchivePayloadDigest, nativePayloadTreeDigest } from './native-payload.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) { + // 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: payloadRoot, 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 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: contentDigest ?? manifestDigest, payloadPath: payloadPath || undefined } } + : { 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') } + 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: contentDigest! } } + : { 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 } +} + +/** 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) : '' +} + +function sha256 (value: string | Uint8Array): string { + return createHash('sha256').update(value).digest('hex') +} + +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 = 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') + 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/installer.test.ts b/packages/core/test/integration/installer.test.ts index 653bf7e..7935929 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -685,6 +685,109 @@ 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' + // 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 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 + // 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 ?? {}, '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 () => { const { install } = await import('../../src/index.js') const { readJsonFile } = await import('../../src/utils/config.js') 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/mcp/mcp-config-writer.test.ts b/packages/core/test/unit/mcp/mcp-config-writer.test.ts index 137c0bb..26864aa 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') @@ -758,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/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/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 new file mode 100644 index 0000000..0a079af --- /dev/null +++ b/packages/core/test/unit/update/antigravity-transaction.test.ts @@ -0,0 +1,422 @@ +import { 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 { 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 { + 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' }] })) + const digest = nativePayloadDigest(root) + + assert.equal(validateStagedPlugin(root, manifest, '1.0.0'), false) + 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('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. + 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) + } + }) + + 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 () => { + 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 }) + } + }) + + 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/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/claude-transaction.test.ts b/packages/core/test/unit/update/claude-transaction.test.ts new file mode 100644 index 0000000..29f4934 --- /dev/null +++ b/packages/core/test/unit/update/claude-transaction.test.ts @@ -0,0 +1,640 @@ +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' + +// 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 + 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 mode even + // though the live file existed with 0644 before the restore. + assert.equal(statSync(fixture.registryPath).mode & 0o777, privateFileMode) + 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 mode afterwards. + const restored = await restoreClaudeNativeState({} as Parameters[0], registration) + assert.equal(restored, true) + assert.equal(statSync(fixture.registryPath).mode & 0o777, privateFileMode) + 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, 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, 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. + 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, 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) + // 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/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..6c3a0a2 --- /dev/null +++ b/packages/core/test/unit/update/codex-transaction.test.ts @@ -0,0 +1,529 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +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' +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 +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('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 }) + 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: [] })) + 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: plannedDigest, + } + + 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) + }) + + 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 }) + 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) + }) + + 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/command-runner.test.ts b/packages/core/test/unit/update/command-runner.test.ts new file mode 100644 index 0000000..9c4cf30 --- /dev/null +++ b/packages/core/test/unit/update/command-runner.test.ts @@ -0,0 +1,313 @@ +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, 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', + 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..d0c5614 --- /dev/null +++ b/packages/core/test/unit/update/coordinator.test.ts @@ -0,0 +1,414 @@ +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, withPinnedMarketplaceCommit } from '../../../src/update/coordinator.js' +import { fallbackJournalPath } from '../../../src/update/fallback-journal.js' +import { getTrackingFilePath } from '../../../src/utils/path.js' +import type { ResolvedArtifactIdentity, UpdatePlanItem, UpdateSource } 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, + temporaryDirectories: [transactionDirectory], + } + + 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, + temporaryDirectories: [transactionDirectory], + } + + 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 }) + }) +}) + +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-journal.test.ts b/packages/core/test/unit/update/fallback-journal.test.ts new file mode 100644 index 0000000..2a99a25 --- /dev/null +++ b/packages/core/test/unit/update/fallback-journal.test.ts @@ -0,0 +1,530 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +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' +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 { getSkillsDir, 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)!, + 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') + 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, 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) + 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) + // 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 () => { + 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('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') + 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, kind: 'file' as const } + : 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) + 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 }) + }) + + 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('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) + 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 } { + 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)!, + 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..c7223e6 --- /dev/null +++ b/packages/core/test/unit/update/fallback-ownership.test.ts @@ -0,0 +1,104 @@ +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +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', () => { + 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) + }) +}) + +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 new file mode 100644 index 0000000..5c01510 --- /dev/null +++ b/packages/core/test/unit/update/fallback-strategy.test.ts @@ -0,0 +1,299 @@ +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 { 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 { + 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('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 + 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 + 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(' update --harness opencode --yes') && !command.includes(' --transaction '))) + } 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 + rmSync(home, { recursive: true, force: true }) + } + }) +}) + +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 new file mode 100644 index 0000000..0e98db0 --- /dev/null +++ b/packages/core/test/unit/update/fallback-transaction.test.ts @@ -0,0 +1,1384 @@ +import { afterEach, beforeEach, describe, it, mock } from 'node:test' +import assert from 'node:assert/strict' +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 } 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' +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 +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('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 = {}', + ].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) + // 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({})) + // 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 }) + }) + + 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', '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', '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', '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 }) + 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') + 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 }) + }) +}) + +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 + 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 + 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 + else process.env.USERPROFILE = previousUserProfile + rmSync(movedHome, { recursive: true, force: true }) + } + // Neither the planned nor the moved canonical path was created. + assert.equal(existsSync(fixture.canonicalPath), false) + assert.equal(movedCanonicalExists, 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/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..205bdf3 --- /dev/null +++ b/packages/core/test/unit/update/inventory.test.ts @@ -0,0 +1,438 @@ +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 { 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 +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('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') + 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.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.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 }) + } + }) + + 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('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' }] }, + }) + + 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/mcp-edit.test.ts b/packages/core/test/unit/update/mcp-edit.test.ts new file mode 100644 index 0000000..bc8daf1 --- /dev/null +++ b/packages/core/test/unit/update/mcp-edit.test.ts @@ -0,0 +1,204 @@ +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, 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' + +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('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') + 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..f023a14 --- /dev/null +++ b/packages/core/test/unit/update/mcp-toml-edit.test.ts @@ -0,0 +1,244 @@ +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('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' + + 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/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..056fa3b --- /dev/null +++ b/packages/core/test/unit/update/strategies.test.ts @@ -0,0 +1,571 @@ +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' +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 + 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 { + 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, + ownership: target === 'pi' ? 'package-owned' : 'native-plugin', + installed: true, + source, + version: { current: undefined, latest: '1.0.1', status: 'update-available' }, + metadata: { + nativeEvidence: [{ path: evidencePath, digest: createHash('sha256').update(evidence).digest('hex') }], + }, + } +} + +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 = { ...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('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 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) + 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 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, [ + '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 = { ...candidate.metadata, packageRoot: oldPayload, configPath: installedPath } + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: nativePayloadDigest(newPayload)!, + } + 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 = { ...candidate.metadata, configPath: installedPath } + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: nativePayloadDigest(newPayload)!, + } + 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 = { ...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') + 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 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, [ + '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..0156727 --- /dev/null +++ b/packages/core/test/unit/update/version-source.test.ts @@ -0,0 +1,254 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { isSafeManifestPath, readArchiveWithLimit, 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)) + }) +}) + +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/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/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 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) }