diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4398568..905197c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,4 +35,5 @@ jobs: - run: pnpm test env: NSOLID_TEST_CORE_ALREADY_BUILT: '1' + NSOLID_TEST_REQUIRE_BUILD: '1' - run: node scripts/test-marketplace-install.js diff --git a/.gitignore b/.gitignore index 9b5b922..d359b15 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ packages/pi-plugin/skills/ # OpenCode .opencode/sessions/ .opencode/logs/ -.codegraph/ .opencode/plans .opencode/reviews - +.codegraph/ +.codegraph diff --git a/README.md b/README.md index 8bb3d41..0bb43f5 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,29 @@ 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 `. +When the CLI is launched from a workspace checkout, a local path, `npx`, Volta, or another wrapper, it cannot prove which (if any) global installation it belongs to, so `update` reports the launch as `unsupported`: a read-only `update --check` exits `0` (and does not probe npm/pnpm global roots), a mutating `update` exits `2` without modifying anything, and both print safe exact-version manual install commands for npm and pnpm. + +A fallback refresh that would add MCP servers while no valid NodeSource credentials are available fails before any change with `MCP_RECONCILIATION_REQUIRED`; run `nsolid-plugin setup --harness opencode` (or the tracked harness) to authenticate, then retry the update. The refresh never applies a skills-only partial update in that state. + 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. ### Claude Code @@ -215,10 +236,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/docs/update-lifecycle.md b/docs/update-lifecycle.md new file mode 100644 index 0000000..155b63d --- /dev/null +++ b/docs/update-lifecycle.md @@ -0,0 +1,75 @@ +# Update lifecycle + +This guide describes the fallback and native update lifecycle implemented by the +update coordinator. A check plan is read-only; a mutation plan captures verified +artifact identity, trusted ownership evidence, and (for fallback) a nonce-bound +transaction manifest before execution. + +## States and transitions + +1. **Planned** — inventory and version resolution produce an `UpdatePlan`. A + mutable fallback plan also owns a private manifest staging directory. No + live destination is changed during planning. +2. **Awaiting approval** — the coordinator may return a confirmation-required + result. Planning resources are released only when no recovery or execution + evidence requires preservation. +3. **Journal open / mutating** — the fallback parent snapshots authenticated + owned paths and obtains an in-memory journal handle. The child must validate + the manifest, nonce, result containment, and frontier evidence before it + claims mutation authority. +4. **Command finished** — a zero exit code is not enough. The command runner + must report `treeTerminated: true` before the parent can reclaim authority or + roll back. An unconfirmed tree leaves recovery state unresolved. +5. **Committed** — after the parent reclaims the owner handle, journal evidence + and postconditions prove the update. The journal commits and removes only + authenticated backup/quarantine material. +6. **Recovered or preserved** — a confirmed failure goes through journal-owned + restore. A failed proof, concurrent change, or incomplete durable journal + operation preserves the journal, snapshot, quarantine, and affected live + paths for the next recovery decision. Next-run check mode only reports this + state; a non-check plan may restore authenticated owned paths, but the + journal and snapshot remain and block a new mutation. + +## Cleanup ownership + +- The **coordinator** releases verified npm artifact downloads and directories + recorded by the plan as planning-owned. It never derives a deletion target + from command arguments or child-reported paths. +- The **fallback strategy** owns its execution workspace and fresh child-result + directory. Their cleanup decision is an internal execution outcome: + `{ result, planResources: 'release' | 'preserve' }`. Missing or malformed + authorization preserves planning resources. +- The **journal module** owns authenticated snapshots, backups, stages, + quarantines, recovery, and commit. Child preservation arrays are reporting + only and never authorize deletion or overwrite. +- Native strategy modules retain ownership of their own rollback bundles and + use their existing conservative preservation decisions. + +The two fallback execution directories remain separate. Their independent +containment identities and different preservation needs are security evidence; +combining them would not provide a meaningful simplification without weakening +replay, symlink/concurrent replacement, or detached-child recovery checks. + +## Public and internal results + +`UpdateStrategy.execute` and `executeUpdatePlan` keep their result-only public +interfaces. The coordinator consumes the fallback strategy's internal outcome, +but publishes only `UpdateResult` values inside `UpdateSummary`; `planResources` +is never present in public JSON. Public rollback and preservation fields are +reporting projections, not cleanup authority. + +## Safety invariants + +- Only explicit whole-tree termination evidence authorizes journal reclaim or + rollback; exit status and timeout absence do not. +- A trusted in-memory manifest, authenticated backup/CAS evidence, genuine + handles, and recorded containment identities authorize filesystem operations. + Mutable journal fields and child assertions do not create authority. +- Nonce-bound child results, result-directory identity checks, frontier proofs, + and pre/post asynchronous checks remain mandatory. +- Concurrent edits, unconfirmed descendants, unauthenticated backups, and + incomplete durable bookkeeping are preserved rather than overwritten or + deleted. +- Physical restoration and durable journal finalization remain distinct facts. + A restored path with pending journal bookkeeping is still an incomplete + recovery and remains reportable. 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..5021e23 --- /dev/null +++ b/openspec/changes/add-update-flow/specs/update-flow/spec.md @@ -0,0 +1,566 @@ +# 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** never reports the launching workspace or local bundle version as an installed `currentVersion`, because no global installation identity was proven +**And** a read-only check runs no npm/pnpm global-root ownership probe +**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 strict content digest +**And** verifies the installed payload additionally matches the planned comparison digest under the named `codex-installed-v1` installed-comparison profile, which excludes only the root `.codex-marketplace-install.json` regular file written by the Codex marketplace installer +**And** keeps the strict planned `contentDigest` as the immutable source evidence used by backup, rollback, drift, and native-evidence checks +**And** treats a reserved-name symlink or directory on the installed side, a planned payload that already ships the reserved metadata path, an unrecognized comparison profile, or a missing/mismatched comparison digest as a content mismatch that rolls back +**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 human confirmation shows what will change (added/removed/refreshed skills and MCP servers) instead of the step-by-step plan, which remains available with `--verbose` and in the structured `--json` output +**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: Credentialless fallback refresh requires reconciliation + +**Given** a tracked direct/fallback installation has no valid NodeSource credentials +**And** the newly planned bundle adds MCP servers beyond the tracked state +**When** its fallback refresh runs +**Then** the refresh fails before any mutation with code `MCP_RECONCILIATION_REQUIRED` +**And** the safe message explains that reconciling the harness MCP state requires valid credentials and prints `nsolid-plugin setup --harness ` guidance before retrying the update +**And** no skills-only partial refresh or partial MCP merge occurs +**And** tracked skills, MCP configuration, and bundle evidence remain unchanged +**And** a later run with valid credentials can reconcile and complete the same update + +#### 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: Fallback child failures surface a parent-approved code and message + +**Given** a direct/fallback refresh child process fails after the parent created its private transaction workspace and bound a nonce to the planned result path +**When** the parent maps the child failure into the public result +**Then** the child publishes a schema-versioned, bounded, nonce-bound result envelope inside the parent-created private workspace instead of relying on stdout/stderr text +**And** the parent validates the envelope's containment, regular-file kind, ownership, permissions, size, schema, nonce, and allowlisted code before trusting it +**And** a known child code is rendered through a parent-owned safe message template, so arbitrary child text can never appear in public output +**And** raw child stdout/stderr is never forwarded into the public result, JSON, or verbose diagnostics +**And** a missing, malformed, stale, wrong-nonce, unknown-code, oversized, symlinked, or spoofed result envelope keeps the existing generic fallback error +**And** parent-owned journal recovery remains authoritative over any child rollback claim + +#### 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..316167a --- /dev/null +++ b/openspec/changes/add-update-flow/tasks.md @@ -0,0 +1,106 @@ +# Tasks + +## Task 1: Define update contracts and semantic-version behavior + +- [x] **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 + +- [x] **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 + +- [x] **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 + +- [x] **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 + +- [x] **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 + +- [x] **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 + +- [x] **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 + +- [x] **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 + +- [x] **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 + +- [x] **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 + +- [x] **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 + +- [x] **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 + +- [x] **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 + +- [x] **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 + +- [x] **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..bfec21d 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -5,21 +5,24 @@ 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 } from './update/types.js' +import { printUpdatePlan, printUpdateSummary, shouldDisplayUpdatePlan } from './update/render.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 @@ -54,36 +57,60 @@ function printUsage (): void { console.log(`Usage: nsolid-plugin [options] Commands: - setup Authenticate with NodeSource and prepare the MCP bridge runtime (may open a browser; first run needs npm access) - install Install N|Solid Plugin skills/MCP for a harness (fallback direct installer; prepares the MCP bridge runtime first; does not open a browser) - uninstall Remove N|Solid Plugin skills for a harness - logout Forget your stored NodeSource login (removes credentials only) - 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 + setup Authenticate with NodeSource and prepare the MCP bridge (may open a browser) + install Install skills/MCP for a harness without opening a browser + uninstall Remove skills for a harness + logout Forget the stored NodeSource login + switch-org Re-authenticate to switch NodeSource organizations (opens a browser) + doctor Check installation health for a harness + restore Restore a harness MCP config from the latest backup + version Report 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(', ')} - --keep-credentials Do not remove credentials even if this was the last harness (uninstall only) - --bundle Path to bundle.json (default: core package bundle.json) - --skills-source Path to skills source directory (default: core package root) - --backup Restore a specific backup file (restore command only) - --list List available backups (restore command only) - --verbose Enable detailed logging to stderr - --json Output doctor report as JSON (machine-readable) + --harness Target harness (required without a TTY): ${HARNESS_VALUES.join(', ')} + --yes Apply updates without interactive confirmation + --check Only report update status; never mutate + --all Include every detected installation (cannot combine with --harness) + --json Machine-readable output --no-color Disable colored output - --quiet Suppress step-by-step progress output (setup/install/switch-org) - --yes Skip interactive confirmation prompts - --accounts-url Explicit origin-only accounts URL override for setup/switch-org + --quiet Suppress step-by-step progress (setup/install/switch-org) + --verbose Detailed logging to stderr; shows the full technical update plan + --bundle Path to bundle.json + --skills-source

Path to skills source directory + --backup Restore a specific backup file (restore only) + --list List available backups (restore only) + --keep-credentials Do not remove credentials on last-harness uninstall + --accounts-url Accounts URL override for setup/switch-org + --version Print CLI and bundled plugin versions --help Show this help message -Distribution notes: - Claude/Codex/Antigravity: install from the GitHub plugin root; setup authenticates and prepares the MCP bridge runtime. - Pi: use pi install for package-owned skills; CLI install/setup only writes MCP config. - OpenCode: run setup --harness opencode — it authenticates, prepares the MCP bridge runtime, and installs skills + MCP config in one step. - Install: the fallback direct installer provisions the MCP bridge runtime first, then installs assets; it never opens a browser. - After switch-org, a direct-config harness passed to --harness (OpenCode, Pi, fallback CLI installs) has its MCP config refreshed on the spot; Claude/Codex/Antigravity native plugins must be reconnected, and other direct-config harnesses need a later setup/install to re-bake the new org's token. - Auth: only setup/switch-org may open a browser.`) +Notes: + Claude/Codex/Antigravity: install from the GitHub plugin root, then run setup. + Pi: use pi install for skills; setup only writes MCP config. + OpenCode: setup --harness opencode installs everything in one step. + After switch-org, re-run setup/install per harness to re-bake the new org token. + Only setup and 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 isInteractive (): boolean { @@ -243,10 +270,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 +290,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 +323,49 @@ 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) + const willConfirm = isInteractive() && values.yes !== true + // Human surface: --check and every interactive confirmation show the + // plan — including mutable items whose version is unknown — while + // machine runs (--json) and approved runs (--yes) stay quiet. + if (shouldDisplayUpdatePlan(plan, { json: values.json === true, check: values.check === true, willConfirm })) { + printUpdatePlan(plan, color, values.verbose === true) + } + if (values.json !== true && !values.check && values.yes === true) { + for (const item of plan.items) { + if (item.version.status === 'update-available' && item.version.current && item.version.latest) { + process.stderr.write(`→ Updating ${item.installationId} (${item.version.current} → ${item.version.latest})…\n`) + } + } + } + const summary = await executeUpdatePlan(plan, updateOptions) + if (values.json === true) console.log(JSON.stringify(summary)) + else if (!values.check) 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-provenance.ts b/packages/core/src/update/antigravity-provenance.ts new file mode 100644 index 0000000..dec43ee --- /dev/null +++ b/packages/core/src/update/antigravity-provenance.ts @@ -0,0 +1,34 @@ +/** + * Shared registration provenance for the Antigravity nsolid-plugin import. + * + * Both the inventory detection (`inventory.ts` `manifestContainsPlugin`) and + * the staged-plugin / restored-state validation (`antigravity-transaction.ts` + * `validateStagedPlugin`) must agree on what proves an `imports` entry belongs + * to this plugin: the entry must declare the canonical plugin identity AND + * carry an accepted registration source. A correct name with a missing, + * foreign, or spoofed source proves nothing, and object-form entries are only + * recognized under the canonical key — otherwise a lookalike manifest could + * pass one module while the other rejects it. + */ + +export const NSOLID_PLUGIN_ID = 'nsolid-plugin' + +/** Object-form imports are only recognized under the canonical key. */ +export function isNsolidPluginImportKey (key: string): boolean { + return key === NSOLID_PLUGIN_ID +} + +/** + * An import belongs to this plugin only when it declares the canonical + * identity (`name` or `plugin` exactly `nsolid-plugin`) and its registration + * source is either the `antigravity` marker or the exact NodeSource GitHub + * root with an optional `.git` suffix (case-insensitive). Any other or + * missing source is rejected. + */ +export function isNsolidPluginImport (entry: unknown): boolean { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false + const value = entry as { name?: unknown; plugin?: unknown; source?: unknown } + if (value.name !== NSOLID_PLUGIN_ID && value.plugin !== NSOLID_PLUGIN_ID) return false + if (value.source === 'antigravity') return true + return typeof value.source === 'string' && /^https:\/\/github\.com\/NodeSource\/nsolid-plugin(?:\.git)?$/i.test(value.source) +} diff --git a/packages/core/src/update/antigravity-transaction.ts b/packages/core/src/update/antigravity-transaction.ts new file mode 100644 index 0000000..43009b2 --- /dev/null +++ b/packages/core/src/update/antigravity-transaction.ts @@ -0,0 +1,356 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { isTreeTerminationUnconfirmed } from './command-runner.js' +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, ownedTreeDigest, removeOwnedPath } from './owned-fs.js' +import type { SiblingBackupPath } from './owned-fs.js' +import { runTransactionCommands } from './transaction-commands.js' +import { nativePayloadTreeDigest, sha256Hex } from './native-payload.js' +import { isNsolidPluginImport, isNsolidPluginImportKey } from './antigravity-provenance.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 failed = (error: UpdateError): AntigravityTransactionResult => ({ + success: false, rollbackAttempted, rollbackSucceeded, error, + }) + 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 = await 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) + if (!commandResult.success && isTreeTerminationUnconfirmed(commandResult.result)) { + preserveBackup = true + return { + success: false, + rollbackAttempted: false, + error: { + code: 'ANTIGRAVITY_TREE_TERMINATION_UNCONFIRMED', + message: `Antigravity command ended and descendant termination could not be confirmed; backups were preserved at ${rootBackupStorage.directory} and ${manifestBackupStorage.directory}`, + }, + } + } + + // Capture the exact post-mutation state this transaction is authorized to + // replace during rollback, whether the commands succeeded or not. + authorizedRootDigest = existsSync(pluginRoot) ? await treeDigest(pluginRoot) : null + authorizedManifestDigest = existsSync(manifestPath) ? sha256Hex(readFileSync(manifestPath)) : null + if (!commandResult.success) { + const { result } = commandResult + await attemptRollback() + return failed(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 failed({ 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 failed({ + 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) => isNsolidPluginImport(entry)) + if (manifest.imports && typeof manifest.imports === 'object') { + // The canonical key and the value must agree, exactly like the inventory + // contract: the value must declare the plugin identity with an accepted + // registration source, and only under the canonical object key. + return Object.entries(manifest.imports as Record).some(([key, value]) => + isNsolidPluginImportKey(key) && isNsolidPluginImport(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 (declaresPluginIdentity(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) + // Edit ownership deliberately stays broader than the shared provenance + // predicate (see declaresPluginIdentity below): a malformed entry must + // stay removable during rollback even when its value proves no identity. + if (key === 'nsolid-plugin' || declaresPluginIdentity(getNodeValue(valueNode))) { + ranges.push({ start: property.offset, end: property.offset + property.length }) + } + } + } + return ranges.sort((left, right) => left.start - right.start) +} + +/** + * Edit-ownership matcher for rollback byte-splicing only: broader than the + * shared `isNsolidPluginImport` provenance predicate. Validation never uses + * this — an import validated for staging or restoration must additionally + * carry an accepted registration source. + */ +function declaresPluginIdentity (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' +} + +async function treeDigest (target: string): Promise { + // Run the lstat-first guard before the legacy digestibility check so a + // symlink at the tree root is rejected without traversing its referent. + const digest = await ownedTreeDigest(target) + if (digest === null || nativePayloadTreeDigest(target) === undefined) return undefined + return digest +} + +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 (await 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) ? await 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 && await 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..f08c5b2 --- /dev/null +++ b/packages/core/src/update/claude-transaction.ts @@ -0,0 +1,652 @@ +import { createHash, randomBytes } from 'node:crypto' +import { readFile, rm } from 'node:fs/promises' +import { closeSync, existsSync, fchmodSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, statSync, writeSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { isSameOrContained } from '../utils/path.js' +import type { CommandRunner, CommandSpec, ResolvedArtifactIdentity, UpdateError } from './types.js' +import { isTreeTerminationUnconfirmed, isCommandSuccessful } from './command-runner.js' +import { readClaudePluginScope } from './claude-record.js' +import { nativePayloadDigest } from './native-evidence.js' +import { copyOwnedPath, createSiblingBackupPath, ownedFileDigest, ownedPathKind, ownedTreeDigest, removeOwnedPath, type OwnedPathKind, type SiblingBackupPath } from './owned-fs.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 + /** Canonical digest of everything except the selected plugin/marketplace. */ + foreignDigest?: 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 + /** Approved plugin cache root recorded at backup; removals re-verify against it. */ + approvedCacheRoot?: string + /** The post-command registry resolution was rejected: it must never be re-resolved. */ + postRootRejected?: boolean +} + +/** 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 foreignDigest = foreignRegistrationDigest(bytes, spec.pluginId, target) + 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, foreignDigest }) + } else { + registration.push({ path: target, existed: false, foreignDigest: foreignRegistrationDigest(undefined, spec.pluginId, target) }) + } + } + // A registry-named previous root outside the approved cache aborts + // before any backup or mutation; an absent root proceeds without a + // payload backup exactly as before. + const previousResolution = resolveClaudePayloadRoot(spec.configPath, spec.pluginId, spec.scope) + if (previousResolution.status === 'rejected') { + throw new Error(`installed Claude payload root rejected: ${previousResolution.reason}`) + } + const previousRoot = previousResolution.status === 'ok' ? previousResolution.root : undefined + payload.approvedCacheRoot = spec.configPath !== undefined && path.isAbsolute(spec.configPath) + ? path.resolve(path.dirname(spec.configPath), 'cache') + : undefined + if (previousRoot) { + payload.root = previousRoot + payload.kind = await ownedPathKind(previousRoot) + if (payload.kind !== 'missing') { + payload.backupStorage = await createSiblingBackupPath(previousRoot, 'payload-backup') + payload.originalDigest = await 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 = await 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 (isTreeTerminationUnconfirmed(result)) { + keepBackup() + return { + success: false, + rollbackAttempted: false, + recoveryPath: recoveryRoot, + error: { + code: 'CLAUDE_TREE_TERMINATION_UNCONFIRMED', + message: `Claude command ended 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) + } + } + + // A rejected post-command root is recorded as rejected — never as an + // absent root — so restore treats it as drift instead of a legitimate + // removal. + const postResolution = resolveClaudePayloadRoot(spec.configPath, spec.pluginId, spec.scope, spec.expectedVersion) + if (postResolution.status === 'rejected') { + payload.postRootRejected = true + } else { + payload.postRoot = postResolution.status === 'ok' ? postResolution.root : undefined + } + payload.postDigest = payload.postRoot ? await stateDigest(payload.postRoot) : null + for (const entry of registration) entry.postDigest = existsSync(entry.path) ? await stateDigest(entry.path) : null + + for (const entry of registration) { + const current = existsSync(entry.path) ? readFileSync(entry.path) : undefined + if (entry.foreignDigest !== undefined && foreignRegistrationDigest(current, spec.pluginId, entry.path) !== entry.foreignDigest) { + return await fail(spec, payload, registration, recoveryRoot, { success: false, rollbackAttempted: true }, { + code: 'CLAUDE_FOREIGN_STATE_CHANGED', + message: 'Claude changed unrelated plugin, marketplace, or enabled-plugin state', + }, keepBackup, dependencies) + } + } + + if (spec.artifact && (spec.artifact.kind === 'git' || spec.artifact.kind === 'local-snapshot')) { + if (!payload.postRoot || !payload.postDigest || nativePayloadDigest(payload.postRoot) !== 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). + // A rejected post-command resolution stays rejected: the mutable registry + // is never re-read to resurrect it into removal authority. An absent root + // keeps the historical re-resolution for registrations removed mid-flight. + if (payload.postRoot === undefined && payload.postRootRejected !== true) { + const recapture = resolveClaudePayloadRoot(transactionSpec.configPath, transactionSpec.pluginId, transactionSpec.scope) + if (recapture.status === 'ok') { + payload.postRoot = recapture.root + } else if (recapture.status === 'rejected') { + payload.postRootRejected = true + } + } + if (payload.postDigest === undefined && payload.postRoot) payload.postDigest = await stateDigest(payload.postRoot) ?? null + for (const entry of registration) { + if (entry.postDigest === undefined) entry.postDigest = existsSync(entry.path) ? await 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 = await 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) ? await stateDigest(entry.path) : null + if (current !== entry.postDigest) return false + } + // A post-command root rejected as outside the approved cache with no + // original root to restore is drift, not a legitimate removal: remove + // nothing, keep the backup bundle, report failure upstream. + if (payload.postRoot === undefined && payload.postRootRejected === true && !payload.root) return false + if (payload.postRoot !== undefined) { + const current = existsSync(payload.postRoot) ? await 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) ? await 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) { + // Final authorization immediately before each destructive removal: a + // root that no longer sits inside the recorded cache, changed kind, or + // traverses a replaced ancestor blocks the restore instead of deleting. + if (payload.postRoot && !await claudeRemovalRootAuthorized(payload.postRoot, payload.approvedCacheRoot)) return false + if (!await claudeRemovalRootAuthorized(payload.root, payload.approvedCacheRoot)) return false + if (payload.postRoot) await removeOwnedPath(payload.postRoot) + await removeOwnedPath(payload.root) + await copyOwnedPath(payload.backupStorage.path, payload.root) + } else if (payload.postRoot) { + if (!await claudeRemovalRootAuthorized(payload.postRoot, payload.approvedCacheRoot)) return false + 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) ? await stateDigest(entry.path) : null + if (restored !== (entry.existed ? entry.digest ?? null : null)) return false + } + if (payload.root) { + const restored = existsSync(payload.root) ? await stateDigest(payload.root) : null + if (restored !== payloadOriginalDigest) return false + } + return true + } catch { + return false + } +} + +/** Discriminated registry resolution: absent means no payload is registered, rejected means the registry names a concrete path outside the approved cache. */ +type ClaudePayloadRootResolution = + | { readonly status: 'ok', readonly root: string } + | { readonly status: 'absent' } + | { readonly status: 'rejected', readonly reason: string } + +/** Lexical containment mirroring codex-transaction.ts; callers add the kind and ancestor proofs. */ + +/** Every ancestor from the root's parent up to (not including) the cache root must be a real non-link directory. */ +function claudeAncestorChainOwned (root: string, approvedCacheRoot: string): boolean { + const stop = path.resolve(approvedCacheRoot) + let current = path.dirname(path.resolve(root)) + while (true) { + let stats + try { + stats = lstatSync(current) + } catch { + return false + } + if (!stats.isDirectory() || stats.isSymbolicLink()) return false + if (current === stop) return true + const parent = path.dirname(current) + if (parent === current) return false + current = parent + } +} + +/** The plugin segment of an approved cache layout must identify the intended plugin: a same-depth sibling plugin directory is never owned. */ +function claudeLayoutBindsPlugin (segments: readonly string[], pluginId: string): string | undefined { + const expectedName = pluginId.split('@', 1)[0]?.toLowerCase() + if (!expectedName) return 'the plugin identity is empty' + // / or //. + if (segments.length !== 2 && segments.length !== 3) { + return 'it does not match the approved cache layout / or //' + } + const pluginSegment = segments.length === 2 ? segments[0] : segments[1] + if (pluginSegment?.toLowerCase() !== expectedName) return `its plugin segment does not identify plugin ${pluginId}` + if (segments.length === 3 && pluginId.includes('@')) { + const expectedMarketplace = pluginId.split('@').at(-1)?.toLowerCase() + if (segments[0]?.toLowerCase() !== expectedMarketplace) { + return `its marketplace segment does not identify the ${expectedMarketplace} marketplace` + } + } + return undefined +} + +/** Full approval proof for one registry-named root: containment, layout depth, plugin identity, leaf kind, ancestor chain. */ +function claudeApprovedPayloadRoot (root: string, approvedCacheRoot: string, pluginId: string): string | undefined { + const resolved = path.resolve(root) + const cacheResolved = path.resolve(approvedCacheRoot) + if (!isSameOrContained(resolved, cacheResolved) || resolved === cacheResolved) { + return `installed payload root ${root} is outside the approved plugin cache ${approvedCacheRoot}` + } + const segments = path.relative(cacheResolved, resolved).split(path.sep) + if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) { + return `installed payload root ${root} does not match the approved cache layout / or //` + } + const binding = claudeLayoutBindsPlugin(segments, pluginId) + if (binding !== undefined) { + return `installed payload root ${root} ${binding}` + } + let leaf + try { + leaf = lstatSync(resolved) + } catch { + return `installed payload root ${root} cannot be inspected` + } + if (!leaf.isDirectory() || leaf.isSymbolicLink()) { + return `installed payload root ${root} is not a real directory` + } + if (!claudeAncestorChainOwned(resolved, cacheResolved)) { + return `installed payload root ${root} traverses an unowned ancestor` + } + return undefined +} + +export function resolveClaudePayloadRoot ( + configPath: string | undefined, + pluginId: string, + scope: string, + expectedVersion?: string +): ClaudePayloadRootResolution { + if (!configPath || !path.isAbsolute(configPath)) return { status: 'absent' } + const approvedCacheRoot = path.resolve(path.dirname(configPath), 'cache') + let roots: string[] + try { + const data = JSON.parse(readFileSync(configPath, 'utf8')) as unknown + if (!data || typeof data !== 'object' || Array.isArray(data)) return { status: 'absent' } + const plugins = (data as Record).plugins + if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return { status: 'absent' } + const value = (plugins as Record)[pluginId] + const records = Array.isArray(value) ? value : [value] + 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] : [] + }) + } catch { + return { status: 'absent' } + } + if (roots.length !== 1) return { status: 'absent' } + const root = roots[0] + if (typeof root !== 'string') return { status: 'absent' } + const rejection = claudeApprovedPayloadRoot(root, approvedCacheRoot, pluginId) + if (rejection !== undefined) return { status: 'rejected', reason: rejection } + return { status: 'ok', root } +} + +/** + * Final authorization immediately before a destructive payload removal: the + * root must still sit inside the cache root recorded at backup, still be a + * real non-link directory, and still traverse only real directories up to the + * cache root. Snapshots without a recorded cache root (legacy manually + * constructed drift-gate fixtures) keep prior behavior. + */ +async function claudeRemovalRootAuthorized (root: string, approvedCacheRoot: string | undefined): Promise { + if (approvedCacheRoot === undefined) return true + const cacheResolved = path.resolve(approvedCacheRoot) + const resolved = path.resolve(root) + if (!isSameOrContained(resolved, cacheResolved) || resolved === cacheResolved) return false + // A missing root is the authorized removed-payload case (the drift gate + // above already proved current bytes are the original or absent): only a + // live non-directory blocks the removal. + const kind = await ownedPathKind(resolved) + if (kind !== 'missing' && kind !== 'directory') return false + return claudeAncestorChainOwned(resolved, cacheResolved) +} + +/** @internal Exported for unit tests only. */ +export function foreignRegistrationDigest (bytes: Buffer | undefined, pluginId: string, targetPath: string): string { + const ownedIds = new Set([pluginId]) + if (/marketplace/i.test(path.basename(targetPath))) ownedIds.add(pluginId.split('@').at(-1) ?? '') + const leaves: Array<[string, unknown] | [string, string, number]> = [] + let parsed: unknown + try { parsed = bytes ? JSON.parse(bytes.toString('utf8')) : {} } catch { return sha256(bytes ?? Buffer.alloc(0)) } + const visit = (value: unknown, segments: string[]): void => { + const location = segments.join('\0') + if (Array.isArray(value)) { + // Containers are part of the canonical form: an empty array, or a + // container whose shape changes without changing any primitive leaf, + // must still alter the digest. Owned records are excluded before the + // container size is recorded, mirroring the object branch: a legacy + // array-shaped registration that only adds or updates the owned plugin + // record must not change the foreign digest. + const entries = value.filter((entry) => !isOwnedClaudeRecord(entry, ownedIds)) + leaves.push([location, '\0array', entries.length]) + for (const entry of entries) visit(entry, segments) + return + } + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([key]) => !ownedIds.has(key)) + .sort(([left], [right]) => left.localeCompare(right)) + leaves.push([location, '\0object', entries.length]) + for (const [key, entry] of entries) visit(entry, [...segments, key]) + return + } + leaves.push([location, value]) + } + visit(parsed, []) + return sha256(Buffer.from(JSON.stringify(leaves))) +} + +function isOwnedClaudeRecord (value: unknown, ownedIds: ReadonlySet): boolean { + if (typeof value === 'string') return ownedIds.has(value) + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const record = value as Record + return ['id', 'name', 'plugin', 'pluginId', 'marketplace'].some((key) => typeof record[key] === 'string' && ownedIds.has(record[key] as string)) +} + +/** Canonical lstat-first digest of a registration file or payload directory tree. */ +async function stateDigest (target: string): Promise { + try { + const kind = await ownedPathKind(target) + if (kind === 'file') return await ownedFileDigest(target) ?? undefined + if (kind === 'directory') { + // Preserve the existing undefined convention for empty/undigestible + // payloads while using the shared lstat-first digest for transaction + // state identity. + if (nativePayloadDigest(target) === undefined) return undefined + return await ownedTreeDigest(target) ?? undefined + } + return undefined + } 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/cli-guidance.ts b/packages/core/src/update/cli-guidance.ts new file mode 100644 index 0000000..cac2315 --- /dev/null +++ b/packages/core/src/update/cli-guidance.ts @@ -0,0 +1,21 @@ +import { isStableVersion } from './version.js' + +/** + * Complete exact-version recovery commands for an unowned CLI launch. + * Guidance is emitted only after registry resolution produced a concrete + * stable version; placeholders and unversioned package-manager commands are + * intentionally never returned. + */ +export function cliExactVersionManualCommands (latestVersion: string | undefined, launcherSource?: string): string[] { + if (!isStableVersion(latestVersion)) return [] + const commands = [ + `npm install --global nsolid-plugin@${latestVersion}`, + `pnpm add --global nsolid-plugin@${latestVersion}`, + `npx -y nsolid-plugin@${latestVersion} `, + ] + const launcherSegments = launcherSource?.replaceAll('\\', '/').split('/') ?? [] + if (launcherSegments.includes('.volta')) { + commands.push(`volta install nsolid-plugin@${latestVersion}`) + } + return commands +} 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..5ff3086 --- /dev/null +++ b/packages/core/src/update/codex-transaction.ts @@ -0,0 +1,552 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { isTreeTerminationUnconfirmed } from './command-runner.js' +import type { CommandRunner, UpdateError, UpdatePlanItem } from './types.js' +import { isSameOrContained, resolveHome } from '../utils/path.js' +import { compareVersions, isStableVersion } from './version.js' +import { assertNoSymlinksInTree, copyOwnedPath, createSiblingBackupPath, ownedFileDigest, ownedPathKind, ownedTreeDigest, removeOwnedPath, writeOwnedFile } from './owned-fs.js' +import type { SiblingBackupPath } from './owned-fs.js' +import { nativePayloadDigest } from './native-evidence.js' +import { isPayloadNormalizationProfile, 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 +} + +export interface CodexTransactionDependencies { + /** Synchronize immediately after rollback's authorized live state is captured. */ + afterAuthorizedStateCaptured?: () => void | Promise +} + +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, + dependencies: CodexTransactionDependencies = {} +): 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' }, + } + } + if (cacheKind !== 'missing' && cacheKind !== 'directory') { + return { + success: false, + rollbackAttempted: false, + error: { code: 'CODEX_CACHE_KIND_UNSUPPORTED', message: 'Codex cache root must be a real directory, not a link or reparse point' }, + } + } + if (cacheKind === 'directory') { + try { + await assertNoSymlinksInTree(cachePath) + } catch { + return { + success: false, + rollbackAttempted: false, + error: { code: 'CODEX_CACHE_KIND_UNSUPPORTED', message: 'Codex cache tree must not contain symbolic links or junctions' }, + } + } + } + + // 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 = async (): Promise => ({ + 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 !== undefined ? authorizedConfigDigest : await ownedFileDigest(configPath), + cache: authorizedCacheDigest !== undefined ? authorizedCacheDigest : await liveCacheDigestAt(cachePath), + }, + }) + + // Await at each call site so restoration completes inside the transaction's + // catch/finally boundary, including backup retention on failed restoration. + const rollbackFailure = async (error: UpdateError): Promise => { + rollbackAttempted = true + rollbackSucceeded = await restoreFiles(await backupSnapshot()) + return { success: false, rollbackAttempted, rollbackSucceeded, error } + } + + 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 = await 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) + if (!commandResult.success && isTreeTerminationUnconfirmed(commandResult.result)) { + preserveBackup = true + return { + success: false, + rollbackAttempted: false, + error: { + code: 'CODEX_TREE_TERMINATION_UNCONFIRMED', + message: `Codex command ended and descendant termination could not be confirmed; backups were preserved at ${configBackupStorage.directory} and ${cacheBackupStorage.directory}`, + }, + } + } + + // Revalidate path kinds before reading or replacing any post-command + // state. A command that swaps either owned root or inserts a nested cache + // symlink creates drift: never follow it, never remove it, and preserve + // both authenticated backups for manual recovery. + const postConfigKind = await ownedPathKind(configPath) + const postConfigDigest = postConfigKind === 'missing' || postConfigKind === 'file' + ? await ownedFileDigest(configPath) + : undefined + const postCacheKind = await ownedPathKind(cachePath) + let postCacheTreeSafe = postCacheKind === 'missing' + if (postCacheKind === 'directory') { + try { + await assertNoSymlinksInTree(cachePath) + postCacheTreeSafe = true + } catch { + postCacheTreeSafe = false + } + } + if ( + (postConfigKind !== 'missing' && postConfigKind !== 'file') || + (postCacheKind !== 'missing' && postCacheKind !== 'directory') || + !postCacheTreeSafe + ) { + rollbackAttempted = mutationStarted && backupsComplete + rollbackSucceeded = false + preserveBackup = true + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_TRANSACTION_FAILED', message: 'Codex owned paths changed kind or introduced a symbolic link during the update' }, + } + } + // Capture the exact post-command state once, before any validation or + // rollback logic runs; only this state may be replaced during rollback. + authorizedConfigDigest = postConfigDigest! + authorizedCacheDigest = await ownedTreeDigest(cachePath) + await dependencies.afterAuthorizedStateCaptured?.() + if (!commandResult.success) { + const { command, result } = commandResult + // 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(await 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) { + return await rollbackFailure({ 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) { + return await rollbackFailure({ 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')) { + // Installed-vs-plan equivalence uses the planned comparison digest + // under a matching named normalization profile (only proven harness + // metadata is normalized). The strict contentDigest stays the + // immutable-source evidence and is never compared against installed + // bytes; plans without a comparison identity fail closed, and an + // unrecognized profile is rejected instead of digesting strictly. + const expectedProfile = item.artifact.comparisonProfile + const expectedDigest = item.artifact.comparisonDigest + const digest = selectedPayload && expectedProfile && isPayloadNormalizationProfile(expectedProfile) + ? nativePayloadDigest(selectedPayload, { profile: expectedProfile }) + : undefined + if (!selectedPayload || !expectedProfile || !expectedDigest || !digest || digest !== expectedDigest) { + return await rollbackFailure({ 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))) { + return await rollbackFailure({ code: 'CODEX_REGISTRATION_MISSING', message: 'Codex did not recreate the selected plugin registration and its preserved fields' }) + } + if (!restoredUserFields) { + return await rollbackFailure({ 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)))) { + return await rollbackFailure({ 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(await 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 } +} + +async function liveCacheDigestAt (target: string): Promise { + try { + return await ownedTreeDigest(target) + } catch { + return 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 && await 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 and kind changes are + // never overwritten or removed. + if (await ownedFileDigest(snapshot.config.target) !== snapshot.authorized.config) return false + if (await ownedTreeDigest(snapshot.cache.target) !== snapshot.authorized.cache) return false + const authorizedConfigKind = snapshot.authorized.config === null ? 'missing' : 'file' + const authorizedCacheKind = snapshot.authorized.cache === null ? 'missing' : 'directory' + if (configBackupBytes !== undefined) { + await writeOwnedFile(snapshot.config.target, configBackupBytes, { mode: 0o600, expectedKind: authorizedConfigKind }) + } else if (!snapshot.config.existed) { + await removeOwnedPath(snapshot.config.target, authorizedConfigKind) + } + if (snapshot.cache.complete && snapshot.cache.existed) { + await removeOwnedPath(snapshot.cache.target, authorizedCacheKind) + await copyOwnedPath(snapshot.cache.backup, snapshot.cache.target) + } else if (!snapshot.cache.existed) { + await removeOwnedPath(snapshot.cache.target, authorizedCacheKind) + } + // Restored bytes must match the captured originals, not merely exist. + const configRestored = snapshot.config.existed + ? snapshot.config.complete && await ownedFileDigest(snapshot.config.target) === snapshot.config.originalDigest + : await ownedPathKind(snapshot.config.target) === 'missing' + const cacheRestored = snapshot.cache.existed + ? snapshot.cache.complete && await ownedTreeDigest(snapshot.cache.target) === snapshot.cache.originalDigest + : await ownedPathKind(snapshot.cache.target) === 'missing' + return configRestored && cacheRestored + } catch { + return false + } +} + +export function resolveCodexPluginCachePath ( + configPath: string, + pluginId: string, + marketplace: string | undefined, + hintedPath: string | undefined +): string | undefined { + const cacheBase = path.resolve(path.dirname(configPath), 'plugins', 'cache') + const pluginName = pluginId.split('@', 1)[0].toLowerCase() + if (hintedPath) { + const candidate = path.resolve(hintedPath) + if (isSameOrContained(candidate, cacheBase) && candidate !== cacheBase && !isBroadCachePath(candidate, cacheBase) && isPluginCacheCandidate(candidate, pluginName)) { + return candidate + } + } + + const directories: string[] = [] + collectDirectories(cacheBase, 0, directories) + const marketplaceKeys = new Set([ + ...(marketplace ? marketplace.split('/').map((part) => part.replace(/\.git$/, '').toLowerCase()) : []), + pluginId.split('@')[1]?.toLowerCase(), + ].filter((value): value is string => typeof value === 'string' && value.length > 0)) + const candidates = directories + .filter((candidate) => candidate !== cacheBase && !isBroadCachePath(candidate, cacheBase)) + .filter((candidate) => isPluginCacheCandidate(candidate, pluginName)) + .map((candidate) => ({ candidate, score: pluginCacheScore(candidate, pluginName, marketplaceKeys) })) + .sort((left, right) => right.score - left.score || pathDepth(left.candidate) - pathDepth(right.candidate)) + const best = candidates[0] + const tied = candidates[1] && candidates[1].score === best?.score && pathDepth(candidates[1].candidate) === pathDepth(best.candidate) + return best && !tied ? best.candidate : undefined +} + +function collectDirectories (root: string, depth: number, output: string[]): void { + if (depth > 5) return + let entries + try { entries = readdirSync(root, { withFileTypes: true }) } catch { return } + output.push(root) + for (const entry of entries) { + if (entry.isDirectory()) collectDirectories(path.join(root, entry.name), depth + 1, output) + } +} + +function isPluginCacheCandidate (candidate: string, pluginName: string): boolean { + const segments = candidate.toLowerCase().split(path.sep) + const basename = path.basename(candidate).toLowerCase() + return basename === pluginName || basename.startsWith(`${pluginName}@`) || segments.includes(pluginName) || readCodexPayloadVersions(candidate, pluginName).length > 0 +} + +function pluginCacheScore (candidate: string, pluginName: string, marketplaceKeys: ReadonlySet): number { + const basename = path.basename(candidate).toLowerCase() + const segments = candidate.toLowerCase().split(path.sep) + const parent = path.basename(path.dirname(candidate)).toLowerCase() + const marketplaceMatched = marketplaceKeys.has(parent) + if (basename === pluginName) return marketplaceMatched ? 220 : 100 + if (basename.startsWith(`${pluginName}@`)) return marketplaceMatched ? 215 : 95 + if (segments.includes(pluginName)) return marketplaceMatched ? 180 : 90 + return 50 +} + +function isBroadCachePath (candidate: string, cacheBase: string): boolean { + const basename = path.basename(candidate).toLowerCase() + return candidate === cacheBase || basename === 'cache' || basename === 'plugins' +} + +function pathDepth (filePath: string): number { + return filePath.split(path.sep).length +} diff --git a/packages/core/src/update/command-runner.ts b/packages/core/src/update/command-runner.ts new file mode 100644 index 0000000..ffa56a6 --- /dev/null +++ b/packages/core/src/update/command-runner.ts @@ -0,0 +1,1071 @@ +import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { accessSync, constants, existsSync, readFileSync, readdirSync } 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 +const COMMAND_TREE_TOKEN_ENV = 'NSOLID_COMMAND_TREE_TOKEN' + +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 + // A detached grandchild can be reparented before timeout handling begins, + // at which point PPID-only discovery can no longer connect it to the command. + // Give every POSIX command a unique inherited lineage marker so termination + // can still enumerate descendants after reparenting. + const treeToken = process.platform === 'win32' ? undefined : randomUUID() + const spawnEnv = treeToken ? { ...env, [COMMAND_TREE_TOKEN_ENV]: treeToken } : env + // Pre-spawn /proc evidence for termination provability (Linux): a tokenless + // detached grandchild whose intermediate exits reparents to the nearest + // ancestor subreaper and becomes invisible to group, PPID, session, and + // token enumeration at termination time. Recording which process identities + // predate the spawn lets the termination verdict classify every post-spawn + // survivor against the child's ancestry instead of trusting whatever + // enumeration is still available at kill time. + const startSnapshot = process.platform === 'linux' && treeToken !== undefined ? procStartSnapshot() : undefined + + 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: spawnEnv, + shell: false, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }) + const lineage = child.pid !== undefined && startSnapshot !== undefined && treeToken !== undefined + ? createPosixLineage(child.pid, treeToken, startSnapshot) + : undefined + const lineageTimer = lineage !== undefined + ? setInterval(() => samplePosixLineage(lineage), 50) + : undefined + lineageTimer?.unref() + + 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 (lineageTimer !== undefined) clearInterval(lineageTimer) + if (lineage !== undefined) samplePosixLineage(lineage) + if (pid !== undefined) { + timeoutTermination = terminateTree(pid, treeToken, startSnapshot, lineage) + 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 (lineageTimer !== undefined) clearInterval(lineageTimer) + 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 + // The root exiting closes neither its process tree nor the mutation + // window. Cancel the execution timer, but keep the result pending until + // reparented descendants have been accounted for independently. + clearTimeout(timer) + if (lineageTimer !== undefined) clearInterval(lineageTimer) + treeTerminated = false + confirmExitedTree(lineage).then((terminated) => { + treeTerminated = terminated + finish(code, signal ?? undefined, terminated ? undefined : 'TREE_TERMINATION_UNCONFIRMED') + }).catch(() => finish(code, signal ?? undefined, 'TREE_TERMINATION_UNCONFIRMED')) + }) + }) +} + +export function isCommandSuccessful (result: CommandResult): boolean { + return !result.timedOut && result.exitCode === 0 && !result.spawnErrorCode && !isTreeTerminationUnconfirmed(result) +} + +export function isTreeTerminationUnconfirmed (result: CommandResult): boolean { + return result.treeTerminated !== true +} + +/** + * After root exit, PPID and process-group IDs alone can be stale/reused. Only + * signal descendants with our inherited token and a revalidated start identity; + * never signal the dead root PID or an unattributed process. The pre-spawn + * snapshot also detects survivors which stripped the token and reparented. + * Without that evidence (including other platforms) defer recovery: absence + * from a post-exit process listing is not proof of whole-tree termination. + */ +async function confirmExitedTree (lineage: PosixLineage | undefined): Promise { + if (process.platform !== 'linux' || lineage === undefined) return false + const started = Date.now() + const deadline = started + 1_500 + do { + const snapshot = enumerateProcStatTable() + if (!snapshot.complete) return false + let marked = false + for (const [pid, fields] of snapshot.table) { + if (fields.state === 'Z' || fields.state === 'X') continue + if (processHasTreeToken(pid, lineage.treeToken) !== true) continue + marked = true + if (readProcStatFields(pid)?.starttime !== fields.starttime || processHasTreeToken(pid, lineage.treeToken) !== true) continue + try { process.kill(pid, Date.now() - started < 500 ? 'SIGTERM' : 'SIGKILL') } catch { /* rechecked by the next snapshot */ } + } + if (!marked && terminationVerdict(lineage.startSnapshot, snapshot, lineage.childPid, lineage.childStarttime, lineage)) return true + await new Promise((resolve) => setTimeout(resolve, 25)) + } while (Date.now() < deadline) + return false +} + +/** + * 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 +/** + * Capture the quoted `%dp0%`-relative entrypoint target of an invocation line. + * npm's cmd-shim writes the target relative to the shim directory in two + * layouts: `%dp0%\node_modules\\...` for a shim that sits next to + * `node_modules` (the bundled `npm.cmd`/`npx.cmd`), and `%dp0%\..\...` + * for a shim inside `node_modules\.bin` (every dependency bin, and pnpm when + * it was installed through npm). Legacy shims use `%~dp0\` instead of + * `%dp0%\`. + */ +const SHIM_TARGET_RE = /%(?:dp0%|~dp0)\\([^"\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 shimDir = path.win32.dirname(shimPath) + 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) { + // Extract the quoted %dp0%-relative `.js|cjs` target npm's cmd-shim emits + // on the invocation line, then anchor it under the node_modules root the + // shim's layout implies. + const match = SHIM_TARGET_RE.exec(invocation) + if (!match) continue + const resolved = resolveShimTargetRoot(shimDir, match[1]) + if (!resolved) continue + const packageEntry = verifyPackageBinOwnership(resolved.modulesRoot, resolved.inModules, shimName, namesEqual) + if (!packageEntry) continue + const entrypoint = path.win32 + .resolve(resolved.modulesRoot, ...resolved.inModules) + .split('\\') + .join(path.sep) + if (!existsSync(entrypoint)) continue + return entrypoint + } + return undefined +} + +/** + * Anchor a shim's captured `%dp0%`-relative target under the node_modules root + * its layout implies: a shim next to `node_modules` references + * `node_modules\\...`, while a shim inside `node_modules\.bin` + * references `..\\...` one level up. Any traversal beyond that root (or + * any other shape) is rejected, so the derived entrypoint always stays inside + * the shim's own node_modules tree. + */ +function resolveShimTargetRoot ( + shimDir: string, + relative: string +): { modulesRoot: string; inModules: string[] } | undefined { + const segments = relative.replace(/\//g, '\\').split('\\') + const isSafe = (rest: string[]): boolean => + rest.length > 0 && rest.every((segment) => segment.length > 0 && segment !== '.' && segment !== '..') + if (segments[0]?.toLowerCase() === 'node_modules') { + const inModules = segments.slice(1) + if (!isSafe(inModules)) return undefined + return { modulesRoot: path.win32.resolve(shimDir, 'node_modules'), inModules } + } + if (segments[0] === '..' && path.win32.basename(shimDir).toLowerCase() === '.bin') { + const inModules = segments.slice(1) + if (!isSafe(inModules)) return undefined + return { modulesRoot: path.win32.dirname(shimDir), inModules } + } + return undefined +} + +/** + * Prove that the package at `inModules` (segments under the shim's + * node_modules root, 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 ( + modulesRoot: string, + inModules: readonly string[], + shimName: string, + namesEqual: (left: string, right: string) => boolean +): string | undefined { + if (inModules.length === 0 || inModules[0].length === 0) return undefined + // The package directory is one segment, or two for a scoped package. + const packageSegments = inModules[0].startsWith('@') ? 2 : 1 + if (inModules.length <= packageSegments) return undefined + const packageDir = inModules.slice(0, packageSegments).join('\\') + const inPackageRelative = inModules.slice(packageSegments).join('\\') + + const manifestPath = path.win32 + .resolve(modulesRoot, 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. + * + * treeTerminated is only true when every process created after our child + * spawned is either proven exited or proven unrelated to the child by ancestry + * and start time. A surviving post-spawn process whose changed session or + * process group reaches one of our own ancestors (the reparenting target of an + * escaped grandchild) cannot be proven unrelated, so the verdict fails closed. + * On Linux the caller supplies the pre-spawn `/proc` identity snapshot that + * makes that classification possible; without it (other platforms, or an + * unreadable /proc) termination can only be partially attributed, so the + * verdict refuses confirmation instead of claiming success. + */ +async function terminateTree ( + pid: number, + treeToken?: string, + startSnapshot?: ReadonlyMap, + lineage?: PosixLineage +): 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) + } + // A pre-spawn snapshot enables provable termination on Linux: every process + // created after the child spawned must be proven gone or proven unrelated. + const linuxSnapshot = process.platform === 'linux' ? startSnapshot : undefined + const childStarttime = linuxSnapshot !== undefined ? readProcStatFields(pid)?.starttime : undefined + const preSignal = linuxSnapshot !== undefined ? enumerateProcStatTable() : undefined + // Capture the ancestry before signaling: a descendant may have called + // setsid() and escaped the original process group while retaining its PPID. + const tracked = await collectPosixProcessTree(pid) + const marked = treeToken ? await processesWithTreeToken(treeToken) : undefined + const enumerationAvailable = tracked !== undefined && marked !== undefined + const observed = new Set([pid, ...(tracked ?? []), ...(marked ?? [])]) + // Post-spawn candidates observed at kill time: a process that appeared after + // the child spawned and is still attributable to it (session, process group, + // tree token, or PPID ancestry) joins the observed set so it is signaled + // with the rest of the tree. Unattributable post-spawn processes are never + // signaled: killing them could hit unrelated user processes. + if (linuxSnapshot !== undefined && preSignal !== undefined) { + for (const [candidatePid, fields] of preSignal.table) { + if (candidatePid === pid) continue + if (predatesSpawn(linuxSnapshot, childStarttime, candidatePid, fields)) continue + const attributable = fields.session === pid || fields.pgrp === pid || + (marked?.has(candidatePid) ?? false) || + ppidChainWithinTableReaches(candidatePid, pid, preSignal.table) + if (attributable) observed.add(candidatePid) + } + } + try { + process.kill(-pid, 'SIGTERM') + } catch { /* group may not exist */ } + signalProcesses(observed, 'SIGTERM') + if (enumerationAvailable && treeToken && await waitForPosixTreeExit(pid, observed, treeToken, 'SIGTERM', 500)) { + return terminationVerdict(linuxSnapshot, preSignal, pid, childStarttime, lineage) + } + // Refresh while parents are still observable so children created during + // timeout handling are included before the final, non-catchable signal. + const refreshed = await collectPosixProcessTree(pid, observed) + if (refreshed) for (const descendant of refreshed) observed.add(descendant) + const refreshedMarked = treeToken ? await processesWithTreeToken(treeToken) : undefined + if (refreshedMarked) for (const descendant of refreshedMarked) observed.add(descendant) + try { process.kill(-pid, 'SIGKILL') } catch { /* group may already be gone */ } + signalProcesses(observed, 'SIGKILL') + const confirmed = enumerationAvailable && refreshed !== undefined && refreshedMarked !== undefined && treeToken !== undefined && + await waitForPosixTreeExit(pid, observed, treeToken, 'SIGKILL', 1_000) + if (!confirmed) return false + return terminationVerdict(linuxSnapshot, preSignal, pid, childStarttime, lineage) +} + +/** A process predates the child when its pid+starttime existed pre-spawn or it started before the child did. */ +function predatesSpawn (startSnapshot: ReadonlyMap, childStarttime: number | undefined, pid: number, fields: ProcStatFields): boolean { + if (startSnapshot.get(pid) === fields.starttime) return true + return childStarttime !== undefined && fields.starttime < childStarttime +} + +/** + * Final termination verdict for a timed-out command tree. Without pre-spawn + * identity evidence (non-Linux, or /proc unreadable at spawn time) there is + * no way to attribute a detached, token-stripped, reparented descendant: + * macOS PPID and token enumeration cannot connect it to the command once its + * intermediate exited. Missing evidence must refuse confirmation, mirroring + * the ordinary-exit standard in confirmExitedTree, never grant it — the + * historical default of treating the observed-tree check as the whole verdict + * claimed termination that could not be proven. With a snapshot, an empty + * observed set is necessary but not sufficient: the verdict additionally + * requires that no post-spawn process survives whose relation to the child + * cannot be ruled out (see postSpawnSurvivorsUnrelated), and that /proc stayed + * reliable from before the spawn through the verdict (fail closed otherwise). + * + * Exported as the deterministic decision seam for regression tests: the + * missing-evidence branch below is the exact macOS discrepancy (a detached, + * token-stripped, reparented descendant is unattributable there), and no + * Linux test run can reach it through `runCommand` alone. + */ +export function terminationVerdict ( + linuxSnapshot: ReadonlyMap | undefined, + preSignal: ProcStatTable | undefined, + childPid: number, + childStarttime: number | undefined, + lineage: PosixLineage | undefined +): boolean { + if (linuxSnapshot === undefined || preSignal === undefined) return false + if (!preSignal.complete || lineage?.complete === false) return false + if (lineage !== undefined && [...lineage.escaped.values()].some(({ pid, starttime }) => { + const fields = preSignal.table.get(pid) + return fields === undefined || fields.starttime !== starttime || fields.state === 'Z' || fields.state === 'X' + })) return false + return postSpawnSurvivorsUnrelated(childPid, childStarttime, linuxSnapshot) +} + +interface PosixLineage { + childPid: number + childStarttime: number | undefined + treeToken: string + startSnapshot: ReadonlyMap + escaped: Map + complete: boolean +} + +interface ProcStatFields { + state: string + ppid: number + pgrp: number + session: number + starttime: number +} + +interface ProcStatTable { + table: Map + /** False when /proc could not be read reliably; verdicts fail closed. */ + complete: boolean +} + +function createPosixLineage (childPid: number, treeToken: string, startSnapshot: ReadonlyMap): PosixLineage { + return { + childPid, + childStarttime: readProcStatFields(childPid)?.starttime, + treeToken, + startSnapshot, + escaped: new Map(), + complete: true, + } +} + +/** + * Observe descendants while their PPID chain is intact. A descendant that + * leaves the child's session and process group can disappear before timeout; + * once that happens, later scans cannot prove it created no further escaped + * descendants. Record its pid+starttime identity so termination fails closed + * if that identity is already gone when signaling begins. + */ +function samplePosixLineage (lineage: PosixLineage): void { + const snapshot = enumerateProcStatTable() + if (!snapshot.complete) lineage.complete = false + for (const [candidatePid, fields] of snapshot.table) { + if (candidatePid === lineage.childPid) continue + if (fields.state === 'Z' || fields.state === 'X') continue + if (predatesSpawn(lineage.startSnapshot, lineage.childStarttime, candidatePid, fields)) continue + if (fields.session === lineage.childPid || fields.pgrp === lineage.childPid) continue + if (!ppidChainWithinTableReaches(candidatePid, lineage.childPid, snapshot.table)) continue + const hasToken = processHasTreeToken(candidatePid, lineage.treeToken) + if (hasToken === undefined) { + lineage.complete = false + continue + } + if (!hasToken) lineage.escaped.set(`${candidatePid}:${fields.starttime}`, { pid: candidatePid, starttime: fields.starttime }) + } +} + +function processHasTreeToken (pid: number, treeToken: string): boolean | undefined { + try { + const marker = `${COMMAND_TREE_TOKEN_ENV}=${treeToken}` + return readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0').includes(marker) + } catch { + return undefined + } +} + +/** + * Parse the fixed fields of a /proc//stat record. `comm` may contain + * spaces and parentheses, so parsing starts after the LAST ')'; the remaining + * whitespace-separated fields then start at field 3 (state), making field N + * index N-3: state(3), ppid(4), pgrp(5), session(6), starttime(22). + */ +function parseProcStat (stat: string): ProcStatFields | undefined { + const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/) + const record = { + state: fields[0], + ppid: Number(fields[1]), + pgrp: Number(fields[2]), + session: Number(fields[3]), + starttime: Number(fields[19]), + } + if (!record.state || ![record.ppid, record.pgrp, record.session, record.starttime].every(Number.isSafeInteger)) return undefined + return record +} + +function readProcStatFields (pid: number): ProcStatFields | undefined { + try { + return parseProcStat(readFileSync(`/proc/${pid}/stat`, 'utf8')) + } catch { + return undefined + } +} + +/** Snapshot pid → starttime for every process alive right now (Linux). Individual unreadable entries are skipped; only a failed /proc listing yields undefined. */ +function procStartSnapshot (): Map | undefined { + let entries: string[] + try { + entries = readdirSync('/proc') + } catch { + return undefined + } + const snapshot = new Map() + for (const entry of entries) { + if (!/^\d+$/.test(entry)) continue + const fields = readProcStatFields(Number(entry)) + if (fields !== undefined) snapshot.set(Number(entry), fields.starttime) + } + return snapshot +} + +/** + * Full /proc process table for termination verdicts. A process that exits + * between the listing and its read is simply gone (it cannot be a survivor); + * any other read failure or a malformed record marks the table incomplete so + * the verdict fails closed instead of trusting a degraded enumeration. + */ +function enumerateProcStatTable (): ProcStatTable { + const table = new Map() + let entries: string[] + try { + entries = readdirSync('/proc') + } catch { + return { table, complete: false } + } + let complete = true + for (const entry of entries) { + if (!/^\d+$/.test(entry)) continue + const candidatePid = Number(entry) + let stat: string + try { + stat = readFileSync(`/proc/${candidatePid}/stat`, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') complete = false + continue + } + const fields = parseProcStat(stat) + if (fields === undefined) { + complete = false + continue + } + table.set(candidatePid, fields) + } + return { table, complete } +} + +/** + * Pids of every living ancestor of this process, up to and including pid 1 + * (process.pid itself is excluded). A broken link just ends the walk; pid 1 is + * always present because it is the fallback reparenting target. + */ +function ancestorsOfSelf (): Set { + const ancestors = new Set([1]) + let current = process.pid + const visited = new Set([current]) + while (current !== 1 && current !== 0) { + const fields = readProcStatFields(current) + if (fields === undefined) break + current = fields.ppid + if (visited.has(current)) break + visited.add(current) + if (current !== 0) ancestors.add(current) + } + return ancestors +} + +/** + * Whether the ppid chain of `candidatePid` (within one /proc table snapshot) + * reaches `targetPid`, or any chain node whose session or process group equals + * `targetPid`. Bounded by a visited set against mid-walk reparenting cycles. + */ +function ppidChainWithinTableReaches (candidatePid: number, targetPid: number, table: ReadonlyMap): boolean { + let nodePid = candidatePid + const visited = new Set([candidatePid]) + for (;;) { + const node = table.get(nodePid) + if (node === undefined) return false + if (node.ppid === targetPid || node.session === targetPid || node.pgrp === targetPid) return true + if (visited.has(node.ppid)) return false + visited.add(node.ppid) + nodePid = node.ppid + } +} + +/** + * True when no post-spawn process survives whose relation to the child cannot + * be ruled out. Each survivor's ancestry is walked within one /proc snapshot: + * a birth link into a pre-spawn process proves the survivor descends from that + * older process and never from the child. A link into our own ancestry is the + * reparenting shape of an escaped orphan and fails closed unless the process + * still matches that ancestor's inherited session and process group, proving + * it is a concurrent sibling. Pure post-spawn chains rooted at init or a dead + * parent also remain unresolved and fail closed. + */ +function postSpawnSurvivorsUnrelated ( + childPid: number, + childStarttime: number | undefined, + startSnapshot: ReadonlyMap +): boolean { + const { table, complete } = enumerateProcStatTable() + if (!complete) return false + const ancestors = ancestorsOfSelf() + for (const [survivorPid, fields] of table) { + if (fields.state === 'Z' || fields.state === 'X') continue + if (predatesSpawn(startSnapshot, childStarttime, survivorPid, fields)) continue + let nodePid = survivorPid + let node = fields + const visited = new Set([survivorPid]) + for (;;) { + if (nodePid === childPid || node.session === childPid || node.pgrp === childPid) return false + if (predatesSpawn(startSnapshot, childStarttime, nodePid, node)) break + const parentPid = node.ppid + if (parentPid === 0 || parentPid === 1) return false + if (visited.has(parentPid)) return false + const parent = table.get(parentPid) + if (parent === undefined) return false + if (ancestors.has(parentPid)) { + // A child normally inherits both session and process group from its + // parent. A post-spawn process that still matches its pre-existing + // ancestor on both is therefore a concurrent sibling, not part of our + // child tree. A mismatch proves setsid/setpgid intervened and leaves + // reparenting from our child possible, so the verdict fails closed. + if (node.session === parent.session && node.pgrp === parent.pgrp) break + return false + } + visited.add(parentPid) + nodePid = parentPid + node = parent + } + } + return true +} + +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 waitForPosixTreeExit ( + pid: number, + tracked: Set, + treeToken: string, + signal: NodeJS.Signals, + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const marked = await processesWithTreeToken(treeToken) + if (marked === undefined) return false + for (const descendant of marked) tracked.add(descendant) + signalProcesses(marked, signal) + if (marked.size === 0 && processGroupIsGone(pid) && [...tracked].every(processIsGone)) return true + await new Promise((resolve) => setTimeout(resolve, 25)) + } + const marked = await processesWithTreeToken(treeToken) + if (marked === undefined) return false + for (const descendant of marked) tracked.add(descendant) + signalProcesses(marked, signal) + return marked.size === 0 && processGroupIsGone(pid) && [...tracked].every(processIsGone) +} + +function processGroupIsGone (pid: number): boolean { + try { process.kill(-pid, 0); return false } catch { return true } +} + +function processIsGone (pid: number): boolean { + if (process.platform === 'linux') { + try { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') + const state = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/, 1)[0] + if (state === 'Z' || state === 'X') return true + } catch { return true } + } + try { process.kill(pid, 0); return false } catch { return true } +} + +async function processesWithTreeToken (treeToken: string): Promise | undefined> { + return process.platform === 'linux' + ? linuxProcessesWithTreeToken(treeToken) + : await psProcessesWithTreeToken(treeToken) +} + +function linuxProcessesWithTreeToken (treeToken: string): Set | undefined { + let entries: string[] + try { entries = readdirSync('/proc') } catch { return undefined } + const marker = `${COMMAND_TREE_TOKEN_ENV}=${treeToken}` + const matches = new Set() + for (const entry of entries) { + if (!/^\d+$/.test(entry)) continue + try { + const environment = readFileSync(`/proc/${entry}/environ`, 'utf8').split('\0') + if (environment.includes(marker)) matches.add(Number(entry)) + } catch { /* process exited or belongs to another user */ } + } + return matches +} + +async function psProcessesWithTreeToken (treeToken: string): Promise | undefined> { + return await new Promise((resolve) => { + const matches = new Set() + const marker = `${COMMAND_TREE_TOKEN_ENV}=${treeToken}` + const ps = spawn('/bin/ps', ['eww', '-axo', 'pid=,command='], { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }) + let output = '' + ps.stdout?.setEncoding('utf8') + ps.stdout?.on('data', (chunk: string) => { output += chunk }) + ps.once('error', () => resolve(undefined)) + ps.once('exit', (code) => { + if (code !== 0) return resolve(undefined) + for (const line of output.split('\n')) { + const match = /^\s*(\d+)\s+/.exec(line) + if (match && line.includes(marker)) matches.add(Number(match[1])) + } + resolve(matches) + }) + }) +} + +function signalProcesses (pids: ReadonlySet, signal: NodeJS.Signals): void { + for (const childPid of pids) { + try { process.kill(childPid, signal) } catch { /* process may already be gone */ } + } +} + +async function collectPosixProcessTree (rootPid: number, existing: ReadonlySet = new Set()): Promise | undefined> { + const parents = process.platform === 'linux' ? linuxProcessParents() : await psProcessParents() + if (!parents) return undefined + const collected = new Set([rootPid, ...existing]) + let changed = true + while (changed) { + changed = false + for (const [child, parent] of parents) { + if (collected.has(parent) && !collected.has(child)) { + collected.add(child) + changed = true + } + } + } + return collected +} + +function linuxProcessParents (): Map | undefined { + const parents = new Map() + let entries: string[] = [] + try { entries = readdirSync('/proc') } catch { return undefined } + for (const entry of entries) { + if (!/^\d+$/.test(entry)) continue + try { + const stat = readFileSync(`/proc/${entry}/stat`, 'utf8') + const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/) + const child = Number(entry) + const parent = Number(fields[1]) + if (Number.isSafeInteger(child) && Number.isSafeInteger(parent)) parents.set(child, parent) + } catch { /* process exited during the snapshot */ } + } + return parents +} + +async function psProcessParents (): Promise | undefined> { + return await new Promise((resolve) => { + const parents = new Map() + const ps = spawn('/bin/ps', ['-axo', 'pid=,ppid='], { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }) + let output = '' + ps.stdout?.setEncoding('utf8') + ps.stdout?.on('data', (chunk: string) => { output += chunk }) + ps.once('error', () => resolve(undefined)) + ps.once('exit', (code) => { + if (code !== 0) return resolve(undefined) + for (const line of output.split('\n')) { + const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line) + if (match) parents.set(Number(match[1]), Number(match[2])) + } + resolve(parents) + }) + }) +} + +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..f7a9cf3 --- /dev/null +++ b/packages/core/src/update/coordinator.ts @@ -0,0 +1,593 @@ +import { HARNESS_VALUES } from '../types.js' +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, fetchVerifiedNpmArtifactBytes, resolveFixedGitBundleVersion, resolveMarketplaceVersion, resolveRegistryVersion } from './version-source.js' +import { classifyVersionSet, classifyVersions } from './version.js' +import type { + ResolvedArtifactIdentity, + UpdateContext, + UpdateInstallation, + UpdateOptions, + UpdateOwnership, + UpdatePlan, + UpdatePlanItem, + UpdateResult, + UpdateSource, + UpdateStatus, + UpdateStrategy, + UpdateSummary, + UpdateTarget, + VersionLookupResult, +} from './types.js' +import { planItem, resultFromPlan } from './strategies/common.js' +import { fallbackStrategy, isValidMutableFallbackPlan, summarizeFallbackChanges, summarizeFallbackChangesFromBytes } from './strategies/fallback.js' +import type { InternalUpdateExecutionOutcome, InternalUpdateExecutor, PlanResourceDisposition } from './strategies/execution.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 { inspectFallbackJournal, recoverFallbackJournal } from './fallback-journal.js' +import { getTrackingFilePath } from '../utils/path.js' +import { cliExactVersionManualCommands } from './cli-guidance.js' + +const STATUSES: readonly UpdateStatus[] = [ + 'current', + 'update-available', + 'newer-than-registry', + 'updated', + 'skipped', + 'not-installed', + 'unsupported', + 'unknown', + 'failed', +] + +const TARGETS: readonly UpdateTarget[] = ['cli', ...HARNESS_VALUES] +const OWNERSHIPS: readonly UpdateOwnership[] = [ + 'global-package', + 'native-plugin', + 'package-owned', + 'fallback', + 'none', +] + +export async function planUpdates (options: UpdateOptions = {}): Promise { + validateScope(options) + // A read-only check never restores: it inspects the pending journal without + // creating recovery storage, renaming, copying, removing, or rewriting any + // journal, tracking, snapshot, or backup bytes. Only a real (non-check) + // plan may run restore-only next-run recovery, which rewrites owned tracked + // paths from the authenticated snapshot while the journal and snapshot + // always survive. Any pending journal (restored, unrestorable, malformed, + // or forged) still blocks the plan with guidance instead of committing or + // cleaning anything. The concrete outcome is carried into the guidance so + // the message never claims "preserved untouched" after a restore happened. + if (options.check === true) { + const pendingRecovery = await inspectFallbackJournal(getTrackingFilePath()) + if (pendingRecovery.pending) { + return { + checkOnly: true, + items: [recoveryPlanItem(pendingRecovery)], + } + } + } else { + const pendingRecovery = await recoverFallbackJournal(getTrackingFilePath()) + if (pendingRecovery.pending) { + return { + checkOnly: false, + items: [recoveryPlanItem(pendingRecovery)], + } + } + } + const commandRunner = options.commandRunner ?? createCommandRunner() + const context: UpdateContext = { options, commandRunner } + const detected = await detectInstallations({ + commandRunner, + cwd: options.cwd, + packageRoot: options.packageRoot, + executablePath: options.executablePath, + 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 ( + installation.target === 'cli' && + options.check !== true && + installation.source.kind === 'global-package' && + (plannedStatus === 'update-available' || plannedStatus === 'unknown') + ) { + // Deferred structural evidence is sufficient to classify a no-op, but an + // actual global mutation requires the positive package-manager realpath + // proof immediately before its command can be planned. Structurally + // unsupported workspace, npx, and wrapper launches never enter this path. + installation = await detectCliInstallation({ + commandRunner, + cwd: options.cwd, + packageRoot: options.packageRoot, + executablePath: options.executablePath, + includeCli: true, + readOnly: false, + }) + } + if ( + options.check !== true && + installation.source.kind !== 'unsupported' && + 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 + } + } + 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, resolved.version.latest) }) + 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) { + let item = planItem(resolved) + // A read-only check still answers "what will change": for fallback + // installations it fetches the verified artifact into memory and + // summarizes the skill/MCP diff. The check path never writes to disk. + // Best-effort — a failed summary never blocks the check. + if ( + resolved.source.kind === 'fallback' && + (resolved.version.status === 'update-available' || resolved.version.status === 'unknown') && + resolved.artifact?.kind === 'npm' + ) { + try { + const changes = resolved.artifact.tarballPath + ? await summarizeFallbackChanges(resolved, resolved.artifact.tarballPath) + : await summarizeFallbackChangesFromBytes( + resolved, + await fetchVerifiedNpmArtifactBytes(resolved.artifact, { fetchImpl: options.fetchImpl }) + ) + if (changes) item = { ...item, changes } + } catch { /* summary stays absent; the version report is unaffected */ } + } + // An unproven CLI launch still carries the exact-version recovery + // commands so the read-only report stays actionable. + items.push( + resolved.target === 'cli' && resolved.source.kind === 'unsupported' + ? { ...item, manualCommands: cliExactVersionManualCommands(resolved.version.latest, resolved.source.source) } + : item + ) + 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, ...preservationExtras(item) }) + : 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 execution: InternalUpdateExecutionOutcome + let fallbackExecutionAttempted = false + if (item.planningError) { + execution = { result: resultFromPlan(item, 'failed', { error: item.planningError }), planResources: 'release' } + } else if (item.requiresConfirmation && !approved) { + execution = { + result: resultFromPlan(item, 'skipped', { error: { code: 'CONFIRMATION_REQUIRED', message: 'Update was not approved' } }), + planResources: 'release', + } + } else if (!item.requiresConfirmation) { + execution = { result: resultFromPlan(item, statusForPlan(item, false)), planResources: 'release' } + } else { + const strategy = strategyForPlan(item) + if (strategy === fallbackStrategy && !isValidMutableFallbackPlan(item)) { + execution = { + result: resultFromPlan(item, 'failed', { error: { code: 'INVALID_PLAN', message: 'Mutable fallback update plan must include a transaction and command' } }), + // The public seam rejected this plan before execution. No internal + // result exists that could authorize releasing its planned state. + planResources: 'preserve', + } + } else { + fallbackExecutionAttempted = strategy === fallbackStrategy + try { + execution = await executeStrategy(strategy, item, { options, commandRunner }) + } catch { + // An unexpected fallback exception has no authenticated completion + // verdict. Preserve its plan state instead of deriving authorization + // from the public error code or message. + if (!fallbackExecutionAttempted) await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + execution = { + result: resultFromPlan(item, 'failed', { error: { code: 'UPDATE_EXECUTION_FAILED', message: 'Update strategy failed' } }), + planResources: fallbackExecutionAttempted ? 'preserve' : 'release', + } + } + } + } + if (execution.planResources === 'release') await cleanupPlanState(item) + results.push(execution.result) + } + return summarizeResults(false, results) +} + +type FallbackRecoveryOutcome = Awaited> + +function recoveryPlanItem (recovery: FallbackRecoveryOutcome): 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 }, + } + // Report the actual outcome instead of a static claim. Restore-only + // recovery may have rewritten some tracked paths while preserving others: + // the message must name exactly what was restored, what was left for + // manual inspection, and never claim "preserved untouched" after a + // restoration happened. + const inspectDetail = recovery.preservedPaths.length > 0 + ? ` Live paths left for manual inspection: ${recovery.preservedPaths.join(', ')}.` + : '' + const restoredDetail = recovery.restoredPaths.length > 0 + ? ` Restored to tracked state: ${recovery.restoredPaths.join(', ')}.` + : '' + let message: string + if (recovery.restoredPaths.length > 0 && recovery.preservedPaths.length > 0) { + message = `A previous fallback transaction was PARTIALLY restored to its tracked state automatically.${restoredDetail}${inspectDetail} Its journal and snapshot were preserved next to the tracking file; resolve the remaining paths and remove them deliberately (restore or delete) before updating.` + } else if (recovery.restoredPaths.length > 0) { + message = `A previous fallback transaction was restored to its tracked state automatically.${restoredDetail} Its journal and snapshot were preserved next to the tracking file. Remove them deliberately (restore or delete) before updating.` + } else if (recovery.recovered) { + message = `A previous fallback transaction needs no restoration: every owned path is already at its tracked state.${inspectDetail} Its journal and snapshot were preserved next to the tracking file. Remove them deliberately (restore or delete) before updating.` + } else { + message = `A previous fallback transaction is still pending and was preserved untouched.${inspectDetail} Resolve it manually (inspect the journal and snapshot next to the tracking file, restore or remove them deliberately) before updating.` + } + const item: UpdatePlanItem = { + ...planItem(installation), + planningError: { + code: 'FALLBACK_RECOVERY_PENDING', + message, + }, + } + // Reporting-only structural evidence of what recovery left on disk; it + // never authorizes filesystem mutation. Empty arrays are omitted so the + // public plan/result shape stays stable. + if (recovery.preservedArtifacts.length > 0) item.preservedArtifacts = recovery.preservedArtifacts + if (recovery.preservedPaths.length > 0) item.preservedPaths = recovery.preservedPaths + return item +} + +/** + * Reporting-only propagation of recovery preservation evidence from a plan + * item into its public result. The arrays describe what recovery left on + * disk; they never authorize filesystem mutation. Empty/absent arrays are + * omitted so structured output keeps one stable schema. + */ +function preservationExtras (item: Pick): Partial { + const extra: Partial = {} + if (item.preservedArtifacts !== undefined && item.preservedArtifacts.length > 0) extra.preservedArtifacts = item.preservedArtifacts + if (item.preservedPaths !== undefined && item.preservedPaths.length > 0) extra.preservedPaths = item.preservedPaths + return extra +} + +/** Release planning-owned artifact/manifest resources; journal artifacts stay with the journal API. */ +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(() => {}) + } +} + +/** + * Run a strategy through its internal outcome seam when one exists. The + * fallback strategy is the only strategy that owns authenticated recovery + * artifacts today; a missing or malformed outcome therefore fails closed and + * preserves its planning resources. + */ +async function executeStrategy ( + strategy: UpdateStrategy, + item: UpdatePlanItem, + context: UpdateContext +): Promise { + if (strategy === fallbackStrategy) { + const internal = (strategy as UpdateStrategy & Partial).executeWithOutcome + if (typeof internal !== 'function') { + return { result: await strategy.execute(item, context), planResources: 'preserve' } + } + const candidate = await internal.call(strategy, item, context) as Partial + const hasValidResult = isUpdateResult(candidate?.result) + const result = hasValidResult + ? candidate.result! + : resultFromPlan(item, 'failed', { error: { code: 'UPDATE_EXECUTION_FAILED', message: 'Update strategy returned an invalid execution outcome' } }) + return { + result, + // A missing disposition, including an outcome from an older adapter, + // fails closed. A malformed result cannot authorize release either. + planResources: hasValidResult && candidate.planResources === 'release' ? 'release' : 'preserve', + } + } + const result = await strategy.execute(item, context) + return { result, planResources: legacyPlanResourceDisposition(result) } +} + +function isUpdateResult (value: unknown): value is UpdateResult { + if (typeof value !== 'object' || value === null) return false + const candidate = value as { installationId?: unknown; target?: unknown; ownership?: unknown; status?: unknown; changed?: unknown } + return typeof candidate.installationId === 'string' && + TARGETS.includes(candidate.target as UpdateTarget) && + OWNERSHIPS.includes(candidate.ownership as UpdateOwnership) && + STATUSES.includes(candidate.status as UpdateStatus) && + typeof candidate.changed === 'boolean' +} + +function legacyPlanResourceDisposition (result: UpdateResult): PlanResourceDisposition { + return result.error?.code === 'CLI_TREE_TERMINATION_UNCONFIRMED' ? 'preserve' : 'release' +} + +function mustPreservePlanState (result: UpdateResult): boolean { + // The CLI strategy predates the internal outcome seam and still reports + // its conservative preservation decision through its stable error code. + return legacyPlanResourceDisposition(result) === 'preserve' +} + +export function summarizePlan (plan: UpdatePlan): UpdateSummary { + return summarizeResults(plan.checkOnly, plan.items.map((item) => item.planningError + ? resultFromPlan(item, 'failed', { error: item.planningError, ...preservationExtras(item) }) + : 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' + // An unsupported source has no proven installation identity, so it must + // not inherit a version-derived status such as update-available. + if (item.source.kind === 'unsupported') 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'], latestVersion?: string): 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 cliExactVersionManualCommands(latestVersion, undefined) + } +} + +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-frontier.ts b/packages/core/src/update/fallback-frontier.ts new file mode 100644 index 0000000..1173792 --- /dev/null +++ b/packages/core/src/update/fallback-frontier.ts @@ -0,0 +1,982 @@ +import { lstat, realpath } from 'node:fs/promises' +import type { BigIntStats, Stats } from 'node:fs' +import path from 'node:path' +import { isCanonicalPath, isSameOrContained } from './fallback-ownership.js' +import { assertSafeSkillName } from '../utils/skill-name.js' +import type { FallbackAnchorIdentity, FallbackFrontierEvidence, FallbackLeafActivation, FallbackLeafRole, FallbackLeafTarget, FallbackPathEvidence, FallbackTransactionIdentity } from './types.js' +import type { HarnessType } from '../types.js' + +/** + * Shared read-only evidence model for planned missing parent directories + * (frontiers). Derivation inspects the live filesystem with `lstat` only — + * it never follows links and never mutates anything. Publication, rollback, + * and recovery consume this evidence but live in later stages; this module + * defines the exact contract both planners share. + * + * Every authorized leaf is classified exactly once: an existing leaf (whose + * live kind must match its role), a missing file/link leaf under an existing + * parent (an independent leaf destination — never a directory frontier), or a + * leaf created by publishing a topmost missing directory frontier. + * + * Binding decision (Step 3, option A): conservative Pure Node. No HMAC, no + * native dependencies, no atomic-replace promises. A frontier is published + * by exclusive `mkdir` plus exclusive population, so partial visibility is + * explicitly accepted; authority over a planned-missing frontier stays with + * the process that reserved it and the parent never destroys frontiers. + */ + +/** Stable rejection codes for frontier derivation, parsing, and revalidation. */ +export const FALLBACK_FRONTIER_ERROR_CODES = { + INVALID_INPUT: 'FALLBACK_FRONTIER_INVALID_INPUT', + COLLISION: 'FALLBACK_FRONTIER_COLLISION', + COMPONENT_NOT_DIRECTORY: 'FALLBACK_FRONTIER_COMPONENT_NOT_DIRECTORY', + ANCHOR_IDENTITY_MISMATCH: 'FALLBACK_FRONTIER_ANCHOR_IDENTITY_MISMATCH', + DRIFT: 'FALLBACK_FRONTIER_DRIFT', + /** An existing destination's live kind contradicts the kind its role requires. */ + LEAF_KIND_MISMATCH: 'FALLBACK_FRONTIER_LEAF_KIND_MISMATCH', +} as const + +export class FallbackFrontierError extends Error { + public readonly code: string + + constructor (code: string, message: string) { + super(message) + this.name = 'FallbackFrontierError' + this.code = code + } +} + +/** Hard bounds keeping frontier evidence small, deterministic, and reviewable. */ +export const FALLBACK_FRONTIER_BOUNDS = { + maxLeaves: 64, + maxFrontiers: 16, + maxPathBytes: 512, + maxLeafIdBytes: 128, + maxMissingSegmentsPerLeaf: 16, +} as const + +const LEAF_ROLES: readonly FallbackLeafRole[] = ['skill', 'link', 'mcp-config', 'tracking'] +const LEAF_ACTIVATIONS: readonly FallbackLeafActivation[] = ['required', 'conditional'] + +/** + * Expected live terminal kind per leaf role. Links are legitimate only for + * `link` leaves (managed harness links); skill destinations must be real + * directories and config/tracking destinations regular files. `lstat` never + * follows links, so a managed link never traverses to its target. + */ +const LEAF_EXPECTED_KINDS: Record = { + skill: 'directory', + link: 'symlink', + 'mcp-config': 'file', + tracking: 'file', +} + +/** Concrete live terminal kind of one leaf or staged/live tree entry. */ +export type FallbackLeafKind = 'file' | 'directory' | 'symlink' + +/** Kinds a harness may legitimately materialize a managed `link` leaf as. */ +export type FallbackLinkKind = 'symlink' | 'directory' + +export interface FallbackLinkMaterializationPolicy { + readonly allowedKinds: readonly FallbackLinkKind[] +} + +/** Strictest policy: only real symlinks count as managed links. Safe default for every non-harness-aware consumer. */ +const STRICT_SYMLINK_POLICY: FallbackLinkMaterializationPolicy = Object.freeze({ allowedKinds: Object.freeze(['symlink']) }) + +/** + * Trusted internal policy for how a harness materializes managed skill links, + * derived ONLY from the trusted harness identity plus the runtime platform: + * Pi always copies skill directories (any platform); every other harness uses + * real symlinks on POSIX and may fall back to copied directories on Windows. + * Unknown harnesses fail closed. The policy is NEVER serialized: protocol v3 + * evidence stays kind-agnostic, and every consumer derives the identical + * policy from the trusted manifest harness instead of accepting caller-provided + * kinds. + */ +export function fallbackLinkMaterialization (harness: HarnessType): FallbackLinkMaterializationPolicy { + switch (harness) { + case 'pi': + return Object.freeze({ allowedKinds: Object.freeze(['directory']) }) + case 'claude': + case 'codex': + case 'antigravity': + case 'opencode': + return process.platform === 'win32' + ? Object.freeze({ allowedKinds: Object.freeze(['symlink', 'directory']) }) + : STRICT_SYMLINK_POLICY + default: + throw invalid(`unknown harness ${String(harness)} has no trusted link materialization policy`) + } +} + +/** Live kinds a leaf's role accepts under the trusted policy (singletons except `link`). */ +function permittedLeafKinds (role: FallbackLeafRole, policy: FallbackLinkMaterializationPolicy): readonly FallbackLeafKind[] { + if (role !== 'link') return Object.freeze([LEAF_EXPECTED_KINDS[role]]) + return policy.allowedKinds +} + +/** Compare two strings by their UTF-8 byte sequences (canonical evidence order). */ +export function compareUtf8 (a: string, b: string): number { + return Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')) +} + +/** + * Normalized trusted inputs for the parent's leaf derivation. The child + * consumes the resulting graph and can never widen its approved destinations. + */ +export interface FallbackFrontierLeafInputs { + /** Tracked owned-skill evidence. A path already covered by the bundle is deduplicated (bundle wins). */ + ownedSkills: readonly FallbackPathEvidence[] + /** Tracked owned-link evidence. A path already covered by the bundle is deduplicated (bundle wins). */ + ownedLinks: readonly FallbackPathEvidence[] + /** Whole-file MCP config evidence; paths are deduplicated and UTF-8-sorted into stable `mcp-config:` ids. */ + ownedMcpConfigPaths: readonly FallbackPathEvidence[] + /** Absolute tracking-file path; always yields the required `tracking` leaf. */ + trackingPath: string + /** Resolved absolute skills destination root for the harness. */ + destination: string + /** Resolved absolute harness link root; omit for harnesses without managed links (opencode). */ + linkDir?: string + /** Skill names from the verified bundle; each contributes a required skill leaf (plus a required link leaf when a link root exists). */ + bundleSkillNames: readonly string[] +} + +/** + * Derive the complete authorized leaf set from trusted normalized planner + * inputs: every skill and link destination the verified bundle will create + * (new or replaced), every tracked destination the bundle no longer contains + * (planned removals), the union of canonical and tracked MCP config files, + * and the tracking file. Ids use disjoint `skill:`/`owned-`/`mcp-` prefixes + * and depend only on the inputs, so the same planned state always yields the + * same leaf graph. Pure and deterministic: no filesystem access, no clock, + * no randomness. Distinct destinations must never collapse into one derived + * id: basename collisions among tracked evidence and duplicate bundle names + * are rejected instead of being silently merged. + */ +export function deriveFallbackFrontierLeafTargets (inputs: FallbackFrontierLeafInputs): FallbackLeafTarget[] { + if (typeof inputs !== 'object' || inputs === null) throw invalid('leaf inputs must be an object') + const requiredKeys = ['ownedSkills', 'ownedLinks', 'ownedMcpConfigPaths', 'trackingPath', 'destination', 'bundleSkillNames'] + for (const key of Object.keys(inputs)) { + if (key !== 'linkDir' && !requiredKeys.includes(key)) throw invalid(`leaf inputs have an unknown field ${key}`) + } + for (const key of requiredKeys) { + if (!(key in inputs)) throw invalid(`leaf inputs are missing the field ${key}`) + } + if (typeof inputs.destination !== 'string' || !path.isAbsolute(inputs.destination)) throw invalid('leaf inputs destination must be an absolute path') + if (inputs.linkDir !== undefined && (typeof inputs.linkDir !== 'string' || !path.isAbsolute(inputs.linkDir))) throw invalid('leaf inputs linkDir must be an absolute path') + if (typeof inputs.trackingPath !== 'string' || !path.isAbsolute(inputs.trackingPath)) throw invalid('leaf inputs trackingPath must be an absolute path') + if (!Array.isArray(inputs.ownedSkills) || !Array.isArray(inputs.ownedLinks) || !Array.isArray(inputs.ownedMcpConfigPaths)) { + throw invalid('leaf inputs owned evidence must be arrays') + } + if (!Array.isArray(inputs.bundleSkillNames)) throw invalid('leaf inputs bundleSkillNames must be an array of strings') + if (inputs.bundleSkillNames.some((name) => typeof name !== 'string')) throw invalid('leaf inputs bundleSkillNames must be an array of strings') + if (new Set(inputs.bundleSkillNames).size !== inputs.bundleSkillNames.length) throw invalid('leaf inputs bundleSkillNames contain duplicate names') + if (inputs.bundleSkillNames.length > FALLBACK_FRONTIER_BOUNDS.maxLeaves) { + throw invalid(`leaf inputs bundleSkillNames exceed the ${FALLBACK_FRONTIER_BOUNDS.maxLeaves}-leaf bound`) + } + for (const name of inputs.bundleSkillNames) { + try { + assertSafeSkillName(name) + } catch { + throw invalid(`bundle skill name ${name} is not a safe single path segment`) + } + } + for (const evidenceList of [inputs.ownedSkills, inputs.ownedLinks, inputs.ownedMcpConfigPaths]) { + if (evidenceList.length > FALLBACK_FRONTIER_BOUNDS.maxLeaves) { + throw invalid(`leaf inputs owned evidence exceeds the ${FALLBACK_FRONTIER_BOUNDS.maxLeaves}-leaf bound`) + } + for (const entry of evidenceList) { + if (typeof entry !== 'object' || entry === null || typeof entry.path !== 'string') { + throw invalid('leaf inputs owned evidence entries must carry string paths') + } + } + } + + const leaves = new Map() + // Same-destination claims must be resolved explicitly: silent drops would + // erase semantic postconditions from the leaf graph. + const add = (leaf: FallbackLeafTarget): void => { + const previous = leaves.get(leaf.path) + if (previous === undefined) { + leaves.set(leaf.path, leaf) + return + } + if (previous.role !== leaf.role) { + throw new FallbackFrontierError( + FALLBACK_FRONTIER_ERROR_CODES.COLLISION, + `Leaves ${previous.id} (${previous.role}) and ${leaf.id} (${leaf.role}) claim the same destination ${leaf.path} with incompatible roles` + ) + } + // Same role: identical duplicates collapse, and the verified bundle wins + // over tracked evidence (required supersedes conditional). Any other + // same-role disagreement has no precedence rule and is a collision. + if (previous.id === leaf.id && previous.activation === leaf.activation) return + if (previous.activation === 'required' && leaf.activation === 'conditional') return + if (previous.activation === 'conditional' && leaf.activation === 'required') { + leaves.set(leaf.path, leaf) + return + } + throw new FallbackFrontierError( + FALLBACK_FRONTIER_ERROR_CODES.COLLISION, + `Leaves ${previous.id} and ${leaf.id} claim the same destination ${leaf.path} without a precedence rule` + ) + } + for (const name of inputs.bundleSkillNames) { + add({ id: `skill:${name}`, role: 'skill', activation: 'required', path: path.resolve(inputs.destination, name) }) + if (inputs.linkDir !== undefined) add({ id: `link:${name}`, role: 'link', activation: 'required', path: path.resolve(inputs.linkDir, name) }) + } + for (const entry of inputs.ownedSkills) { + const ownedPath = path.resolve(entry.path) + add({ id: `owned-skill:${path.basename(ownedPath)}`, role: 'skill', activation: 'conditional', path: ownedPath }) + } + for (const entry of inputs.ownedLinks) { + const ownedPath = path.resolve(entry.path) + add({ id: `owned-link:${path.basename(ownedPath)}`, role: 'link', activation: 'conditional', path: ownedPath }) + } + const mcpPaths = [...new Set(inputs.ownedMcpConfigPaths.map((entry) => path.resolve(entry.path)))].sort(compareUtf8) + mcpPaths.forEach((configPath, index) => { + // Conditional: the render plan may skip a no-op config, and the parent + // cannot prove frontier inactivity from mutable state — the platform + // preflight therefore treats every derived frontier as active. + add({ id: `mcp-config:${index}`, role: 'mcp-config', activation: 'conditional', path: configPath }) + }) + add({ id: 'tracking', role: 'tracking', activation: 'required', path: path.resolve(inputs.trackingPath) }) + // Distinct destinations must never share a derived id: a collision here + // would silently collapse semantic postconditions in later stages. + const idOwners = new Map() + for (const leaf of leaves.values()) { + const previousPath = idOwners.get(leaf.id) + if (previousPath !== undefined) { + throw new FallbackFrontierError(FALLBACK_FRONTIER_ERROR_CODES.COLLISION, `Leaves ${previousPath} and ${leaf.path} share the derived id ${leaf.id}; refusing to collapse distinct destinations`) + } + idOwners.set(leaf.id, leaf.path) + } + return [...leaves.values()] +} + +/** The deterministic frontier plan: frontiers and existing leaves in UTF-8 byte order. */ +export interface FallbackFrontierPlan { + frontiers: readonly FallbackFrontierEvidence[] + /** Authorized leaves that already exist on disk; no parent creation is needed for them. */ + existingLeaves: readonly FallbackLeafTarget[] + /** + * Missing file/link leaves whose parent already exists. They are independent + * leaf destinations (exclusive leaf writes in later stages) and are never + * directory frontiers, so they never trigger the platform preflight. + */ + missingLeaves: readonly FallbackLeafTarget[] +} + +export type FallbackFrontierPlatformSupport = + | { supported: true } + | { supported: false; code: 'FALLBACK_PARENT_CREATION_UNSUPPORTED'; frontierPaths: readonly string[] } + +function invalid (message: string): FallbackFrontierError { + return new FallbackFrontierError(FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT, message) +} + +function isProperSegmentAncestor (ancestor: string, candidate: string): boolean { + const relative = path.relative(ancestor, candidate) + return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) +} + +function assertValidLeafInput (leaf: unknown, index: number): asserts leaf is FallbackLeafTarget { + if (typeof leaf !== 'object' || leaf === null) throw invalid(`leaf ${index} must be an object`) + const record = leaf as Record + assertExactKeys(record, ['id', 'role', 'activation', 'path'], `leaf ${index}`) + const candidate = leaf as Partial + if (typeof candidate.id !== 'string' || candidate.id.length === 0 || Buffer.byteLength(candidate.id, 'utf8') > FALLBACK_FRONTIER_BOUNDS.maxLeafIdBytes) { + throw invalid(`leaf ${index} id must be a non-empty string of at most ${FALLBACK_FRONTIER_BOUNDS.maxLeafIdBytes} bytes`) + } + if (!LEAF_ROLES.includes(candidate.role as FallbackLeafRole)) throw invalid(`leaf ${candidate.id} has unknown role`) + if (!LEAF_ACTIVATIONS.includes(candidate.activation as FallbackLeafActivation)) throw invalid(`leaf ${candidate.id} has unknown activation`) + if (typeof candidate.path !== 'string' || !isCanonicalPath(candidate.path)) { + throw invalid(`leaf ${candidate.id} path must be an absolute canonical path without traversal or remote spelling`) + } + if (Buffer.byteLength(candidate.path, 'utf8') > FALLBACK_FRONTIER_BOUNDS.maxPathBytes) { + throw invalid(`leaf ${candidate.id} path exceeds ${FALLBACK_FRONTIER_BOUNDS.maxPathBytes} bytes`) + } +} + +function segmentComponentsOf (resolved: string): { root: string; segments: readonly string[] } { + const root = path.parse(resolved).root + const segments = resolved.slice(root.length).split(path.sep).filter((segment) => segment.length > 0) + return { root, segments } +} + +interface LeafInspection { + leaf: FallbackLeafTarget + existing: boolean + /** Missing non-directory leaf whose parent exists; an independent destination, never a frontier. */ + missingUnderExistingParent?: boolean + anchorPath?: string + frontierPath?: string + missingComponentCount?: number +} + +function leafKindMatches (stats: Stats, expected: FallbackLeafKind): boolean { + if (expected === 'symlink') return stats.isSymbolicLink() + if (stats.isSymbolicLink()) return false + return expected === 'directory' ? stats.isDirectory() : stats.isFile() +} + +/** + * Walk every path component from the filesystem root down to the leaf with + * `lstat` (never following links). Intermediate components must be existing + * real directories. The first missing intermediate directory is the frontier; + * its parent — verified as a real directory — is the anchor. A missing + * terminal file/link leaf under an existing parent is an independent leaf + * destination; only a missing terminal directory leaf is itself a frontier. + */ +async function inspectLeaf (leaf: FallbackLeafTarget, policy: FallbackLinkMaterializationPolicy): Promise { + const resolved = path.resolve(leaf.path) + const { root, segments } = segmentComponentsOf(resolved) + if (segments.length === 0) throw invalid(`leaf ${leaf.id} path must name a destination below its filesystem root`) + const permittedKinds = permittedLeafKinds(leaf.role, policy) + let current = root + let depth = 0 + for (const segment of segments) { + const candidate = path.join(current, segment) + const isTerminal = depth === segments.length - 1 + let stats + try { + stats = await lstat(candidate) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOTDIR') { + // A live ancestor stopped being a directory between syscalls; fail + // closed instead of planning through a file or link. + throw new FallbackFrontierError( + FALLBACK_FRONTIER_ERROR_CODES.COMPONENT_NOT_DIRECTORY, + `An ancestor of ${candidate} of leaf ${leaf.id} is not a directory; refusing to plan through it` + ) + } + if (code === 'ENOENT') { + const missingComponentCount = segments.length - depth + if (missingComponentCount > FALLBACK_FRONTIER_BOUNDS.maxMissingSegmentsPerLeaf) { + throw invalid(`leaf ${leaf.id} is ${missingComponentCount} missing components below its anchor; the bound is ${FALLBACK_FRONTIER_BOUNDS.maxMissingSegmentsPerLeaf}`) + } + // A missing non-skill leaf whose parent exists is an independent leaf + // destination (exclusive leaf write later), never a directory frontier + // — including a missing Pi copied-directory link, which must never be + // reclassified as a directory-skill frontier. Only missing parent + // directories create frontiers. + if (isTerminal && leaf.role !== 'skill') { + return { leaf, existing: false, missingUnderExistingParent: true } + } + return { leaf, existing: false, anchorPath: current, frontierPath: candidate, missingComponentCount } + } + throw error + } + if (isTerminal) { + // The terminal leaf exists: its live kind must be policy-permitted for + // the role without following links — skill directories, managed links in + // a trusted materialization kind (symlink, or a real copied directory + // where the harness/platform policy allows it), and config/tracking + // regular files. + const kindPermitted = permittedKinds.some((permitted) => leafKindMatches(stats, permitted)) + if (!kindPermitted) { + throw new FallbackFrontierError( + FALLBACK_FRONTIER_ERROR_CODES.LEAF_KIND_MISMATCH, + `The existing destination ${candidate} of leaf ${leaf.id} is not a permitted kind (${permittedKinds.join(' or ')}) for its role ${leaf.role}; refusing to plan over a kind-conflicting destination` + ) + } + return { leaf, existing: true } + } + if (!stats.isDirectory()) { + throw new FallbackFrontierError( + FALLBACK_FRONTIER_ERROR_CODES.COMPONENT_NOT_DIRECTORY, + `The path component ${candidate} of leaf ${leaf.id} exists but is not a real directory; refusing to plan through links or files` + ) + } + current = candidate + depth += 1 + } + throw invalid(`leaf ${leaf.id} path must name a destination below its filesystem root`) +} + +async function anchorIdentityOf (anchorPath: string): Promise { + const [stats, resolvedReal] = await Promise.all([ + lstat(anchorPath, { bigint: true }) as Promise, + realpath(anchorPath), + ]) + if (!stats.isDirectory()) { + throw new FallbackFrontierError( + FALLBACK_FRONTIER_ERROR_CODES.ANCHOR_IDENTITY_MISMATCH, + `The anchor ${anchorPath} is not a directory at identity capture; refusing to attach a frontier to it` + ) + } + if (resolvedReal !== anchorPath) { + throw new FallbackFrontierError( + FALLBACK_FRONTIER_ERROR_CODES.ANCHOR_IDENTITY_MISMATCH, + `The anchor ${anchorPath} resolved to ${resolvedReal}; refusing to attach a frontier to an aliased directory` + ) + } + return { + path: anchorPath, + realpath: resolvedReal, + type: 'directory', + device: stats.dev.toString(10), + inode: stats.ino.toString(10), + } +} + +/** + * Derive the frontier plan for exactly the authorized leaves supplied by the + * caller. Read-only: inspects the filesystem, never mutates, never widens the + * leaf set. Deterministic: equal inputs produce byte-identical plans. + */ +export async function deriveFallbackFrontierPlan ( + leaves: readonly FallbackLeafTarget[], + linkPolicy: FallbackLinkMaterializationPolicy = STRICT_SYMLINK_POLICY +): Promise { + if (!Array.isArray(leaves)) throw invalid('leaves must be an array') + if (leaves.length > FALLBACK_FRONTIER_BOUNDS.maxLeaves) throw invalid(`at most ${FALLBACK_FRONTIER_BOUNDS.maxLeaves} leaves are supported`) + leaves.forEach((leaf, index) => assertValidLeafInput(leaf, index)) + + const byPath = new Map() + for (const leaf of leaves) { + const previous = byPath.get(leaf.path) + if (previous !== undefined) { + throw new FallbackFrontierError(FALLBACK_FRONTIER_ERROR_CODES.COLLISION, `Leaves ${previous.id} and ${leaf.id} claim the same destination ${leaf.path}`) + } + byPath.set(leaf.path, leaf) + } + for (const ancestor of leaves) { + for (const other of leaves) { + if (ancestor.path !== other.path && isProperSegmentAncestor(ancestor.path, other.path)) { + throw new FallbackFrontierError(FALLBACK_FRONTIER_ERROR_CODES.COLLISION, `Leaf ${ancestor.path} is an ancestor of leaf ${other.path}; nested authorized destinations are not supported`) + } + } + } + + const inspections = await Promise.all(leaves.map((leaf) => inspectLeaf(leaf, linkPolicy))) + const existingLeaves = inspections + .filter((inspection): inspection is LeafInspection & { existing: true } => inspection.existing) + .map((inspection) => inspection.leaf) + .sort((a, b) => compareUtf8(a.path, b.path)) + const missingLeaves = inspections + .filter((inspection): inspection is LeafInspection & { missingUnderExistingParent: true } => inspection.missingUnderExistingParent === true) + .map((inspection) => inspection.leaf) + .sort((a, b) => compareUtf8(a.path, b.path)) + + const frontierLeaves = new Map() + for (const inspection of inspections) { + if (inspection.existing || inspection.missingUnderExistingParent === true || inspection.frontierPath === undefined || inspection.anchorPath === undefined) continue + const group = frontierLeaves.get(inspection.frontierPath) + if (group === undefined) frontierLeaves.set(inspection.frontierPath, { anchorPath: inspection.anchorPath, leaves: [inspection.leaf] }) + else group.leaves.push(inspection.leaf) + } + // The strict parser accepts at most maxFrontiers entries, so derivation + // must enforce the same bound; otherwise derived evidence could not + // round-trip through the manifest. + if (frontierLeaves.size > FALLBACK_FRONTIER_BOUNDS.maxFrontiers) { + throw invalid(`derivation produced ${frontierLeaves.size} frontiers; the bound is ${FALLBACK_FRONTIER_BOUNDS.maxFrontiers}`) + } + + // Defensive: frontiers are keyed by path so distinct keys cannot collide, + // but a forged or future-divergent graph must still never nest frontiers. + const frontierPaths = [...frontierLeaves.keys()] + for (const outer of frontierPaths) { + for (const inner of frontierPaths) { + if (outer !== inner && isProperSegmentAncestor(outer, inner)) { + throw new FallbackFrontierError(FALLBACK_FRONTIER_ERROR_CODES.COLLISION, `Frontier ${outer} contains frontier ${inner}; overlapping frontiers are not supported`) + } + } + } + + const frontiers: FallbackFrontierEvidence[] = [] + for (const frontierPath of frontierPaths) { + const group = frontierLeaves.get(frontierPath)! + const anchor = await anchorIdentityOf(group.anchorPath) + const sortedLeaves = [...group.leaves].sort((a, b) => compareUtf8(a.path, b.path)) + frontiers.push({ + frontierPath, + activation: sortedLeaves.some((leaf) => leaf.activation === 'required') ? 'required' : 'conditional', + anchor, + leaves: sortedLeaves, + }) + } + frontiers.sort((a, b) => compareUtf8(a.frontierPath, b.frontierPath)) + + return { frontiers, existingLeaves, missingLeaves } +} + +/** + * Platform preflight for the binding decision: only Linux with coherent local + * filesystem identity supports publishing planned-missing frontiers initially. + * Plans whose parents all exist remain supported everywhere; conditional + * frontiers explicitly marked inactive for this run do not block. Everything + * else fails closed with FALLBACK_PARENT_CREATION_UNSUPPORTED BEFORE any + * journal reservation or live mutation. + */ +export function evaluateFallbackFrontierPlatformSupport ( + frontiers: readonly FallbackFrontierEvidence[], + options?: { inactiveFrontierPaths?: readonly string[] } +): FallbackFrontierPlatformSupport { + const inactive = new Set(options?.inactiveFrontierPaths ?? []) + for (const inactivePath of inactive) { + const frontier = frontiers.find((entry) => entry.frontierPath === inactivePath) + if (frontier === undefined) throw invalid(`inactive frontier ${inactivePath} is not part of the plan`) + if (frontier.activation !== 'conditional') { + throw invalid(`frontier ${inactivePath} is required for this run and cannot be marked inactive`) + } + } + if (process.platform === 'linux') return { supported: true } + const blocking = frontiers + .filter((frontier) => !inactive.has(frontier.frontierPath)) + .map((frontier) => frontier.frontierPath) + .sort(compareUtf8) + if (blocking.length === 0) return { supported: true } + return { supported: false, code: 'FALLBACK_PARENT_CREATION_UNSUPPORTED', frontierPaths: blocking } +} + +function assertExactKeys (value: Record, expected: readonly string[], label: string): void { + const actual = Object.keys(value).sort() + const wanted = [...expected].sort() + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw invalid(`${label} must have exactly the fields ${wanted.join(', ')}`) + } +} + +function assertDecimalIdentity (value: unknown, label: string): asserts value is string { + if (typeof value !== 'string' || !/^\d+$/.test(value)) throw invalid(`${label} must be a decimal string`) +} + +/** Strictly parse one frontier evidence value; rejects unknown fields and every shape violation. */ +export function assertFallbackFrontierEvidence (value: unknown): asserts value is FallbackFrontierEvidence { + if (typeof value !== 'object' || value === null) throw invalid('frontier evidence must be an object') + const frontier = value as Record + assertExactKeys(frontier, ['frontierPath', 'activation', 'anchor', 'leaves'], 'frontier evidence') + if (typeof frontier.frontierPath !== 'string' || !isCanonicalPath(frontier.frontierPath)) throw invalid('frontierPath must be an absolute canonical path') + if (Buffer.byteLength(frontier.frontierPath, 'utf8') > FALLBACK_FRONTIER_BOUNDS.maxPathBytes) throw invalid('frontierPath exceeds the path bound') + if (!LEAF_ACTIVATIONS.includes(frontier.activation as FallbackLeafActivation)) throw invalid('frontier activation is unknown') + + const anchor = frontier.anchor + if (typeof anchor !== 'object' || anchor === null) throw invalid('frontier anchor must be an object') + const anchorRecord = anchor as Record + assertExactKeys(anchorRecord, ['path', 'realpath', 'device', 'inode', 'type'], 'frontier anchor') + if (typeof anchorRecord.path !== 'string' || !isCanonicalPath(anchorRecord.path)) throw invalid('anchor path must be an absolute canonical path') + if (anchorRecord.realpath !== anchorRecord.path) throw invalid('anchor realpath must equal the anchor path by construction') + if (anchorRecord.type !== 'directory') throw invalid("anchor type must be 'directory'") + assertDecimalIdentity(anchorRecord.device, 'anchor device') + assertDecimalIdentity(anchorRecord.inode, 'anchor inode') + if (path.dirname(frontier.frontierPath as string) !== anchorRecord.path) { + throw invalid('frontierPath must be a direct child of the anchor path') + } + + const leaves = frontier.leaves + if (!Array.isArray(leaves) || leaves.length === 0 || leaves.length > FALLBACK_FRONTIER_BOUNDS.maxLeaves) { + throw invalid('frontier leaves must be a non-empty array within bounds') + } + let previousPath: string | undefined + for (const entry of leaves) { + assertValidLeafInput(entry, 0) + if (previousPath !== undefined && compareUtf8(previousPath, entry.path) >= 0) { + throw invalid('frontier leaves must be strictly sorted in UTF-8 byte order without duplicates') + } + previousPath = entry.path + if (!isSameOrContained(entry.path, frontier.frontierPath as string)) { + throw invalid(`leaf ${entry.id} is not at or below the frontier ${frontier.frontierPath as string}`) + } + const { segments } = segmentComponentsOf(entry.path) + const { segments: frontierSegments } = segmentComponentsOf(frontier.frontierPath as string) + const missingComponentCount = segments.length - frontierSegments.length + if (missingComponentCount < 0 || missingComponentCount + 1 > FALLBACK_FRONTIER_BOUNDS.maxMissingSegmentsPerLeaf) { + throw invalid(`leaf ${entry.id} exceeds the missing-segment bound below its frontier`) + } + } +} + +/** + * Reject globally impossible evidence: graphs where every frontier is + * individually well-formed but which no derivation could ever produce. The + * parser must never accept a language wider than the derivable graph + * language, so tampered manifests fail closed before any filesystem access. + */ +function assertGloballyPossibleFrontierGraph (frontiers: readonly FallbackFrontierEvidence[]): void { + let totalLeaves = 0 + for (const frontier of frontiers) totalLeaves += frontier.leaves.length + if (totalLeaves > FALLBACK_FRONTIER_BOUNDS.maxLeaves) { + throw invalid(`the graph carries ${totalLeaves} leaves across frontiers; the bound is ${FALLBACK_FRONTIER_BOUNDS.maxLeaves}`) + } + for (const outer of frontiers) { + for (const inner of frontiers) { + if (outer.frontierPath !== inner.frontierPath && isProperSegmentAncestor(outer.frontierPath, inner.frontierPath)) { + throw invalid(`frontier ${outer.frontierPath} contains frontier ${inner.frontierPath}; nested frontiers are impossible`) + } + } + } + const leafPaths = new Map() + const leafIdOwners = new Map() + for (const frontier of frontiers) { + // Activation must exactly aggregate the frontier's leaves: required iff + // any leaf is required, conditional iff all leaves are conditional. + const anyRequired = frontier.leaves.some((leaf) => leaf.activation === 'required') + if (anyRequired !== (frontier.activation === 'required')) { + throw invalid(`frontier ${frontier.frontierPath} activation must be required exactly when any leaf is required`) + } + for (const leaf of frontier.leaves) { + // A file or link leaf can never equal a directory frontier path: the + // only legitimate self-referencing leaf is the missing directory skill + // leaf that its own frontier publishes. + if (leaf.path === frontier.frontierPath && leaf.role !== 'skill') { + throw invalid(`leaf ${leaf.id} (${leaf.role}) cannot equal the directory frontier ${frontier.frontierPath}`) + } + const previousPath = leafPaths.get(leaf.path) + if (previousPath !== undefined) { + throw invalid(`leaves ${previousPath.id} and ${leaf.id} claim the same destination ${leaf.path} across frontiers`) + } + const previousIdPath = leafIdOwners.get(leaf.id) + if (previousIdPath !== undefined) { + throw invalid(`leaf id ${leaf.id} is duplicated across frontiers (${previousIdPath} and ${leaf.path})`) + } + leafPaths.set(leaf.path, leaf) + leafIdOwners.set(leaf.id, leaf.path) + } + } + const leafPathList = [...leafPaths.keys()] + for (const ancestor of leafPathList) { + for (const other of leafPathList) { + if (ancestor !== other && isProperSegmentAncestor(ancestor, other)) { + throw invalid(`leaf ${ancestor} is an ancestor of leaf ${other}; nested authorized destinations are impossible`) + } + } + } +} + +/** Strictly parse a whole frontier list; enforces bounds, uniqueness, and canonical byte order. */ +export function assertFallbackFrontierEvidenceList (value: unknown): asserts value is FallbackFrontierEvidence[] { + if (!Array.isArray(value)) throw invalid('frontier evidence must be an array') + if (value.length > FALLBACK_FRONTIER_BOUNDS.maxFrontiers) throw invalid(`at most ${FALLBACK_FRONTIER_BOUNDS.maxFrontiers} frontiers are supported`) + value.forEach((entry) => assertFallbackFrontierEvidence(entry)) + for (let index = 1; index < value.length; index++) { + if (compareUtf8(value[index - 1].frontierPath, value[index].frontierPath) >= 0) { + throw invalid('frontier evidence must be strictly sorted by frontierPath in UTF-8 byte order without duplicates') + } + } + assertGloballyPossibleFrontierGraph(value) +} + +/** + * Select the leaves of one frontier that a publication unit may materialize: + * every `required` leaf is always included, and only `conditional` leaves + * whose exact id appears in the authorized selector are added. The selector + * is pure planning input — it widens nothing on disk by itself — but it must + * still be strict: unknown ids, duplicates, non-string entries, and + * overflow are rejected instead of silently dropped, so a tampered or buggy + * caller fails closed before any staging happens. + * + * Pure and deterministic: no filesystem access, no mutation of the frontier + * evidence or the selector, and the result is a new UTF-8 byte-ordered + * frozen array of the evidence's own leaf objects (never copies that could + * drift from the recorded graph). + */ +export function selectActiveFallbackFrontierLeaves ( + frontier: FallbackFrontierEvidence, + activeConditionalLeafIds: readonly string[] = [] +): readonly FallbackLeafTarget[] { + assertFallbackFrontierEvidence(frontier) + if (!Array.isArray(activeConditionalLeafIds)) throw invalid('the active conditional leaf selector must be an array') + if (activeConditionalLeafIds.length > FALLBACK_FRONTIER_BOUNDS.maxLeaves) { + throw invalid(`the active conditional leaf selector exceeds the ${FALLBACK_FRONTIER_BOUNDS.maxLeaves}-leaf bound`) + } + const selected = new Set() + for (const id of activeConditionalLeafIds) { + if (typeof id !== 'string' || id.length === 0 || Buffer.byteLength(id, 'utf8') > FALLBACK_FRONTIER_BOUNDS.maxLeafIdBytes) { + throw invalid('active conditional leaf selector entries must be non-empty strings within the id bound') + } + if (selected.has(id)) throw invalid(`the active conditional leaf selector duplicates id ${id}`) + selected.add(id) + } + const conditionalById = new Map() + for (const leaf of frontier.leaves) { + if (leaf.activation === 'required' && selected.has(leaf.id)) { + throw invalid(`leaf ${leaf.id} is required and must never appear in the conditional selector`) + } + if (leaf.activation === 'conditional') conditionalById.set(leaf.id, leaf) + } + for (const id of selected) { + if (!conditionalById.has(id)) throw invalid(`selector id ${id} is not a conditional leaf of this frontier`) + } + const active = frontier.leaves + .filter((leaf) => leaf.activation === 'required' || selected.has(leaf.id)) + .sort((left, right) => compareUtf8(left.path, right.path)) + return Object.freeze(active) +} + +function frontierEquals (expected: FallbackFrontierEvidence, observed: FallbackFrontierEvidence): string | undefined { + if (expected.frontierPath !== observed.frontierPath) return 'frontierPath' + if (expected.activation !== observed.activation) return 'activation' + for (const key of ['path', 'realpath', 'type', 'device', 'inode'] as const) { + if (expected.anchor[key] !== observed.anchor[key]) return `anchor.${key}` + } + if (expected.leaves.length !== observed.leaves.length) return 'leaves.length' + for (let index = 0; index < expected.leaves.length; index++) { + const a = expected.leaves[index] + const b = observed.leaves[index] + if (a.id !== b.id || a.role !== b.role || a.activation !== b.activation || a.path !== b.path) return `leaves[${index}]` + } + return undefined +} + +/** One physical filesystem target the fallback journal must carry as an entry. */ +export interface FallbackJournalPhysicalTarget { + path: string + /** True when this target is a topmost planned-missing frontier journaled as one missing publication unit. */ + frontier: boolean +} + +/** + * Derive the journal's exact NON-OVERLAPPING physical entry set from a + * trusted manifest. This is the one shared derivation used by journal begin + * (to create entries and backups) and strict validation (to reject entry + * insertion, removal, path, kind, and digest tampering). Pure and + * deterministic: no filesystem access, no clock, no randomness. + * + * - Ordinary tracking/owned/bundle/MCP targets NOT covered by a + * plannedMissingFrontier are retained verbatim (deduplicated by resolved + * path, in manifest order). + * - Every covered leaf is omitted: a frontier covers its authorized leaves, + * so they are represented by the single frontier entry. A candidate under + * a frontier that is NOT one of that frontier's authorized leaves is a + * manifest inconsistency and is rejected. + * - Each topmost frontier is added exactly once as a planned-missing + * publication unit, in UTF-8 byte order after the retained targets. + * - Existing directory anchors stay validation-only: frontier anchors never + * become entries or snapshots through frontier evidence. + */ +export function deriveFallbackJournalPhysicalTargets (manifest: FallbackTransactionIdentity): FallbackJournalPhysicalTarget[] { + assertFallbackFrontierEvidenceList(manifest.plannedMissingFrontiers) + const frontiers = manifest.plannedMissingFrontiers + const seen = new Set() + const retained: string[] = [] + const addCandidate = (rawPath: string): void => { + const resolved = path.resolve(rawPath) + if (seen.has(resolved)) return + seen.add(resolved) + const covering = frontiers.filter((frontier) => isSameOrContained(resolved, frontier.frontierPath)) + if (covering.length === 0) { + retained.push(resolved) + return + } + if (covering.length > 1) { + throw new FallbackFrontierError( + FALLBACK_FRONTIER_ERROR_CODES.COLLISION, + `Manifest target ${resolved} is covered by multiple frontiers (${covering.map((entry) => entry.frontierPath).join(', ')}); frontiers must be disjoint` + ) + } + const leafPaths = new Set(covering[0].leaves.map((leaf) => path.resolve(leaf.path))) + if (!leafPaths.has(resolved)) { + throw new FallbackFrontierError( + FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT, + `Manifest target ${resolved} falls under frontier ${covering[0].frontierPath} without being one of its authorized leaves` + ) + } + // Covered leaves are represented by the single frontier entry; they + // never get their own overlapping physical entry or backup. + } + addCandidate(manifest.trackingPath) + for (const entry of manifest.ownedSkills) addCandidate(entry.path) + for (const entry of manifest.ownedLinks) addCandidate(entry.path) + for (const entry of manifest.ownedMcpConfigPaths) addCandidate(typeof entry === 'string' ? entry : entry.path) + for (const entry of manifest.bundleDestinations ?? []) addCandidate(entry.path) + const frontierTargets = frontiers + .map((frontier) => ({ path: path.resolve(frontier.frontierPath), frontier: true })) + .sort((left, right) => compareUtf8(left.path, right.path)) + const retainedPaths = new Set(retained) + if (frontierTargets.some((entry) => retainedPaths.has(entry.path))) { + throw new FallbackFrontierError(FALLBACK_FRONTIER_ERROR_CODES.COLLISION, 'A frontier path cannot also be a retained journal target') + } + return [...retained.map((target) => ({ path: target, frontier: false })), ...frontierTargets] +} + +/** + * Revalidate recorded frontier evidence against the CURRENT filesystem for + * journal begin and mutation claim. Every frontier must still hold its + * exact anchor identity (path, type, realpath, device, inode) on a fully + * non-link real-directory component chain, the frontier path must still be + * missing, and rederiving the recorded leaves must reproduce exactly the + * recorded graph. Any other outcome is FALLBACK_FRONTIER_DRIFT and must + * fail the transaction closed BEFORE any journal reservation or CAS write. + */ +export async function revalidateFallbackFrontierEvidence ( + frontiers: readonly FallbackFrontierEvidence[], + linkPolicy: FallbackLinkMaterializationPolicy = STRICT_SYMLINK_POLICY +): Promise { + assertFallbackFrontierEvidenceList(frontiers) + for (const frontier of frontiers) { + try { + await revalidateOneFrontier(frontier, linkPolicy) + } catch (error) { + if (error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.DRIFT) throw error + throw new FallbackFrontierError(FALLBACK_FRONTIER_ERROR_CODES.DRIFT, `Frontier ${frontier.frontierPath} drifted before the transaction: ${(error as Error).message}`) + } + } +} + +async function frontierPathState (frontierPath: string): Promise<'missing' | 'present' | 'unprovable'> { + try { + await lstat(frontierPath) + return 'present' + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'unprovable' + } +} + +/** + * Revalidate ONLY the authenticated anchor chain of one frontier: real non-link + * directory components down to the anchor, exact recorded device/inode + * identity, and unchanged realpath. Unlike `revalidateFallbackFrontierEvidence` + * it does NOT require the frontier path to still be missing — it is the + * shared anchor proof for operations on a frontier that this process has + * already published (e.g. process-local rollback), where the live path is + * expected to exist. Throws `FallbackFrontierError` (DRIFT) on any mismatch. + */ +export async function revalidateFallbackFrontierAnchor (frontier: FallbackFrontierEvidence): Promise { + assertFallbackFrontierEvidenceList([frontier]) + try { + await revalidateOneFrontierAnchor(frontier) + } catch (error) { + if (error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.DRIFT) throw error + throw new FallbackFrontierError(FALLBACK_FRONTIER_ERROR_CODES.DRIFT, `Frontier ${frontier.frontierPath} drifted before the transaction: ${(error as Error).message}`) + } +} + +async function revalidateOneFrontierAnchor (frontier: FallbackFrontierEvidence): Promise { + const drift = (detail: string): FallbackFrontierError => new FallbackFrontierError(FALLBACK_FRONTIER_ERROR_CODES.DRIFT, `Frontier ${frontier.frontierPath} drifted: ${detail}`) + const anchorPath = path.resolve(frontier.anchor.path) + const { root, segments } = segmentComponentsOf(anchorPath) + let current = root + for (const segment of segments) { + const component = path.join(current, segment) + let stats: Stats + try { + stats = await lstat(component) + } catch { + throw drift(`anchor component ${component} is missing`) + } + if (stats.isSymbolicLink() || !stats.isDirectory()) throw drift(`anchor component ${component} is not a real directory`) + current = component + } + const [identity, real] = await Promise.all([ + lstat(frontier.anchor.path, { bigint: true }) as Promise, + realpath(frontier.anchor.path), + ]) + if (identity.isSymbolicLink() || !identity.isDirectory()) throw drift('the anchor is not a real directory') + if (identity.dev.toString(10) !== frontier.anchor.device || identity.ino.toString(10) !== frontier.anchor.inode) throw drift('the anchor device/inode identity changed') + if (real !== frontier.anchor.realpath || real !== anchorPath) throw drift('the anchor realpath changed') +} + +async function revalidateOneFrontier (frontier: FallbackFrontierEvidence, linkPolicy: FallbackLinkMaterializationPolicy): Promise { + const drift = (detail: string): FallbackFrontierError => new FallbackFrontierError(FALLBACK_FRONTIER_ERROR_CODES.DRIFT, `Frontier ${frontier.frontierPath} drifted: ${detail}`) + // Shared anchor proof: the anchor chain must still be real, non-link + // directories down to the recorded anchor, with unchanged device/inode + // identity and realpath. realpath alone cannot prove per-component kinds. + await revalidateOneFrontierAnchor(frontier) + const state = await frontierPathState(frontier.frontierPath) + if (state === 'present') throw drift('the frontier path exists') + if (state === 'unprovable') throw drift('the frontier path state cannot be proven') + // Rederiving the recorded leaves must reproduce exactly this frontier: + // every recorded leaf still missing below the still-missing frontier, with + // the same anchor identity, activation, order, and leaf graph. + const plan = await deriveFallbackFrontierPlan(frontier.leaves, linkPolicy) + if (plan.existingLeaves.length > 0 || plan.missingLeaves.length > 0 || plan.frontiers.length !== 1) { + throw drift('rederiving the recorded leaves does not reproduce exactly one missing frontier') + } + const field = frontierEquals(frontier, plan.frontiers[0]) + if (field !== undefined) throw drift(`rederived evidence differs at ${field}`) +} + +/** + * Exact structural expectation for a published frontier subtree, derived pure + * and deterministically from trusted evidence. One spelling shared by journal + * registration/validation/publication so the child can never widen the tree + * and no consumer can fork the semantics. + * + * The frontier-level structure (everything from the frontier root down to + * each leaf) is exactly the leaf set plus the intermediate directories that + * leaf paths imply: nothing else may exist at that level. Below a `skill` + * leaf (a managed directory) arbitrary file/directory content is allowed, + * but no symlinks anywhere; `link` leaves are the only legitimate symlinks + * and their readlink text must be exactly the final live path of the + * corresponding `skill:` leaf. + */ +export interface FallbackFrontierTreeExpectation { + /** Frontier-level relative directory paths strictly required (leaf ancestors); UTF-8 byte order. */ + directories: readonly string[] + /** Frontier-level relative leaf path → required terminal kind(s). Permissive link policies list every permitted kind; concrete plans still record actual kinds. */ + leafKinds: ReadonlyMap + /** Relative link-leaf path → required readlink text (absolute final skill destination). */ + linkTargets: ReadonlyMap +} + +/** + * Derive the tree expectation for one frontier. `allLeaves` must carry the + * complete authorized leaf set of the manifest (every frontier's leaves), so + * a `link:` leaf can be bound to its `skill:` final destination. + * Pure: no filesystem access, no clock, no randomness. + */ +export function deriveFrontierTreeExpectation ( + frontier: FallbackFrontierEvidence, + allLeaves: readonly FallbackLeafTarget[], + options?: { + /** Trusted resolver for `link:` bindings beyond frontier leaves (existing skill destinations); must derive only from trusted evidence and return undefined when unknown. */ + skillDestinationResolver?: (leaf: FallbackLeafTarget) => string | undefined + /** Trusted harness-derived link materialization policy; defaults to real symlinks only. */ + linkPolicy?: FallbackLinkMaterializationPolicy + } +): FallbackFrontierTreeExpectation { + if (!isCanonicalPath(frontier.frontierPath)) throw invalid('frontier path must be absolute and canonical') + const directories = new Set() + const leafKinds = new Map() + const linkTargets = new Map() + const skillPathsById = new Map(allLeaves.map((leaf) => [leaf.id, path.resolve(leaf.path)])) + const linkPolicy = options?.linkPolicy ?? STRICT_SYMLINK_POLICY + for (const leaf of frontier.leaves) { + if (typeof leaf?.path !== 'string' || !isCanonicalPath(leaf.path)) throw invalid('frontier leaf paths must be absolute and canonical') + const relative = path.relative(frontier.frontierPath, path.resolve(leaf.path)) + if (relative === '') { + // A directory skill leaf may exactly equal the frontier root: the whole + // published tree is that skill's managed content. Link/file leaves can + // never equal a missing directory frontier. + if (LEAF_EXPECTED_KINDS[leaf.role] !== 'directory') throw invalid('only a directory skill leaf may equal the frontier root') + leafKinds.set('', 'directory') + continue + } + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw invalid(`frontier leaf ${leaf.path} is not below the frontier ${frontier.frontierPath}`) + } + if (leafKinds.has(relative)) throw invalid(`frontier leaves collapse into one relative path: ${relative}`) + // A staged/live link leaf is one concrete kind; under a permissive policy + // the same leaf could legitimately be either a real symlink or a copied + // directory, so the expectation records every permitted kind. Concrete + // stage/publication plans still observe and record ACTUAL kinds. + const permittedKinds = permittedLeafKinds(leaf.role, linkPolicy) + if (leaf.role === 'link' && permittedKinds.length > 1) { + leafKinds.set(relative, [...permittedKinds]) + } else { + leafKinds.set(relative, permittedKinds[0]) + } + if (leaf.role === 'link') { + const skillName = leaf.id.startsWith('link:') ? leaf.id.slice('link:'.length) : undefined + let finalSkillPath = skillName === undefined ? undefined : skillPathsById.get(`skill:${skillName}`) + if (finalSkillPath === undefined && options?.skillDestinationResolver !== undefined) { + const resolved = options.skillDestinationResolver(leaf) + if (resolved !== undefined) { + if (typeof resolved !== 'string' || !isCanonicalPath(resolved)) throw invalid('skill destination resolver must return an absolute canonical path or undefined') + finalSkillPath = resolved + } + } + if (finalSkillPath === undefined) { + throw invalid(`link leaf ${leaf.id} has no corresponding skill: leaf for its final destination`) + } + linkTargets.set(relative, finalSkillPath) + } + // Every strict ancestor of the leaf inside the frontier must be created. + const segments = relative.split(path.sep) + for (let depth = 1; depth < segments.length; depth++) directories.add(segments.slice(0, depth).join(path.sep)) + } + return { directories: [...directories].sort(compareUtf8), leafKinds, linkTargets } +} diff --git a/packages/core/src/update/fallback-journal.ts b/packages/core/src/update/fallback-journal.ts new file mode 100644 index 0000000..d7ee987 --- /dev/null +++ b/packages/core/src/update/fallback-journal.ts @@ -0,0 +1,3557 @@ +import { isDeepStrictEqual } from 'node:util' +import { createHash, randomUUID } from 'node:crypto' +import { constants as fsPromisesConstants, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readlink, readdir, rename, rm, symlink, writeFile, type FileHandle } from 'node:fs/promises' +import { existsSync, lstatSync, readFileSync, realpathSync, type BigIntStats, type Dirent, type Stats } from 'node:fs' +import path from 'node:path' +import { FALLBACK_PROTOCOL_VERSION, type FallbackAnchorIdentity, type FallbackFrontierEvidence, type FallbackLeafTarget, type FallbackPathEvidence, type FallbackTransactionIdentity } from './types.js' +import { assertFallbackFrontierEvidenceList, compareUtf8, deriveFallbackJournalPhysicalTargets, deriveFrontierTreeExpectation, fallbackLinkMaterialization, revalidateFallbackFrontierAnchor, revalidateFallbackFrontierEvidence, selectActiveFallbackFrontierLeaves, type FallbackFrontierTreeExpectation } from './fallback-frontier.js' +import { isValidTrackingData, type TrackingData } from '../skills/skill-tracker.js' +import { isCanonicalPath, isSameOrContained, matchesTrackedOwnership } from './fallback-ownership.js' + +export type FallbackJournalPhase = 'initializing' | 'prepared' | 'mutating' +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 (fallback-path-v2 framing). */ + 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 + /** Set once the staged payload (or deletion) has been swapped in. Validation evidence only: it never authorizes a deletion or an overwrite. */ + applied?: boolean + /** Sibling quarantine path receiving the replaced live bytes until cleanup. */ + quarantine?: string + /** + * Durable, STRICTLY VALIDATED reporting/blocking field: a frontier rollback + * prepared or attempted but not durably completed. It NEVER grants + * destructive authority — it makes every restore/recovery/commit path + * treat the transaction as incomplete (preserve and report) while the + * ambiguous physical state is deliberately resolved. + */ + frontierRollbackPending?: boolean +} + +/** + * Identity of one process. `startIdentity` carries the Linux process start + * tick from /proc when available so a recycled PID can never inherit the + * ownership recorded for the original process. + */ +export interface ProcessIdentity { + pid: number + startIdentity?: string +} + +export interface FallbackJournal { + version: 3 + phase: FallbackJournalPhase + /** Monotonic revision; every durable write compares and increments it. */ + revision: number + /** Unique per begin; binds handles to exactly one journal instance. */ + transactionId: string + /** Canonical manifest digest; self-consistency only, never restore authority. */ + manifestDigest: string + manifest: FallbackTransactionIdentity + journalPath: string + snapshotDirectory?: string + owner: ProcessIdentity + mutator?: ProcessIdentity & { claimedAt: string } + entries: readonly FallbackJournalEntry[] + /** Reporting-only residue recorded during the transaction; never an authorization. */ + preservedArtifacts?: readonly string[] + preservedPaths?: readonly string[] +} + +/** + * In-process authority for journal operations. Handles are never serialized: + * the trusted manifest copy and the artifact capabilities live only in the + * memory of the process that created or claimed them. A value read from the + * journal file can never authorize a destructive operation on its own. + */ +export interface FallbackJournalHandle { + readonly kind: 'fallback-journal-handle' + readonly journalPath: string + readonly transactionId: string + readonly manifestDigest: string + /** Trusted in-memory manifest: the owner's planning capability. */ + readonly manifest: FallbackTransactionIdentity + role: 'owner' | 'mutator' + readonly actor: ProcessIdentity + revision: number +} + +export interface FallbackJournalResult { + journal: FallbackJournal + handle: FallbackJournalHandle +} + +/** Structured result replacing bare booleans; preservation arrays are reporting only. */ +export interface FallbackJournalOperationResult { + succeeded: boolean + /** + * True when a planned-missing frontier was physically rolled back (its + * proven live tree moved into a preserved quarantine, restoring the + * original missing state) but the durable journal bookkeeping for that + * rollback did not complete. Reporting-only: the physical restore IS done + * and the preserved quarantine is listed in preservedArtifacts. + */ + frontierJournalPending?: boolean + /** + * True when the operation could not even start because the journal does not + * match the trusted manifest or a backup failed authentication: nothing was + * mutated and everything is preserved (surfaces as FALLBACK_STATE_UNPROVEN). + */ + unproven?: boolean + /** + * Reporting-only diagnostic for a swallowed fail-closed error, e.g. the + * FALLBACK_JOURNAL_LOCK_BUSY message naming the exact mutation lock path + * that requires manual recovery. It never authorizes deletion of the lock, + * journal, or any preserved artifact. + */ + diagnostic?: string + preservedArtifacts: string[] + preservedPaths: string[] +} + +interface ArtifactCapability { + container: string + payload?: string + token: string + role: 'stage' | 'quarantine' | 'restore' | 'snapshot' + dev?: number + ino?: number + /** Immutable complete-stage digest recorded at frontier registration; apply requires payload AND journal to match it. */ + stageDigest?: string + /** + * Exact expected direct-child structure (child name → fallback path kind) + * recorded when this process created the container. Authenticated cleanup + * refuses to delete unless reality still matches it exactly. + */ + children?: ReadonlyMap + /** + * Frozen private copied-link binding proven at stage registration for an + * independent harness link destination. Apply re-proves the staged payload + * and the published path against this record alone; it is never serialized + * and never derived from journal fields or the mutable live skill bytes. + */ + copiedLinkBinding?: Readonly + /** + * Frozen private copied-link bindings for one frontier stage, keyed by + * frontier-relative leaf path. Publication proves staged and published + * leaves against these records; they are copied into the completion record + * so authorized rollback proves against the same frozen records. + */ + copiedLinkBindings?: Readonly>> +} + +/** + * Artifact capabilities are in-memory authority, never serialized state: only + * a container this process created (and still holds the token for) may be + * removed, and only after its exact structural identity is reverified. They + * are keyed by the unique transaction id (never by handle object identity): + * journal APIs return revision-bumped handle copies, and the capability + * domain must survive those spreads within this one process. + */ +const handleCapabilities = new Map>() + +// Only a verified publication by this process authorizes replacing changed +// tracking during rollback. Disk applied/stageDigest fields cannot grant this +// authority, and an external parent without it must preserve concurrent bytes. +const publishedTrackingDigests = new Map() + +/** + * Process-local record of non-frontier planned-missing destinations this + * process actually published, keyed by transaction id and resolved path. + * Recorded ONLY inside the completed swap of `applyFallbackEntry`, after the + * published bytes have been proven on disk — never from register/stage + * bookkeeping, never from serialized journal fields. It holds the exact live + * kind and digest captured at publication time, so restore may + * quarantine-rename a live planned-missing destination only when these exact + * bytes are still what is live. A foreign process (including a recovering + * parent) has no record and always preserves, mirroring the frontier + * publication model (`completedFrontierPublications`). + */ +interface PublishedFallbackEntry { + kind: FallbackPathKind + digest: string +} +const publishedFallbackEntries = new Map>() + +/** Record one proven completed publication of a planned-missing destination. */ +function recordPublishedFallbackEntry (transactionId: string, resolved: string, kind: FallbackPathKind, digest: string): void { + let published = publishedFallbackEntries.get(transactionId) + if (published === undefined) { + published = new Map() + publishedFallbackEntries.set(transactionId, published) + } + published.set(resolved, { kind, digest }) +} + +/** + * Object-identity authority registry: every handle object issued by a + * legitimate transition (begin/claim/reclaim/register/apply) is bound here to + * an IMMUTABLE snapshot of the metadata it was issued with. A destructive + * frontier rollback requires the CURRENT object's fields to still equal the + * bound metadata exactly, so in-place field mutation and spread copies both + * fail closed. Weak so genuine handles can be garbage-collected normally. + */ +interface GenuineHandleMetadata { + readonly transactionId: string + readonly journalPath: string + readonly manifestDigest: string + readonly role: FallbackJournalHandle['role'] + readonly actor: ProcessIdentity + readonly revision: number +} + +const genuineJournalHandles = new WeakMap() + +/** Snapshot one handle's immutable issued metadata. */ +function genuineHandleMetadataOf (handle: FallbackJournalHandle): GenuineHandleMetadata { + return Object.freeze({ + transactionId: handle.transactionId, + journalPath: handle.journalPath, + manifestDigest: handle.manifestDigest, + role: handle.role, + actor: { ...handle.actor }, + revision: handle.revision, + }) +} + +/** + * The ONLY way a handle object becomes destructive-frontier authority in this + * process: bind the exact issued metadata to this object. Never exposed + * outside this module. + */ +function issueGenuineHandle (handle: FallbackJournalHandle): FallbackJournalHandle { + genuineJournalHandles.set(handle, genuineHandleMetadataOf(handle)) + return handle +} + +/** A genuine current handle whose object fields still equal its bound metadata exactly. */ +function handleMatchesIssuedMetadata (handle: FallbackJournalHandle): boolean { + const bound = genuineJournalHandles.get(handle) + if (bound === undefined) return false + return handle.transactionId === bound.transactionId && + handle.journalPath === bound.journalPath && + handle.manifestDigest === bound.manifestDigest && + handle.role === bound.role && + processMatches(handle.actor) && + handle.actor.pid === bound.actor.pid && + handle.actor.startIdentity === bound.actor.startIdentity && + handle.revision === bound.revision +} + +function capabilitiesFor (handle: FallbackJournalHandle): Map { + let map = handleCapabilities.get(handle.transactionId) + if (map === undefined) { + map = new Map() + handleCapabilities.set(handle.transactionId, map) + } + return map +} + +/** + * Process-local authoritative record of one completed frontier publication: + * the exact reserved directory identity this process created by exclusive + * mkdir plus the registered complete-stage digest the published tree was + * proven against. Never serialized, never derived from journal fields: a + * forged `applied`/`stageDigest` on disk can never produce one. + */ +export interface FallbackFrontierPublication { + frontierPath: string + /** dev/ino of the reserved directory this process created and populated. */ + dev: number + ino: number + /** Registered complete-stage digest the live tree was proven against. */ + stageDigest: string + /** True when stage-cleanup or post-apply bookkeeping did not finish: publication IS complete, only residue reporting is pending. */ + cleanupPending: boolean + /** Frozen copy of the conditional leaf IDs this process authorized as active at registration. Reporting only: never disk-derived authority. */ + activeConditionalLeafIds: readonly string[] +} + +/** + * Process-local publication record including the frozen copied-link + * bindings. Internal authority state only: `publishedFallbackFrontiers` + * strips the bindings from every exported report object. + */ +interface InternalFrontierPublication extends FallbackFrontierPublication { + copiedLinkBindings?: Readonly>> +} + +/** Registered stage plan for one frontier: exact leaf kinds, staged leaf digests, link texts, and the immutable active conditional leaf selection. */ +interface FallbackFrontierStagePlan { + leafKinds: Map + leafDigests: Map + linkTexts: Map + /** Sorted frozen selected conditional leaf IDs; the immutable apply/rollback proof selection. */ + activeConditionalLeafIds: readonly string[] +} + +const frontierStagePlans = new Map>() +const completedFrontierPublications = new Map>() + +function frontierPlanFor (transactionId: string): Map { + let map = frontierStagePlans.get(transactionId) + if (map === undefined) { + map = new Map() + frontierStagePlans.set(transactionId, map) + } + return map +} + +/** Process-local publication records for a transaction; empty unless this very process published frontiers through its own capabilities. Internal binding authority is stripped from every report: callers receive reporting metadata only and can never mutate internal state. */ +export function publishedFallbackFrontiers (handle: FallbackJournalHandle): readonly FallbackFrontierPublication[] { + return [...(completedFrontierPublications.get(handle.transactionId)?.values() ?? [])].map((record) => Object.freeze({ + frontierPath: record.frontierPath, + dev: record.dev, + ino: record.ino, + stageDigest: record.stageDigest, + cleanupPending: record.cleanupPending, + activeConditionalLeafIds: record.activeConditionalLeafIds, + })) +} + +/** + * Awaited test-only seam between the final container identity recheck and the + * tombstone rename inside authenticated cleanup. Production never installs + * one; tests use it to deterministically run code inside that window and + * prove a swapped-in replacement or smuggled content can never be deleted. + */ +type FallbackArtifactSwapSeam = (artifact: { container: string; role: ArtifactCapability['role'] }) => Promise | void +let fallbackArtifactSwapSeam: FallbackArtifactSwapSeam | undefined + +export function setFallbackArtifactSwapSeamForTests (seam?: FallbackArtifactSwapSeam): void { + fallbackArtifactSwapSeam = seam +} + +/** Deterministic awaited test windows inside frontier publication; production never installs one. */ +export interface FallbackFrontierPublicationSeamEvent { + phase: 'before-reserve' | 'reserved-identity' | 'before-leaf' | 'post-population' | 'stage-ready' | 'after-applied' + frontierPath: string + leaf?: string +} + +type FallbackFrontierPublicationSeam = (event: FallbackFrontierPublicationSeamEvent) => Promise | void + +let fallbackFrontierPublicationSeam: FallbackFrontierPublicationSeam | undefined + +export function setFallbackFrontierPublicationSeamForTests (seam?: FallbackFrontierPublicationSeam): void { + fallbackFrontierPublicationSeam = seam +} + +/** Deterministic awaited test windows inside frontier rollback; production never installs one. */ +export interface FallbackFrontierRollbackSeamEvent { + phase: 'proof-complete' | 'quarantine-ready' | 'moved' | 'proven' + frontierPath: string + quarantinePath?: string +} + +type FallbackFrontierRollbackSeam = (event: FallbackFrontierRollbackSeamEvent) => Promise | void + +let fallbackFrontierRollbackSeam: FallbackFrontierRollbackSeam | undefined + +export function setFallbackFrontierRollbackSeamForTests (seam?: FallbackFrontierRollbackSeam): void { + fallbackFrontierRollbackSeam = seam +} + +/** + * Remove-only test reset: clears every process-local publication state — + * frontier stage plans and completion records AND planned-missing published + * entry records — simulating a process restart. It can never create + * authority; a cleared registry only ever yields fail-closed preservation. + */ +export function clearFallbackFrontierPublicationStateForTests (): void { + completedFrontierPublications.clear() + frontierStagePlans.clear() + publishedFallbackEntries.clear() +} + +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` +} + +// --- Mutation transition lock ----------------------------------------------------- +// +// The claim/reclaim read-check-casWrite sequence is the only journal +// transition where two different processes can legitimately pass the same +// liveness checks against the same on-disk prior state (the owner <-> mutator +// authority handoff). Without inter-process exclusion, every claimant that +// reads the journal before any of them writes passes the deep-equality CAS +// (last-writer-wins with the SAME incremented revision) and multiple live +// processes hold genuine mutator authority simultaneously. The lock below +// serializes exactly these two authority transitions with an exclusive-create +// lock file next to the journal. + +const FALLBACK_JOURNAL_TRANSITION_LOCK_POLL_MS = 5 +const FALLBACK_JOURNAL_TRANSITION_LOCK_TIMEOUT_MS = 10_000 + +/** Test-only override of the transition lock contention deadline; production always uses the default. */ +let transitionLockTimeoutMsForTests: number | undefined + +export function setFallbackJournalTransitionLockTimeoutForTests (ms: number | undefined): void { + transitionLockTimeoutMsForTests = ms +} + +interface FallbackJournalTransitionLockRecord { + kind: 'fallback-journal-transition-lock' + journalPath: string + /** Random value proving release deletes only the exact lock this invocation created. */ + token: string + holder: ProcessIdentity + acquiredAt: string +} + +function fallbackJournalTransitionLockPath (journalPath: string): string { + return `${journalPath}.mutation-lock` +} + +export function isFallbackJournalLockBusyError (error: unknown): boolean { + return error instanceof Error && error.message.startsWith('FALLBACK_JOURNAL_LOCK_BUSY') +} + +/** + * Run one journal authority-transition critical section under inter-process + * exclusion. The lock is an exclusive-create (`wx`) file next to the journal + * carrying the holder's process identity. Contention is retried on a bounded + * deadline and then FAILS CLOSED: an abandoned (crashed holder) or ambiguous + * lock is never deleted or taken over, because a check-then-delete stale + * recovery has a proven TOCTOU window (two reapers reading the same dead + * record; the second unlinks the first reaper's replacement). Such a lock + * must be resolved by manual recovery — the error names the exact path. + * Release verifies the on-disk record still token-matches the record this + * invocation wrote, so a replaced or tampered lock file is never deleted. + */ +async function withFallbackJournalTransitionLock (journalPath: string, critical: () => Promise): Promise { + const lockPath = fallbackJournalTransitionLockPath(journalPath) + const deadline = Date.now() + (transitionLockTimeoutMsForTests ?? FALLBACK_JOURNAL_TRANSITION_LOCK_TIMEOUT_MS) + const record: FallbackJournalTransitionLockRecord = { + kind: 'fallback-journal-transition-lock', + journalPath, + token: randomUUID(), + holder: currentProcessIdentity(), + acquiredAt: new Date().toISOString(), + } + for (;;) { + let handle: FileHandle + try { + handle = await open(lockPath, 'wx', 0o600) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + if (Date.now() >= deadline) { + throw new Error(`FALLBACK_JOURNAL_LOCK_BUSY at ${lockPath}: the mutation transition lock is held by another live process, or was abandoned by a crashed process and requires manual recovery`) + } + await new Promise((resolve) => setTimeout(resolve, FALLBACK_JOURNAL_TRANSITION_LOCK_POLL_MS)) + continue + } + try { + await handle.writeFile(JSON.stringify(record, null, 2) + '\n') + await handle.sync() + } catch (error) { + // The record could not be stamped: an unidentifiable lock file is left + // in place (never deleted) and every later transition fails closed. + await handle.close().catch(() => {}) + throw new Error(`FALLBACK_JOURNAL_LOCK_BUSY at ${lockPath}: the lock holder record could not be written; the unidentifiable lock requires manual recovery`, { cause: error }) + } + await handle.close().catch(() => {}) + try { + return await critical() + } finally { + await releaseFallbackJournalTransitionLock(lockPath, record) + } + } +} + +/** Release ONLY the exact lock this invocation created; never a replaced, foreign, or unreadable record. */ +async function releaseFallbackJournalTransitionLock (lockPath: string, record: FallbackJournalTransitionLockRecord): Promise { + try { + const disk = JSON.parse(await readFile(lockPath, 'utf8')) as Partial + if (disk?.kind !== record.kind || disk?.token !== record.token || disk?.journalPath !== record.journalPath) return + await rm(lockPath) + } catch { /* missing or unreadable record: nothing this invocation may delete */ } +} + +/** + * Fallback digest framing: framed, recursive, locale-independent. All fallback + * evidence (manifests, journals, tracking) uses this single digest domain; + * digests from other domains (for example the owned-fs framing) are never + * reinterpreted as fallback evidence. + */ +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 }) + const ordered = [...entries].sort((left, right) => Buffer.compare(Buffer.from(left.name, 'utf8'), Buffer.from(right.name, 'utf8'))) + for (const entry of ordered) { + 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 + } +} + +/** Deterministic recursively key-sorted JSON: arrays keep order, undefined object fields are omitted, and undefined/sparse array elements plus non-finite numbers are rejected. */ +export function canonicalJsonString (value: unknown): string { + return JSON.stringify(canonicalize(value)) +} + +function canonicalize (value: unknown): unknown { + if (value === null) return null + const type = typeof value + if (type === 'string' || type === 'boolean') return value + if (type === 'number') { + if (!Number.isFinite(value)) throw new TypeError('Non-finite numbers have no canonical JSON form') + return value + } + if (type === 'object') { + if (Array.isArray(value)) { + const output: unknown[] = [] + for (const [index, element] of value.entries()) { + if (!Object.prototype.hasOwnProperty.call(value, index)) throw new TypeError('Sparse arrays have no canonical JSON form') + if (element === undefined) throw new TypeError('Undefined array elements have no canonical JSON form') + output.push(canonicalize(element)) + } + return output + } + const source = value as Record + const output: Record = {} + for (const key of Object.keys(source).sort((left, right) => Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')))) { + const element = source[key] + if (element === undefined) continue + output[key] = canonicalize(element) + } + return output + } + throw new TypeError(`Values of type ${type} have no canonical JSON form`) +} + +/** sha256(canonicalJsonString(manifest)); transported explicitly to the child via --manifest-digest. */ +export function manifestDigestOf (manifest: FallbackTransactionIdentity): string { + return createHash('sha256').update(canonicalJsonString(manifest)).digest('hex') +} + +function currentProcessIdentity (): ProcessIdentity { + return { pid: process.pid, startIdentity: processStartIdentity(process.pid) } +} + +/** Linux process start ticks; undefined where /proc is unavailable. */ +function processStartIdentity (pid: number): string | undefined { + if (process.platform !== 'linux') return undefined + try { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') + const close = stat.indexOf(') ') + if (close < 0) return undefined + const fields = stat.slice(close + 2).split(' ') + // starttime is overall field 22; the slice above starts at field 3. + const start = fields[19] + return typeof start === 'string' && start.length > 0 ? start : undefined + } catch { + return undefined + } +} + +function processMatches (identity: ProcessIdentity): boolean { + if (identity.pid !== process.pid) return false + const mine = processStartIdentity(process.pid) + if (identity.startIdentity === undefined || mine === undefined) return true + return identity.startIdentity === mine +} + +function processIsLive (identity: ProcessIdentity): boolean { + if (identity.pid === process.pid) { + // PID equality alone is not proof of life: the record may belong to a + // dead predecessor whose PID this process recycled. When both start + // identities are known, a mismatch proves the recorded process is gone + // (a recycled PID must never block reclaim). Without a comparable start + // identity, the process stays treated as live (fail closed for reclaim). + const mine = processStartIdentity(process.pid) + if (identity.startIdentity !== undefined && mine !== undefined) return identity.startIdentity === mine + return true + } + if (identity.startIdentity !== undefined && process.platform === 'linux') { + const start = processStartIdentity(identity.pid) + // A start mismatch means the PID was recycled: the recorded process is dead. + return start !== undefined && start === identity.startIdentity + } + try { + process.kill(identity.pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +function plannedEvidenceMap (manifest: FallbackTransactionIdentity): Map { + const evidence = new Map() + const add = (entry: FallbackPathEvidence): void => { + if (entry !== undefined && !evidence.has(path.resolve(entry.path))) evidence.set(path.resolve(entry.path), entry) + } + for (const entry of manifest.ownedSkills ?? []) add(entry) + for (const entry of manifest.ownedLinks ?? []) add(entry) + for (const entry of manifest.ownedMcpConfigPaths ?? []) { + if (typeof entry !== 'string') add(entry) + } + for (const entry of manifest.bundleDestinations ?? []) add(entry) + return evidence +} + +/** + * Begin a journal transaction. The journal path is reserved with `wx` BEFORE + * any snapshot work so concurrent begins (same process or cross-process) fail + * closed with FALLBACK_JOURNAL_BUSY. The manifest is the planning authority: + * every journaled path must already be described by it, and live drift fails + * the begin before anything is created. + */ +export async function beginFallbackJournal (manifest: FallbackTransactionIdentity): Promise { + // Protocol gate BEFORE any reservation or snapshot work: a manifest from an + // incompatible planner must never create journal or filesystem state. + if (manifest.protocolVersion !== FALLBACK_PROTOCOL_VERSION) throw new Error('FALLBACK_PROTOCOL_UNSUPPORTED') + // Frontier evidence gate, also before ANY filesystem access: protocol v3 + // requires exactly-parsed planned-missing frontier evidence. Absent, + // malformed, extra-keyed, or tampered graphs are not v3 transactions. + try { + assertFallbackFrontierEvidenceList(manifest.plannedMissingFrontiers) + } catch (error) { + throw new Error('FALLBACK_FRONTIER_EVIDENCE_INVALID', { cause: error }) + } + const trackingPath = path.resolve(manifest.trackingPath) + const currentTrackingDigest = trackingDigest(trackingPath) + if (!currentTrackingDigest || currentTrackingDigest !== manifest.trackingDigest || !await manifestMatchesTrackingFile(manifest)) { + throw new Error('FALLBACK_TRACKING_DRIFT') + } + for (const entry of [...manifest.ownedSkills, ...manifest.ownedLinks]) { + const kind = await pathKind(entry.path) + const digest = kind === 'missing' ? undefined : await pathDigest(entry.path) + if (kind !== entry.kind || digest !== entry.digest) throw new Error('FALLBACK_TRACKING_DRIFT') + } + // The journal's exact physical entry set is derived from the manifest + // before anything is created: covered leaves are represented by their + // frontier, hidden candidates are manifest inconsistencies, and every + // derivation failure is rejected as invalid evidence, not as a backup + // failure. + let physicalTargets: ReturnType + try { + physicalTargets = deriveFallbackJournalPhysicalTargets(manifest) + } catch (error) { + throw new Error('FALLBACK_FRONTIER_EVIDENCE_INVALID', { cause: error }) + } + // Frontier live revalidation BEFORE the reservation and any snapshot work: + // the recorded anchor identity, the non-link component chain, the still- + // missing frontier path, and the exact recorded leaf graph must all hold + // right now, or the transaction fails closed before any journal or + // filesystem state exists. + try { + await revalidateFallbackFrontierEvidence(manifest.plannedMissingFrontiers, fallbackLinkMaterialization(manifest.harness)) + } catch (error) { + throw new Error('FALLBACK_FRONTIER_DRIFT', { cause: error }) + } + const journalPath = fallbackJournalPath(trackingPath) + const manifestDigest = manifestDigestOf(manifest) + const transactionId = randomUUID() + const owner = currentProcessIdentity() + // Reserve before any snapshot work: two begins in one process or in two + // processes cannot both create snapshots. + try { + const reservation = await open(journalPath, 'wx', 0o600) + await reservation.close() + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('FALLBACK_JOURNAL_BUSY') + throw error + } + const initial: FallbackJournal = { + version: 3, + phase: 'initializing', + revision: 0, + transactionId, + manifestDigest, + manifest, + journalPath, + owner, + entries: [], + } + let snapshotDirectory: string | undefined + try { + await writeDurable(journalPath, initial) + snapshotDirectory = await mkdtemp(path.join(path.dirname(trackingPath), '.nsolid-plugin-update-')) + const snapshotIdentity = await lstat(snapshotDirectory) + const planned = plannedEvidenceMap(manifest) + // Exactly the shared derivation: retained ordinary targets plus one + // planned-missing entry per topmost frontier. Covered leaves never get + // overlapping entries, and existing directory anchors stay validation- + // only (never entries or backups). + const entries: FallbackJournalEntry[] = [] + for (const [index, target] of physicalTargets.map((entry) => entry.path).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}`) + const evidence = planned.get(target) + if (evidence && (evidence.kind !== kind || evidence.digest !== digest)) throw new Error(`planned fallback path changed: ${target}`) + if (kind !== 'missing') await cp(target, backup, { recursive: true, force: true, verbatimSymlinks: true, dereference: false }) + if (evidence && kind !== 'missing' && (await pathDigest(backup) !== evidence.digest || await pathDigest(target) !== evidence.digest)) { + throw new Error(`fallback path changed while it was being snapshotted: ${target}`) + } + if (target === trackingPath && kind !== 'missing' && trackingDigest(backup) !== manifest.trackingDigest) { + throw new Error(`tracking backup does not match the planned tracking digest: ${target}`) + } + if (evidence && evidence.kind === 'missing' && kind !== 'missing') { + throw new Error(`planned-missing fallback destination appeared before journaling: ${target}`) + } + entries.push({ path: target, kind, existed: kind !== 'missing', digest, backup: kind !== 'missing' ? backup : undefined }) + } + const prepared: FallbackJournal = { ...initial, phase: 'prepared', revision: 1, snapshotDirectory, entries } + await casWrite(journalPath, prepared, initial) + const handle: FallbackJournalHandle = { + kind: 'fallback-journal-handle', + journalPath, + transactionId, + manifestDigest, + manifest, + role: 'owner', + actor: owner, + revision: 1, + } + issueGenuineHandle(handle) + capabilitiesFor(handle).set('\0snapshot', { + container: snapshotDirectory, + token: transactionId, + role: 'snapshot', + dev: snapshotIdentity.dev, + ino: snapshotIdentity.ino, + children: new Map(entries + .filter((entry) => entry.backup !== undefined) + .map((entry) => [path.basename(entry.backup!), entry.kind] as const)), + }) + return { journal: prepared, handle } + } catch (error) { + if (snapshotDirectory !== undefined) { + await rm(snapshotDirectory, { recursive: true, force: true }).catch(() => {}) + } + // Remove the reservation only when the file on disk is still the one this + // invocation wrote; anything else is preserved untouched. + try { + const current = JSON.parse(await readFile(journalPath, 'utf8')) as FallbackJournal + if (current?.transactionId === transactionId) await rm(journalPath, { force: true }).catch(() => {}) + } catch { /* preserve unparseable state */ } + throw new Error('FALLBACK_BACKUP_FAILED', { cause: error }) + } +} + +/** + * Advance a prepared journal to mutating. Owner role only; the disk state is + * strictly reloaded and the revision must match the handle. + */ +export async function markFallbackJournalMutating (handle: FallbackJournalHandle): Promise { + const journal = await reloadFallbackJournal(handle) + requireRole(journal, handle, 'owner') + if (journal.phase !== 'prepared') throw new Error('Invalid fallback journal') + const revision = await casWrite(handle.journalPath, { ...journal, phase: 'mutating' }, journal) + return issueGenuineHandle({ ...handle, revision }) +} + +/** + * Claim the right to mutate as the external child. The expected canonical + * manifest digest must come from the trusted caller (the CLI recomputes it + * from the transaction file it verified); it is never read back from the + * journal. The whole read-check-casWrite handoff runs under the inter-process + * mutation transition lock and the journal state is fully revalidated while + * the lock is held, so exactly one live claimant can ever win. Returns a + * mutator handle, or null when the journal is missing, mismatched, held by + * another live process, or the mutation transition lock is contended or + * abandoned (abandoned locks are reported with their path by the parent's + * reclaim; they are never auto-deleted). + */ +export async function claimFallbackJournalMutation (manifest: FallbackTransactionIdentity, expectedManifestDigest?: string): Promise { + // Protocol gate BEFORE any journal read or mutation: an incompatible child + // fails with a stable code and leaves the on-disk transaction untouched. + if (manifest.protocolVersion !== FALLBACK_PROTOCOL_VERSION) throw new Error('FALLBACK_PROTOCOL_UNSUPPORTED') + // Frontier evidence gate BEFORE any journal read or CAS write: the claimed + // manifest must carry exactly-parsed v3 frontier evidence or the claim is + // refused with the on-disk journal byte-identical. + try { + assertFallbackFrontierEvidenceList(manifest.plannedMissingFrontiers) + } catch (error) { + throw new Error('FALLBACK_FRONTIER_EVIDENCE_INVALID', { cause: error }) + } + // Frontier live revalidation BEFORE any journal read or CAS write: the + // claimed transaction must still match the recorded anchor identity, the + // non-link component chain, the still-missing frontier path, and the exact + // recorded leaf graph. Any drift fails closed with the journal untouched. + try { + await revalidateFallbackFrontierEvidence(manifest.plannedMissingFrontiers, fallbackLinkMaterialization(manifest.harness)) + } catch (error) { + throw new Error('FALLBACK_FRONTIER_DRIFT', { cause: error }) + } + const journalPath = fallbackJournalPath(manifest.trackingPath) + try { + return await withFallbackJournalTransitionLock(journalPath, async () => { + if (!existsSync(journalPath)) return null + let journal: FallbackJournal + try { + journal = strictLoad(journalPath) + } catch { + return null + } + const expected = expectedManifestDigest ?? manifestDigestOf(manifest) + if (journal.manifestDigest !== expected || manifestDigestOf(journal.manifest) !== expected) return null + if (!sameManifest(journal.manifest, manifest)) return null + if (journal.manifest.nonce === undefined || journal.manifest.nonce !== manifest.nonce) return null + if (journal.phase !== 'mutating') return null + if (!await journalOwnershipIsValid(journal)) return null + if (journal.mutator !== undefined && !processMatches(journal.mutator) && processIsLive(journal.mutator)) return null + const mutator = currentProcessIdentity() + try { + const revision = await casWrite(journalPath, { ...journal, mutator: { ...mutator, claimedAt: new Date().toISOString() } }, journal) + const claimed: FallbackJournalHandle = { + kind: 'fallback-journal-handle', + journalPath, + transactionId: journal.transactionId, + manifestDigest: journal.manifestDigest, + manifest, + role: 'mutator', + actor: mutator, + revision, + } + issueGenuineHandle(claimed) + return claimed + } catch { + return null + } + }) + } catch (error) { + // A contended or abandoned transition lock fails closed exactly like a + // journal held by another live process: the claimant reports no authority. + // The parent's reclaim is the surface that names the actionable lock path + // (FALLBACK_JOURNAL_LOCK_BUSY) when a lock was abandoned by a crash. + if (isFallbackJournalLockBusyError(error)) return null + throw error + } +} + +/** + * Parent reclaim after the child command has completed (and NEVER while tree + * termination is unconfirmed — enforcing that is caller policy). Refuses while + * the recorded mutator is a live different process; same-process test harnesses + * can reclaim their own mutator. The whole read-check-casWrite handoff runs + * under the inter-process mutation transition lock, so a concurrent claimant + * can never slip past the mutator liveness check (claim and reclaim are + * serialized: whichever transition wins the lock revalidates the on-disk + * journal state while holding it). The owner adopts + * the latest disk revision without adopting any dynamic journal field as + * authority: restore decisions come only from the trusted in-memory manifest. + */ +export async function reclaimFallbackJournalMutation (handle: FallbackJournalHandle): Promise { + if (!handleMatchesIssuedMetadata(handle)) throw new Error('Invalid fallback journal') + const { revision } = await withFallbackJournalTransitionLock(handle.journalPath, async () => { + const journal = strictLoad(handle.journalPath) + if (journal.transactionId !== handle.transactionId) throw new Error('Invalid fallback journal') + if (journal.manifestDigest !== handle.manifestDigest || manifestDigestOf(journal.manifest) !== handle.manifestDigest) throw new Error('Invalid fallback journal') + if (!sameManifest(journal.manifest, handle.manifest)) throw new Error('Invalid fallback journal') + if (!processMatches(journal.owner)) throw new Error('Invalid fallback journal') + if (journal.mutator !== undefined && !processMatches(journal.mutator) && processIsLive(journal.mutator)) { + throw new Error('FALLBACK_MUTATOR_LIVE') + } + return { revision: await casWrite(handle.journalPath, { ...journal, mutator: undefined }, journal) } + }) + const reclaimed: FallbackJournalHandle = { ...handle, role: 'owner', revision } + issueGenuineHandle(reclaimed) + return reclaimed +} + +/** + * Recover a failed parent transaction after orchestration has confirmed that + * the command process tree terminated. Reclaim and restore stay one journal + * operation so consumers do not need to coordinate owner authority, CAS, and + * journal-owned preservation evidence themselves. This operation never + * interprets command output or decides whether tree termination was confirmed. + */ +export async function recoverFallbackJournalMutation ( + handle: FallbackJournalHandle, + snapshotDirectory?: string +): Promise { + const preserveOwnedArtifacts = (): FallbackJournalOperationResult => ({ + succeeded: false, + unproven: true, + preservedArtifacts: dedupePaths([handle.journalPath, ...(snapshotDirectory === undefined ? [] : [snapshotDirectory])]), + preservedPaths: [], + }) + let owner: FallbackJournalHandle + try { + owner = await reclaimFallbackJournalMutation(handle) + } catch (error) { + // A fail-closed transition lock (FALLBACK_JOURNAL_LOCK_BUSY) is swallowed + // exactly like any other reclaim refusal, but its message — which names + // the exact lock path and states that manual recovery is required — is + // surfaced as reporting-only evidence instead of being lost entirely. + if (isFallbackJournalLockBusyError(error)) return { ...preserveOwnedArtifacts(), diagnostic: (error as Error).message } + return preserveOwnedArtifacts() + } + try { + return await restoreFallbackJournal(owner) + } catch { + return preserveOwnedArtifacts() + } +} + +/** Register a staged replacement payload for one entry. Mutator role; the capability stays in this process's memory. */ +export async function registerFallbackStage (handle: FallbackJournalHandle, target: string, payload: { directory?: string; bytes?: Buffer }): Promise { + const journal = await reloadFallbackJournal(handle) + requireRole(journal, handle, 'mutator') + 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') + try { + 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) throw new Error(`Cannot digest staged payload for ${resolved}`) + const payloadKind = await pathKind(stagePath) + // Copied-link binding proof BEFORE the durable CAS: a failure here aborts + // the registration cleanly and the catch path removes this stage container. + // The proven binding is frozen into the stage capability so apply and any + // authorized same-process rollback prove against the frozen record alone. + const copiedLinkBinding = await assertIndependentLinkStageBinding(handle, resolved, stagePath, payloadKind) + const token = randomUUID() + await writeFile(path.join(stageDirectory, `.nsolid-stage-${token}`), token, { mode: 0o600 }) + const identity = await lstat(stageDirectory) + capabilitiesFor(handle).set(resolved, { + container: stageDirectory, + payload: stagePath, + token, + role: 'stage', + dev: identity.dev, + ino: identity.ino, + children: new Map([ + ['payload', payloadKind], + [`.nsolid-stage-${token}`, 'file'], + ]), + copiedLinkBinding, + stageDigest, + }) + // Trusted bundle-skill digest binding, computed from the registered + // staged payload BEFORE the CAS so binding failures abort cleanly. It is + // stored only after the CAS succeeds. + const skillDestination = bundleSkillDestinationOf(handle.manifest, resolved) + const skillBinding = payload.directory !== undefined && skillDestination !== undefined + ? { finalSkillPath: resolved, skillName: skillDestination.skillName, digest: await digestRegisteredSkillPayload(resolved, stagePath) } + : undefined + if (skillBinding !== undefined) assertRegisteredSkillBindingAvailable(handle, skillBinding.finalSkillPath, skillBinding) + const revision = await casWrite(handle.journalPath, journalWithEntryUpdate(journal, entry, { stage: stagePath, stageDigest, applied: false }), journal) + if (skillBinding !== undefined) storeRegisteredSkillBinding(handle, skillBinding.finalSkillPath, skillBinding) + const staged = { ...handle, revision } + issueGenuineHandle(staged) + return staged + } catch (error) { + await rm(stageDirectory, { recursive: true, force: true }).catch(() => {}) + throw error + } +} + +/** Hard bounds for any frontier-rooted tree (staged source or published target); enforced before any live mutation. */ +const FRONTIER_TREE_BOUNDS = { + maxDepth: 16, + maxNodes: 512, + maxRelativePathBytes: 512, + maxTotalBytes: 32 * 1024 * 1024, + maxFileBytes: 8 * 1024 * 1024, +} as const + +/** One walked tree entry relative to the frontier root; `other` covers non-regular entries (fifo, socket, …) and always fails closed. Directories carry their observed dev/ino for chain authentication. */ +interface FrontierWalkEntry { + relative: string + kind: 'file' | 'directory' | 'symlink' | 'other' + linkText?: string + dev?: number + ino?: number +} + +/** + * Walk a frontier-rooted tree with `lstat` only (never dereferences), in + * deterministic UTF-8 pre-order: parents always sort before their + * descendants, so the returned order is directly usable as an exclusive + * population sequence. Depth, node-count, path-length, and size bounds fail + * closed before any caller mutates anything. + */ +async function walkFrontierTree (root: string): Promise { + const walked: FrontierWalkEntry[] = [] + let nodes = 0 + let totalBytes = 0 + const visit = async (absolute: string, relative: string, depth: number): Promise => { + if (depth > FRONTIER_TREE_BOUNDS.maxDepth) throw new Error(`Frontier tree exceeds the maximum depth of ${FRONTIER_TREE_BOUNDS.maxDepth}`) + if (++nodes > FRONTIER_TREE_BOUNDS.maxNodes) throw new Error(`Frontier tree exceeds the maximum of ${FRONTIER_TREE_BOUNDS.maxNodes} entries`) + if (Buffer.byteLength(relative, 'utf8') > FRONTIER_TREE_BOUNDS.maxRelativePathBytes) throw new Error(`Frontier tree path ${relative} exceeds the maximum path length`) + const stats = await lstat(absolute) + if (stats.isSymbolicLink()) { + totalBytes += stats.size + walked.push({ relative, kind: 'symlink', linkText: await readlink(absolute), dev: stats.dev, ino: stats.ino }) + return + } + if (stats.isDirectory()) { + walked.push({ relative, kind: 'directory', dev: stats.dev, ino: stats.ino }) + const children = await readdir(absolute, { withFileTypes: true }) + for (const child of [...children].sort((left, right) => compareUtf8(left.name, right.name))) { + await visit(path.join(absolute, child.name), relative === '' ? child.name : `${relative}${path.sep}${child.name}`, depth + 1) + } + return + } + if (stats.isFile()) { + if (stats.size > FRONTIER_TREE_BOUNDS.maxFileBytes) throw new Error(`Frontier tree file ${relative} exceeds the maximum file size`) + totalBytes += stats.size + walked.push({ relative, kind: 'file', dev: stats.dev, ino: stats.ino }) + return + } + walked.push({ relative, kind: 'other' }) + } + await visit(root, '', 0) + if (totalBytes > FRONTIER_TREE_BOUNDS.maxTotalBytes) throw new Error(`Frontier tree exceeds the maximum total size of ${FRONTIER_TREE_BOUNDS.maxTotalBytes} bytes`) + return walked.slice(1) +} + +/** + * One bounded walk that validates the structural bounds AND computes the + * canonical tree digest: never a second, unbounded traversal. The digest + * framing is byte-identical to `pathDigest` (symlink text, full file bytes, + * UTF-8-ordered directory names), so a bounded digest can be compared with + * any digest `pathDigest` produced for the same tree. + */ +async function boundedFrontierTreeDigest (root: string): Promise { + const walked = await walkFrontierTree(root) + const memo = new Map() + const compute = async (relative: string): Promise => { + const entry = relative === '' + ? { relative: '', kind: 'directory' as const } + : walked.find((candidate) => candidate.relative === relative) + if (entry === undefined) throw new Error(`Frontier tree walk lost entry ${relative}`) + const cached = memo.get(relative) + if (cached !== undefined) return cached + const single = createHash('sha256') + if (entry.kind === 'directory') { + // Children in canonical UTF-8 order: directories always precede their + // descendants in the pre-order walk, so every child is already walked. + const children = walked + .filter((candidate) => (path.dirname(candidate.relative) === '.' ? '' : path.dirname(candidate.relative)) === relative) + .map((candidate) => candidate.relative) + const ordered = children.sort((left, right) => Buffer.compare(Buffer.from(path.basename(left), 'utf8'), Buffer.from(path.basename(right), 'utf8'))) + single.update('directory\0') + for (const child of ordered) { + single.update(path.basename(child)).update('\0').update(await compute(child)) + } + } else if (entry.kind === 'symlink') { + single.update('symlink\0').update(entry.linkText ?? await readlink(path.join(root, relative))) + } else if (entry.kind === 'file') { + single.update('file\0').update(await readFile(path.join(root, relative))) + } else { + throw new Error(`Frontier tree contains a non-regular entry at ${relative}`) + } + const digest = single.digest('hex') + memo.set(relative, digest) + return digest + } + return await compute('') +} + +/** + * Classify one relative tree path against the trusted expectation. The + * frontier level is exactly the leaf set plus the directories their paths + * imply; only the interior of a `skill` leaf (a managed directory) may carry + * arbitrary file/directory content, and nothing may be unplanned. + */ +function frontierTreePathClass (expectation: FallbackFrontierTreeExpectation, relative: string): 'leaf' | 'directory' | 'skill-interior' | 'unplanned' { + if (expectation.leafKinds.has(relative)) return 'leaf' + // A directory skill leaf exactly at the frontier root makes the entire + // published tree that skill's managed content. + if (relative !== '' && expectation.leafKinds.get('') === 'directory') return 'skill-interior' + if (expectation.directories.includes(relative)) return 'directory' + for (const [leafRelative, leafKind] of expectation.leafKinds) { + if ((leafKind === 'directory' || (Array.isArray(leafKind) && leafKind.includes('directory'))) && relative.startsWith(`${leafRelative}${path.sep}`)) return 'skill-interior' + } + return 'unplanned' +} + +/** + * Validate a frontier-rooted tree (the private staged subtree at registration + * time, or the live published tree at proof time) against the trusted + * expectation, and return the registered plan evidence from the staged side: + * exact leaf kinds, per-leaf digests, and link texts. Any unplanned content, + * kind mismatch, forbidden symlink, wrong link text, or missing leaf fails + * closed without touching anything. + */ +async function assertStagedFrontierTree (root: string, expectation: FallbackFrontierTreeExpectation): Promise> { + const leafKinds = new Map() + const leafDigests = new Map() + const linkTexts = new Map() + for (const entry of await walkFrontierTree(root)) { + const pathClass = frontierTreePathClass(expectation, entry.relative) + if (entry.kind === 'other') throw new Error(`Frontier tree contains a non-regular entry at ${entry.relative}`) + if (pathClass === 'unplanned') throw new Error(`Frontier tree contains unplanned content at ${entry.relative}`) + if (pathClass === 'leaf') { + const expectedKind = expectation.leafKinds.get(entry.relative) + const kindOk = Array.isArray(expectedKind) ? expectedKind.includes(entry.kind) : entry.kind === expectedKind + if (!kindOk) throw new Error(`Frontier leaf ${entry.relative} is ${entry.kind}, expected ${Array.isArray(expectedKind) ? expectedKind.join(' or ') : expectedKind}`) + leafKinds.set(entry.relative, entry.kind) + if (entry.kind === 'symlink') { + const expectedText = expectation.linkTargets.get(entry.relative) + if (expectedText === undefined || entry.linkText !== expectedText) { + throw new Error(`Frontier link ${entry.relative} does not reference the planned final skill path`) + } + linkTexts.set(entry.relative, entry.linkText!) + } + const digest = entry.kind === 'directory' ? undefined : await pathDigest(path.join(root, entry.relative)) + if (entry.kind !== 'directory' && !digest) throw new Error(`Cannot digest frontier leaf ${entry.relative}`) + if (digest !== undefined) leafDigests.set(entry.relative, digest) + continue + } + // Ancestor directory or skill-leaf interior: links are forbidden anywhere + // outside an exact managed link leaf. + if (entry.kind === 'symlink') throw new Error(`Frontier tree contains a symlink at ${entry.relative}; only managed link leaves may be symlinks`) + } + for (const relative of expectation.leafKinds.keys()) { + // The root itself (a skill leaf equal to the frontier root) is verified + // as a real non-link directory by the caller. + if (relative === '') continue + if (!leafKinds.has(relative)) throw new Error(`Frontier tree is missing planned leaf ${relative}`) + } + return { leafKinds, leafDigests, linkTexts } +} + +/** + * PRIVATE process-local registry of registered bundle-skill payload digests, + * scoped by transaction id and keyed by the resolved FINAL skill destination + * path. It is populated only after a genuine journal CAS succeeds, only for + * bundle `skill:` destinations resolved from trusted manifest evidence + * (never caller input), and only for bounded real symlink-free staged + * directories digested with the canonical tree framing. The registry is + * never serialized, never exported, and survives stage cleanup so copied-link + * validation can always prove against the exact registered skill bytes. + */ +interface RegisteredSkillBinding { + readonly skillName: string + readonly digest: string +} + +const registeredSkillDigests = new Map>() + +function skillRegistryFor (transactionId: string): Map { + let map = registeredSkillDigests.get(transactionId) + if (map === undefined) { + map = new Map() + registeredSkillDigests.set(transactionId, map) + } + return map +} + +/** + * The bundle-skill destination matching `resolved`, derived ONLY from trusted + * manifest evidence; undefined for every non-skill registration target. + */ +function bundleSkillDestinationOf (manifest: FallbackTransactionIdentity, resolved: string): { readonly skillName: string } | undefined { + // `approvedDestinationRoots` is trusted protocol-v3 planning evidence. The + // first root is always the shared bundle-skill destination; later roots are + // harness link destinations. A bundle destination must be its direct child, + // so a link payload can never be mistaken for a skill merely by basename. + const skillRoot = manifest.approvedDestinationRoots[0] + if (typeof skillRoot !== 'string' || !isCanonicalPath(skillRoot)) return undefined + if (path.dirname(resolved) !== path.resolve(skillRoot)) return undefined + const matched = (manifest.bundleDestinations ?? []).some((entry) => path.resolve(entry.path) === resolved) + return matched ? { skillName: path.basename(resolved) } : undefined +} + +/** + * Digest one registered skill payload directory with the canonical tree + * framing after proving it is a bounded real directory containing no symlinks + * or special files anywhere inside. Digests are computed BEFORE the journal + * CAS so a binding failure aborts the registration cleanly. + */ +async function digestRegisteredSkillPayload (finalSkillPath: string, payloadDirectory: string): Promise { + for (const entry of await walkFrontierTree(payloadDirectory)) { + if (entry.kind === 'symlink') throw new Error(`Registered skill payload for ${finalSkillPath} contains a symlink at ${entry.relative}`) + if (entry.kind === 'other') throw new Error(`Registered skill payload for ${finalSkillPath} contains a non-regular entry at ${entry.relative}`) + } + return await boundedFrontierTreeDigest(payloadDirectory) +} + +/** + * Store one computed skill binding after the journal CAS succeeded. A + * conflicting re-registration (same final path, different name or bytes) + * fails closed; an identical re-registration is idempotent. + */ +function assertRegisteredSkillBindingAvailable (handle: FallbackJournalHandle, finalSkillPath: string, binding: RegisteredSkillBinding): void { + const previous = skillRegistryFor(handle.transactionId).get(finalSkillPath) + if (previous !== undefined && (previous.skillName !== binding.skillName || previous.digest !== binding.digest)) { + throw new Error(`Conflicting re-registration of skill payload ${finalSkillPath}`) + } +} + +function storeRegisteredSkillBinding (handle: FallbackJournalHandle, finalSkillPath: string, binding: RegisteredSkillBinding): void { + assertRegisteredSkillBindingAvailable(handle, finalSkillPath, binding) + skillRegistryFor(handle.transactionId).set(finalSkillPath, Object.freeze({ skillName: binding.skillName, digest: binding.digest })) +} + +/** + * Process-local retained proof of copied links THIS process published: the + * frozen binding survives a physically verified apply so same-process + * restore verifies the bytes it is about to quarantine-rename against what + * this process actually published — never against disk journal fields alone. + * Never serialized, never authority-granting: absence keeps the existing + * quarantine-preserve restore behavior (e.g. an external child publisher). + */ +const appliedCopiedLinkBindings = new Map>>() + +function retainAppliedCopiedLinkBinding (handle: FallbackJournalHandle, resolved: string, binding: Readonly): void { + let map = appliedCopiedLinkBindings.get(handle.transactionId) + if (map === undefined) { + map = new Map() + appliedCopiedLinkBindings.set(handle.transactionId, map) + } + map.set(resolved, binding) +} + +function retainedAppliedCopiedLinkBinding (handle: FallbackJournalHandle, resolved: string): Readonly | undefined { + return appliedCopiedLinkBindings.get(handle.transactionId)?.get(resolved) +} + +/** Boolean proof wrappers for restore gates: any mismatch preserves everything. */ +async function livePathMatchesRetainedCopiedLinkBinding (resolved: string, binding: Readonly): Promise { + try { + await assertPathMatchesCopiedLinkBinding(`Published copied link ${resolved}`, resolved, binding) + return true + } catch { + return false + } +} + +async function movedPathMatchesRetainedCopiedLinkBinding (movedPath: string, originalLivePath: string, binding: Readonly): Promise { + try { + await assertPathMatchesCopiedLinkBinding(`Quarantined copied link ${movedPath}`, movedPath, binding, { resolveFrom: path.dirname(originalLivePath) }) + return true + } catch { + return false + } +} + +/** + * The trusted harness link destination root: the second approved destination + * root, or undefined when the harness has no separate link root. A root equal + * to the skill destination is never treated as a link root. + */ +function trustedLinkRootOf (manifest: FallbackTransactionIdentity): string | undefined { + const skillRoot = manifest.approvedDestinationRoots[0] + const linkRoot = manifest.approvedDestinationRoots[1] + if (typeof linkRoot !== 'string' || !isCanonicalPath(linkRoot)) return undefined + const resolvedLink = path.resolve(linkRoot) + if (typeof skillRoot === 'string' && resolvedLink === path.resolve(skillRoot)) return undefined + return resolvedLink +} + +/** The trusted final skill destination for one link-root child: skill root + exact basename. */ +function trustedFinalSkillPathOf (manifest: FallbackTransactionIdentity, linkPath: string): string | undefined { + const skillRoot = manifest.approvedDestinationRoots[0] + if (typeof skillRoot !== 'string' || !isCanonicalPath(skillRoot)) return undefined + return path.join(path.resolve(skillRoot), path.basename(linkPath)) +} + +/** + * One frozen private copied-link binding proven at stage registration: the + * concrete materialization kind, the trusted final skill destination it was + * proven against, and — for a directory copy — the bounded symlink-free + * digest accepted against the registered skill payload, or — for a symlink — + * the exact link text. Later apply/rollback checks prove against this record + * alone, never against the registry or mutable live skill bytes. + */ +interface FrozenCopiedLinkBinding { + readonly kind: 'directory' | 'symlink' + readonly finalSkillPath: string + readonly digest?: string + readonly linkText?: string +} + +function freezeCopiedLinkBinding (binding: FrozenCopiedLinkBinding): Readonly { + return Object.freeze({ ...binding }) +} + +/** + * Prove one concrete staged, published, or quarantined path against its + * frozen copied-link binding. A directory copy must remain a bounded real + * symlink-free directory whose digest equals the frozen record; a symlink + * must still carry the exact frozen link text resolving to the frozen final + * skill destination from the checked location. Any other kind or mismatch + * fails closed. + */ +async function assertPathMatchesCopiedLinkBinding (label: string, targetPath: string, binding: Readonly, options: { resolve?: boolean, resolveFrom?: string } = {}): Promise { + const kind = await pathKind(targetPath) + if (binding.kind === 'directory') { + if (kind !== 'directory') throw new Error(`${label} is ${kind}; its frozen copied-link binding requires a directory`) + const digest = await digestRegisteredSkillPayload(binding.finalSkillPath, targetPath) + if (digest !== binding.digest) throw new Error(`${label} does not match its frozen copied-link digest`) + return + } + if (kind !== 'symlink') throw new Error(`${label} is ${kind}; its frozen copied-link binding requires a symlink`) + const text = await readlink(targetPath) + if (text !== binding.linkText) throw new Error(`${label} does not match its frozen copied-link target`) + // Resolution is proven from the LIVE location; a staged copy (whose parent + // directory differs) may legitimately hold the exact trusted text without + // resolving from its temporary location, and a MOVED copy (e.g. inside a + // quarantine container) must resolve the exact text against its ORIGINAL + // live location instead of the quarantine parent. + const resolveBase = options.resolveFrom !== undefined ? options.resolveFrom : path.dirname(targetPath) + if ((options.resolve ?? true) && path.resolve(resolveBase, text) !== binding.finalSkillPath) { + throw new Error(`${label} does not resolve to its frozen final skill destination`) + } +} + +/** + * Independent copied-link binding for one link-root child stage. A staged + * real directory must byte-match the PRIVATE registered skill payload for its + * trusted final destination (policy must permit directory copies); a staged + * symlink must reference exactly that destination; every other kind rejects. + * Non-link targets return undefined. Fails closed when the binding is + * unavailable — it can never fall back to live or staged-only evidence. + */ +async function assertIndependentLinkStageBinding ( + handle: FallbackJournalHandle, + resolved: string, + stagePath: string, + payloadKind: FallbackPathKind +): Promise | undefined> { + const linkRoot = trustedLinkRootOf(handle.manifest) + if (linkRoot === undefined || path.dirname(resolved) !== linkRoot) return undefined + if (!(handle.manifest.bundleDestinations ?? []).some((entry) => path.resolve(entry.path) === resolved)) return undefined + const finalSkillPath = trustedFinalSkillPathOf(handle.manifest, resolved) + if (finalSkillPath === undefined) throw new Error(`Link destination ${resolved} has no trusted final skill destination`) + if (payloadKind === 'symlink') { + const text = await readlink(stagePath) + if (path.resolve(path.dirname(stagePath), text) !== finalSkillPath) { + throw new Error(`Staged link ${resolved} references ${text} instead of the final skill path ${finalSkillPath}`) + } + return freezeCopiedLinkBinding({ kind: 'symlink', finalSkillPath, linkText: text }) + } + if (payloadKind === 'directory') { + const policy = fallbackLinkMaterialization(handle.manifest.harness) + if (!policy.allowedKinds.includes('directory')) { + throw new Error(`Staged link ${resolved} is a directory but the ${handle.manifest.harness} link policy requires a symlink`) + } + const binding = skillRegistryFor(handle.transactionId).get(finalSkillPath) + if (binding === undefined) throw new Error(`Copied link ${resolved} has no registered skill payload for ${finalSkillPath}`) + const digest = await digestRegisteredSkillPayload(finalSkillPath, stagePath) + if (digest !== binding.digest) { + throw new Error(`Copied link ${resolved} does not match the registered skill payload for ${finalSkillPath}`) + } + return freezeCopiedLinkBinding({ kind: 'directory', finalSkillPath, digest }) + } + throw new Error(`Staged link ${resolved} is ${payloadKind}; only a symlink or a policy-permitted directory copy is supported`) +} + +/** + * Trusted `link:` → final skill destination resolver built ONLY from + * manifest evidence: first a `skill:` leaf of any planned frontier, then + * (existing destination case) the unique same-basename bundle destination the + * planner captured under the harness destination root — the link leaf itself + * identifies the link-root child, so exactly one candidate must remain. + * Unknown or ambiguous names return undefined and fail closed downstream. + */ +function trustedSkillDestinationResolver (manifest: FallbackTransactionIdentity): (leaf: FallbackLeafTarget) => string | undefined { + return (leaf) => { + if (!leaf.id.startsWith('link:')) return undefined + const skillName = leaf.id.slice('link:'.length) + for (const frontier of manifest.plannedMissingFrontiers) { + for (const candidate of frontier.leaves) { + if (candidate.id === `skill:${skillName}`) return path.resolve(candidate.path) + } + } + const candidates = (manifest.bundleDestinations ?? []) + .map((entry) => path.resolve(entry.path)) + .filter((candidate) => path.basename(candidate) === skillName && candidate !== path.resolve(leaf.path)) + return candidates.length === 1 ? candidates[0] : undefined + } +} + +/** Every authenticated destination/source directory that must still be the exact recorded inode for an operation at `relative` to proceed: the root plus all strict ancestors. */ +function authenticatedAncestorsOf (relative: string): string[] { + const segments = relative === '' ? [] : relative.split(path.sep) + const chain: string[] = [''] + for (let depth = 1; depth < segments.length; depth++) chain.push(segments.slice(0, depth).join(path.sep)) + return chain +} + +interface DirectoryIdentity { dev: number; ino: number } + +/** + * Validate that every directory of the relevant ancestor chain is still a + * real non-link directory with its exact recorded dev/ino, so a replacement + * by a symlink can never redirect a write or a digest read outside the + * authenticated reservation / staged source. + */ +async function assertAuthenticatedDirectoryChain ( + base: string, + identities: ReadonlyMap, + chain: readonly string[], + fail: (detail: string) => Error, + label: string +): Promise { + for (const prefix of chain) { + const identity = identities.get(prefix) + const display = prefix === '' ? '' : prefix + if (identity === undefined) throw fail(`${label} directory ${display} has no recorded authenticated identity`) + let stats: Stats | undefined + try { + stats = await lstat(path.join(base, prefix)) + } catch { /* treated as replacement below */ } + if (stats === undefined || !stats.isDirectory() || stats.isSymbolicLink() || stats.dev !== identity.dev || stats.ino !== identity.ino) { + throw fail(`${label} directory ${display} was replaced during publication`) + } + } +} + +/** One captured real-directory component of a live ancestor chain. */ +interface LiveAncestorIdentity { path: string; dev: number; ino: number } + +/** + * Capture the COMPLETE live ancestor chain of `resolved` (every component + * from dirname(resolved) up to the filesystem root). Every component must be + * a real non-link directory with an exact dev/ino identity; a missing, link, + * or non-directory component makes the chain unprovable and the caller must + * preserve the target untouched. Lexical or realpath containment alone is + * never sufficient: only captured identities authorize traversal below. + */ +async function captureLiveAncestorChain (resolved: string): Promise { + const chain: LiveAncestorIdentity[] = [] + let current = path.dirname(path.resolve(resolved)) + const root = path.parse(current).root + while (true) { + let stats: Stats + try { + stats = await lstat(current) + } catch { + return undefined + } + if (!stats.isDirectory() || stats.isSymbolicLink()) return undefined + chain.push({ path: current, dev: stats.dev, ino: stats.ino }) + if (current === root) return chain + current = path.dirname(current) + } +} + +/** + * Recheck every captured ancestor identity immediately before a mutation + * through the chain: each component must still be the exact recorded real + * non-link directory. A mid-operation replacement fails closed so no + * mkdtemp/rename/copy/remove ever traverses a substituted ancestor. + */ +async function liveAncestorChainMatches (chain: readonly LiveAncestorIdentity[]): Promise { + for (const component of chain) { + let stats: Stats + try { + stats = await lstat(component.path) + } catch { + return false + } + if (!stats.isDirectory() || stats.isSymbolicLink() || stats.dev !== component.dev || stats.ino !== component.ino) return false + } + return true +} + +/** + * Deterministic exclusive population sequence derived from the staged + * subtree: pre-order UTF-8 (parents before descendants), each op created + * exclusively at apply time. Every op is classified against the trusted + * expectation again here, so stage tampering between registration and apply + * can never add unplanned live paths. The staged ancestor-directory + * identities observed now are the source-side chain authority for apply. + */ +async function buildFrontierPopulationOps (staged: string, expectation: FallbackFrontierTreeExpectation): Promise<{ ops: Array<{ relative: string; kind: 'mkdir' | 'file' | 'symlink'; source: string }>; sourceDirectories: Map }> { + const ops: Array<{ relative: string; kind: 'mkdir' | 'file' | 'symlink'; source: string }> = [] + const sourceDirectories = new Map([['', await lstat(staged).then((stats) => ({ dev: stats.dev, ino: stats.ino }))]]) + for (const entry of await walkFrontierTree(staged)) { + if (entry.kind === 'other') throw new Error(`Staged frontier tree contains a non-regular entry at ${entry.relative}`) + if (frontierTreePathClass(expectation, entry.relative) === 'unplanned') throw new Error(`Staged frontier tree contains unplanned content at ${entry.relative}`) + const source = path.join(staged, entry.relative) + if (entry.kind === 'directory') { + ops.push({ relative: entry.relative, kind: 'mkdir', source }) + sourceDirectories.set(entry.relative, { dev: entry.dev!, ino: entry.ino! }) + } else if (entry.kind === 'file') ops.push({ relative: entry.relative, kind: 'file', source }) + else ops.push({ relative: entry.relative, kind: 'symlink', source }) + } + return { ops, sourceDirectories } +} + +/** Cheap anchor identity recheck: real non-link directory with the recorded dev/ino (decimal-string framing). */ +async function anchorIdentityMatches (anchor: FallbackAnchorIdentity): Promise { + try { + const stats = await lstat(anchor.path, { bigint: true }) as BigIntStats + return stats.isDirectory() && !stats.isSymbolicLink() && + stats.dev.toString(10) === anchor.device && stats.ino.toString(10) === anchor.inode + } catch { + return false + } +} + +/** + * Register a complete staged frontier subtree as the publication payload for + * one planned-missing journal frontier. The staged tree is validated exactly + * against the trusted manifest frontier (leaf kinds, link texts, no unplanned + * content, no symlinks outside managed link leaves), copied into an owned + * stage container next to the frontier's anchor, digested, and durably bound + * to the journal entry. Only an exact plannedMissingFrontiers path may be + * registered: the child can never invent a frontier. + * + * The active conditional leaf selection is fixed at registration: every + * required leaf is always active, and only explicitly named conditional leaf + * IDs (validated by the pure frontier helper) may be materialized. The + * selection is stored as a sorted frozen copy in the process-local stage plan + * and publication authority; apply and rollback rederive their proofs from + * that immutable selection, never from journal fields or caller-mutated + * arrays. No disk field grants selection authority. + */ +export async function registerFallbackFrontierStage ( + handle: FallbackJournalHandle, + frontierPath: string, + stagedCompleteDirectory: string, + options: { activeConditionalLeafIds?: readonly string[] } = {} +): Promise { + const activeConditionalLeafIds = Object.freeze([...(options.activeConditionalLeafIds ?? [])].sort(compareUtf8)) + const journal = await reloadFallbackJournal(handle) + requireRole(journal, handle, 'mutator') + const resolved = path.resolve(frontierPath) + const frontier = handle.manifest.plannedMissingFrontiers.find((candidate) => path.resolve(candidate.frontierPath) === resolved) + if (frontier === undefined) throw new Error(`No planned fallback frontier for ${resolved}`) + const entry = journal.entries.find((candidate) => path.resolve(candidate.path) === resolved) + if (!entry) throw new Error(`No fallback journal entry for ${resolved}`) + if (entry.existed || entry.kind !== 'missing') throw new Error(`Fallback frontier entry for ${resolved} is not a planned-missing publication unit`) + if (entry.stage !== undefined || entry.stageDigest !== undefined || entry.applied === true) { + throw new Error(`Fallback frontier ${resolved} already has a registered stage`) + } + const staged = path.resolve(stagedCompleteDirectory) + if (!isCanonicalPath(staged)) throw new Error(`Staged frontier tree ${staged} must be an absolute canonical path`) + if (await pathKind(staged) !== 'directory') throw new Error(`Staged frontier tree ${staged} must be a real directory`) + const activeLeaves = selectActiveFallbackFrontierLeaves(frontier, activeConditionalLeafIds) + const expectation = deriveFrontierTreeExpectation( + { ...frontier, leaves: [...activeLeaves] }, + handle.manifest.plannedMissingFrontiers.flatMap((candidate) => candidate.leaves), + { skillDestinationResolver: trustedSkillDestinationResolver(handle.manifest), linkPolicy: fallbackLinkMaterialization(handle.manifest.harness) } + ) + const plan = await assertStagedFrontierTree(staged, expectation) + const stageDirectory = await mkdtemp(path.join(path.dirname(resolved), `.${path.basename(resolved).replace(/^\.+/, '')}.nsolid-stage-`)) + // Authenticated cleanup authority is captured IMMEDIATELY after allocation: + // the catch path never blind-rm's, it proves this exact inode (plus marker + // and recorded children) before any destruction and preserves otherwise. + const stageIdentity = await lstat(stageDirectory) + const containerAllocated = true + const stagePath = path.join(stageDirectory, 'payload') + let stageDigest: string | undefined + let payloadKind: FallbackPathKind | undefined + let token: string | undefined + let children = new Map() + try { + await cp(staged, stagePath, { recursive: true, force: true, verbatimSymlinks: true, dereference: false }) + stageDigest = await pathDigest(stagePath) + if (!stageDigest) throw new Error(`Cannot digest staged frontier payload for ${resolved}`) + if (stageDigest !== await pathDigest(staged)) throw new Error(`Staged frontier copy for ${resolved} does not match its source`) + payloadKind = await pathKind(stagePath) + token = randomUUID() + await writeFile(path.join(stageDirectory, `.nsolid-stage-${token}`), token, { mode: 0o600 }) + children = new Map([['payload', payloadKind], [`.nsolid-stage-${token}`, 'file']]) + // Frontier copied-link bindings: an active link leaf staged as a real + // directory must byte-match the PRIVATE registered skill payload for its + // trusted final destination (policy must permit directory copies); a + // symlink leaf freezes its exact proven link text. Every binding is + // frozen into the stage capability so publication and any authorized + // same-process rollback prove against these records alone — never against + // the registry or the mutable live skill bytes. Validation happens BEFORE + // the durable CAS so a failure aborts this registration and cleans its + // container. + const frontierLinkPolicy = fallbackLinkMaterialization(handle.manifest.harness) + const copiedLinkBindings: Record> = {} + for (const leaf of activeLeaves) { + if (leaf.role !== 'link') continue + const relative = path.relative(resolved, path.resolve(leaf.path)) + const finalSkillPath = trustedSkillDestinationResolver(handle.manifest)(leaf) + if (finalSkillPath === undefined) throw new Error(`Link leaf ${leaf.id} has no trusted final skill destination`) + const leafKind = plan.leafKinds.get(relative) + if (leafKind === 'symlink') { + const linkText = plan.linkTexts.get(relative) + if (linkText === undefined) throw new Error(`Link leaf ${leaf.id} has no proven link text`) + copiedLinkBindings[relative] = freezeCopiedLinkBinding({ kind: 'symlink', finalSkillPath, linkText }) + continue + } + if (leafKind !== 'directory') { + throw new Error(`Frontier link leaf ${leaf.path} is staged as ${leafKind ?? 'an unregistered kind'}; only a symlink or a policy-permitted directory copy is supported`) + } + if (!frontierLinkPolicy.allowedKinds.includes('directory')) { + throw new Error(`Frontier link leaf ${leaf.path} is a directory but the ${handle.manifest.harness} link policy requires a symlink`) + } + const binding = skillRegistryFor(handle.transactionId).get(finalSkillPath) + if (binding === undefined) throw new Error(`Copied frontier link ${leaf.path} has no registered skill payload for ${finalSkillPath}`) + const linkPayload = relative === '' ? stagePath : path.join(stagePath, relative) + const digest = await digestRegisteredSkillPayload(finalSkillPath, linkPayload) + if (digest !== binding.digest) { + throw new Error(`Copied frontier link ${leaf.path} does not match the registered skill payload for ${finalSkillPath}`) + } + copiedLinkBindings[relative] = freezeCopiedLinkBinding({ kind: 'directory', finalSkillPath, digest }) + } + const frozenCopiedLinkBindings = Object.keys(copiedLinkBindings).length > 0 + ? (Object.freeze({ ...copiedLinkBindings }) as Readonly>>) + : undefined + capabilitiesFor(handle).set(resolved, { + container: stageDirectory, + payload: stagePath, + token, + role: 'stage', + dev: stageIdentity.dev, + ino: stageIdentity.ino, + stageDigest, + children, + copiedLinkBindings: frozenCopiedLinkBindings, + }) + frontierPlanFor(handle.transactionId).set(resolved, { ...plan, activeConditionalLeafIds }) + if (fallbackFrontierPublicationSeam !== undefined) { + await fallbackFrontierPublicationSeam({ phase: 'stage-ready', frontierPath: resolved }) + } + // Bind every active bundle skill within this registered frontier payload + // to its own staged directory bytes. The final path is derived from the + // trusted skill leaf; a link or MCP subtree cannot acquire this authority. + const skillBindings = await Promise.all(activeLeaves + .filter((leaf) => leaf.id.startsWith('skill:')) + .map(async (leaf) => { + const finalSkillPath = path.resolve(leaf.path) + const destination = bundleSkillDestinationOf(handle.manifest, finalSkillPath) + if (destination === undefined) return undefined + const relative = path.relative(resolved, finalSkillPath) + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Registered skill ${finalSkillPath} is outside frontier ${resolved}`) + } + const payload = relative === '' ? stagePath : path.join(stagePath, relative) + return { + finalSkillPath, + skillName: destination.skillName, + digest: await digestRegisteredSkillPayload(finalSkillPath, payload), + } + })) + for (const binding of skillBindings) { + if (binding !== undefined) assertRegisteredSkillBindingAvailable(handle, binding.finalSkillPath, binding) + } + const revision = await casWrite(handle.journalPath, journalWithEntryUpdate(journal, entry, { stage: stagePath, stageDigest, applied: false }), journal) + for (const binding of skillBindings) { + if (binding !== undefined) storeRegisteredSkillBinding(handle, binding.finalSkillPath, binding) + } + const registered = { ...handle, revision } + issueGenuineHandle(registered) + return registered + } catch (error) { + // Authenticated cleanup of this invocation's own container ONLY: identity, + // marker, and recorded children must prove out, otherwise the container is + // preserved (it stays a registered capability) and reported in the error. + const capability = capabilitiesFor(handle).get(resolved) + frontierPlanFor(handle.transactionId).delete(resolved) + let removed = false + if (capability !== undefined && token !== undefined) { + removed = await removeAuthenticatedContainer(capability).catch(() => false) + } + if (removed) capabilitiesFor(handle).delete(resolved) + const preserved = containerAllocated && !removed ? ` The stage container was preserved at ${stageDirectory} for inspection.` : '' + throw new Error(`${(error as Error).message}.${preserved}`, { cause: error }) + } +} + +/** + * Publish one planned-missing frontier through its process-local capability: + * exclusive non-recursive `mkdir` reservation (an existing destination — even + * a byte-identical one — is a collision and is never adopted or replaced), + * immediate dev/ino identity capture, exclusive population of only that + * reserved directory (non-recursive mkdir, COPYFILE_EXCL, non-replacing + * symlink; `rename(stage, target)` is never used), and a final proof of + * reserved identity, exact structure, and full digest equality with the + * registered complete stage. Only then is the entry marked applied and the + * process-local publication state completed. + * + * Any failure after reservation PRESERVES the live (possibly partial) + * frontier and the authenticated stage artifacts: nothing is removed or + * quarantined, the journal stays pending, and a stable + * FALLBACK_FRONTIER_PUBLICATION_INCOMPLETE failure is raised. Partial + * visibility during population is the accepted Pure-Node contract. + */ +async function applyFrontierPublication ( + handle: FallbackJournalHandle, + journal: FallbackJournal, + entry: FallbackJournalEntry, + frontier: FallbackFrontierEvidence, + resolved: string, + stageCapability: ArtifactCapability +): Promise { + const fail = (detail: string): Error => new Error(`FALLBACK_FRONTIER_PUBLICATION_INCOMPLETE: ${detail}`) + // ---- Pre-reservation revalidation: failures leave zero live changes ---- + if (entry.stage !== stageCapability.payload || stageCapability.payload === undefined) { + throw new Error(`Fallback journal stage for ${resolved} does not match the registered capability`) + } + if (entry.stageDigest === undefined) throw fail('the registered stage carries no digest') + if (completedFrontierPublications.get(handle.transactionId)?.has(resolved)) throw fail('the frontier was already published by this transaction') + // The capability retains the immutable registered complete digest: the disk + // journal field AND the actual payload must both match it, so simultaneous + // payload + disk-journal digest tampering still fails before any live + // mutation. + const registeredDigest = stageCapability.stageDigest + if (registeredDigest === undefined || entry.stageDigest !== registeredDigest) { + throw fail('the journal stage digest does not match the registered capability digest') + } + // The staged payload is attacker-mutable between registration and apply: + // enforce the structural bounds FIRST with a single bounded walk that also + // digests the tree (never a second, unbounded traversal) so oversized, + // deep, too-many, or too-long tamper fails closed before any unbounded + // read and before live reservation/mutation. + const stageDigest = await boundedFrontierTreeDigest(stageCapability.payload) + if (!stageDigest || stageDigest !== registeredDigest) { + throw new Error(`Staged frontier payload for ${resolved} no longer matches its registered digest`) + } + // Copied-link staged sources must still satisfy the bindings frozen at + // registration BEFORE the frontier is reserved: symlink texts compare + // exactly (the temporary staged parent cannot resolve relative texts), + // directory copies re-digest against the frozen records alone. + if (stageCapability.copiedLinkBindings !== undefined) { + for (const [relative, binding] of Object.entries(stageCapability.copiedLinkBindings)) { + await assertPathMatchesCopiedLinkBinding(`Staged frontier copied link ${resolved}/${relative}`, path.join(stageCapability.payload, relative), binding, { resolve: false }) + } + } + if (await pathKind(resolved) !== 'missing') { + // Any existing destination — pre-existing or raced into the window — is a + // collision with the planned-missing state: never adopted, never replaced, + // even byte-identical. + throw new Error(`FALLBACK_FRONTIER_COLLISION: ${resolved} is not missing; the existing destination is preserved untouched`) + } + // The trusted evidence must hold right now: anchor identity and component + // chain, still-missing frontier path, exact recorded leaf graph. + await revalidateFallbackFrontierEvidence([frontier], fallbackLinkMaterialization(handle.manifest.harness)) + const plan = frontierPlanFor(handle.transactionId).get(resolved) + if (plan === undefined) throw fail('no registered frontier stage plan exists in this process') + // The expectation is rederived from the IMMUTABLE process-local selection + // fixed at registration: required leaves plus the frozen authorized + // conditional set. Journal fields and caller-mutated arrays can never + // widen it. + const expectation = deriveFrontierTreeExpectation( + { ...frontier, leaves: [...frontier.leaves.filter((leaf) => leaf.activation === 'required' || plan.activeConditionalLeafIds.includes(leaf.id))] }, + handle.manifest.plannedMissingFrontiers.flatMap((candidate) => candidate.leaves), + { skillDestinationResolver: trustedSkillDestinationResolver(handle.manifest), linkPolicy: fallbackLinkMaterialization(handle.manifest.harness) } + ) + const { ops, sourceDirectories } = await buildFrontierPopulationOps(stageCapability.payload, expectation) + const destinationIdentities = new Map() + + // ---- Reservation: exclusive, non-recursive, never replacing ---- + if (fallbackFrontierPublicationSeam !== undefined) await fallbackFrontierPublicationSeam({ phase: 'before-reserve', frontierPath: resolved }) + try { + await mkdir(resolved, { recursive: false }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new Error(`FALLBACK_FRONTIER_COLLISION: ${resolved} appeared before publication; the existing destination is preserved untouched`, { cause: error }) + } + throw fail(`the frontier directory could not be reserved: ${(error as NodeJS.ErrnoException).code ?? (error as Error).message}`) + } + + try { + // ---- Reserved identity observation with an immediate recheck window ---- + const observe = async (): Promise<{ dev: number; ino: number }> => { + const stats = await lstat(resolved) + if (!stats.isDirectory() || stats.isSymbolicLink()) throw fail('the reserved frontier path is not a real directory') + return { dev: stats.dev, ino: stats.ino } + } + const reserved = await observe() + destinationIdentities.set('', { dev: reserved.dev, ino: reserved.ino }) + if (fallbackFrontierPublicationSeam !== undefined) await fallbackFrontierPublicationSeam({ phase: 'reserved-identity', frontierPath: resolved }) + const reobserved = await observe() + if (reobserved.dev !== reserved.dev || reobserved.ino !== reserved.ino) { + throw fail('the reserved frontier directory was replaced between reservation and identity observation') + } + + // ---- Exclusive population of the reserved directory only ---- + for (const op of ops) { + if (fallbackFrontierPublicationSeam !== undefined) { + await fallbackFrontierPublicationSeam({ phase: 'before-leaf', frontierPath: resolved, leaf: op.relative }) + } + // Recheck anchor and reserved identities, then the complete relevant + // destination AND staged source ancestor chains (exact recorded + // dev/ino, real non-link directories), so a replacement by a symlink + // can never redirect writes or digest reads outside the reservation. + if (!await anchorIdentityMatches(frontier.anchor)) throw fail(`the anchor drifted during population at ${op.relative}`) + const current = await observe() + if (current.dev !== reserved.dev || current.ino !== reserved.ino) { + throw fail(`the reserved frontier directory was replaced during population at ${op.relative}`) + } + await assertAuthenticatedDirectoryChain(resolved, destinationIdentities, authenticatedAncestorsOf(op.relative), fail, 'destination') + await assertAuthenticatedDirectoryChain(stageCapability.payload!, sourceDirectories, authenticatedAncestorsOf(op.relative), fail, 'staged source') + const livePath = path.join(resolved, op.relative) + if (op.kind === 'mkdir') { + await mkdir(livePath, { recursive: false }).catch((error: NodeJS.ErrnoException) => { + throw fail(`exclusive directory creation failed at ${op.relative}: ${error.code ?? error.message}`) + }) + const created = await lstat(livePath) + destinationIdentities.set(op.relative, { dev: created.dev, ino: created.ino }) + continue + } + if (op.kind === 'file') { + if (await pathKind(op.source) !== 'file') throw fail(`staged source for ${op.relative} is no longer a regular file`) + if (plan.leafKinds.get(op.relative) === 'file' && plan.leafDigests.get(op.relative) !== await pathDigest(op.source)) { + throw fail(`staged source for ${op.relative} no longer matches its registered digest`) + } + await copyFile(op.source, livePath, fsPromisesConstants.COPYFILE_EXCL).catch((error: NodeJS.ErrnoException) => { + throw fail(`exclusive file creation failed at ${op.relative}: ${error.code ?? error.message}`) + }) + continue + } + // Symlink: only a managed link leaf, re-verified against the registered + // final skill path without dereferencing anything. + const expectedText = plan.linkTexts.get(op.relative) + const stagedText = await readlink(op.source).catch(() => undefined) + if (expectedText === undefined || stagedText !== expectedText) { + throw fail(`staged link source for ${op.relative} does not reference the planned final skill path`) + } + await symlink(expectedText, livePath).catch((error: NodeJS.ErrnoException) => { + throw fail(`exclusive link creation failed at ${op.relative}: ${error.code ?? error.message}`) + }) + } + + // ---- Post-population proof: every created destination directory still + // holds its exact authenticated identity, then exact structure and full + // digest equality with the registered complete stage. ---- + if (fallbackFrontierPublicationSeam !== undefined) await fallbackFrontierPublicationSeam({ phase: 'post-population', frontierPath: resolved }) + for (const prefix of destinationIdentities.keys()) { + await assertAuthenticatedDirectoryChain(resolved, destinationIdentities, [prefix], fail, 'published') + } + const published = await observe() + if (published.dev !== reserved.dev || published.ino !== reserved.ino) throw fail('the published frontier directory was replaced after population') + await assertStagedFrontierTree(resolved, expectation) + const publishedDigest = await pathDigest(resolved) + if (!publishedDigest || publishedDigest !== stageDigest) throw fail('the published frontier digest does not match the registered complete stage digest') + if (!await anchorIdentityMatches(frontier.anchor)) throw fail('the anchor drifted after population') + // Published copied links must satisfy the frozen bindings from their LIVE + // location: kind, digest/link text, and final-skill resolution. + if (stageCapability.copiedLinkBindings !== undefined) { + for (const [relative, binding] of Object.entries(stageCapability.copiedLinkBindings)) { + await assertPathMatchesCopiedLinkBinding(`Published frontier copied link ${resolved}/${relative}`, path.join(resolved, relative), binding) + } + } + + // ---- Durable completion: once this CAS succeeds the publication IS + // complete — later bookkeeping/cleanup failures never turn it into an + // incomplete publication and never promise pending/stage preservation. + // The proof of this physically verified publication is retained BEFORE + // the CAS: even if the CAS fails, this process keeps what it published + // (cleanupPending) for reporting and future authorized rollback. The + // record never grants authority — rollback still requires the journal's + // applied state — and a retry now reports 'already published' instead + // of colliding with the live tree. ---- + let publications = completedFrontierPublications.get(handle.transactionId) + if (publications === undefined) { + publications = new Map() + completedFrontierPublications.set(handle.transactionId, publications) + } + publications.set(resolved, Object.freeze({ frontierPath: resolved, dev: reserved.dev, ino: reserved.ino, stageDigest, cleanupPending: true, activeConditionalLeafIds: plan.activeConditionalLeafIds, copiedLinkBindings: stageCapability.copiedLinkBindings !== undefined ? Object.freeze({ ...stageCapability.copiedLinkBindings }) : undefined })) + const applied = await withCasWrite(handle, journalWithEntryUpdate(journal, resolved, { applied: true }), journal) + const active = { ...handle, revision: applied.revision } + // ---- Post-publication bookkeeping: fail-closed preservation reported as + // cleanup-pending; never a publication failure. The cleanup outcome is + // decided once and frozen into the record independently of whether the + // bookkeeping CAS below succeeds. ---- + const stageRemoved = await removeAuthenticatedContainer(stageCapability).catch(() => false) + const preservedArtifacts = [...(applied.preservedArtifacts ?? [])] + publications.set(resolved, Object.freeze({ frontierPath: resolved, dev: reserved.dev, ino: reserved.ino, stageDigest, cleanupPending: !stageRemoved, activeConditionalLeafIds: plan.activeConditionalLeafIds, copiedLinkBindings: stageCapability.copiedLinkBindings !== undefined ? Object.freeze({ ...stageCapability.copiedLinkBindings }) : undefined })) + if (stageRemoved) { + capabilitiesFor(active).delete(resolved) + } else { + // Cleanup is fail-closed preservation; the capability stays registered + // so later commit/restore cleanup can still authenticate against it. + preservedArtifacts.push(stageCapability.container) + } + try { + if (fallbackFrontierPublicationSeam !== undefined) await fallbackFrontierPublicationSeam({ phase: 'after-applied', frontierPath: resolved }) + const next = await withCasWrite(active, { + ...applied, + preservedArtifacts: dedupePaths(preservedArtifacts), + entries: applied.entries.map((candidate) => path.resolve(candidate.path) === resolved + ? (stageRemoved ? { ...candidate, stage: undefined } : candidate) + : candidate), + }, applied) + const completed = { ...active, revision: next.revision } + issueGenuineHandle(completed) + return completed + } catch { + // The journal already durably records applied:true (with the stage still + // recorded when cleanup failed): an accurate completed-but-cleanup- + // pending state without granting any disk authority. The in-memory + // record reports it and stays frozen to callers. + frontierPlanFor(handle.transactionId).delete(resolved) + const record = publications.get(resolved) + if (record !== undefined) { + publications.set(resolved, Object.freeze({ ...record, cleanupPending: true })) + } + issueGenuineHandle(active) + return active + } + } catch (error) { + // Preservation: the live (possibly partial) frontier, its populated + // bytes, and the authenticated stage artifacts all stay exactly as they + // are; the journal stays pending for deliberate resolution. + if (error instanceof Error && error.message.startsWith('FALLBACK_FRONTIER_PUBLICATION_INCOMPLETE')) throw error + throw fail(`frontier publication failed: ${(error as Error).message}`) + } +} + +/** + * Process-local rollback authority for one completed planned-missing frontier + * publication. Authority is EXCLUSIVELY the immutable in-memory completion + * record created by a successful publication in THIS process (bound to the + * exact transactionId, frontierPath, reserved dev/ino, and registered + * complete-stage digest): disk journal fields and exported reporting copies + * never authorize destructive action. The capability is one-shot: after a + * successful quarantine the record is consumed so replay, cross-journal, + * cross-frontier, or cross-handle attempts cannot move anything. + */ +async function rollbackFrontierPublication ( + handle: FallbackJournalHandle, + journal: FallbackJournal, + resolved: string +): Promise<{ outcome: 'rolled-back' | 'preserved' | 'absent'; journalPending: boolean; revision?: number; quarantineContainer?: string; ambiguous?: boolean; detail?: string; nextHandle?: FallbackJournalHandle }> { + // ---- Object-identity authority gate (R2 blocker 1): only a handle object + // issued by a legitimate transition in THIS process, whose CURRENT fields + // still equal its immutable issued metadata exactly, may drive a + // destructive frontier rollback. In-place field mutation and spread copies + // both fail. Additionally: a genuine current mutator must still be the + // journal mutator of THIS process, and a genuine owner must be the product + // of a legitimate reclaim (journal mutator cleared, owner current). ---- + if (!handleMatchesIssuedMetadata(handle)) return { outcome: 'preserved', journalPending: false, quarantineContainer: undefined } + let activeHandle = handle + const journalMutatorMatches = journal.mutator !== undefined && processMatches(journal.mutator) + const ownerIsCurrent = processMatches(journal.owner) + if (handle.role === 'mutator') { + if (!journalMutatorMatches) return { outcome: 'preserved', journalPending: false } + } else if (handle.role === 'owner') { + if (journal.mutator !== undefined || !ownerIsCurrent) return { outcome: 'preserved', journalPending: false } + } else { + return { outcome: 'preserved', journalPending: false } + } + const record = completedFrontierPublications.get(activeHandle.transactionId)?.get(resolved) + // No process-local completion authority (never published here, already + // consumed by an earlier rollback, or the process restarted): fail closed + // with preservation — never derived from journal fields. + if (record === undefined) return { outcome: 'preserved', journalPending: false } + let allocatedContainer: string | undefined + const fail = (detail: string): Error => new Error(`FALLBACK_FRONTIER_ROLLBACK_UNPROVEN: ${detail}`) + try { + // ---- Revalidation before any destructive byte moves ---- + // The journal entry must still be an applied planned-missing frontier + // publication bound to the recorded complete-stage digest. + const entry = journal.entries.find((candidate) => path.resolve(candidate.path) === resolved) + if (entry === undefined || entry.existed || entry.kind !== 'missing') throw fail('the journal entry is not a planned-missing frontier') + if (entry.applied !== true) throw fail('the journal entry is not applied') + if (entry.stageDigest !== record.stageDigest) throw fail('the journal stage digest does not match the completion record') + // The live frontier must still be the exact reserved directory identity + // this process created and proved at publication time. + const stats = await lstat(resolved).catch(() => undefined) + if (stats === undefined || !stats.isDirectory() || stats.isSymbolicLink()) return { outcome: 'absent', journalPending: false } + if (stats.dev !== record.dev || stats.ino !== record.ino) throw fail('the live frontier directory is not the reserved publication identity') + // The anchor identity must hold right now: the frontier path is EXPECTED + // to be live here (this process published it), so only the anchor chain + // is revalidated — full structural/semantic/digest proof follows below. + const frontier = handle.manifest.plannedMissingFrontiers.find((candidate) => path.resolve(candidate.frontierPath) === resolved) + if (frontier === undefined) throw fail('the frontier is no longer planned evidence') + await revalidateFallbackFrontierAnchor(frontier) + // Rollback proof uses the IMMUTABLE selection frozen into the publication + // record at registration: journal fields and caller arrays cannot widen it. + const expectation = deriveFrontierTreeExpectation( + { ...frontier, leaves: [...frontier.leaves.filter((leaf) => leaf.activation === 'required' || record.activeConditionalLeafIds.includes(leaf.id))] }, + handle.manifest.plannedMissingFrontiers.flatMap((candidate) => candidate.leaves), + { skillDestinationResolver: trustedSkillDestinationResolver(handle.manifest), linkPolicy: fallbackLinkMaterialization(handle.manifest.harness) } + ) + // Complete bounded structural/semantic proof and full digest equality with + // the immutable registered digest: any drift, extra child, kind mismatch, + // forbidden symlink, or partial publication preserves everything. + await assertStagedFrontierTree(resolved, expectation) + const digest = await boundedFrontierTreeDigest(resolved) + if (!digest || digest !== record.stageDigest) throw fail('the live frontier digest does not match the registered complete-stage digest') + // Frozen copied-link bindings proven against the LIVE tree BEFORE any + // destructive move: kind, digest/link text, and final-skill resolution + // from the live leaf location. Runs before the quarantine allocation so + // the tightest rechecks stay closest to the move. + if (record.copiedLinkBindings !== undefined) { + for (const [relative, binding] of Object.entries(record.copiedLinkBindings)) { + await assertPathMatchesCopiedLinkBinding(`Live frontier copied link ${resolved}/${relative}`, path.join(resolved, relative), binding).catch((error: unknown) => { + throw fail(`the live frontier copied link ${relative} no longer matches its frozen binding: ${(error as Error).message}`) + }) + } + } + if (fallbackFrontierRollbackSeam !== undefined) await fallbackFrontierRollbackSeam({ phase: 'proof-complete', frontierPath: resolved }) + + // ---- Quarantine container: fresh authenticated same-filesystem sibling. + // The container path lives OUTSIDE every inner try so EVERY failure after + // allocation reports the exact allocated container (no rethrow can lose + // the artifact). ---- + const quarantineStorage = await mkdtemp(path.join(path.dirname(resolved), `.${path.basename(resolved).replace(/^\.+/, '')}.nsolid-quarantine-`)) + allocatedContainer = quarantineStorage + const quarantinePath = path.join(quarantineStorage, path.basename(resolved)) + const token = randomUUID() + await writeFile(path.join(quarantineStorage, `.nsolid-quarantine-${token}`), token, { mode: 0o600 }) + const containerIdentity = await lstat(quarantineStorage) + const capability: ArtifactCapability = { + container: quarantineStorage, + payload: quarantinePath, + token, + role: 'quarantine', + dev: containerIdentity.dev, + ino: containerIdentity.ino, + // The payload child legitimately appears only with the rename: the + // pre-move structure check must see the marker only, so any racing + // extra child (an observable collision) fails closed before the move. + children: new Map([[`.nsolid-quarantine-${token}`, 'file']]), + } + // Authenticated container proof immediately before the move: exact dev/ + // ino, marker carrying the exact token, and the expected children only. + const containerStat = await lstat(quarantineStorage) + if (containerStat.dev !== containerIdentity.dev || containerStat.ino !== containerIdentity.ino) throw fail('the quarantine container was replaced before the move') + if (!await containerMatchesCapability(capability)) throw fail('the quarantine container structure does not match its recorded expectation') + const markerPath = path.join(quarantineStorage, `.nsolid-quarantine-${token}`) + if (await readFile(markerPath, 'utf8') !== token) throw fail('the quarantine marker does not carry the exact token') + + // ---- Durable rollback-pending state BEFORE any rename (R2 blockers 2+3): + // the journal durably records frontierRollbackPending + the exact + // quarantine container, and the updated handle is issued from the CAS. If + // this preparation CAS fails, NOTHING moves. The pending fields are + // strictly validated reporting/blocking state — they never grant + // destructive authority by themselves. ---- + let pendingJournal: FallbackJournal + try { + pendingJournal = await withCasWrite(activeHandle, journalWithEntryUpdate(journal, resolved, { frontierRollbackPending: true, quarantine: quarantinePath }), journal) + } catch { + throw fail('the durable rollback-pending state could not be recorded; nothing was moved') + } + journal = pendingJournal + activeHandle = issueGenuineHandle({ ...activeHandle, revision: pendingJournal.revision }) + if (fallbackFrontierRollbackSeam !== undefined) { + await fallbackFrontierRollbackSeam({ phase: 'quarantine-ready', frontierPath: resolved, quarantinePath }) + } + + // ---- TOCTOU re-authentication IMMEDIATELY before the rename: the + // quarantine-ready seam (and any async work around it) is a window in + // which the container could be replaced by a symlink, stripped, or given + // a payload collision. Re-prove the exact container identity, marker + // token, exact expected children (marker only — no payload), real non-link + // container kind, and that the payload destination is truly ABSENT before + // anything moves. ---- + { + const preMove = await lstat(quarantineStorage) + if (preMove.dev !== containerIdentity.dev || preMove.ino !== containerIdentity.ino || !preMove.isDirectory() || preMove.isSymbolicLink()) { + throw fail('the quarantine container identity changed before the move') + } + if (!await containerMatchesCapability(capability)) throw fail('the quarantine container structure does not match its recorded expectation before the move') + if (await readFile(markerPath, 'utf8') !== token) throw fail('the quarantine marker does not carry the exact token before the move') + const payloadStat = await lstat(quarantinePath).catch(() => undefined) + if (payloadStat !== undefined) throw fail('the quarantine payload destination is not absent before the move') + } + + // ---- From here on, ANY ordinary exception (seam throws, extra child / + // structure failure after the move, identity/digest mismatch, collision) + // becomes ONE structured incomplete outcome: retain the durable pending + // state, retire the destructive publication authority if a physical move + // may have occurred, and report the exact container. ---- + let movedPhysically = false + try { + try { + await rename(resolved, quarantinePath) + movedPhysically = true + } catch { + // A racing child at the payload path (visible only through the seam + // window) makes the rename fail on a non-empty directory: the + // collision is preserved, never overwritten. + throw fail('the frontier could not be moved into the quarantine container') + } + // The payload child is now real: record the post-move structure so any + // later authenticated cleanup validates reality. + capability.children = new Map([[path.basename(resolved), 'directory'], [`.nsolid-quarantine-${token}`, 'file']]) + // ---- TOCTOU re-authentication immediately after the rename: re-prove + // the same container identity, marker token, and exact post-move + // children (payload + marker) before any proof or pending-state + // clearing. A swapped container or injected child keeps the live + // frontier, pending journal, and quarantine evidence preserved. ---- + { + const postMove = await lstat(quarantineStorage) + if (postMove.dev !== containerIdentity.dev || postMove.ino !== containerIdentity.ino || !postMove.isDirectory() || postMove.isSymbolicLink()) { + throw fail('the quarantine container identity changed after the move') + } + if (!await containerMatchesCapability(capability)) throw fail('the quarantine container structure does not match its recorded expectation after the move') + if (await readFile(markerPath, 'utf8') !== token) throw fail('the quarantine marker does not carry the exact token after the move') + } + if (fallbackFrontierRollbackSeam !== undefined) { + await fallbackFrontierRollbackSeam({ phase: 'moved', frontierPath: resolved, quarantinePath }) + } + + // ---- Post-rename proof: the moved root IS the exact original inode with + // the exact proven structure/digest, and the original path is missing. ---- + const moved = await lstat(quarantinePath).catch(() => undefined) + if (moved === undefined || !moved.isDirectory() || moved.isSymbolicLink() || moved.dev !== record.dev || moved.ino !== record.ino) { + throw fail('the moved frontier root is not the exact original inode') + } + await assertStagedFrontierTree(quarantinePath, expectation) + const movedDigest = await boundedFrontierTreeDigest(quarantinePath) + if (!movedDigest || movedDigest !== record.stageDigest) throw fail('the moved frontier digest does not match the registered complete-stage digest') + // The moved tree proves its frozen bindings with exact text equality + // resolved against the ORIGINAL live leaf location — never against + // the quarantine parent, where valid relative targets would mis-resolve. + if (record.copiedLinkBindings !== undefined) { + for (const [relative, binding] of Object.entries(record.copiedLinkBindings)) { + await assertPathMatchesCopiedLinkBinding(`Quarantined frontier copied link ${quarantinePath}/${relative}`, path.join(quarantinePath, relative), binding, { resolveFrom: path.dirname(path.join(resolved, relative)) }).catch((error: unknown) => { + throw fail(`the quarantined frontier copied link ${relative} no longer matches its frozen binding: ${(error as Error).message}`) + }) + } + } + if (await pathKind(resolved) !== 'missing') { + // A replacement appeared at the original path in the rename→prove + // window: keep it, keep the quarantine, and preserve the ambiguity + // instead of pretending the restore is exact. + throw fail('a new path appeared at the frontier location after the move') + } + if (fallbackFrontierRollbackSeam !== undefined) { + await fallbackFrontierRollbackSeam({ phase: 'proven', frontierPath: resolved, quarantinePath }) + } + + // ---- Successful proof: retire the one-shot publication authority, + // clear the durable pending state and the entry's applied/stage fields + // via the issued handle. ---- + completedFrontierPublications.get(activeHandle.transactionId)!.delete(resolved) + const preservedArtifacts = [...(journal.preservedArtifacts ?? []), quarantineStorage] + try { + const finished = await withCasWrite(activeHandle, { + ...journal, + preservedArtifacts: dedupePaths(preservedArtifacts), + entries: journal.entries.map((candidate) => path.resolve(candidate.path) === resolved + ? { ...candidate, applied: false, stage: undefined, stageDigest: undefined, frontierRollbackPending: undefined, quarantine: undefined } + : candidate), + }, journal) + journal = finished + activeHandle = issueGenuineHandle({ ...handle, revision: finished.revision }) + return { outcome: 'rolled-back', journalPending: false, revision: finished.revision, quarantineContainer: quarantineStorage, nextHandle: activeHandle } + } catch { + // The final bookkeeping CAS failed after a FULLY PROVEN move: the + // physical restore IS done, but the durable pending state remains and + // the revision is untrustworthy. Report pending accurately. + return { outcome: 'rolled-back', journalPending: true, revision: activeHandle.revision, quarantineContainer: quarantineStorage } + } + } catch (error) { + // ONE structured incomplete outcome for every post-move ambiguity or + // failure. If a physical move may have occurred, retire the destructive + // publication authority so no replay can move anything; the durable + // pending state is retained on disk (reporting/blocking only). + if (movedPhysically) { + completedFrontierPublications.get(activeHandle.transactionId)?.delete(resolved) + } + const detail = error instanceof Error ? error.message : String(error) + return { + outcome: 'preserved', + journalPending: true, + quarantineContainer: quarantineStorage, + ambiguous: true, + detail, + } + } + } catch (error) { + // Every failure after allocation is a structured preservation outcome so + // the exact quarantine container cannot disappear from reporting. A + // durable pending record blocks all automatic retries and retires the + // publication authority even when the failure happened before rename. + if (allocatedContainer !== undefined) { + const journalPending = pendingContainerOf(journal, resolved) !== undefined + if (journalPending) completedFrontierPublications.get(activeHandle.transactionId)?.delete(resolved) + return { + outcome: 'preserved', + journalPending, + quarantineContainer: allocatedContainer, + ambiguous: journalPending, + detail: error instanceof Error ? error.message : String(error), + } + } + // Pre-allocation proof failures preserve the live root without creating an + // artifact. They are unproven but not a durable rollback-pending state. + if (error instanceof Error && error.message.startsWith('FALLBACK_FRONTIER_ROLLBACK_UNPROVEN')) { + return { outcome: 'preserved', journalPending: false } + } + throw fail(`frontier rollback failed: ${error instanceof Error ? error.message : String(error)}`) + } +} + +/** The durable quarantine container recorded in a pending journal entry, if any (reporting only). */ +function pendingContainerOf (journal: FallbackJournal, resolved: string): string | undefined { + const entry = journal.entries.find((candidate) => path.resolve(candidate.path) === resolved) + if (entry?.frontierRollbackPending !== true || entry.quarantine === undefined) return undefined + return path.dirname(path.resolve(entry.quarantine)) +} + +/** + * Swap one entry's staged payload into place on the same volume. The stage is + * verified against the in-memory capability (never against the journal record + * alone), the live state is re-checked immediately before the swap, and the + * replaced bytes move to a sibling quarantine that is preserved (never rm'd) + * until an authenticated cleanup. + */ +export async function applyFallbackEntry (handle: FallbackJournalHandle, target: string): Promise { + let active = handle + let journal = await reloadFallbackJournal(active) + requireRole(journal, active, 'mutator') + 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 stageCapability = capabilitiesFor(active).get(resolved) + const frontier = active.manifest.plannedMissingFrontiers.find((candidate) => path.resolve(candidate.frontierPath) === resolved) + if (frontier !== undefined) { + // A planned-missing frontier is applied only through its process-local + // publication capability registered by registerFallbackFrontierStage. + // Without that capability the mutator preserves the live state and + // fails closed; a frontier is never a deletion candidate. + if (!stageCapability || stageCapability.role !== 'stage' || stageCapability.payload === undefined) { + throw new Error(`No registered stage capability for ${resolved}`) + } + return await applyFrontierPublication(active, journal, entry, frontier, resolved, stageCapability) + } + if (!stageCapability || stageCapability.role !== 'stage' || stageCapability.payload === undefined) { + return await applyDeletionOnlyEntry(active, journal, entry, resolved) + } + if (entry.stage !== stageCapability.payload) throw new Error(`Fallback journal stage for ${resolved} does not match the registered capability`) + const stageDigest = await pathDigest(stageCapability.payload) + if (!stageDigest || stageDigest !== entry.stageDigest || stageDigest !== stageCapability.stageDigest) throw new Error(`Staged payload for ${resolved} no longer matches its registered digest`) + // A copied-link stage must still satisfy the binding frozen at + // registration BEFORE any live mutation: the frozen record alone is the + // authority, never the registry or the mutable live skill bytes. + if (stageCapability.copiedLinkBinding !== undefined) { + await assertPathMatchesCopiedLinkBinding(`Staged copied link ${resolved}`, stageCapability.payload, stageCapability.copiedLinkBinding) + } + + // Immediate revalidation: a concurrent writer must never be clobbered by + // the swap. A destination journaled as missing that now exists is drift too. + const kind = await pathKind(resolved) + const liveDigest = kind !== 'missing' ? await pathDigest(resolved) : null + if (liveDigest === undefined || (entry.existed ? kind !== entry.kind || liveDigest !== entry.digest : kind !== 'missing')) { + throw new Error(`Fallback target ${resolved} drifted after journaling; aborting without touching concurrent bytes`) + } + + const quarantineStorage = await mkdtemp(path.join(path.dirname(resolved), `.${path.basename(resolved).replace(/^\.+/, '')}.nsolid-quarantine-`)) + const quarantinePath = path.join(quarantineStorage, path.basename(resolved)) + const quarantineToken = randomUUID() + await writeFile(path.join(quarantineStorage, `.nsolid-quarantine-${quarantineToken}`), quarantineToken, { mode: 0o600 }) + const quarantineIdentity = await lstat(quarantineStorage) + const quarantineCapability: ArtifactCapability = { + container: quarantineStorage, + payload: quarantinePath, + token: quarantineToken, + role: 'quarantine', + dev: quarantineIdentity.dev, + ino: quarantineIdentity.ino, + children: new Map([[`.nsolid-quarantine-${quarantineToken}`, 'file']]), + } + // Durably record the quarantine BEFORE the live path moves: a termination in + // any later window leaves a journal that explains the missing target. + journal = await withCasWrite(active, journalWithEntryUpdate(journal, entry, { quarantine: quarantinePath }), journal) + active = { ...active, revision: journal.revision } + // The payload child only legitimately appears with the live rename: record + // it immediately before the move so the cleanup structure matches reality. + if (kind !== 'missing') { + quarantineCapability.children = new Map([...quarantineCapability.children!, [path.basename(quarantinePath), kind]]) + await rename(resolved, quarantinePath) + } + await rename(stageCapability.payload, resolved) + // The staged payload moved into place: the stage container's exact remaining + // structure is now its marker only. Record that so authenticated cleanup + // validates the post-swap reality instead of the pre-swap one. + stageCapability.children = new Map([[`.nsolid-stage-${stageCapability.token}`, 'file']]) + const appliedDigest = await pathDigest(resolved) + if (!appliedDigest || appliedDigest !== entry.stageDigest) throw new Error(`Swap for ${resolved} did not produce the staged digest`) + // Completed publication proof, captured while the exact bytes are proven + // live: the process-local ownership evidence restore later requires for + // planned-missing destinations. Registering or staging alone never grants it. + recordPublishedFallbackEntry(active.transactionId, resolved, await pathKind(resolved), appliedDigest) + if (resolved === path.resolve(active.manifest.trackingPath)) publishedTrackingDigests.set(active.transactionId, appliedDigest) + // Post-swap proof for a copied link: the published path itself must still + // satisfy the frozen binding (kind, digest/link text, final destination + // resolution from the live location) before the swap is recorded applied. + // The proof is retained BEFORE the durable CAS: even if the CAS below + // fails, same-process restore can still verify against what this process + // physically published. + if (stageCapability.copiedLinkBinding !== undefined) { + await assertPathMatchesCopiedLinkBinding(`Published copied link ${resolved}`, resolved, stageCapability.copiedLinkBinding) + retainAppliedCopiedLinkBinding(active, resolved, stageCapability.copiedLinkBinding) + } + journal = await withCasWrite(active, journalWithEntryUpdate(journal, resolved, { applied: true }), journal) + active = { ...active, revision: journal.revision } + // Both containers are cleaned by their creator while the capabilities are + // still in memory. Failures are reporting-only residue and never fail an + // applied swap; the authenticated snapshot remains the rollback source. + const preservedArtifacts = [...(journal.preservedArtifacts ?? [])] + const stageRemoved = await removeAuthenticatedContainer(stageCapability).catch(() => false) + if (!stageRemoved) preservedArtifacts.push(stageCapability.container) + capabilitiesFor(active).delete(resolved) + const quarantineRemoved = await removeAuthenticatedContainer(quarantineCapability).catch(() => false) + if (!quarantineRemoved) preservedArtifacts.push(quarantineCapability.container) + journal = await withCasWrite(active, { + ...journal, + preservedArtifacts: dedupePaths(preservedArtifacts), + entries: journal.entries.map((candidate) => path.resolve(candidate.path) === resolved + ? { ...candidate, stage: undefined, quarantine: undefined } + : candidate), + }, journal) + const swapped = { ...active, revision: journal.revision } + issueGenuineHandle(swapped) + return swapped +} + +/** + * Deletion-only apply for a planned removal (a bundle no longer ships this + * owned skill/link). The mutator's trusted in-memory manifest must authorize + * the exact resolved path as originally-existing ownership evidence — + * ownedSkills/ownedLinks ONLY: planned-missing bundle destinations, MCP-only + * evidence, and anything absent from the manifest are never deletion + * candidates. The live state is re-checked against that evidence immediately + * before the move, the live bytes go into an authenticated sibling quarantine + * (preserved until commit/restore removes it through the in-memory + * capability), and `applied` is recorded strictly as validation evidence. + * + * This branch never hides a lost replacement stage: a journal-recorded stage + * or stage digest without its in-memory capability, or an incomplete prior + * deletion (quarantine recorded but not applied), still fails closed. + * A planned-missing owned path with nothing live to remove is an already- + * satisfied deletion and succeeds as a no-op; a live path at a planned- + * missing location is drift and still fails closed. + */ +async function applyDeletionOnlyEntry ( + handle: FallbackJournalHandle, + journal: FallbackJournal, + entry: FallbackJournalEntry, + resolved: string +): Promise { + // A journal-recorded stage is a replacement whose payload capability was + // lost (or was never created in this process): never silently delete it. + if (entry.stage !== undefined || entry.stageDigest !== undefined) { + throw new Error(`No registered stage capability for ${resolved}`) + } + // A previously recorded, not-yet-applied quarantine means a prior deletion + // attempt died mid-move; re-applying could pile containers, so fail closed. + if (entry.quarantine !== undefined && entry.applied !== true) { + throw new Error(`A pending quarantine for ${resolved} must be resolved before re-applying`) + } + const evidence = ownedRemovalEvidence(handle.manifest).get(resolved) + // A planned-missing owned path (e.g. a harness link the layout never + // created) with nothing live to remove is an already-satisfied deletion: + // succeed as a no-op instead of failing the whole refresh. A path that is + // absent from the removal evidence entirely (bundle destinations, MCP-only + // evidence, unmanifested paths) never reaches this branch and is refused + // below, and anything live at a planned-missing path is drift and also + // fails closed below. + if (evidence !== undefined && evidence.kind === 'missing' && !entry.existed && await pathKind(resolved) === 'missing') { + return handle + } + if (!entry.existed || evidence === undefined || evidence.kind === 'missing' || evidence.digest === undefined) { + throw new Error(`No registered stage capability for ${resolved}`) + } + // Immediate revalidation against the trusted planning evidence: only bytes + // identical to the owned original may be removed. + const kind = await pathKind(resolved) + const liveDigest = kind !== 'missing' ? await pathDigest(resolved) : undefined + if (kind !== evidence.kind || liveDigest !== evidence.digest) { + throw new Error(`Fallback target ${resolved} drifted after journaling; aborting without touching concurrent bytes`) + } + const storage = await mkdtemp(path.join(path.dirname(resolved), `.${path.basename(resolved).replace(/^\.+/, '')}.nsolid-quarantine-`)) + const quarantinePath = path.join(storage, path.basename(resolved)) + const token = randomUUID() + const capability: ArtifactCapability = { + container: storage, + payload: quarantinePath, + token, + role: 'quarantine', + children: new Map([[`.nsolid-quarantine-${token}`, 'file']]), + } + let moved = false + try { + await writeFile(path.join(storage, `.nsolid-quarantine-${token}`), token, { mode: 0o600 }) + const identity = await lstat(storage) + capability.dev = identity.dev + capability.ino = identity.ino + // Register the cleanup authority before anything moves: from here on the + // container is only ever removed through this in-memory capability. + capabilitiesFor(handle).set(resolved, capability) + // Durably record the quarantine BEFORE the live path moves, mirroring the + // swap path: a termination in any later window leaves a journal that + // explains the missing target. + const recorded = await withCasWrite(handle, journalWithEntryUpdate(journal, entry, { quarantine: quarantinePath }), journal) + // The payload child only legitimately appears with the live rename: record + // it immediately before the move so the cleanup structure matches reality. + capability.children = new Map([...capability.children!, [path.basename(quarantinePath), kind]]) + await rename(resolved, quarantinePath) + moved = true + if (await pathKind(resolved) !== 'missing') throw new Error(`Deletion for ${resolved} did not clear the target`) + const applied = await withCasWrite({ ...handle, revision: recorded.revision }, journalWithEntryUpdate(recorded, resolved, { applied: true }), recorded) + // The quarantine is preservation, not garbage: it stays in place until + // commit or restore removes it through the capability registered above. + const appliedHandle = { ...handle, revision: applied.revision } + issueGenuineHandle(appliedHandle) + return appliedHandle + } catch (error) { + if (moved && await pathKind(resolved) === 'missing') { + // A failed deletion must not destroy the original bytes: move them back. + await rename(quarantinePath, resolved).catch(() => {}) + } + // Remove the container only when it no longer holds the live bytes; + // otherwise the capability stays as preserved-artifact authority. Even + // this payload-less removal goes through the authenticated cleanup: + // unexpected container content preserves it instead of deleting it. + if (await pathKind(quarantinePath) === 'missing') { + const removed = await removeAuthenticatedContainer(capability).catch(() => false) + if (removed) capabilitiesFor(handle).delete(resolved) + } + throw error + } +} + +/** + * Trusted deletion authority: only tracked skill/link ownership evidence may + * authorize a deletion-only apply. MCP whole-file evidence, bundle + * destinations, and the tracking file are deliberately excluded. + */ +function ownedRemovalEvidence (manifest: FallbackTransactionIdentity): Map { + const evidence = new Map() + for (const entry of [...(manifest.ownedSkills ?? []), ...(manifest.ownedLinks ?? [])]) { + if (entry !== undefined && !evidence.has(path.resolve(entry.path))) evidence.set(path.resolve(entry.path), entry) + } + return evidence +} + +/** + * Restore every entry to its original state. Authority is EXCLUSIVELY the + * trusted in-memory manifest carried by a FallbackJournalHandle: owned paths + * are rewritten from backups authenticated against that manifest, and + * planned-missing destinations are moved to a quarantine (never rm'd). + * A plain journal object — even a perfectly self-consistent one read back + * from disk — is never destructive authority and is refused without touching + * anything; next-run recovery's restricted, tracking-scoped restore lives + * only in recoverFallbackJournal. + * Mutable journal fields (applied/stageDigest/quarantine) never authorize a + * deletion or an overwrite: backups must authenticate against manifest + * evidence, and each entry is re-checked immediately before it is replaced. + * The owner role disposes the journal and snapshot on success; a mutator + * handle leaves them for its owner. + */ +export async function restoreFallbackJournal (handle: FallbackJournalHandle): Promise { + const preservedArtifacts: string[] = [] + const preservedPaths: string[] = [] + if (handle === undefined || (handle as unknown as { kind?: unknown }).kind !== 'fallback-journal-handle') { + // Refuse plain journals fail-closed: disk self-consistency is not authority. + return { succeeded: false, unproven: true, preservedArtifacts, preservedPaths } + } + try { + const journalPath = handle.journalPath + let journal: FallbackJournal + try { + journal = await reloadFallbackJournal(handle) + } catch { + // The journal cannot be proven to match the trusted manifest (or be + // structurally sound): nothing is mutated and everything is preserved. + return { succeeded: false, unproven: true, preservedArtifacts, preservedPaths } + } + const authority = handle.manifest + if (!sameManifest(journal.manifest, authority)) { + return { succeeded: false, unproven: true, preservedArtifacts, preservedPaths } + } + // Preflight before the first destructive byte moves: the snapshot must be + // provably real, and every backup that will be restored must authenticate + // against manifest evidence. A tampered or rotted backup aborts with the + // live paths untouched and the artifacts preserved. + if (!snapshotShapeIsValid(journal)) return { succeeded: false, unproven: true, preservedArtifacts, preservedPaths } + const evidence = plannedEvidenceMap(authority) + for (const entry of journal.entries) { + if (!entry.existed) continue + if (!entry.backup) return { succeeded: false, unproven: true, preservedArtifacts, preservedPaths } + if (!await backupAuthenticates(entry, evidence, authority)) { + return { succeeded: false, unproven: true, preservedArtifacts, preservedPaths } + } + } + // A backup authenticates the original, not the current tracking file. + // Refuse BEFORE any restoration when current bytes are neither the + // original nor this process's verified publication. Do not merge foreign + // records or derive overwrite authority from mutable journal fields. + const trackingPath = path.resolve(authority.trackingPath) + const trackingEntry = journal.entries.find((entry) => path.resolve(entry.path) === trackingPath) + const approvedTrackingDigest = await pathKind(trackingPath) === 'file' ? await pathDigest(trackingPath) : undefined + if (!approvedTrackingDigest || !trackingEntry || + (approvedTrackingDigest !== trackingEntry.digest && approvedTrackingDigest !== publishedTrackingDigests.get(handle.transactionId))) { + return { succeeded: false, unproven: true, preservedArtifacts, preservedPaths: [trackingPath] } + } + // Quarantine containers created by this restore are this invocation's + // capabilities; they are cleaned after postvalidation succeeds. + const restoreCapabilities = new Map() + // Planned-missing frontiers are the publication units of the process + // that reserved them: destructive restore authority is EXCLUSIVELY a + // process-local completion capability created by a successful + // publication in THIS process (mutator child or locally reclaimed + // owner). An external parent or a next-run process without that + // capability always preserves, even when every journal field matches. + const frontierPaths = new Set(authority.plannedMissingFrontiers.map((frontier) => path.resolve(frontier.frontierPath))) + // Set when a live planned-missing destination had to be preserved because + // no process-local evidence proves this transaction published it: the + // rollback is then incomplete/unproven and the journal stays for manual + // resolution, exactly like a preserved live frontier. + let incomplete = false + // ---- Durable pending preflight (R2 blockers 2+3): any journal entry + // durably marked frontierRollbackPending makes the WHOLE restore + // incomplete/unproven — the journal/quarantine/snapshot stay, the exact + // container is reported, and NO generic postvalidation or cleanup runs. + // The disk state is reporting/blocking only, never destructive authority: + // the restore refuses regardless of any process-local capability. ---- + const pendingEntry = journal.entries.find((candidate) => candidate.frontierRollbackPending === true) + if (pendingEntry !== undefined) { + const pendingTarget = path.resolve(pendingEntry.path) + preservedPaths.push(pendingTarget) + if (pendingEntry.quarantine !== undefined) { + preservedArtifacts.push(path.dirname(path.resolve(pendingEntry.quarantine))) + preservedPaths.push(path.resolve(pendingEntry.quarantine)) + } + for (const entry of journal.entries) { + const target = path.resolve(entry.path) + if (target === pendingTarget) continue + if (frontierPaths.has(target) && await pathKind(target) === 'missing') continue + preservedPaths.push(target) + } + return { succeeded: false, unproven: true, preservedArtifacts: dedupePaths(preservedArtifacts), preservedPaths: dedupePaths(preservedPaths) } + } + // First pass: roll back every frontier this process published through + // its own capability. The publication record binds the exact reserved + // identity and registered digest, so this cannot be forged by any disk + // state; unproven or absent-capability frontiers are handled below. + for (const resolved of frontierPaths) { + if (await pathKind(resolved) === 'missing') continue + if (completedFrontierPublications.get(handle.transactionId)?.has(resolved) !== true) continue + const rollback = await rollbackFrontierPublication(handle, journal, resolved).catch((error: unknown) => { + // Unproven rollback: the live frontier (or any ambiguity it left + // behind) is preserved and reported; the restore continues with the + // remaining independently authenticated entries. + if (error instanceof Error && error.message.startsWith('FALLBACK_FRONTIER_ROLLBACK_UNPROVEN')) return undefined + throw error + }) + if (rollback === undefined) { + preservedPaths.push(resolved) + incomplete = true + journal = await reloadFallbackJournal(handle).catch(() => journal) + continue + } + if (rollback.outcome === 'absent') continue + // Every rollback result carries the exact allocated quarantine + // container whenever one was allocated. + if (rollback.quarantineContainer !== undefined) preservedArtifacts.push(rollback.quarantineContainer) + if (rollback.ambiguous === true) { + // A physical move HAPPENED but could not be proven: the durable + // pending state is on disk, the destructive publication authority was + // retired, and the whole operation is incomplete. Stop and report + // every remaining unrestored target; journal/snapshot stay. + journal = await reloadFallbackJournal(handle).catch(() => journal) + for (const entry of journal.entries) { + const target = path.resolve(entry.path) + if (target === resolved) continue + if (frontierPaths.has(target) && await pathKind(target) === 'missing') continue + preservedPaths.push(target) + } + return { succeeded: false, unproven: true, preservedArtifacts: dedupePaths(preservedArtifacts), preservedPaths: dedupePaths(preservedPaths) } + } + if (rollback.outcome === 'rolled-back') { + if (rollback.journalPending) { + // Final bookkeeping CAS failure after a fully proven move: the + // handle revision is untrustworthy, so the restore MUST STOP. + // Report succeeded:false with frontierJournalPending:true, the + // quarantine artifact, and every remaining unrestored target. + journal = await reloadFallbackJournal(handle).catch(() => journal) + for (const entry of journal.entries) { + const target = path.resolve(entry.path) + if (frontierPaths.has(target) && await pathKind(target) === 'missing') continue + preservedPaths.push(target) + } + return { succeeded: false, frontierJournalPending: true, preservedArtifacts: dedupePaths(preservedArtifacts), preservedPaths: dedupePaths(preservedPaths) } + } + // Adopt the handle re-issued by the rollback (fresh revision, still + // bound to its issued metadata) so further frontiers can roll back. + if (rollback.nextHandle !== undefined) handle = rollback.nextHandle + journal = await reloadFallbackJournal(handle).catch(() => journal) + continue + } + // Preserved (unproven, nothing moved): reload the journal and let the + // per-entry pass report the live frontier as incomplete below. + journal = await reloadFallbackJournal(handle).catch(() => journal) + } + for (const entry of journal.entries) { + const resolved = path.resolve(entry.path) + const kind = await pathKind(resolved) + if (entry.existed) { + const liveDigest = kind !== 'missing' ? await pathDigest(resolved) : null + if (liveDigest === undefined) { preservedPaths.push(resolved); continue } + if (resolved === trackingPath && (kind !== 'file' || liveDigest !== approvedTrackingDigest)) { + preservedPaths.push(resolved); continue + } + // Already the authenticated original: nothing to replace. + if (kind === entry.kind && liveDigest === entry.digest) continue + if (!entry.backup) { preservedPaths.push(resolved); continue } + // Same-process published copied link: the bytes about to be + // quarantined must match the retained frozen publication binding — + // what this process published, never disk journal fields alone. + const retainedBinding = retainedAppliedCopiedLinkBinding(handle, resolved) + if (retainedBinding !== undefined && kind !== 'missing' && !await livePathMatchesRetainedCopiedLinkBinding(resolved, retainedBinding)) { + preservedPaths.push(resolved); continue + } + // Preserve the current bytes by quarantine-rename instead of rm, then + // install a verified copy of the authenticated backup atomically. + if (!await renameLiveToRestoreQuarantine(resolved, restoreCapabilities)) { preservedPaths.push(resolved); continue } + if (resolved === trackingPath) { + const moved = restoreCapabilities.get(resolved) + if (!moved?.payload || await pathKind(moved.payload) !== 'file' || await pathDigest(moved.payload) !== approvedTrackingDigest) { + if (moved) preservedArtifacts.push(moved.container) + preservedPaths.push(resolved) + continue + } + } + if (retainedBinding !== undefined && kind !== 'missing') { + // Post-quarantine verification: the moved bytes must still match + // the retained binding; exact link texts resolve against the + // original live location, never the quarantine parent. + const movedCapability = restoreCapabilities.get(resolved) + if (movedCapability?.payload === undefined || !await movedPathMatchesRetainedCopiedLinkBinding(movedCapability.payload, resolved, retainedBinding)) { + preservedPaths.push(resolved); continue + } + } + const temporary = path.join(path.dirname(resolved), `.${path.basename(resolved).replace(/^\.+/, '')}.nsolid-restore-${randomUUID().slice(0, 8)}`) + try { + await cp(entry.backup, temporary, { recursive: true, force: false, errorOnExist: true, verbatimSymlinks: true, dereference: false }) + if (await pathDigest(temporary) !== entry.digest || await pathKind(temporary) !== entry.kind) throw new Error('restored copy mismatch') + if (await pathKind(resolved) !== 'missing') throw new Error('destination reoccupied during restore') + await rename(temporary, resolved) + } catch { + await rm(temporary, { recursive: true, force: true }).catch(() => {}) + preservedPaths.push(resolved) + } + } else if (kind !== 'missing') { + if (frontierPaths.has(resolved)) { + // A live planned-missing frontier is never moved by the parent: + // preserve the path and mark the rollback incomplete. + preservedPaths.push(resolved) + incomplete = true + continue + } + // Planned-missing destination that exists: preserve the bytes by + // quarantine-rename ONLY when this process's own completed + // publication proves the current bytes are exactly what this + // transaction put there. Same-process published copied links prove + // against their retained frozen binding; every other live destination + // proves against the process-local publication record (kind + digest + // captured at the completed swap). Anything else — a foreign file + // created after journaling, a published destination whose bytes were + // replaced afterwards, or any live path in a process without the + // record — is preserved in place and marks the rollback incomplete; + // it is never moved and never deleted. Serialized journal fields + // (applied/stageDigest) never grant this authority. + const retainedMissingBinding = retainedAppliedCopiedLinkBinding(handle, resolved) + if (retainedMissingBinding !== undefined) { + if (!await livePathMatchesRetainedCopiedLinkBinding(resolved, retainedMissingBinding)) { preservedPaths.push(resolved); continue } + } else { + const published = publishedFallbackEntries.get(handle.transactionId)?.get(resolved) + const liveDigest = await pathDigest(resolved) + if (published === undefined || published.kind !== kind || published.digest !== liveDigest) { + preservedPaths.push(resolved) + incomplete = true + continue + } + } + if (!await renameLiveToRestoreQuarantine(resolved, restoreCapabilities)) { preservedPaths.push(resolved); continue } + if (retainedMissingBinding !== undefined) { + const movedCapability = restoreCapabilities.get(resolved) + if (movedCapability?.payload === undefined || !await movedPathMatchesRetainedCopiedLinkBinding(movedCapability.payload, resolved, retainedMissingBinding)) { preservedPaths.push(resolved); continue } + } + } + } + // Postvalidate the live state before any cleanup or journal disposal. + let valid = true + for (const entry of journal.entries) { + const resolved = path.resolve(entry.path) + const kind = await pathKind(resolved) + if (entry.existed) { + const digest = kind !== 'missing' ? await pathDigest(resolved) : null + if (digest === undefined || kind !== entry.kind || digest !== entry.digest) valid = false + if (resolved === path.resolve(authority.trackingPath) && trackingDigest(resolved) !== authority.trackingDigest) valid = false + } else if (kind !== 'missing') { + valid = false + } + } + if (!valid) { + if (incomplete) { + // The rollback cannot complete while a planned-missing destination + // this transaction cannot prove ownership of stays live (frontier or + // plain planned-missing path): report every restore container this + // invocation created so the incomplete rollback stays fully + // discoverable; nothing is destroyed and the journal is preserved for + // manual resolution. + for (const capability of restoreCapabilities.values()) preservedArtifacts.push(capability.container) + return { succeeded: false, unproven: true, preservedArtifacts: dedupePaths(preservedArtifacts), preservedPaths: dedupePaths(preservedPaths) } + } + return { succeeded: false, preservedArtifacts, preservedPaths } + } + await cleanupRestoreArtifacts(journal, handle, restoreCapabilities, preservedArtifacts) + if (handle.role === 'owner') { + // Completing the restore disposes the transaction: only the owner holds + // that authority. A mutator handle deliberately leaves the journal for + // its owner. + await rm(journalPath, { force: true }).catch(() => {}) + publishedTrackingDigests.delete(handle.transactionId) + publishedFallbackEntries.delete(handle.transactionId) + } + return { succeeded: true, preservedArtifacts: dedupePaths(preservedArtifacts), preservedPaths: dedupePaths(preservedPaths) } + } catch { + return { succeeded: false, preservedArtifacts, preservedPaths } + } +} + +/** + * Commit a successfully applied transaction. Cleanup failures are fail-closed: + * the artifact survives and is reported, but the commit itself still succeeds. + * Returns the preserved arrays for result propagation. + */ +export async function commitFallbackJournal (handle: FallbackJournalHandle): Promise { + if (handle === undefined || (handle as unknown as { kind?: unknown }).kind !== 'fallback-journal-handle') { + // Refuse plain journals fail-closed: disk self-consistency is not authority. + throw new Error('Invalid fallback journal') + } + if (handle.role !== 'owner') throw new Error('Invalid fallback journal') + const journalPath = handle.journalPath + const residues = await readReportingResidues(journalPath) + const preservedArtifacts: string[] = [...residues.preservedArtifacts] + const preservedPaths: string[] = [...residues.preservedPaths] + const journal = await reloadFallbackJournal(handle) + if (!snapshotShapeIsValid(journal)) throw new Error('Invalid fallback journal') + // A durable frontierRollbackPending entry means an ambiguous physical + // rollback state is being deliberately resolved: the transaction is NOT + // complete, so commit refuses and nothing is cleaned or removed (the disk + // pending field is reporting/blocking only — never authority). + if (journal.entries.some((candidate) => candidate.frontierRollbackPending === true)) { + for (const entry of journal.entries) { + if (entry.frontierRollbackPending === true && entry.quarantine !== undefined) { + preservedArtifacts.push(path.dirname(path.resolve(entry.quarantine))) + } + } + throw new Error('FALLBACK_FRONTIER_ROLLBACK_PENDING') + } + // Stage/quarantine containers: only in-memory capabilities authorize their + // removal. Anything else (a forged or stale journal record) is preserved. + for (const entry of journal.entries) { + for (const artifact of [entry.stage !== undefined ? path.dirname(entry.stage) : undefined, entry.quarantine !== undefined ? path.dirname(entry.quarantine) : undefined]) { + if (artifact === undefined) continue + const removed = await tryRemoveByCapability(capabilitiesFor(handle), artifact) + if (!removed) preservedArtifacts.push(artifact) + } + } + // The snapshot is removed only via its recorded in-memory capability. + if (journal.snapshotDirectory !== undefined) { + const snapshot = path.resolve(journal.snapshotDirectory) + let removed = false + const snapshotCapability = capabilitiesFor(handle).get('\0snapshot') + if (snapshotCapability !== undefined && snapshotCapability.container === snapshot) { + removed = await removeAuthenticatedContainer(snapshotCapability).catch(() => false) + } + if (!removed) preservedArtifacts.push(snapshot) + } + // The journal is removed last: until it disappears the transaction stays + // discoverable and pending. + await rm(journalPath, { force: true }).catch(() => {}) + publishedTrackingDigests.delete(handle.transactionId) + publishedFallbackEntries.delete(handle.transactionId) + return { succeeded: true, preservedArtifacts: dedupePaths(preservedArtifacts), preservedPaths: dedupePaths(preservedPaths) } +} + +/** Current ownership scope shared by next-run recovery and read-only inspection. */ +function currentTrackingOwnedPaths (trackingPath: string): Set { + let currentTracking: TrackingData | undefined + try { + const parsed: unknown = JSON.parse(readFileSync(path.resolve(trackingPath), 'utf8')) + if (isValidTrackingData(parsed)) currentTracking = parsed as TrackingData + } catch { currentTracking = undefined } + const ownedPaths = new Set([path.resolve(trackingPath)]) + if (currentTracking !== undefined) { + for (const skill of currentTracking.skills) { + for (const owned of [skill.path, ...Object.values(skill.paths ?? {})]) { + if (typeof owned === 'string') ownedPaths.add(path.resolve(owned)) + } + } + for (const server of currentTracking.mcpServers) ownedPaths.add(path.resolve(server.configPath)) + } + return ownedPaths +} + +interface RecoveryInspectionScope { + trackingPath: string + ownedPaths: ReadonlySet + frontierPaths: ReadonlySet + preservedPaths: string[] + preservedArtifacts: string[] + recovered: boolean +} + +/** + * Read-only entry checks shared by inspection and next-run recovery. + * Return the backup and proven ancestor chain only for an owned entry with an + * authenticated backup. Other entries update the report and are skipped. + * A returned chain must still be rechecked before each recovery mutation. + */ +async function inspectRecoveryEntry (entry: FallbackJournalEntry, scope: RecoveryInspectionScope): Promise<{ backup: string; ancestorChain: LiveAncestorIdentity[] } | undefined> { + const resolved = path.resolve(entry.path) + if (entry.frontierRollbackPending === true) { + // Ambiguous publication state is reporting/blocking evidence only. + scope.preservedPaths.push(resolved) + if (entry.quarantine !== undefined) { + scope.preservedArtifacts.push(path.dirname(path.resolve(entry.quarantine))) + scope.preservedPaths.push(path.resolve(entry.quarantine)) + } + scope.recovered = false + return undefined + } + if (scope.frontierPaths.has(resolved)) { + // Planned-missing frontiers are never restored; live presence blocks. + if (await pathKind(resolved) === 'missing') return undefined + } else if (!scope.ownedPaths.has(resolved)) { + if (entry.existed) scope.preservedPaths.push(resolved) + return undefined + } else if (!entry.existed) { + return undefined + } else if (entry.backup && await pathKind(entry.backup) === entry.kind && await pathDigest(entry.backup) === entry.digest) { + // Prove the complete lstat-only ancestor chain before trusting live + // digests: byte-identical content under a substituted parent is unsafe. + const ancestorChain = await captureLiveAncestorChain(resolved) + if (ancestorChain !== undefined) { + if (resolved !== scope.trackingPath) return { backup: entry.backup, ancestorChain } + // A later process has no publication capability for changed tracking. + // Keep the backup and ancestor checks even when no write is needed; + // recovery must not undo restore's concurrent-byte preservation. + if (await pathKind(resolved) === entry.kind && await pathDigest(resolved) === entry.digest) return undefined + } + } + scope.preservedPaths.push(resolved) + scope.recovered = false + return undefined +} + +export interface FallbackRecoveryResult { + pending: boolean + recovered: boolean + restoredPaths: string[] + preservedArtifacts: string[] + preservedPaths: string[] +} + +/** Read-only transaction preflight; its observations never authorize a later write. */ +function prepareFallbackRecovery (trackingPath: string): FallbackRecoveryResult | { journal: FallbackJournal; scope: RecoveryInspectionScope } { + const result: FallbackRecoveryResult = { pending: false, recovered: true, restoredPaths: [], preservedArtifacts: [], preservedPaths: [] } + const journalPath = fallbackJournalPath(trackingPath) + if (!existsSync(journalPath)) return result + result.pending = true + result.recovered = false + let journal: FallbackJournal + try { + journal = strictLoad(journalPath) + } catch { + result.preservedArtifacts.push(journalPath) + return result + } + // Neither inspection nor recovery may run beside another live mutator. + if (journal.mutator !== undefined && !processMatches(journal.mutator) && processIsLive(journal.mutator)) return result + result.preservedArtifacts.push(journalPath) + if (!snapshotShapeIsValid(journal)) return result + if (journal.snapshotDirectory !== undefined) result.preservedArtifacts.push(path.resolve(journal.snapshotDirectory)) + const trackingResolved = path.resolve(journal.manifest.trackingPath) + const trackingEntry = journal.entries.find((entry) => path.resolve(entry.path) === trackingResolved) + // A digest rewritten in the mutable journal cannot authenticate tracking. + if (trackingEntry?.existed === true && trackingEntry.backup !== undefined && trackingDigest(trackingEntry.backup) !== journal.manifest.trackingDigest) { + result.preservedPaths.push(trackingResolved) + return result + } + return { + journal, + scope: { + trackingPath: trackingResolved, + ownedPaths: currentTrackingOwnedPaths(journal.manifest.trackingPath), + frontierPaths: new Set(journal.manifest.plannedMissingFrontiers.map((frontier) => path.resolve(frontier.frontierPath))), + preservedPaths: result.preservedPaths, + preservedArtifacts: result.preservedArtifacts, + recovered: true, + }, + } +} + +function recoveryResult (scope: RecoveryInspectionScope, restoredPaths: readonly string[] = []): FallbackRecoveryResult { + return { pending: true, recovered: scope.recovered, restoredPaths: dedupePaths(restoredPaths), preservedArtifacts: dedupePaths(scope.preservedArtifacts), preservedPaths: dedupePaths(scope.preservedPaths) } +} + +/** + * Next-run recovery. There is no in-memory manifest and no capability set, so + * this path is strictly restore-only and NEVER commits, cleans a snapshot, or + * deletes artifacts. Only paths the CURRENT tracking file declares owned are + * restored, and only when the backup still matches the journaled entry digest + * (documented asymmetry: without the trusted manifest this is the strongest + * available evidence). The tracking backup is additionally preflighted + * against the planned manifest tracking digest (raw bytes) before any live + * path is changed, so a rewritten tracking backup with a recomputed entry + * digest fails closed with nothing restored. Everything else is preserved + * and reported pending. + */ +export async function recoverFallbackJournal (trackingPath: string): Promise { + const prepared = prepareFallbackRecovery(trackingPath) + if ('pending' in prepared) return prepared + const { journal, scope } = prepared + const { preservedPaths, preservedArtifacts } = scope + const restoredPaths: string[] = [] + for (const entry of journal.entries) { + const resolved = path.resolve(entry.path) + const candidate = await inspectRecoveryEntry(entry, scope) + if (candidate === undefined) continue + const { backup, ancestorChain } = candidate + try { + const kind = await pathKind(resolved) + if (kind === entry.kind && kind !== 'missing' && await pathDigest(resolved) === entry.digest) continue + if (!await liveAncestorChainMatches(ancestorChain)) throw new Error('recovery ancestor chain was replaced') + const storage = await mkdtemp(path.join(path.dirname(resolved), `.${path.basename(resolved).replace(/^\.+/, '')}.nsolid-recovery-`)) + let storagePreserved = false + try { + const moved = path.join(storage, path.basename(resolved)) + if (kind !== 'missing') { + if (!await liveAncestorChainMatches(ancestorChain)) throw new Error('recovery ancestor chain was replaced') + await rename(resolved, moved) + } + const temporary = path.join(storage, 'payload') + await cp(backup, temporary, { recursive: true, verbatimSymlinks: true, dereference: false }) + if (await pathDigest(temporary) !== entry.digest || await pathKind(temporary) !== entry.kind) throw new Error('recovery copy mismatch') + // The install into the live tree must still traverse only the exact + // proven ancestor identities: a mid-operation replacement aborts + // here with the live target already quarantined (reported pending). + if (!await liveAncestorChainMatches(ancestorChain)) throw new Error('recovery ancestor chain was replaced') + await rename(temporary, resolved) + // The live bytes now hold the authenticated tracked state: record the + // restoration even if the storage cleanup below is preserved. + restoredPaths.push(resolved) + // Cleanup may only run while the chain is still the exact proven + // one; otherwise the storage container is preserved and reported + // rather than removed through an unproven ancestor. + if (!await liveAncestorChainMatches(ancestorChain)) { + storagePreserved = true + preservedArtifacts.push(storage) + scope.recovered = false + preservedPaths.push(resolved) + continue + } + await rm(storage, { recursive: true, force: true }).catch(() => {}) + } catch (error) { + if (storagePreserved) throw error + // Preserve the quarantine storage untouched on any failure: it may + // hold the replaced live state and its chain can no longer be + // proven, so recursive removal is never attempted. + preservedArtifacts.push(storage) + throw error + } + } catch { + scope.recovered = false + preservedPaths.push(resolved) + } + } + return recoveryResult(scope, restoredPaths) +} + +/** + * Read-only pending-journal inspection for `--check` plans. It answers + * whether a pending journal blocks the plan without creating recovery + * storage, renaming, copying, removing, or rewriting any journal, tracking, + * snapshot, or backup bytes. Live digests are only read and compared so the + * guidance can name the paths left for manual inspection. The returned shape + * matches next-run recovery, except `restoredPaths` is always empty: an + * inspection never restores anything. + */ +export async function inspectFallbackJournal (trackingPath: string): Promise { + const prepared = prepareFallbackRecovery(trackingPath) + if ('pending' in prepared) return prepared + const { journal, scope } = prepared + const { preservedPaths } = scope + for (const entry of journal.entries) { + const resolved = path.resolve(entry.path) + if (await inspectRecoveryEntry(entry, scope) === undefined) continue + const kind = await pathKind(resolved) + if (kind === entry.kind && kind !== 'missing' && await pathDigest(resolved) === entry.digest) continue + preservedPaths.push(resolved) + scope.recovered = false + } + return recoveryResult(scope) +} + +type FallbackEntryUpdate = Partial> + +/** Build proposed state only. Callers retain capability checks, CAS writes and handle issuance. */ +function journalWithEntryUpdate (journal: FallbackJournal, target: FallbackJournalEntry | string, update: FallbackEntryUpdate): FallbackJournal { + return { + ...journal, + entries: journal.entries.map((candidate) => { + const matches = typeof target === 'string' ? path.resolve(candidate.path) === target : candidate === target + return matches ? { ...candidate, ...update } : candidate + }), + } +} + +/** Strict reload for a handle: any missing, malformed, replaced, stale-revision, or mismatched disk state throws; the stale in-memory object is never returned. */ +export async function reloadFallbackJournal (handle: FallbackJournalHandle): Promise { + if (!handleMatchesIssuedMetadata(handle)) throw new Error('Invalid fallback journal') + const journal = strictLoad(handle.journalPath) + if (journal.transactionId !== handle.transactionId) throw new Error('Invalid fallback journal') + if (journal.manifestDigest !== handle.manifestDigest || manifestDigestOf(journal.manifest) !== handle.manifestDigest) throw new Error('Invalid fallback journal') + if (!sameManifest(journal.manifest, handle.manifest)) throw new Error('Invalid fallback journal') + if (journal.revision !== handle.revision) throw new Error('Invalid fallback journal') + return journal +} + +/** Owner-only completion: prove applied state, then optional caller postconditions, before commit. + * Mutable journal fields validate success; the genuine handle remains the authority for restore/commit. + */ +export async function finalizeFallbackJournal ( + owner: FallbackJournalHandle, + validate?: () => Promise +): Promise<{ failure?: 'journal' | 'postconditions'; result: FallbackJournalOperationResult }> { + const journal = await reloadFallbackJournal(owner) + requireRole(journal, owner, 'owner') + const failure = !await journalProvesAppliedState(journal) + ? 'journal' + : validate !== undefined && !await validate() ? 'postconditions' : undefined + return { failure, result: failure === undefined ? await commitFallbackJournal(owner) : await restoreFallbackJournal(owner) } +} + +/** + * Prove success against the strictly reloaded journal and live filesystem. + * Used independently by the local transaction and external parent. These + * mutable fields are validation evidence; they never authorize restore or commit. + */ +export 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 +} + +function strictLoad (journalPath: string): FallbackJournal { + const parsed = JSON.parse(readFileSync(journalPath, 'utf8')) as FallbackJournal + if (!isSafeJournal(parsed) || parsed.journalPath !== path.resolve(journalPath)) throw new Error('Invalid fallback journal') + if (manifestDigestOf(parsed.manifest) !== parsed.manifestDigest) throw new Error('Invalid fallback journal') + return parsed +} + +function requireRole (journal: FallbackJournal, handle: FallbackJournalHandle, role: 'owner' | 'mutator'): void { + if (handle.role !== role) throw new Error('Invalid fallback journal') + if (role === 'mutator') { + if (journal.mutator === undefined || !processMatches(journal.mutator)) throw new Error('Invalid fallback journal') + } else if (!processMatches(journal.owner)) { + throw new Error('Invalid fallback journal') + } +} + +/** + * Compare-and-set durable write: the disk revision must equal expectedRevision, + * the written record itself carries the incremented revision (every later + * strict reload compares against it), and the new revision is returned. + */ +/** + * Compare-and-set durable write: the on-disk journal must be SEMANTICALLY + * IDENTICAL to the exact expected prior state — full deep equality, never + * revision equality alone — before it is replaced. Same-revision tampering of + * any field (transaction identity, manifest, entries, pending/quarantine + * markers) therefore fails closed instead of being overwritten. Property + * order is irrelevant and undefined-valued keys dropped by JSON + * serialization are canonicalized away, so only true semantic divergence + * rejects. The written record itself carries the incremented revision (every + * later strict reload compares against it) and the new revision is returned. + */ +async function casWrite (journalPath: string, next: FallbackJournal, expectedPrior: FallbackJournal): Promise { + const disk: unknown = JSON.parse(await readFile(journalPath, 'utf8')) + if (!isDeepStrictEqual(disk, JSON.parse(JSON.stringify(expectedPrior)))) throw new Error('Invalid fallback journal') + await writeDurable(journalPath, { ...next, revision: expectedPrior.revision + 1 }) + return expectedPrior.revision + 1 +} + +async function withCasWrite (handle: FallbackJournalHandle, next: FallbackJournal, expectedPrior: FallbackJournal): Promise { + const revision = await casWrite(handle.journalPath, next, expectedPrior) + return { ...next, revision } +} + +async function writeDurable (filePath: string, value: unknown): Promise { + const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp` + await writeFile(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }) + const handle = await open(temporary, 'r+') + try { await handle.sync() } finally { await handle.close() } + await rename(temporary, filePath) + try { + const directory = await open(path.dirname(filePath), 'r') + await directory.sync() + await directory.close() + } catch { /* directory fsync is unavailable on some platforms */ } +} + +async function 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 + } +} + +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 + } +} + +/** Manifest-evidence authentication for one backup. Tracking authenticates by raw byte digest. */ +async function backupAuthenticates (entry: FallbackJournalEntry, evidence: Map, manifest: FallbackTransactionIdentity): Promise { + if (entry.backup === undefined) return false + const resolved = path.resolve(entry.path) + if (resolved === path.resolve(manifest.trackingPath)) { + return trackingDigest(entry.backup) === manifest.trackingDigest + } + const entryEvidence = evidence.get(resolved) + if (entryEvidence === undefined || entryEvidence.kind === 'missing') return false + return await pathKind(entry.backup) === entryEvidence.kind && await pathDigest(entry.backup) === entryEvidence.digest +} + +function snapshotStructuralNameIsValid (journal: FallbackJournal): boolean { + if (journal.snapshotDirectory === undefined) return false + const snapshot = path.resolve(journal.snapshotDirectory) + const trackingPath = path.resolve(journal.manifest.trackingPath) + if (path.dirname(snapshot) !== path.dirname(trackingPath)) return false + return /^\.nsolid-plugin-update-[A-Za-z0-9._-]{6}$/.test(path.basename(snapshot)) +} + +/** + * Filesystem-aware gate run before any destructive use of the snapshot: 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. + */ +function snapshotShapeIsValid (journal: FallbackJournal): boolean { + if (journal.phase === 'initializing') return false + if (!snapshotStructuralNameIsValid(journal)) return false + const snapshot = path.resolve(journal.snapshotDirectory!) + try { + const stat = lstatSync(snapshot) + if (!stat.isDirectory()) return false + const realParent = realpathSyncSafe(path.dirname(path.resolve(journal.manifest.trackingPath))) + const realSnapshot = realpathSyncSafe(snapshot) + if (realSnapshot === undefined || realParent === undefined) return false + 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 (realpathSyncSafe(path.dirname(entry.backup)) !== realSnapshot) return false + } + return true + } catch { + return false + } +} + +function realpathSyncSafe (target: string): string | undefined { + try { + return realpathSync(target) + } catch { + return undefined + } +} + +async function renameLiveToRestoreQuarantine (resolved: string, capabilities: Map): Promise { + try { + const kind = await pathKind(resolved) + if (kind === 'missing') return true + const storage = await mkdtemp(path.join(path.dirname(resolved), `.${path.basename(resolved).replace(/^\.+/, '')}.nsolid-quarantine-`)) + const payload = path.join(storage, path.basename(resolved)) + const token = randomUUID() + await writeFile(path.join(storage, `.nsolid-quarantine-${token}`), token, { mode: 0o600 }) + const identity = await lstat(storage) + await rename(resolved, payload) + capabilities.set(resolved, { + container: storage, + payload, + token, + role: 'restore', + dev: identity.dev, + ino: identity.ino, + children: new Map([ + [path.basename(payload), kind], + [`.nsolid-quarantine-${token}`, 'file'], + ]), + }) + return true + } catch { + return false + } +} + +/** Kind check for one readdir dirent against the recorded fallback path kind. */ +function direntKindMatches (dirent: Dirent, expected: FallbackPathKind): boolean { + if (dirent.isSymbolicLink()) return expected === 'symlink' + if (dirent.isDirectory()) return expected === 'directory' + if (dirent.isFile()) return expected === 'file' + return expected === 'other' +} + +/** + * Exact direct-child structure check against the structure recorded when this + * process created the container: every child known, no child missing, and + * every kind as recorded. An extra sentinel child, a missing payload, or a + * kind mismatch fails closed — the whole container is then preserved and + * reported instead of recursively deleted. A capability without a recorded + * structure is never removable (fail closed). + */ +async function containerMatchesCapability (capability: ArtifactCapability, container?: string): Promise { + if (capability.children === undefined) return false + let dirents: Dirent[] + try { + dirents = await readdir(container ?? capability.container, { withFileTypes: true }) + } catch { + return false + } + if (dirents.length !== capability.children.size) return false + for (const dirent of dirents) { + const expected = capability.children.get(dirent.name) + if (expected === undefined) return false + if (!direntKindMatches(dirent, expected)) return false + } + return true +} + +/** + * Trusted removal of one container this process created. Every layer must + * prove out immediately before destruction, or the container is preserved: + * 1. the container is still a real, non-link directory with the recorded + * dev/ino identity; + * 2. its direct children exactly match the role-specific structure recorded + * at creation time (required marker/payload children only — no unexpected + * sentinel content, correct kinds, no links); + * 3. the marker file is a regular non-link file carrying the exact token; + * 4. the dev/ino identity is rechecked immediately before the tombstone + * rename, closing the validation→rename window as far as the platform + * allows (the awaited swap seam runs here for tests only); + * 5. after the rename, the tombstone is proven to BE the authenticated inode + * AND to still carry the exact recorded structure — a replacement swapped + * into the path in that window (or content smuggled into it) is renamed + * back untouched, and nothing old or new is deleted. + */ +async function removeAuthenticatedContainer (capability: ArtifactCapability): Promise { + try { + const stat = await lstat(capability.container) + if (!stat.isDirectory() || stat.isSymbolicLink()) return false + if (capability.dev !== undefined && capability.ino !== undefined && (stat.dev !== capability.dev || stat.ino !== capability.ino)) return false + if (!await containerMatchesCapability(capability)) return false + if (capability.role !== 'snapshot') { + const prefix = capability.role === 'stage' ? 'nsolid-stage' : 'nsolid-quarantine' + const markerPath = path.join(capability.container, `.${prefix}-${capability.token}`) + const marker = await lstat(markerPath).catch(() => undefined) + if (marker === undefined || !marker.isFile() || marker.isSymbolicLink()) return false + if (await readFile(markerPath, 'utf8') !== capability.token) return false + } + // Immediate pre-rename identity recheck: the removal decision and the + // rename must be as close together as the platform allows. + const identity = await lstat(capability.container) + if (!identity.isDirectory() || identity.isSymbolicLink()) return false + if (capability.dev !== undefined && capability.ino !== undefined && (identity.dev !== capability.dev || identity.ino !== capability.ino)) return false + if (fallbackArtifactSwapSeam !== undefined) await fallbackArtifactSwapSeam({ container: capability.container, role: capability.role }) + const tombstone = `${capability.container}.tombstone-${randomUUID().slice(0, 8)}` + try { + await rename(capability.container, tombstone) + } catch { + return false + } + // Post-rename proof: only the authenticated inode with the recorded + // structure may be destroyed. Anything else at the tombstone path is a + // replacement: it goes back to the container path, fully preserved. + const moved = await lstat(tombstone).catch(() => undefined) + const replaced = moved === undefined || !moved.isDirectory() || moved.isSymbolicLink() || + (capability.dev !== undefined && capability.ino !== undefined && (moved.dev !== capability.dev || moved.ino !== capability.ino)) || + !await containerMatchesCapability(capability, tombstone) + if (replaced) { + await rename(tombstone, capability.container).catch(() => {}) + return false + } + await rm(tombstone, { recursive: true, force: true }) + return true + } catch { + return false + } +} + +async function tryRemoveByCapability (capabilities: Map, container: string): Promise { + for (const capability of capabilities.values()) { + if (capability.container === path.resolve(container)) { + return await removeAuthenticatedContainer(capability) + } + } + return false +} + +async function cleanupRestoreArtifacts ( + journal: FallbackJournal, + handle: FallbackJournalHandle, + restoreCapabilities: Map, + preservedArtifacts: string[] +): Promise { + const plannedMissing = new Set(journal.entries.filter((entry) => !entry.existed).map((entry) => path.resolve(entry.path))) + for (const entry of journal.entries) { + for (const artifact of [entry.stage !== undefined ? path.dirname(entry.stage) : undefined, entry.quarantine !== undefined ? path.dirname(entry.quarantine) : undefined]) { + if (artifact === undefined) continue + const byRestore = await tryRemoveByCapability(restoreCapabilities, artifact) + const removed = byRestore || await tryRemoveByCapability(capabilitiesFor(handle), artifact) + if (!removed) preservedArtifacts.push(artifact) + } + } + for (const [target, capability] of restoreCapabilities.entries()) { + // Quarantined bytes of a PLANNED-MISSING destination are never destroyed + // by a rollback: the worst case a forged journal can achieve is moving a + // plan-derived destination into a quarantine, preserved and reported. + if (plannedMissing.has(target)) { + preservedArtifacts.push(capability.container) + continue + } + const removed = await removeAuthenticatedContainer(capability).catch(() => false) + if (!removed) preservedArtifacts.push(capability.container) + } + const snapshotCapability = capabilitiesFor(handle).get('\0snapshot') + if (snapshotCapability !== undefined && journal.snapshotDirectory !== undefined && snapshotCapability.container === path.resolve(journal.snapshotDirectory)) { + const removed = await removeAuthenticatedContainer(snapshotCapability).catch(() => false) + if (!removed) preservedArtifacts.push(snapshotCapability.container) + } +} + +async function readReportingResidues (journalPath: string): Promise<{ preservedArtifacts: string[]; preservedPaths: string[] }> { + try { + const disk = JSON.parse(await readFile(journalPath, 'utf8')) as FallbackJournal + return { + preservedArtifacts: [...(disk.preservedArtifacts ?? [])], + preservedPaths: [...(disk.preservedPaths ?? [])], + } + } catch { + return { preservedArtifacts: [], preservedPaths: [] } + } +} + +function dedupePaths (values: readonly string[]): string[] { + return [...new Set(values.map((value) => path.resolve(value)))].sort((left, right) => Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'))) +} + +function isEvidenceShape (value: unknown): value is FallbackPathEvidence { + if (!value || typeof value !== 'object') return false + const entry = value as { path?: unknown; kind?: unknown; digest?: unknown } + if (typeof entry.path !== 'string' || !['missing', 'file', 'directory', 'symlink', 'other'].includes(String(entry.kind))) return false + return entry.kind === 'missing' ? entry.digest === undefined : typeof entry.digest === 'string' +} + +function isSafeJournal (journal: FallbackJournal): boolean { + if (!journal || journal.version !== 3 || !['initializing', 'prepared', 'mutating'].includes(journal.phase)) return false + if (!Number.isSafeInteger(journal.revision) || journal.revision < 0) return false + if (typeof journal.transactionId !== 'string' || journal.transactionId.length === 0) return false + if (typeof journal.manifestDigest !== 'string' || !/^[0-9a-f]{64}$/.test(journal.manifestDigest)) return false + if (!processIdentityShapeIsValid(journal.owner)) return false + if (journal.mutator !== undefined && (!processIdentityShapeIsValid(journal.mutator) || typeof journal.mutator.claimedAt !== 'string' || !Number.isFinite(Date.parse(journal.mutator.claimedAt)))) return false + const manifest = journal.manifest + if (!manifest || !Array.isArray(manifest.ownedSkills) || !Array.isArray(manifest.ownedLinks) || !Array.isArray(manifest.ownedMcpFields) || !Array.isArray(manifest.ownedMcpConfigPaths)) return false + if (!Array.isArray(manifest.approvedDestinationRoots)) return false + if (manifest.ownedMcpConfigPaths.some((value) => typeof value !== 'string' && !isEvidenceShape(value))) return false + if (manifest.bundleDestinations !== undefined && (!Array.isArray(manifest.bundleDestinations) || manifest.bundleDestinations.some((value) => !isEvidenceShape(value)))) return false + // Only the exact deployed protocol is a valid journal manifest: anything + // else (missing, older, newer) fails closed everywhere a journal is loaded. + if (manifest.protocolVersion !== FALLBACK_PROTOCOL_VERSION) return false + // protocol v3 also requires exactly-parsed frontier evidence: an on-disk + // journal whose manifest lacks or corrupts plannedMissingFrontiers is not a + // valid v3 journal and can never be loaded as transaction state. + try { + assertFallbackFrontierEvidenceList(manifest.plannedMissingFrontiers) + } catch { + return false + } + if (manifest.digestAlgorithm !== undefined && manifest.digestAlgorithm !== 'fallback-path-v2') return false + if (manifest.ownedMcpFields.some((field) => !field || typeof field.configPath !== 'string' || typeof field.server !== 'string' || typeof field.field !== 'string' || typeof field.expectedDigest !== 'string')) return false + if (manifest.ownedSkills.some((entry) => !isEvidenceShape(entry)) || manifest.ownedLinks.some((entry) => !isEvidenceShape(entry))) return false + if (typeof manifest.trackingPath !== 'string' || typeof manifest.trackingDigest !== 'string' || typeof manifest.harness !== 'string' || typeof manifest.installationId !== 'string') return false + if (manifest.nonce !== undefined && typeof manifest.nonce !== 'string') return false + if (typeof journal.journalPath !== 'string') return false + const trackingPath = path.resolve(manifest.trackingPath) + if (journal.journalPath !== fallbackJournalPath(trackingPath)) return false + if (journal.phase === 'initializing') { + if (journal.snapshotDirectory !== undefined || journal.entries.length > 0) return false + } else { + // The snapshot must be exactly what beginFallbackJournal creates: a strict + // direct child of the tracking directory carrying the mkdtemp suffix shape. + if (!snapshotStructuralNameIsValid(journal)) return false + } + if (!manifest.installationId || manifest.installationId !== `${manifest.harness}:fallback`) return false + // The journal's exact physical entry set is recomputed from the manifest + // through the same shared derivation begin uses: retained ordinary targets + // plus one planned-missing entry per topmost frontier. Entry insertion, + // removal, path, kind, and digest tampering therefore cannot load. + let physicalTargets: ReturnType + try { + physicalTargets = deriveFallbackJournalPhysicalTargets(manifest) + } catch { + return false + } + const expectedPaths = new Set(physicalTargets.map((entry) => entry.path)) + const frontierPaths = new Set(physicalTargets.filter((entry) => entry.frontier).map((entry) => entry.path)) + const expectedEvidence = plannedEvidenceMap(manifest) + if ([...expectedPaths].some((value) => !isCanonicalPath(value))) return false + if ((manifest.approvedDestinationRoots as readonly string[]).map((value) => path.resolve(value)).some((value) => !isCanonicalPath(value))) return false + const seen = 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 + if (entry.existed !== (entry.kind !== 'missing')) return false + const target = path.resolve(entry.path) + if (!isCanonicalPath(target) || !expectedPaths.has(target) || seen.has(target)) return false + seen.add(target) + if (entry.existed && typeof entry.digest !== 'string') return false + if (!entry.existed && entry.digest !== undefined) return false + if (frontierPaths.has(target)) { + // A frontier entry is the planned-missing publication unit: it never + // existed, carries no original-state digest, and never has a backup. + // Documented mutable publication fields stay loadable so the + // publication stage can extend this entry; they never grant restore + // authority (the trusted manifest decides what a frontier is). + if (entry.existed || entry.kind !== 'missing' || entry.digest !== undefined || entry.backup !== undefined) return false + } else { + const evidence = expectedEvidence.get(target) + if (evidence !== undefined) { + // Immutable original-state evidence must exactly match the manifest: + // kind/digest tampering must never change what restore authenticates. + if (entry.kind !== evidence.kind || entry.digest !== evidence.digest) return false + } else if (target === trackingPath) { + // The tracking file existed at journal time (begin hashed it). + if (!entry.existed || entry.kind === 'missing') return false + } + if (!entry.existed && entry.backup !== undefined) return false + } + if (entry.backup !== undefined) { + const backup = path.resolve(entry.backup) + // A backup must live strictly inside the snapshot; equality would let a + // forged entry alias the snapshot container itself. + if (journal.snapshotDirectory === undefined) return false + const snapshot = path.resolve(journal.snapshotDirectory) + if (backup === snapshot || !isSameOrContained(backup, snapshot) || !isCanonicalPath(backup)) return false + } + // Stage payloads and quarantine payloads live inside an exact sibling + // container of the target: the record points at /payload (or + // /), so the CONTAINER's parent must equal the + // target's parent. + if (entry.stage !== undefined) { + const stageContainer = path.resolve(path.dirname(entry.stage)) + if (path.dirname(stageContainer) !== path.dirname(target) || !isCanonicalPath(stageContainer)) return false + if (!/^\.[^/\\]*\.nsolid-stage-[A-Za-z0-9._-]{6}$/.test(path.basename(stageContainer))) return false + if (path.basename(entry.stage) !== 'payload') return false + } + if (entry.stageDigest !== undefined && typeof entry.stageDigest !== 'string') return false + if (entry.quarantine !== undefined) { + const quarantineContainer = path.resolve(path.dirname(entry.quarantine)) + if (path.dirname(quarantineContainer) !== path.dirname(target) || !isCanonicalPath(quarantineContainer)) return false + if (!/^\.[^/\\]*\.nsolid-quarantine-[A-Za-z0-9._-]{6}$/.test(path.basename(quarantineContainer))) return false + if (path.basename(entry.quarantine) !== path.basename(target)) return false + } + // The durable rollback-pending flag is a strictly validated reporting/ + // blocking field: it must be a real boolean and may only appear on a + // planned-missing entry (never on a backup-bearing preexisting one). + if (entry.frontierRollbackPending !== undefined) { + if (typeof entry.frontierRollbackPending !== 'boolean') return false + if (entry.frontierRollbackPending && entry.existed) return false + } + } + if (journal.phase !== 'initializing' && [...expectedPaths].some((target) => !seen.has(target))) return false + for (const field of ['preservedArtifacts', 'preservedPaths'] as const) { + const values = journal[field] + if (values === undefined) continue + if (!Array.isArray(values) || values.length > 64 || values.some((value) => typeof value !== 'string' || !path.isAbsolute(value) || value.includes('..') || value.length > 512)) return false + } + return true +} + +function processIdentityShapeIsValid (identity: ProcessIdentity | undefined): boolean { + return identity !== undefined && Number.isSafeInteger(identity.pid) && identity.pid > 0 && + (identity.startIdentity === undefined || typeof identity.startIdentity === 'string') +} + +function sameManifest (left: FallbackTransactionIdentity, right: FallbackTransactionIdentity): boolean { + // Canonical comparison: object key order can never make two equal manifests + // look different (JSON.stringify order dependence was a v2 hazard). + try { + return canonicalJsonString(left) === canonicalJsonString(right) + } 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..40b317f --- /dev/null +++ b/packages/core/src/update/fallback-ownership.ts @@ -0,0 +1,91 @@ +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' +import { assertSafeSkillName } from '../utils/skill-name.js' + +export function matchesTrackedOwnership (tracking: TrackingData, identity: FallbackTransactionIdentity): boolean { + const linkRoot = path.resolve(getHarnessSkillsPath(identity.harness)) + if (identity.ownedLinks.some((entry) => !isSafeDirectChild(entry.path, [linkRoot]))) return false + if (identity.ownedSkills.some((entry) => !isSafeDirectChild(entry.path, identity.approvedDestinationRoots))) 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.ownedSkills.map((entry) => path.resolve(entry.path))) + if (ownedSkillPaths.size !== trackedPaths.size || ![...ownedSkillPaths].every((value) => trackedPaths.has(value))) return false + const expectedLinkPaths = new Set(identity.harness === 'opencode' ? [] : scopedSkills.map((entry) => path.resolve(linkRoot, entry.name))) + const ownedLinkPaths = new Set(identity.ownedLinks.map((entry) => path.resolve(entry.path))) + 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. Entries carry whole-file evidence in the current + // protocol; legacy journals record plain strings. + 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(typeof value === 'string' ? value : value.path))) + 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 isSafeDirectChild (candidate: string, roots: readonly string[]): boolean { + const resolved = path.resolve(candidate) + if (!isCanonicalPath(resolved) || !roots.some((root) => path.dirname(resolved) === path.resolve(root))) return false + try { + assertSafeSkillName(path.basename(resolved)) + return true + } catch { + return false + } +} + +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-planning.ts b/packages/core/src/update/fallback-planning.ts new file mode 100644 index 0000000..0acf971 --- /dev/null +++ b/packages/core/src/update/fallback-planning.ts @@ -0,0 +1,29 @@ +import path from 'node:path' +import type { HarnessType } from '../types.js' +import { getSkillsDir, resolveHome } from '../utils/path.js' +import { getHarnessSkillsPath } from '../skills/skill-linker.js' +import { pathDigest, pathKind } from './fallback-journal.js' +import type { FallbackPathEvidence } from './types.js' + +/** Shared read-only planning inputs; callers retain their ownership checks. */ +export function resolveFallbackDestinations (harness: HarnessType): { destination: string; linkDir?: string } { + const destination = harness === 'opencode' + ? path.resolve(process.env.NSOLID_OPENCODE_SKILLS_DIR ?? resolveHome('~/.config/opencode/skills')) + : getSkillsDir() + const linkDir = harness !== 'opencode' ? path.resolve(getHarnessSkillsPath(harness)) : undefined + return { destination, linkDir } +} + +export function fallbackBundlePaths (destination: string, linkDir: string | undefined, names: readonly string[]): string[] { + return [...new Set([destination, ...(linkDir === undefined ? [] : [linkDir])].flatMap((root) => names.map((name) => path.resolve(path.join(root, name)))))] +} + +export async function captureFallbackPathEvidence (paths: readonly string[]): Promise { + return await Promise.all(paths.map(async (value) => { + const resolved = path.resolve(value) + const kind = await pathKind(resolved) + const digest = kind === 'missing' ? undefined : await pathDigest(resolved) + if (kind !== 'missing' && digest === undefined) throw new Error(`Cannot capture fallback path evidence for ${resolved}`) + return { path: resolved, kind, digest } + })) +} diff --git a/packages/core/src/update/fallback-result-protocol.ts b/packages/core/src/update/fallback-result-protocol.ts new file mode 100644 index 0000000..679141e --- /dev/null +++ b/packages/core/src/update/fallback-result-protocol.ts @@ -0,0 +1,285 @@ +import { constants, lstatSync } from 'node:fs' +import { chmod, lstat, open, rename, rm, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import path from 'node:path' + +/** + * O_NOFOLLOW is unavailable on Windows (the flag degrades to a plain open + * there), so readValidatedFallbackChildResult pairs the flag with an lstat + * symlink rejection plus an fd-vs-path identity comparison that work on + * every platform. + */ +const NO_FOLLOW_FLAG = (constants as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0 + +/** + * Identity of a parent-owned containment directory, recorded at creation time + * (before any other process can observe or replace the path). POSIX carries + * device+inode; on Windows Node's fs exposes the volume serial number as + * `dev` and the NTFS file index as `ino`, so the identity is recorded on + * every platform and a swapped directory is detectable everywhere. + */ +export interface ContainmentDirectoryIdentity { + /** Directory path recorded by the parent (absolute). */ + directory: string + /** Device of the directory at creation time; undefined when not measurable. */ + dev?: number + /** Inode of the directory at creation time; undefined when not measurable. */ + ino?: number +} + +/** + * Record the identity of a directory this process just created. The lstat is + * taken immediately after creation/chmod, before the path can be replaced by + * an attacker, so a later read can verify the directory was not swapped for + * a symlink or another directory. + */ +export async function recordContainmentDirectoryIdentity (directory: string): Promise { + const resolved = path.resolve(directory) + try { + const stat = await lstat(resolved) + return { directory: resolved, dev: stat.dev, ino: stat.ino } + } catch { + return { directory: resolved } + } +} + +/** + * True when the identity of a recorded containment directory still matches + * what is currently at that path. A replaced (symlinked or recreated) + * directory fails the comparison even when the replacement points elsewhere. + */ +export function containmentDirectoryMatches (recorded: ContainmentDirectoryIdentity, currentDirectory: string): boolean { + if (!path.isAbsolute(currentDirectory)) return false + const resolved = path.resolve(currentDirectory) + if (path.resolve(recorded.directory) !== resolved) return false + if (recorded.dev === undefined || recorded.ino === undefined) { + // An unmeasurable identity (lstat failed at recording time, or a legacy + // persisted identity) can never be re-verified, so containment fails + // closed instead of degrading to a lexical-only check. + return false + } + try { + const stat = lstatSync(resolved) + return stat.isDirectory() && !stat.isSymbolicLink() && stat.dev === recorded.dev && stat.ino === recorded.ino + } catch { + return false + } +} + +/** + * Private, file-based result protocol between the `nsolid-plugin-refresh-owned` + * child and its parent. The child publishes a schema-versioned, bounded, + * nonce-bound envelope inside the parent-created private workspace; the parent + * validates every trust boundary before promoting any of it to public state. + * The envelope never transports arbitrary text: messages shown to users are + * parent-owned templates keyed by an allowlisted code. + */ + +export const FALLBACK_CHILD_RESULT_SCHEMA = 2 +export const FALLBACK_CHILD_RESULT_MAX_BYTES = 4096 +export const FALLBACK_CHILD_RESULT_FILENAME = 'result.json' +/** Bounded preservation reporting: arrays are child-supplied reporting data, never operation authority. */ +export const FALLBACK_CHILD_RESULT_MAX_PRESERVED = 32 + +export interface FallbackChildResultRollback { + attempted: boolean + succeeded?: boolean +} + +export interface FallbackChildResultEnvelope { + schema: number + nonce: string + code: string + rollback?: FallbackChildResultRollback + /** Artifact containers preserved because cleanup could not be authenticated. Reporting only. */ + preservedArtifacts?: readonly string[] + /** Live paths left untouched because their state could not be authorized. Reporting only. */ + preservedPaths?: readonly string[] +} + +/** + * Parent-owned safe messages for the child codes the parent accepts. A known + * code can therefore never leak child-controlled data: only these constant + * templates are ever rendered, parameterized solely by the validated plan + * target. Codes outside this map keep the existing generic fallback error. + */ +const CHILD_RESULT_MESSAGES: Record string> = { + MCP_RECONCILIATION_REQUIRED: (target) => `The fallback update requires NodeSource MCP reconciliation but valid credentials are unavailable. Run "nsolid-plugin setup --harness ${target}" to authenticate, then retry the update.`, + FALLBACK_MCP_DRIFT: () => 'The harness MCP configuration did not match the state approved for this fallback update. The refresh was stopped before it could continue unsafely.', + FALLBACK_OWNERSHIP_DRIFT: () => 'The tracked fallback ownership no longer matches this installation. The refresh was stopped.', + UNTRACKED_INSTALLATION: () => 'No NodeSource tracking record covers this installation. Nothing was refreshed.', + FALLBACK_PROTOCOL_UNSUPPORTED: () => 'This fallback update was planned by an incompatible nsolid-plugin version. Update nsolid-plugin manually (for example with your package manager) and retry the update.', + FALLBACK_STATE_UNPROVEN: () => 'The fallback transaction could not prove the state of its owned files, so nothing was changed automatically. A previous transaction may need manual recovery; see the preserved artifacts reported by this update.', + FALLBACK_RECOVERY_PENDING: () => 'A previous fallback transaction is still pending. Restore or remove it manually before updating; the preserved artifacts reported earlier show what to inspect.', +} + +/** The guidance target comes from the validated plan item, never from child data; shape-check it anyway before interpolation. */ +const VALID_GUIDANCE_TARGET = /^[a-z][a-z0-9-]{0,31}$/ + +/** Explicit child args pointing at the parent-planned result path. Older children ignore it safely. */ +export function childResultArgs (resultPath: string): string[] { + return ['--result', resultPath] +} + +/** Structured codes must be safe identifier shapes; anything else is never published or accepted. */ +export function isValidChildResultCode (code: unknown): code is string { + return typeof code === 'string' && code.length > 0 && code.length <= 64 && /^[A-Z][A-Z0-9_]*$/.test(code) +} + +/** Parent-owned safe message for an accepted child code and the validated plan target, or undefined when the code is not accepted. */ +export function fallbackChildResultMessage (code: string, target: string): string | undefined { + const template = CHILD_RESULT_MESSAGES[code] + if (!template) return undefined + return template(VALID_GUIDANCE_TARGET.test(target) ? target : 'harness') +} + +/** Shape-check one bounded preservation array: absolute canonical-ish path strings only. */ +function isValidPreservationArray (value: unknown): value is readonly string[] { + return Array.isArray(value) && value.length <= FALLBACK_CHILD_RESULT_MAX_PRESERVED && + value.every((entry) => typeof entry === 'string' && entry.length > 0 && entry.length <= 512 && path.isAbsolute(entry) && !entry.includes('..')) +} + +/** + * Child side: publish the structured result atomically with mode 0600. + * Unpublishable inputs (unsafe code, missing nonce) are silently skipped so + * the parent falls back to its existing generic error handling. + */ +export async function writeFallbackChildResult ( + resultPath: string, + nonce: string, + code: string, + rollback: FallbackChildResultRollback | undefined, + preservation: { preservedArtifacts?: readonly string[]; preservedPaths?: readonly string[] } = {} +): Promise { + if (!isValidChildResultCode(code)) return + if (typeof nonce !== 'string' || nonce.length === 0) return + if (typeof resultPath !== 'string' || !path.isAbsolute(resultPath)) return + const envelope: FallbackChildResultEnvelope = { schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code } + if (rollback && typeof rollback.attempted === 'boolean') { + envelope.rollback = rollback.succeeded === undefined + ? { attempted: rollback.attempted } + : { attempted: rollback.attempted, succeeded: rollback.succeeded === true } + } + if (preservation.preservedArtifacts !== undefined && isValidPreservationArray(preservation.preservedArtifacts)) { + envelope.preservedArtifacts = [...preservation.preservedArtifacts] + } + if (preservation.preservedPaths !== undefined && isValidPreservationArray(preservation.preservedPaths)) { + envelope.preservedPaths = [...preservation.preservedPaths] + } + const payload = JSON.stringify(envelope) + // The envelope must stay bounded: an over-limit result is never published so + // the parent falls back to its existing generic error handling. + if (Buffer.byteLength(payload) > FALLBACK_CHILD_RESULT_MAX_BYTES) return + const temporary = `${resultPath}.${randomUUID()}.tmp` + try { + await writeFile(temporary, payload, { mode: 0o600 }) + // chmod makes the private mode explicit instead of relying on mode/umask + // interaction alone; the post-rename verification below refuses to leave + // behind a result anyone else could read. + await chmod(temporary, 0o600) + await rename(temporary, resultPath) + if (process.platform !== 'win32') { + const published = await lstat(resultPath) + if ((published.mode & 0o777) !== 0o600) { + await rm(resultPath, { force: true }).catch(() => {}) + } + } + } catch { + await rm(temporary, { force: true }).catch(() => {}) + } +} + +/** + * Parent side: read and fully validate the child result before trusting it. + * Every failed trust boundary (containment, symlink, ownership, size, schema, + * nonce, code shape, rollback shape, malformed JSON) yields undefined so the + * caller keeps its existing generic error behavior. + */ +export async function readValidatedFallbackChildResult ( + resultPath: string, + expectedNonce: string, + options: { + /** Directories this process created; the result must live inside one of them. */ + containmentDirectories?: readonly ContainmentDirectoryIdentity[] + } = {} +): Promise { + try { + if (typeof resultPath !== 'string' || !path.isAbsolute(resultPath)) return undefined + if (typeof expectedNonce !== 'string' || expectedNonce.length === 0) return undefined + // The only legitimate result location is a parent-created private + // workspace recorded on the plan item; anything else is rejected. + const directories = options.containmentDirectories ?? [] + if (directories.length === 0) return undefined + // Lexical containment first (works on every platform): the result path + // must sit directly inside one of the recorded directories. + const lexicalParent = path.dirname(resultPath) + if (!directories.some((identity) => path.resolve(identity.directory) === path.resolve(lexicalParent))) return undefined + // Identity check: the recorded directory must still BE the directory that + // was created at planning time. A parent directory that was deleted and + // replaced by a symlink (or by another directory) fails even if the + // result path still lexically matches, closing the ancestor-swap escape. + const recorded = directories.find((identity) => path.resolve(identity.directory) === path.resolve(lexicalParent)) + if (!recorded || !containmentDirectoryMatches(recorded, lexicalParent)) return undefined + // Windows has no O_NOFOLLOW, so a symlink planted at the result path is + // rejected before the open, and the opened descriptor is then verified to + // still describe the same file the pre-open lstat saw (fd-vs-path + // identity), which also closes the swap-between-lstat-and-open window on + // every platform. On POSIX O_NOFOLLOW additionally makes the open itself + // fail on a symlink. The ownership/mode checks below are POSIX-only; + // Windows isolation rests on inherited temporary-directory ACLs plus the + // containment and nonce validation. + const preOpen = lstatSync(resultPath) + if (preOpen.isSymbolicLink()) return undefined + const handle = await open(resultPath, constants.O_RDONLY | NO_FOLLOW_FLAG) + let text: string + try { + // Check-open-check: after the descriptor is open, re-verify the + // containment directory identity so a swap between the first check and + // the open is still detected. The residual window is the open syscall + // itself; Node exposes no openat2-style ancestor no-follow primitive. + if (!containmentDirectoryMatches(recorded, lexicalParent)) return undefined + const stat = await handle.stat() + if (!stat.isFile()) return undefined + if (stat.ino !== preOpen.ino || stat.dev !== preOpen.dev) return undefined + // Ownership assumption: the envelope must have been written by this user. + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return undefined + // The result is private state: reject anything group- or world-readable + // on POSIX. Windows does not expose these owner/mode bits here; its + // isolation guarantee rests on inherited temporary-directory ACLs plus + // containment and nonce validation. + if (typeof process.getuid === 'function' && (stat.mode & 0o077) !== 0) return undefined + if (stat.size > FALLBACK_CHILD_RESULT_MAX_BYTES) return undefined + // Bounded read from the same descriptor: never re-resolve the path. + const buffer = Buffer.alloc(FALLBACK_CHILD_RESULT_MAX_BYTES) + const read = await handle.read(buffer, 0, FALLBACK_CHILD_RESULT_MAX_BYTES, 0) + text = buffer.toString('utf8', 0, read.bytesRead) + } finally { + await handle.close().catch(() => {}) + } + const parsed = JSON.parse(text) as Partial | null + if (!parsed || typeof parsed !== 'object') return undefined + if (parsed.schema !== FALLBACK_CHILD_RESULT_SCHEMA) return undefined + if (typeof parsed.nonce !== 'string' || parsed.nonce.length === 0 || parsed.nonce !== expectedNonce) return undefined + // Identifier shape is not enough: only codes the parent owns a safe + // message template for may be accepted. An unknown code with a valid + // shape must not surface an envelope (or its rollback claim) publicly. + if (!isValidChildResultCode(parsed.code)) return undefined + if (CHILD_RESULT_MESSAGES[parsed.code] === undefined) return undefined + if (parsed.rollback !== undefined) { + const rollback = parsed.rollback as Partial | null + if (!rollback || typeof rollback !== 'object' || typeof rollback.attempted !== 'boolean') return undefined + if (rollback.succeeded !== undefined && typeof rollback.succeeded !== 'boolean') return undefined + } + // Preservation arrays are reporting data with a strict shape: an invalid + // array rejects the whole envelope rather than degrading silently. + if (parsed.preservedArtifacts !== undefined && !isValidPreservationArray(parsed.preservedArtifacts)) return undefined + if (parsed.preservedPaths !== undefined && !isValidPreservationArray(parsed.preservedPaths)) return undefined + const result: FallbackChildResultEnvelope = { schema: parsed.schema, nonce: parsed.nonce, code: parsed.code } + if (parsed.rollback !== undefined) result.rollback = parsed.rollback as FallbackChildResultRollback + if (parsed.preservedArtifacts !== undefined) result.preservedArtifacts = [...parsed.preservedArtifacts] + if (parsed.preservedPaths !== undefined) result.preservedPaths = [...parsed.preservedPaths] + return result + } catch { + return undefined + } +} diff --git a/packages/core/src/update/fallback-transaction.ts b/packages/core/src/update/fallback-transaction.ts new file mode 100644 index 0000000..447a54d --- /dev/null +++ b/packages/core/src/update/fallback-transaction.ts @@ -0,0 +1,884 @@ +import { existsSync, lstatSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +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 { getAuthFilePath } from '../utils/path.js' +import { resolveFallbackDestinations } from './fallback-planning.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, type SkillTrackingEntry, type TrackingData } from '../skills/skill-tracker.js' +import { installSkillsToDirectory } from '../skills/skill-copier.js' +import { getHarnessSkillsPath, materializeSkillLink } from '../skills/skill-linker.js' +import { assertSafeSkillName } from '../utils/skill-name.js' +import { getAdapter } from '../harnesses/index.js' +import type { FallbackFrontierEvidence, FallbackLeafTarget, FallbackPathEvidence, FallbackTransactionIdentity, UpdateError } from './types.js' +import { FALLBACK_PROTOCOL_VERSION } from './types.js' +import { applyFallbackEntry, claimFallbackJournalMutation, manifestDigestOf, registerFallbackFrontierStage, registerFallbackStage, reloadFallbackJournal, restoreFallbackJournal, trackingDigest, pathDigest, pathKind, type FallbackJournalHandle } 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, isSafeDirectChild, matchesTrackedOwnership, mcpRecordIsExclusivelyOwned } from './fallback-ownership.js' +import { assertFallbackFrontierEvidenceList } from './fallback-frontier.js' + +export interface FallbackRefreshOptions { + harness: HarnessType + bundlePath: string + skillsSource: string + transaction: FallbackTransactionIdentity + /** Explicit canonical manifest digest transported to an external child; same-process callers may omit it and derive it from the trusted transaction argument. */ + manifestDigest?: string +} + +export interface FallbackRefreshResult { + success: boolean + rollbackAttempted?: boolean + rollbackSucceeded?: boolean + /** Artifact containers preserved because cleanup could not be authenticated. Reporting only. */ + preservedArtifacts?: readonly string[] + /** Live paths left untouched because their state could not be authorized. Reporting only. */ + preservedPaths?: readonly string[] + error?: UpdateError +} + +export async function refreshOwnedInstallation (options: FallbackRefreshOptions): Promise { + if (!options.transaction) return failure('INVALID_TRANSACTION_MANIFEST', 'Owned refresh requires a parent transaction manifest') + const validation = await 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 (!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, linkDir } = resolveFallbackDestinations(options.harness) + 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)}`) + } + } + + // 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: string | undefined = adapterCanonical + ? options.transaction.ownedMcpConfigPaths.map((value) => (typeof value === 'string' ? value : value.path)).find((value) => path.resolve(value) === path.resolve(adapterCanonical)) + : adapterCanonical ?? undefined + 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))) { + return failure('UNTRACKED_DESTINATION', `Owned refresh would overwrite an untracked harness link destination: ${skill.name}`) + } + } + } + + const stageRoots: { skills?: string; links?: string; mcp: string[] } = { mcp: [] } + let journal: FallbackJournalHandle | undefined + let preserveRecoveryArtifacts = false + 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 }) + } + + // Preflights complete: claim only the journal reserved by the parent. + const expectedDigest = options.manifestDigest ?? manifestDigestOf(options.transaction) + const claimed = await claimFallbackJournalMutation(options.transaction, expectedDigest) + if (claimed === undefined || claimed === null) { + return failure('FALLBACK_JOURNAL_CLAIM_FAILED', 'Fallback mutation journal could not be claimed safely') + } + journal = claimed + + // Stage containers live as direct siblings of their targets (the + // authenticated-container invariant), so every planned target's parent + // must exist before staging. Creating a plan-approved root or config + // directory here is journaled-transaction work: each target itself + // stays untouched until its apply, and every path below is either an + // approved destination root or a manifest-planned config location. + // A missing shared destination root that the planner derived as a + // planned-missing frontier is NOT created here: its publication is the + // journaled frontier transaction below. The matching frontier is the + // unique one equal to the destination root OR a proper segment ancestor + // of it (e.g. ~/.config covering ~/.config/opencode/skills) carrying at + // least one required skill leaf where every other leaf is either a + // skill leaf or a CONDITIONAL mcp-config leaf (active or not: active + // config bytes join the same staged subtree before the single + // registration). Link, tracking, and other roles are never claimable + // here. Ambiguous roots (zero or several matching candidates) are NOT + // claimed: their covered leaves then fail the ordinary per-entry + // journal gate instead of ever being double-registered. + // ---- PURE FRONTIER CLASSIFICATION (before any live mutation) ---- + // Every planned-missing frontier carrying at least one ACTIVE leaf must + // be claimed by exactly one supported publication route before the + // first live directory is created. Active leaves: required leaves are + // always active; conditional mcp-config leaves are active exactly when + // their exact path has rendered transaction bytes; conditional skill + // leaves are active only when the unique destination-root frontier + // covers a staged bundle skill at that exact path (the demonstrable + // deferred selection); every other conditional leaf is inactive. + const frontiers = classifyFallbackFrontiers( + journal.manifest.plannedMissingFrontiers, destination, linkDir, + new Set(bundle.skills.map((skill) => path.resolve(path.join(destination, skill.name)))), + new Set(plannedMcpBytes.keys()) + ) + if (frontiers.skills === undefined) await mkdir(destination, { recursive: true }) + if (linkDir !== undefined && frontiers.links === undefined) await mkdir(linkDir, { recursive: true }) + // Keep duplicate-MCP validation after the same destination/link mkdir + // points. Each config maps to its single grouped publication route. + const mcpFrontierByPath = new Map() + for (const frontierPlan of frontiers.mcp) { + for (const leaf of frontierPlan.activeLeaves) { + const leafPath = path.resolve(leaf.path) + if (mcpFrontierByPath.has(leafPath)) { + throw new FallbackTransactionError('INVALID_TRANSACTION_MANIFEST', `Multiple fallback frontiers cover the MCP configuration ${leafPath}`) + } + mcpFrontierByPath.set(leafPath, frontierPlan) + } + } + if (frontiers.skills !== undefined) { + for (const leaf of frontiers.skills.activeLeaves) { + const leafPath = path.resolve(leaf.path) + if (leaf.role === 'mcp-config' && leaf.activation === 'conditional' && !mcpFrontierByPath.has(leafPath)) { + mcpFrontierByPath.set(leafPath, frontiers.skills) + } + } + } + for (const plannedConfigPath of plannedMcpBytes.keys()) { + if (mcpFrontierByPath.has(path.resolve(plannedConfigPath))) continue + await mkdir(path.dirname(plannedConfigPath), { recursive: true }) + } + + // ---- STAGE: skills, links, MCP bytes, and tracking bytes are prepared + // completely before any live path is touched. Every staged path is a + // parent-planned journal entry: the child never adds unplanned paths. + { + // The stage container must be a direct sibling of the frontier root + // (the authenticated-container invariant). For an ancestor frontier + // the staged skills live under the exact sub-path the frontier covers + // (path.relative(frontierRoot, destination)), so the registered + // payload mirrors the frontier tree; link copySource keeps pointing + // at the staged skills directory either way. The mirror directories + // are created inside the scratch container only, never live. + const skillsStageTarget = frontiers.skills?.root ?? destination + const skillsStageContainer = await mkdtemp(path.join(path.dirname(skillsStageTarget), `.${path.basename(skillsStageTarget)}.nsolid-stage-`)) + stageRoots.skills = skillsStageContainer + const relativeSkillsStage = frontiers.skills !== undefined ? path.relative(frontiers.skills.root, path.resolve(destination)) : '' + const skillsStage = relativeSkillsStage === '' ? skillsStageContainer : path.join(skillsStageContainer, relativeSkillsStage) + if (skillsStage !== skillsStageContainer) await mkdir(skillsStage, { recursive: true }) + if (frontiers.skills !== undefined) { + // The destination-root frontier payload is the whole staged subtree: + // materialize only the required bundle skill leaves it covers; the + // frontier publication replaces the missing root as one unit. + await installSkillsToDirectory(bundle.skills.filter((skill) => frontiers.skills?.paths.has(path.resolve(path.join(destination, skill.name)))), options.skillsSource, skillsStage) + } else { + await installSkillsToDirectory(bundle.skills, options.skillsSource, skillsStage) + } + for (const skill of bundle.skills) { + const livePath = path.join(destination, skill.name) + const resolvedLive = path.resolve(livePath) + if (frontiers.skills?.paths.has(resolvedLive)) continue + // Exact single-skill frontiers register as one publication unit. + journal = frontiers.singleSkills.has(resolvedLive) + ? await registerFallbackFrontierStage(journal, livePath, path.join(skillsStage, skill.name), { activeConditionalLeafIds: [] }) + : await registerFallbackStage(requireJournalEntry(journal, livePath), livePath, { directory: path.join(skillsStage, skill.name) }) + } + // The destination-root frontier publication keeps its private stage + // container and is registered LATER, after the MCP staging loop, so + // later work can add payload to the exact same staged subtree before + // the one journaled registration. Only conditional leaves whose bytes + // were actually staged may be selected as active: conditional skill + // leaves covered by the staged bundle (normally none: required bundle + // leaves supersede conditional evidence at the same destination) and + // conditional mcp-config leaves whose render planned real bytes. + // Link staging/registration runs AFTER the deferred destination-root + // skill frontier registration below, so every bundle-skill payload + // digest binding is available in the private registry before any + // copied link is registered. Apply order still runs skills before + // links; only the registration order changed. + 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`) + } + stagedMcpBytes.set(planEntry.configPath, planned.bytes) + const resolvedConfig = path.resolve(planEntry.configPath) + const configFrontier = mcpFrontierByPath.get(resolvedConfig) + if (configFrontier !== undefined) { + if (configFrontier !== frontiers.skills) continue + // Active config shared with the destination-root frontier: the + // bytes join the SAME deferred staged subtree at the exact + // frontier-relative path. Directories are created inside the + // scratch container only; the live parent chain stays missing + // until the single journaled frontier publication installs both + // roles. No ordinary per-config journal entry is registered, so + // the config can never be double-published. + const relativeConfig = path.relative(configFrontier.root, resolvedConfig) + if (relativeConfig === '' || relativeConfig.startsWith('..') || path.isAbsolute(relativeConfig)) { + throw new FallbackTransactionError('INVALID_TRANSACTION_MANIFEST', `The MCP configuration ${planEntry.configPath} is not a strict child of its destination frontier ${configFrontier.root}`) + } + const stagedConfigPath = path.join(skillsStageContainer, relativeConfig) + await mkdir(path.dirname(stagedConfigPath), { recursive: true }) + await writeFile(stagedConfigPath, planned.bytes, { mode: 0o600 }) + continue + } + journal = await registerFallbackStage(requireJournalEntry(journal, planEntry.configPath), planEntry.configPath, { bytes: planned.bytes }) + } + for (const frontierPlan of frontiers.mcp) { + const stageRoot = await mkdtemp(path.join(path.dirname(frontierPlan.root), `.${path.basename(frontierPlan.root)}.nsolid-stage-`)) + stageRoots.mcp.push(stageRoot) + for (const leaf of frontierPlan.activeLeaves) { + const planned = plannedMcpBytes.get(path.resolve(leaf.path)) + if (planned === undefined) throw new FallbackTransactionError('INVALID_TRANSACTION_MANIFEST', `The active MCP frontier leaf ${leaf.path} has no planned bytes`) + const stagedPath = path.join(stageRoot, path.relative(frontierPlan.root, path.resolve(leaf.path))) + await mkdir(path.dirname(stagedPath), { recursive: true }) + await writeFile(stagedPath, planned.bytes, { mode: 0o600 }) + } + journal = await registerFallbackFrontierStage(journal, frontierPlan.root, stageRoot, { + activeConditionalLeafIds: frontierPlan.activeConditionalLeafIds, + }) + } + // The deferred destination-root frontier registration: the exact same + // single journaled publication unit as before, performed after the + // MCP per-entry staging loop and before the link staging block below + // (so copied links can bind to its registered skill payloads) and + // before the tracking bytes are built. + if (frontiers.skills !== undefined) { + journal = await registerFallbackFrontierStage(journal, frontiers.skills.root, skillsStageContainer, { activeConditionalLeafIds: frontiers.skills.activeConditionalLeafIds }) + } + if (linkDir) { + // The stage container is a direct sibling of the frontier root (the + // authenticated-container invariant); the frontier root's parent is + // the anchor that already exists. + const linksStage = await mkdtemp(path.join(path.dirname(frontiers.links?.root ?? linkDir), `.${path.basename(frontiers.links?.root ?? linkDir)}.nsolid-stage-`)) + stageRoots.links = linksStage + const stagedFrontierRoot = frontiers.links?.root + for (const skill of bundle.skills) { + const linkPath = stagedFrontierRoot !== undefined ? path.resolve(path.join(linkDir, skill.name)) : path.join(linkDir, skill.name) + if (stagedFrontierRoot !== undefined && !frontiers.links?.paths.has(linkPath)) continue + // Missing link-root frontier (equal to linkDir or a proper + // ancestor): stage the active required bundle links as one + // complete frontier subtree at their exact paths relative to the + // frontier root (e.g. staged/skills/ for frontier ~/.claude + // with linkDir ~/.claude/skills), then register it as a single + // frontier publication unit. Stale conditional link leaves are + // intentionally absent from the staged tree. + const stagedLink = path.join(linksStage, stagedFrontierRoot !== undefined ? path.relative(stagedFrontierRoot, linkPath) : skill.name) + if (stagedFrontierRoot !== undefined) await mkdir(path.dirname(stagedLink), { recursive: true }) + // 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', + }) + if (stagedFrontierRoot === undefined) { + journal = await registerFallbackStage(requireJournalEntry(journal, linkPath), linkPath, { directory: stagedLink }) + } + } + if (stagedFrontierRoot !== undefined) { + journal = await registerFallbackFrontierStage(journal, stagedFrontierRoot, linksStage, { activeConditionalLeafIds: [] }) + } + } + // Field evidence must describe the bytes that will exist after the + // swap, not the pre-update files. + const trackingPath = path.resolve(journal.manifest.trackingPath) + 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(requireJournalEntry(journal, trackingPath), trackingPath, { bytes: Buffer.from(JSON.stringify(updatedTracking, null, 2) + '\n', 'utf8') }) + } + + // Publish in dependency order: owned skills, new skills, skill frontier, + // links, MCP, then tracking. Planning these groups performs no I/O. + // Each application still reloads/revalidates at its original mutation point. + 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) + const outside = (frontier: FrontierPublication | undefined, target: string): boolean => !frontier?.paths.has(path.resolve(target)) + const publications: Array<{ targets: string[]; skipApplied?: boolean }> = [ + { targets: [...pathsToReplace, ...pathsToRemove].filter((target) => outside(frontiers.links, target) && outside(frontiers.skills, target)) }, + { targets: bundle.skills.map((skill) => path.join(destination, skill.name)).filter((target) => outside(frontiers.skills, target)), skipApplied: true }, + { targets: frontiers.skills === undefined ? [] : [frontiers.skills.root], skipApplied: true }, + { targets: linkDir ? previousSkills.filter((entry) => !newNames.has(entry.name)).map((entry) => path.resolve(path.join(linkDir, entry.name))).filter((target) => outside(frontiers.links, target)) : [] }, + { targets: linkDir ? bundle.skills.map((skill) => path.join(linkDir, skill.name)).filter((target) => outside(frontiers.links, target)) : [], skipApplied: true }, + { targets: linkDir && frontiers.links !== undefined ? [frontiers.links.root] : [], skipApplied: true }, + // A no-op MCP render has no stage: applying it would incorrectly delete the config. + { targets: plan.entries.filter((entry) => planHasByteChanges(entry) && plannedMcpBytes.get(path.resolve(entry.configPath)) !== undefined && !mcpFrontierByPath.has(path.resolve(entry.configPath))).map((entry) => entry.configPath) }, + { targets: frontiers.mcp.map((frontier) => frontier.root) }, + { targets: [path.resolve(journal.manifest.trackingPath)] }, + ] + for (const { targets, skipApplied } of publications) { + for (const target of targets) { + journal = await (skipApplied ? applyFallbackEntryIfNeeded : applyFallbackEntry)(journal, target) + } + } + + // External child success: the journal belongs to the waiting parent. + // This process holds only the mutator role — the recorded owner PID is + // the parent that began the journal — so reclaim, prove, and commit are + // parent-only operations and must never run here. The applied journal + // and its mutator record stay in place: after the confirmed child exit + // the parent reclaims owner authority, proves the applied state from + // the strictly reloaded journal plus the live filesystem, and commits + // with the journal as the single snapshot. + return { success: true } + } catch (error) { + // Child-owned rollback: this process still holds the mutator handle and + // its in-memory manifest, so it restores the journaled snapshot itself. + // Every backup is authenticated against manifest evidence before any + // byte moves; mutable journal fields never authorize a deletion. + if (journal !== undefined) { + const restored = await restoreFallbackJournal(journal) + preserveRecoveryArtifacts = !restored.succeeded + const { preservedArtifacts, preservedPaths } = restored + const preservation = { preservedArtifacts, preservedPaths } + if (restored.succeeded) { + if (error instanceof FallbackTransactionError) { + return failure(error.code, error.message, { attempted: true, succeeded: true }, preservation) + } + return failure('FALLBACK_REFRESH_FAILED', 'Owned fallback refresh failed and was rolled back', { attempted: true, succeeded: true }, preservation) + } + if (restored.unproven) { + return failure('FALLBACK_STATE_UNPROVEN', 'The fallback transaction could not prove the state of its owned files, so nothing was changed automatically. Its journal, snapshot, and preserved artifacts were left untouched for manual recovery.', { attempted: false }, preservation) + } + return failure('FALLBACK_ROLLBACK_FAILED', 'Owned fallback refresh failed and rollback was incomplete', { attempted: true, succeeded: false }, preservation) + } + if (error instanceof FallbackTransactionError) { + return failure(error.code, error.message) + } + return failure('FALLBACK_REFRESH_FAILED', 'Owned fallback refresh failed before its journal was claimed') + } finally { + // Staging containers are transaction-owned scratch. The journal-owned + // stage copies live beside each target and survive for recovery. + if (!preserveRecoveryArtifacts) { + for (const stageRoot of [stageRoots.skills, stageRoots.links, ...stageRoots.mcp]) { + if (stageRoot !== undefined) await rm(stageRoot, { recursive: true, force: true }).catch(() => {}) + } + } + } +} + +interface FrontierPublication { + root: string + paths: ReadonlySet + activeLeaves: readonly FallbackLeafTarget[] + activeConditionalLeafIds: readonly string[] +} + +/** Derive publication routes and activation from trusted inputs; never perform I/O or authorize mutation. */ +function classifyFallbackFrontiers ( + plannedFrontiers: readonly FallbackFrontierEvidence[], + destination: string, + linkDir: string | undefined, + bundleSkillPaths: ReadonlySet, + activeMcpPaths: ReadonlySet +): { skills?: FrontierPublication; links?: FrontierPublication; mcp: FrontierPublication[]; singleSkills: ReadonlySet } { + // Only one compatible frontier equal to or above the directory may claim + // it. Ambiguous candidates remain unclaimed and fail the audit below. + const rootFrontier = (target: string | undefined, accepts: (leaves: readonly FallbackLeafTarget[]) => boolean): FallbackFrontierEvidence | undefined => { + if (target === undefined) return undefined + const resolved = path.resolve(target) + const candidates = plannedFrontiers.filter((frontier) => { + const root = path.resolve(frontier.frontierPath) + return (root === resolved || resolved.startsWith(root + path.sep)) && frontier.leaves.length > 0 && accepts(frontier.leaves) + }) + return candidates.length === 1 ? candidates[0] : undefined + } + const destinationFrontier = rootFrontier(destination, (leaves) => + leaves.some((leaf) => leaf.role === 'skill' && leaf.activation === 'required') && + leaves.every((leaf) => leaf.role === 'skill' || (leaf.role === 'mcp-config' && leaf.activation === 'conditional'))) + const linkFrontier = rootFrontier(linkDir, (leaves) => leaves.every((leaf) => leaf.role === 'link')) + const publication = (frontier: FallbackFrontierEvidence, activeLeaves: readonly FallbackLeafTarget[]): FrontierPublication => ({ + root: path.resolve(frontier.frontierPath), + paths: new Set(frontier.leaves.map((leaf) => path.resolve(leaf.path))), + activeLeaves, + activeConditionalLeafIds: activeLeaves.filter((leaf) => leaf.activation === 'conditional').map((leaf) => leaf.id), + }) + let skills: FrontierPublication | undefined + let links: FrontierPublication | undefined + const mcp: FrontierPublication[] = [] + const singleSkills = new Set() + for (const frontier of plannedFrontiers) { + const activeLeaves = frontier.leaves.filter((leaf) => + leaf.activation === 'required' || + (leaf.role === 'mcp-config' && leaf.activation === 'conditional' && activeMcpPaths.has(path.resolve(leaf.path))) || + (leaf.role === 'skill' && leaf.activation === 'conditional' && frontier === destinationFrontier && bundleSkillPaths.has(path.resolve(leaf.path)))) + const isMcp = frontier.leaves.length > 0 && frontier.leaves.every((leaf) => leaf.role === 'mcp-config') + const isSingleSkill = activeLeaves.length === 1 && activeLeaves[0].role === 'skill' && + activeLeaves[0].activation === 'required' && path.resolve(frontier.frontierPath) === path.resolve(activeLeaves[0].path) + // Fail closed before any live mkdir for unsupported active mixes, + // ambiguous roots, or broader-than-exact single-skill shapes. + if (activeLeaves.length > 0 && frontier !== destinationFrontier && frontier !== linkFrontier && !isMcp && !isSingleSkill) { + throw new FallbackTransactionError('INVALID_TRANSACTION_MANIFEST', `Fallback frontier ${frontier.frontierPath} carries active leaves that no supported publication route claims; refusing to mutate live paths before the frontier is claimed`) + } + if (frontier === destinationFrontier) skills = publication(frontier, activeLeaves) + if (frontier === linkFrontier) links = publication(frontier, activeLeaves) + if (isMcp) { + // MCP leaves need staged bytes even when their manifest activation is + // required; the audit above still treats required leaves as active. + const stagedLeaves = frontier.leaves.filter((leaf) => activeMcpPaths.has(path.resolve(leaf.path))) + if (stagedLeaves.length > 0) mcp.push(publication(frontier, stagedLeaves)) + } + if (frontier.leaves.some((leaf) => leaf.role === 'skill' && leaf.activation === 'required' && path.resolve(leaf.path) === path.resolve(frontier.frontierPath))) { + singleSkills.add(path.resolve(frontier.frontierPath)) + } + } + mcp.sort((left, right) => Buffer.compare(Buffer.from(left.root, 'utf8'), Buffer.from(right.root, 'utf8'))) + return { skills, links, mcp, singleSkills } +} + +/** Reload at each publication point: an earlier read cannot prove the current applied state. */ +async function applyFallbackEntryIfNeeded (journal: FallbackJournalHandle, target: string): Promise { + const disk = await reloadFallbackJournal(journal) + if (disk.entries.some((entry) => path.resolve(entry.path) === path.resolve(target) && entry.applied)) return journal + return applyFallbackEntry(journal, target) +} + +/** Every mutation target must be a parent-planned journal entry; a missing entry is a manifest/plan divergence and fails closed. */ +function requireJournalEntry (journal: FallbackJournalHandle, target: string): FallbackJournalHandle { + const resolved = path.resolve(target) + if (!journal.manifest.ownedSkills.concat(journal.manifest.ownedLinks, journal.manifest.bundleDestinations, journal.manifest.ownedMcpConfigPaths.filter((value): value is Exclude => typeof value !== 'string')).some((entry) => path.resolve(entry.path) === resolved) && path.resolve(journal.manifest.trackingPath) !== resolved) { + throw new FallbackTransactionError('INVALID_TRANSACTION_MANIFEST', `The fallback journal has no planned entry for ${resolved}`) + } + return journal +} + +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 validateTransactionIdentity (identity: FallbackTransactionIdentity): Promise { + // Protocol gate FIRST: a manifest from an incompatible planner version is + // rejected before any other evaluation and before any mutation, with a + // stable code the parent can map to manual-update guidance. + if (identity.protocolVersion !== FALLBACK_PROTOCOL_VERSION) { + return { code: 'FALLBACK_PROTOCOL_UNSUPPORTED', message: `This fallback update was planned by an incompatible nsolid-plugin version (protocol ${String(identity.protocolVersion)}; this build speaks protocol ${String(FALLBACK_PROTOCOL_VERSION)}). Update nsolid-plugin manually (for example with your package manager) and retry the update.` } + } + // Frontier evidence gate immediately after the protocol gate, BEFORE any + // journal or live-state work: protocol v3 requires exactly-parsed + // planned-missing frontier evidence. Absent, malformed, extra-keyed, or + // tampered graphs fail closed before any mutation. + try { + assertFallbackFrontierEvidenceList(identity.plannedMissingFrontiers) + } catch (error) { + return { code: 'FALLBACK_FRONTIER_EVIDENCE_INVALID', message: `Fallback transaction contains invalid planned-missing frontier evidence: ${(error as Error).message}` } + } + 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.digestAlgorithm !== undefined && identity.digestAlgorithm !== 'fallback-path-v2') return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction uses an unknown digest domain' } + if (!Array.isArray(identity.ownedSkills) || identity.ownedSkills.some((entry) => !isValidPathEvidence(entry))) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains an unsafe skill path' } + if (!Array.isArray(identity.ownedLinks) || identity.ownedLinks.some((entry) => !isValidPathEvidence(entry))) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains an unsafe link path' } + if (!Array.isArray(identity.ownedMcpConfigPaths) || identity.ownedMcpConfigPaths.some((value) => typeof value !== 'string' && !isValidPathEvidence(value))) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains an unsafe MCP config path' } + if (!Array.isArray(identity.bundleDestinations) || identity.bundleDestinations.some((entry) => !isValidPathEvidence(entry))) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains an unsafe bundle destination' } + 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(typeof value === 'string' ? value : value.path))) + if (identity.ownedMcpConfigPaths.some((value) => !isCanonicalPath(typeof value === 'string' ? value : value.path))) 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' } + } + // Whole-file MCP evidence and planned bundle destinations are revalidated + // against live state: drift between planning and execution blocks the + // mutation exactly like owned-skill drift. + for (const evidence of [...identity.ownedMcpConfigPaths, ...identity.bundleDestinations].filter((value): value is FallbackPathEvidence => typeof value !== 'string')) { + if (!isCanonicalPath(evidence.path)) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains an unsafe evidence path' } + const kind = await pathKind(evidence.path) + const digest = kind === 'missing' ? undefined : await pathDigest(evidence.path) + if (kind !== evidence.kind || digest !== evidence.digest) { + return { code: 'FALLBACK_TRACKING_DRIFT', message: `Fallback planned path changed after planning: ${evidence.path}` } + } + } + // 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. + if (!Array.isArray(identity.approvedDestinationRoots)) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains no destination roots' } + 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 } = resolveFallbackDestinations(identity.harness) + 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' } + } + } + if (identity.ownedSkills.some((entry) => !isSafeDirectChild(entry.path, approvedRoots))) { + return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains a skill outside the approved destination roots' } + } + const linkRoots = identity.harness === 'opencode' ? approvedRoots : [path.resolve(getHarnessSkillsPath(identity.harness))] + if (identity.ownedLinks.some((entry) => !isSafeDirectChild(entry.path, linkRoots))) { + return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains a link outside the approved destination roots' } + } + // Planned bundle destinations sit directly inside an approved root under a + // safe skill name: they were derived by the parent from the verified + // bundle, and the child never accepts unplanned destinations. + if (identity.bundleDestinations.some((entry) => !isSafeDirectChild(entry.path, approvedRoots))) { + return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains a bundle destination outside the approved destination roots' } + } + for (const entry of [...identity.ownedSkills, ...identity.ownedLinks]) { + const kind = await pathKind(entry.path) + const digest = kind === 'missing' ? undefined : await pathDigest(entry.path) + if (kind !== entry.kind || digest !== entry.digest) { + return { code: 'FALLBACK_TRACKING_DRIFT', message: 'Fallback owned path changed after planning' } + } + } + return undefined +} + +function isValidPathEvidence (entry: unknown): entry is FallbackTransactionIdentity['ownedSkills'][number] { + if (!entry || typeof entry !== 'object') return false + const value = entry as { path?: unknown; kind?: unknown; digest?: unknown } + if (typeof value.path !== 'string' || !isCanonicalPath(value.path)) return false + if (!['missing', 'file', 'directory', 'symlink', 'other'].includes(String(value.kind))) return false + return value.kind === 'missing' ? value.digest === undefined : typeof value.digest === 'string' +} + +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 } +} + +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 }, preservation?: { preservedArtifacts?: readonly string[]; preservedPaths?: readonly string[] }): FallbackRefreshResult { + return { + success: false, + rollbackAttempted: rollback?.attempted, + rollbackSucceeded: rollback?.succeeded, + preservedArtifacts: preservation?.preservedArtifacts, + preservedPaths: preservation?.preservedPaths, + 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/index.ts b/packages/core/src/update/index.ts new file mode 100644 index 0000000..a368aca --- /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, manifestDigestOf, canonicalJsonString, recoverFallbackJournal, trackingDigest } from './fallback-journal.js' +export { valueDigest } from './mcp-lookup.js' +export { refreshOwnedInstallation } from './fallback-transaction.js' +export type { FallbackJournal, FallbackJournalHandle, FallbackJournalOperationResult, FallbackJournalPhase, ProcessIdentity } 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..242b113 --- /dev/null +++ b/packages/core/src/update/inventory.ts @@ -0,0 +1,923 @@ +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 { getSkillsDir, 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 { isNsolidPluginImport, isNsolidPluginImportKey } from './antigravity-provenance.js' +import { classifyVersionSet, classifyVersions, isStableVersion, readRunningVersionInfo, resolvePackageRoot } from './version.js' +import { nativePayloadTreeDigest } from './native-payload.js' +import { isSafeDirectChild } from './fallback-ownership.js' + +export interface InventoryOptions { + commandRunner: CommandRunner + cwd?: string + packageRoot?: string + /** + * Launcher path used to prove CLI installation ownership. Production + * defaults to `process.argv[1]`; tests inject a fixture launcher instead of + * mutating global process state. + */ + executablePath?: 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 executablePath = options.executablePath ?? process.argv[1] + const running = safeRunningVersion(packageRoot) + // Launcher shapes that are intrinsically ephemeral or wrapper-owned must + // fail closed on EVERY seam, including the positive ownership probe: a + // Volta image or npx cache must never turn into a global-package identity + // just because a package manager answers a root query. + const definitelyUnsupported = isDefinitelyUnsupportedCliLauncher(executablePath) + const ownership = probeOwnership && !definitelyUnsupported + ? await detectGlobalPackageOwnership({ + commandRunner: options.commandRunner, + packageRoot, + executablePath, + readOnly: options.readOnly, + }) + : undefined + // Read-only checks and deferred mutation inventory must not spawn package + // managers. Structural evidence supplies the running version for status + // classification only; a mutating update still re-probes ownership before + // any global command is planned. + const structural = ownership?.ownership === undefined && (options.readOnly === true || probeOwnership === false) + ? structuralGlobalManager(packageRoot, executablePath) + : undefined + const manager = ownership?.ownership?.manager ?? structural + + const source: UpdateSource = manager + ? { + kind: 'global-package', + packageManager: manager, + packageName: 'nsolid-plugin', + } + : { + kind: 'unsupported', + source: executablePath || '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: manager ? 'global-package' : 'none', + installed: true, + source, + // Without positive ownership evidence the running bundle's version is not + // the global installation's version and must not be reported as one. + version: manager ? classifyVersions(running?.cliVersion, undefined) : classifyVersions(undefined, undefined), + metadata, + } +} + +/** + * Launcher shapes that are intrinsically ephemeral or wrapper-owned. This + * check must run before any generic global-layout matching: an npx cache or a + * Volta shim can itself sit below directories named `node_modules` or `lib` + * without proving ownership of the running package. + */ +export function isDefinitelyUnsupportedCliLauncher (executablePath: string | undefined): boolean { + if (!executablePath) return true + const normalized = path.resolve(executablePath).replace(/\\/g, '/').toLowerCase() + return normalized.includes('/.npm/_npx/') || + normalized.includes('/node_modules/.bin/') || + normalized.includes('/.volta/bin/') || + normalized.includes('/.volta/tools/image/') +} + +/** + * Structural check-time evidence that the running bundle is a global npm or + * pnpm installation. The pre-realpath package root must match a documented + * manager layout, while the launcher's realpath must resolve into the same + * package payload: + * - npm: `/lib/node_modules/nsolid-plugin`, invoked through the + * corresponding `/bin/nsolid-plugin` link (Windows: the generated + * .cmd/.ps1 shim in the prefix root whose body references the payload). + * - pnpm: `/global[/N]/node_modules/nsolid-plugin` (the stable + * link) or the RESOLVED versioned-store layout Node surfaces when the + * payload module is loaded through that link + * (`/global//.pnpm/@/node_modules/`). + * The launcher must resolve into the payload or be the pnpm-home shim + * whose body references it. + * Everything else stays unproven so unowned launches fail closed. + * + * Accepted residual (D2 constrains check-time to structural evidence only): + * a prefix fabricated by hand WITH a bin link resolving into its own payload + * is structurally indistinguishable from a real npm-global install without + * spawning the manager; the same holds for a fabricated `/global/` + * tree with an in-tree launcher. Mutating flows re-probe with real manager + * evidence before any change; a fabrication only affects what `--check` + * reports. + */ +function structuralGlobalManager (packageRoot: string, executablePath: string | undefined): 'npm' | 'pnpm' | undefined { + if (!executablePath || !existsSync(executablePath) || isDefinitelyUnsupportedCliLauncher(executablePath)) return undefined + const rawRoot = path.resolve(packageRoot) + const rawLauncher = path.resolve(executablePath) + const resolvedRoot = safeRealpath(rawRoot) + const resolvedLauncher = safeRealpath(rawLauncher) + if (path.basename(rawRoot) !== 'nsolid-plugin' || path.basename(resolvedRoot) !== 'nsolid-plugin') return undefined + + // pnpm before the generic containment gate: ownership is proven from + // either the stable pre-realpath link or the RESOLVED versioned-store + // layout, bound to a launcher that resolves into this payload or is the + // pnpm-home shim whose body references it. A pnpm-shaped root with an + // unproven launcher fails closed instead of falling through to npm. + const pnpm = pnpmOwnershipProven(rawRoot, resolvedRoot, rawLauncher, resolvedLauncher) + if (pnpm === true) return 'pnpm' + if (pnpm === false) return undefined + + // Windows-style npm/pnpm launchers are generated .cmd/.ps1 shim files that + // live NEXT TO the global layout (outside the package payload) and carry + // the payload path in their body. Symlink launchers (POSIX prefix/bin, + // pnpm home, Volta images) must resolve into the payload instead. A shim + // file whose body does not reference the package payload is not treated as + // a generated launcher, so it still fails the containment requirement. + const launcherIsShim = isGeneratedLauncherShim(rawLauncher) + if (!launcherIsShim && !isSameOrContained(resolvedLauncher, resolvedRoot)) return undefined + + // npm requires linkage from the matching prefix bin directory. A workspace + // whose path merely ends in lib/node_modules/nsolid-plugin therefore cannot + // claim global ownership by pointing at its own in-tree launcher, and a + // Volta tool image never proves npm-global ownership. + const rawNodeModules = path.dirname(rawRoot) + if (path.basename(rawNodeModules) === 'node_modules') { + const lib = path.dirname(rawNodeModules) + if (path.basename(lib) === 'lib') { + const prefix = path.dirname(lib) + const launcherFromPrefixBin = path.dirname(rawLauncher) === path.join(prefix, 'bin') && path.basename(rawLauncher).replace(/\.(?:cmd|ps1|exe)$/i, '') === 'nsolid-plugin' + if (launcherFromPrefixBin) return 'npm' + } + } + + // npm on Windows installs the .cmd/.ps1 shim in the prefix root itself, + // next to node_modules (no separate bin directory). The shim shape check + // keeps this branch exercisable on every platform through crafted fixtures. + if ((process.platform === 'win32' || launcherIsShim) && path.basename(rawNodeModules) === 'node_modules') { + const npmPrefix = path.dirname(rawNodeModules) + const launcherFromPrefix = path.dirname(rawLauncher) === npmPrefix && path.basename(rawLauncher).replace(/\.(?:cmd|ps1|exe)$/i, '') === 'nsolid-plugin' + if (launcherFromPrefix) return 'npm' + } + + // A real Windows npm-global launch runs the payload entrypoint directly: + // the generated prefix shim invokes node with + // `/node_modules/nsolid-plugin/dist/src/cli.js`, so process.argv[1] + // is the payload path rather than the shim. Recognize that shape only when + // the launcher is the documented bin entrypoint of THIS payload (already + // containment-checked above) AND the prefix carries a generated npm shim + // whose body references this package — the shim binds the prefix to an + // actual npm-global installation, so a project-local node_modules tree, a + // fabricated prefix without the shim, or an arbitrary dist/src/cli.js path + // stays unsupported. Mutating flows still re-probe with real manager + // evidence before any change. + if (path.basename(rawNodeModules) === 'node_modules') { + const npmPrefix = path.dirname(rawNodeModules) + const relative = path.relative(resolvedRoot, resolvedLauncher).replaceAll('\\', '/') + const isDocumentedBinEntrypoint = relative === 'dist/src/cli.js' + const prefixShimProven = isGeneratedLauncherShim(path.join(npmPrefix, 'nsolid-plugin.cmd')) || + isGeneratedLauncherShim(path.join(npmPrefix, 'nsolid-plugin.ps1')) || + (process.platform === 'win32' && prefixShimReferencesPayload(path.join(npmPrefix, 'nsolid-plugin'))) + if (isDocumentedBinEntrypoint && prefixShimProven) return 'npm' + } + return undefined +} + +/** + * pnpm ownership proof: undefined = not pnpm-shaped; true = proven; false = + * pnpm-shaped but the launcher could not be bound to this payload (fail + * closed instead of falling through to the npm classifier). Node resolves + * the payload module through the global link, so the resolved store layout + * is the shape production actually observes; the pre-realpath link shape + * remains valid for callers that can supply it. + */ +function pnpmOwnershipProven (rawRoot: string, resolvedRoot: string, rawLauncher: string, resolvedLauncher: string | undefined): boolean | undefined { + const homes = new Set() + const linkHome = pnpmLayoutHome(rawRoot) + if (linkHome !== undefined) homes.add(linkHome) + const storeHome = pnpmLayoutHome(resolvedRoot) + if (storeHome !== undefined) homes.add(storeHome) + const pnpmHome = [...homes][0] + if (pnpmHome === undefined) return undefined + if (resolvedLauncher !== undefined && isSameOrContained(resolvedLauncher, resolvedRoot)) return true + const shimName = path.basename(rawLauncher).replace(/\.(?:cmd|ps1|exe)$/i, '') + if (shimName === 'nsolid-plugin' && path.dirname(rawLauncher) === pnpmHome) { + // Windows-generated .cmd/.ps1 shims are bound by their generated body + // shape (node_modules\ entrypoint); POSIX pnpm sh scripts embed an + // absolute payload path in their body. + if (isGeneratedLauncherShim(rawLauncher) || pnpmHomeShimReferencesPayload(rawLauncher, rawRoot, resolvedRoot)) return true + } + // A pnpm-shaped root with an unproven launcher must not fall through. + return false +} + +/** pnpm global layout home: /global[/N]//.../nsolid-plugin */ +function pnpmLayoutHome (candidate: string): string | undefined { + const normalized = candidate.replace(/\\/g, '/') + const match = /^(.*)\/global\/(?:\d+\/)?(?:node_modules\/nsolid-plugin|\.pnpm\/[^/]+\/node_modules\/nsolid-plugin)$/.exec(normalized) + if (!match) return undefined + const home = match[1] + if (!home) return undefined + return home.split('/').join(path.sep) +} + +/** The POSIX pnpm-home sh script must embed an absolute payload path in its body. */ +function pnpmHomeShimReferencesPayload (launcher: string, rawRoot: string, resolvedRoot: string): boolean { + try { + const body = readFileSync(launcher, 'utf8') + return [rawRoot, resolvedRoot].some((root) => body.includes(root) || body.includes(root.replace(/\\/g, '/'))) + } catch { + return false + } +} + +/** + * True when the launcher is a generated npm/pnpm Windows shim whose body + * references this package's payload directory. npm generates cmd/ps1 shims + * that embed the installed entrypoint (`node_modules\nsolid-plugin\...`); + * pnpm generates equivalent shims. The body reference is what binds the + * otherwise-standalone shim file to the exact package payload. + */ +function isGeneratedLauncherShim (launcher: string): boolean { + if (!/\.(?:cmd|ps1)$/i.test(path.basename(launcher))) return false + try { + return /node_modules[\\/]+nsolid-plugin/i.test(readFileSync(launcher, 'utf8')) + } catch { + return false + } +} + +/** + * Body check for an extensionless npm shim (Windows generates one alongside + * the .cmd/.ps1 pair): it counts as prefix evidence only when its body + * references this package's payload directory. + */ +function prefixShimReferencesPayload (shimPath: string): boolean { + try { + return /node_modules[\\/]+nsolid-plugin/i.test(readFileSync(shimPath, 'utf8')) + } catch { + return false + } +} + +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 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 skillRoot = harness === 'opencode' + ? path.resolve(process.env.NSOLID_OPENCODE_SKILLS_DIR ?? resolveHome('~/.config/opencode/skills')) + : getSkillsDir() + const ownershipProven = rawTrackedSkills.length === trackedSkills.length && trackedSkills.every((entry) => isSafeDirectChild(entry.path, [skillRoot])) + 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, candidate.pluginRoot)) + // 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, candidate.pluginRoot))) + 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, pluginRoot: string): boolean { + const plugin = safeReadJson(path.join(pluginRoot, 'plugin.json')) + if (plugin?.name !== 'nsolid-plugin') return false + const data = safeReadJson(manifestPath) + const imports = data?.imports + // Shared provenance predicate: canonical identity plus an accepted + // registration source (antigravity marker or the exact NodeSource GitHub + // root), identical to the staged-plugin validation contract. + if (Array.isArray(imports)) return imports.some((entry) => isNsolidPluginImport(entry)) + if (isRecord(imports)) { + return Object.entries(imports).some(([key, value]) => isNsolidPluginImportKey(key) && isNsolidPluginImport(value)) + } + 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..b9b32f1 --- /dev/null +++ b/packages/core/src/update/mcp-edit.ts @@ -0,0 +1,214 @@ +import { applyEdits, findNodeAtLocation, getNodeValue, modify, parseTree, type FormattingOptions, type JSONPath, type ModificationOptions, 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 + const preferredNode = findNodeAtLocation(tree, [preferred]) + if (preferredNode) { + if (preferredNode.type !== 'object') throw new McpEditError('MCP_BLOCK_INVALID', `Existing ${preferred} value is not an object`) + return preferred + } + const alternate: JsonMcpKey = preferred === 'mcpServers' ? 'mcp' : 'mcpServers' + const alternateNode = findNodeAtLocation(tree, [alternate]) + if (alternateNode) { + if (alternateNode.type !== 'object') throw new McpEditError('MCP_BLOCK_INVALID', `Existing ${alternate} value is not an object`) + return alternate + } + return preferred +} + +/** + * 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' + } + 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') { + throw new McpEditError('MCP_BLOCK_INVALID', `The ${mcpKey} container is ${mcpNode.type} and cannot be replaced safely`) + } + 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 +} + +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..dc1fdf0 --- /dev/null +++ b/packages/core/src/update/mcp-lookup.ts @@ -0,0 +1,122 @@ +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 (Object.hasOwn(parsed, preferredKey) && !isContainer(preferred)) return undefined + 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..08e6840 --- /dev/null +++ b/packages/core/src/update/mcp-reconciliation.ts @@ -0,0 +1,150 @@ +import type { McpServerRef } from '../types.js' +import { valueDigest } from './mcp-lookup.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)) { + // Digest-aware: a field whose tracked digest already matches the + // desired value must not be planned as an update. A no-op update + // would render bytes identical to the live file, which in turn + // leaves the apply stage without staged bytes — and applying a + // journaled config entry with no staged replacement deletes it. + if (valueDigest(desiredValue[field]) !== expectedDigest) { + 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..b029b0b --- /dev/null +++ b/packages/core/src/update/native-evidence.ts @@ -0,0 +1,91 @@ +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' +import type { PayloadDigestOptions } 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. + * + * Without options this is strict source evidence. A named normalization + * profile may be requested only for installed-payload equivalence comparison + * against a plan that carries the same named profile; it never replaces the + * strict digest for drift, authorization, backup, or rollback checks. + */ +export function nativePayloadDigest (root: string, options: PayloadDigestOptions = {}): string | undefined { + return nativePayloadTreeDigest(root, options) +} + +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..c7ec614 --- /dev/null +++ b/packages/core/src/update/native-payload.ts @@ -0,0 +1,302 @@ +import { createHash } from 'node:crypto' +import { lstatSync, readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { gunzipSync } from 'node:zlib' +import { readTarEntries } from './tar-format.js' + +type PayloadEntry = + | { kind: 'file'; content: Buffer } + | { kind: 'symlink'; target: string } + | { kind: 'directory' } + +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 +} + +/** + * Named payload normalization profiles for installed-comparison digests. + * Each profile lists the exact payload-root entries proven to be created or + * rewritten by a harness; those entries are normalized ONLY in the + * installed-comparison digest, never in the strict evidence digest. + * + * Role semantics (enforced by the digest helpers below): + * - Installed side (`nativePayloadTreeDigest`): only a ROOT REGULAR FILE with + * a reserved name is excluded; a symlink or directory at a reserved name + * stays significant so a crafted entry cannot hide behind the profile. + * - Planning side (`gitArchivePayloadDigest`, planned identity helpers): the + * planned source must be free of reserved paths. Any reserved-path entry in + * the planned payload yields NO comparison identity, so execution fails + * closed instead of masking payload-authored content. + * Nested paths and unapproved files always stay significant under both roles. + */ +export type PayloadNormalizationProfile = 'codex-installed-v1' + +/** Root entries proven to be written by the Codex marketplace installer. */ +export const PAYLOAD_NORMALIZATION_PROFILES: Readonly> = { + 'codex-installed-v1': ['.codex-marketplace-install.json'], +} + +// Only reserved root directories need identity markers. Ordinary and nested +// directories remain implicit so existing strict payload digests do not change. +const RESERVED_NORMALIZATION_ROOT_NAMES: ReadonlySet = new Set( + Object.values(PAYLOAD_NORMALIZATION_PROFILES).flat() +) + +/** Profile used for Codex installed-payload equivalence comparisons. */ +export const CODEX_INSTALLED_COMPARISON_PROFILE: PayloadNormalizationProfile = 'codex-installed-v1' + +/** Runtime validation: unrecognized profile values must never digest silently. */ +export function isPayloadNormalizationProfile (value: unknown): value is PayloadNormalizationProfile { + return typeof value === 'string' && Object.prototype.hasOwnProperty.call(PAYLOAD_NORMALIZATION_PROFILES, value) +} + +export interface PayloadDigestOptions { + /** Named normalization profile; undefined keeps strict evidence behavior. */ + profile?: PayloadNormalizationProfile +} + +/** Strict evidence digest plus the installed-comparison identity of one planned payload capture. */ +export interface PlannedPayloadIdentity { + contentDigest?: string + comparisonDigest?: string + comparisonProfile?: PayloadNormalizationProfile +} + +/** 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') +} + +/** Raw single capture of a payload tree; limits enforced, no normalization. */ +function captureTreePayload (root: string): Map | undefined { + try { + const resolvedRoot = path.resolve(root) + if (lstatSync(resolvedRoot).isSymbolicLink()) throw new Error('symlink at payload root') + const entries = new Map() + let totalBytes = 0 + const walk = (directory: string, relativeRoot: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + // Root .git is always excluded from every identity; profile + // normalization happens at digest time so one raw capture can feed + // both the strict and the comparison digest. + 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()) { + if (!relativeRoot && RESERVED_NORMALIZATION_ROOT_NAMES.has(entry.name)) { + entries.set(`${entry.name}/`, { kind: 'directory' }) + } + walk(absolute, relative) + } else if (stat.isFile()) { + const content = readFileSync(absolute) + totalBytes += content.length + entries.set(relative, { kind: 'file', content }) + } else if (stat.isSymbolicLink()) { + throw new Error('symlink in payload tree') + } 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 entries + } catch { + return undefined + } +} + +/** + * Digest an installed payload tree. Strict by default; under a profile only + * proven harness-written root regular files are normalized. Any symlink in + * the installed tree fails closed instead of contributing redirected bytes. + */ +export function nativePayloadTreeDigest (root: string, options: PayloadDigestOptions = {}): string | undefined { + const entries = captureTreePayload(root) + if (!entries) return undefined + if (options.profile === undefined) return digestEntries(entries) + // An unrecognized profile must never silently fall back to strict behavior. + if (!isPayloadNormalizationProfile(options.profile)) return undefined + return digestEntries(installedComparisonEntries(entries, options.profile)) +} + +/** + * Digest the planned payload of a `git archive` tarball. Entries outside + * `scope.payloadPath` (for example sibling plugins in a multi-plugin + * marketplace repository) are excluded instead of compared, and the manifest + * must exist inside the subtree when `scope.manifestPath` is set. Strict by + * default; under a profile the planned source must be free of reserved + * harness paths, so a reserved-path entry yields no digest and the plan + * fails closed. + */ +export function gitArchivePayloadDigest (compressedArchive: Buffer, scope: ArchivePayloadScope = {}, options: PayloadDigestOptions = {}): string | undefined { + const entries = captureArchivePayload(compressedArchive, scope) + if (!entries) return undefined + if (options.profile === undefined) return digestEntries(entries) + // An unrecognized profile must never silently fall back to strict behavior. + if (!isPayloadNormalizationProfile(options.profile)) return undefined + return plannedComparisonDigest(entries, options.profile) +} + +/** Plan-side identity from a local snapshot: ONE raw capture feeds both digests. */ +export function plannedPayloadIdentityFromTree (root: string, profile: PayloadNormalizationProfile): PlannedPayloadIdentity { + if (!isPayloadNormalizationProfile(profile)) return {} + return plannedIdentityFromCapture(captureTreePayload(root), profile) +} + +/** Plan-side identity from a Git archive: ONE parse feeds both digests. */ +export function plannedPayloadIdentityFromArchive (compressedArchive: Buffer, scope: ArchivePayloadScope, profile: PayloadNormalizationProfile): PlannedPayloadIdentity { + if (!isPayloadNormalizationProfile(profile)) return {} + return plannedIdentityFromCapture(captureArchivePayload(compressedArchive, scope), profile) +} + +function plannedIdentityFromCapture (entries: Map | undefined, profile: PayloadNormalizationProfile): PlannedPayloadIdentity { + if (!entries) return {} + const contentDigest = digestEntries(entries) + if (!contentDigest) return {} + const comparisonDigest = plannedComparisonDigest(entries, profile) + if (!comparisonDigest) return { contentDigest } + return { contentDigest, comparisonDigest, comparisonProfile: profile } +} + +/** Drop only proven harness-written root regular files from a captured tree. */ +function installedComparisonEntries (entries: Map, profile: PayloadNormalizationProfile): Map { + const filtered = new Map(entries) + for (const name of PAYLOAD_NORMALIZATION_PROFILES[profile]) { + if (filtered.get(name)?.kind === 'file') filtered.delete(name) + } + return filtered +} + +/** + * Planned-side comparison digest: reserved harness paths must not exist in + * the planned source; any collision yields no comparison identity so that + * execution fails closed instead of masking payload content. + */ +function plannedComparisonDigest (entries: Map, profile: PayloadNormalizationProfile): string | undefined { + for (const name of PAYLOAD_NORMALIZATION_PROFILES[profile]) { + if (entries.has(name)) return undefined + const prefix = `${name}/` + for (const relative of entries.keys()) { + if (relative.startsWith(prefix)) return undefined + } + } + return digestEntries(entries) +} + +/** Raw single parse of the installable payload subtree of a `git archive`. */ +function captureArchivePayload (compressedArchive: Buffer, scope: ArchivePayloadScope): Map | 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 archiveRoot: string | undefined + let totalBytes = 0 + for (const { path: rawPath, type, body, linkTarget } of readTarEntries(archive)) { + const entryPath = normalizeArchivePath(rawPath) + 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 (type === '0' || type === '\0') { + if (entries.has(relative)) throw new Error('duplicate tar entry') + totalBytes += body.length + entries.set(relative, { kind: 'file', content: Buffer.from(body) }) + } else if (type === '2') { + if (entries.has(relative)) throw new Error('duplicate tar entry') + entries.set(relative, { kind: 'symlink', target: linkTarget }) + } else if (type === '5') { + if (!relative.includes('/') && RESERVED_NORMALIZATION_ROOT_NAMES.has(relative)) { + const marker = `${relative}/` + if (entries.has(marker)) throw new Error('duplicate tar entry') + entries.set(marker, { kind: 'directory' }) + } + } else throw new Error('unsupported tar entry') + } + } + } + if (entries.size > MAX_PAYLOAD_FILES || totalBytes > MAX_PAYLOAD_BYTES) throw new Error('payload exceeds limits') + } + if (manifestPath && entries.get(manifestPath)?.kind !== 'file') return undefined + return entries + } catch { + return undefined + } +} + +/** + * Windows readlink returns the stored symlink target with native separators + * while tar linknames keep POSIX separators. Separators are normalized only + * for ordinary Win32 paths, where `\` and `/` are interchangeable. + * Device-namespace targets (`\\?\`, `\\.\`, `\??\`, including + * slash-spelled variants) are digested verbatim: extended paths disable + * Win32 normalization, so forward slashes inside them are not equivalent + * separators and must not be rewritten. Distinct targets can never collide + * on POSIX, where the target is digested verbatim. + */ +function symlinkTargetForDigest (target: string): string { + if (process.platform !== 'win32') return target + if (isWindowsDeviceNamespaceTarget(target)) return target + return target.replace(/\\/g, '/') +} + +/** + * True for Win32 device-namespace paths (`\\?\`, `\\.\`, `\??\`) in + * either separator spelling. Forward slashes inside these paths are not + * equivalent separators, so such targets must never be normalized. + * Exported for unit testing the classification; the digest is the only caller. + */ +export function isWindowsDeviceNamespaceTarget (target: string): boolean { + const normalized = target.replace(/\//g, '\\') + return normalized.startsWith('\\\\?\\') || normalized.startsWith('\\\\.\\') || normalized.startsWith('\\??\\') +} + +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 if (entry.kind === 'symlink') hash.update(symlinkTargetForDigest(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') + } +} diff --git a/packages/core/src/update/owned-fs.ts b/packages/core/src/update/owned-fs.ts new file mode 100644 index 0000000..2fa1fcf --- /dev/null +++ b/packages/core/src/update/owned-fs.ts @@ -0,0 +1,227 @@ +import { cp, lstat, mkdtemp, open, readFile, readdir, rename, rm } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import path from 'node:path' + +export type OwnedPathKind = 'missing' | 'file' | 'directory' | 'junction-or-symlink' | 'other' + +export interface SiblingBackupPath { + directory: string + path: string +} + +/** Error for owned-filesystem contract violations; never silently swallowed. */ +export class OwnedFsError extends Error { + readonly code: 'SYMLINK_IN_TREE' | 'KIND_CHANGED' + readonly relativePath?: string + + constructor (code: 'SYMLINK_IN_TREE' | 'KIND_CHANGED', relativePath?: string, message?: string) { + super(message ?? (relativePath !== undefined ? `${code}: ${relativePath}` : code)) + this.name = 'OwnedFsError' + this.code = code + this.relativePath = relativePath + } +} + +const RETRYABLE_FS_ERRORS = new Set(['EPERM', 'EBUSY', 'ENOTEMPTY']) +const OWNED_TEMP_ATTEMPTS = 5 + +/** Locale-independent total ordering over the exact UTF-8 path bytes. */ +function comparePathNames (left: string, right: string): number { + return Buffer.compare(Buffer.from(left), Buffer.from(right)) +} + +/** 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 + } +} + +/** SHA-256 hex digest of a regular file's bytes; null for any other path kind. */ +export async function ownedFileDigest (targetPath: string): Promise { + const kind = await ownedPathKind(targetPath) + if (kind !== 'file') return null + const bytes = await readFile(targetPath) + return createHash('sha256').update(bytes).digest('hex') +} + +/** + * Deterministic digest of an owned directory tree. Entries are framed with + * their kind, POSIX relative path, and content digest (files) or link target + * (symlinks) so two identical trees hash identically regardless of the order + * the filesystem reports them, and any single-byte content change changes the + * digest. By default ANY nested symlink (or a symlink at the root itself) + * throws `OwnedFsError('SYMLINK_IN_TREE', relativePath)` instead of being + * recorded, so callers can never mistake a redirected tree for a stable one. + */ +export async function ownedTreeDigest ( + targetPath: string, + options: { allowSymlinks?: false } = {} +): Promise { + const kind = await ownedPathKind(targetPath) + if (kind === 'missing') return null + if (kind === 'junction-or-symlink') { + // A symlinked root is never dereferenced: it cannot be digested as a tree, + // and silently returning null would hide the redirect from callers. + throw new OwnedFsError('SYMLINK_IN_TREE', '.') + } + if (kind !== 'directory') return null + const entries: Array<{ relative: string, kind: string, digest: string }> = [] + const walk = async (absolute: string, relative: string): Promise => { + const children = await readdir(absolute) + for (const child of children.sort(comparePathNames)) { + const childAbsolute = path.join(absolute, child) + const childRelative = relative === '' ? child : `${relative}/${child}` + const childKind = await ownedPathKind(childAbsolute) + if (childKind === 'junction-or-symlink') { + throw new OwnedFsError('SYMLINK_IN_TREE', childRelative) + } + if (childKind === 'directory') { + // Directory structure itself is part of the identity: a tree with an + // extra empty directory must not collide with the same tree without it. + entries.push({ relative: childRelative, kind: 'directory', digest: '' }) + await walk(childAbsolute, childRelative) + continue + } + if (childKind === 'file') { + const bytes = await readFile(childAbsolute) + entries.push({ relative: childRelative, kind: 'file', digest: createHash('sha256').update(bytes).digest('hex') }) + continue + } + entries.push({ relative: childRelative, kind: 'other', digest: '' }) + } + } + await walk(targetPath, '') + entries.sort((left, right) => comparePathNames(left.relative, right.relative)) + const hash = createHash('sha256') + for (const entry of entries) { + hash.update(entry.kind).update('\0').update(entry.relative).update('\0').update(entry.digest).update('\0') + } + return hash.digest('hex') +} + +/** + * Assert that `root` is a directory containing no symlink anywhere, without + * dereferencing anything. On the first symlink (sorted path order) throws + * `OwnedFsError('SYMLINK_IN_TREE', relativePath)`, with `'.'` when the root + * itself is a symlink. A missing root throws `OwnedFsError('KIND_CHANGED')` + * since callers pass owned trees they believe exist; any other stat error + * propagates unchanged. + */ +export async function assertNoSymlinksInTree (root: string): Promise { + const kind = await ownedPathKind(root) + if (kind === 'missing') throw new OwnedFsError('KIND_CHANGED', undefined, `Owned tree is missing: ${root}`) + if (kind === 'junction-or-symlink') throw new OwnedFsError('SYMLINK_IN_TREE', '.') + if (kind !== 'directory') throw new OwnedFsError('KIND_CHANGED', undefined, `Owned tree is not a directory: ${root}`) + const walk = async (absolute: string, relative: string): Promise => { + const children = (await readdir(absolute)).sort(comparePathNames) + for (const child of children) { + const childAbsolute = path.join(absolute, child) + const childRelative = relative === '' ? child : `${relative}/${child}` + const childKind = await ownedPathKind(childAbsolute) + if (childKind === 'junction-or-symlink') throw new OwnedFsError('SYMLINK_IN_TREE', childRelative) + if (childKind === 'directory') await walk(childAbsolute, childRelative) + } + } + await walk(root, '') +} + +/** 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') + if (sourceKind === 'junction-or-symlink') throw new OwnedFsError('SYMLINK_IN_TREE', '.') + if (sourceKind === 'directory') await assertNoSymlinksInTree(source) + 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))) + } + } +} + +/** + * Atomically write a regular file at an owned path. The target must currently + * be `expectedKind` ('missing' or 'file'); anything else — including a + * symlink — throws `OwnedFsError('KIND_CHANGED')` and the existing symlink is + * left untouched (its referent is never modified). Bytes go to a randomized + * sibling temp file, are fsynced, the target kind is revalidated immediately + * before an atomic rename over the target, and the temp file is removed on + * every failure path. + */ +export async function writeOwnedFile ( + targetPath: string, + bytes: Buffer, + options: { mode?: number, expectedKind: 'missing' | 'file' } +): Promise { + const assertExpectedKind = (current: OwnedPathKind): void => { + if (current !== options.expectedKind) throw new OwnedFsError('KIND_CHANGED', undefined, `Owned path kind is '${current}', expected '${options.expectedKind}': ${targetPath}`) + } + assertExpectedKind(await ownedPathKind(targetPath)) + const absolute = path.resolve(targetPath) + const directory = path.dirname(absolute) + const basename = path.basename(absolute) + let temp: string | undefined + let handle: Awaited> | undefined + for (let attempt = 0; attempt < OWNED_TEMP_ATTEMPTS; attempt++) { + const candidate = path.join(directory, `.${basename}.nsolid-tmp-${process.pid}-${Math.random().toString(36).slice(2)}-${attempt}`) + try { + handle = await open(candidate, 'wx', options.mode ?? 0o600) + temp = candidate + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST' && attempt < OWNED_TEMP_ATTEMPTS - 1) continue + throw error + } + } + if (handle === undefined || temp === undefined) throw new Error('Owned temporary file could not be created') + try { + await handle.writeFile(bytes) + await handle.sync() + await handle.close() + handle = undefined + assertExpectedKind(await ownedPathKind(targetPath)) + await rename(temp, absolute) + } catch (error) { + // Only a successfully opened `wx` candidate belongs to this invocation. + // Close it before removing it; a colliding pre-existing path is foreign + // and is never assigned to `temp`, so it can never be cleaned up here. + if (handle !== undefined) await handle.close().catch(() => {}) + await rm(temp, { force: true }).catch(() => {}) + throw error + } +} diff --git a/packages/core/src/update/package-content.ts b/packages/core/src/update/package-content.ts new file mode 100644 index 0000000..11db786 --- /dev/null +++ b/packages/core/src/update/package-content.ts @@ -0,0 +1,90 @@ +import { lstatSync, readFileSync, readlinkSync, readdirSync, realpathSync } from 'node:fs' +import path from 'node:path' +import { gunzipSync } from 'node:zlib' +import { readTarEntries } from './tar-format.js' + +/** Caps both the compressed tarball and its decompressed output; copied from tarball.ts. */ +const MAX_TARBALL_BYTES = 64 * 1024 * 1024 + +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 compressed = readFileSync(tarballPath) + if (compressed.length > MAX_TARBALL_BYTES) throw new Error('tarball exceeds size bound') + const archive = gunzipSync(compressed, { maxOutputLength: MAX_TARBALL_BYTES }) + const entries = new Map() + for (const { path: entryPath, type, body, linkTarget } of readTarEntries(archive)) { + const relative = packageRelativePath(entryPath) + 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: linkTarget }) + else if (type !== '5') throw new Error('unsupported tar entry') + } + } + 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 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/plan-projection.ts b/packages/core/src/update/plan-projection.ts new file mode 100644 index 0000000..c7a02ad --- /dev/null +++ b/packages/core/src/update/plan-projection.ts @@ -0,0 +1,56 @@ +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { PublicPlanStep, UpdatePlanStep } from './types.js' + +const REDACTED_TEMP = '' + +function osTempDirectory (): string { + return path.resolve(tmpdir()) +} + +/** + * Replace any OS-temporary-rooted path inside a value with '/'. + * Planning embeds per-run staging paths (transaction manifests, downloaded + * tarballs, fresh result locations) that are transient internal state, with + * mkdtemp-random directory names: only the file name survives redaction. + * Handles both standalone paths and arguments embedding one (--package=). + */ +function redactTempPath (value: string): string { + const temp = osTempDirectory() + const index = value.indexOf(temp) + if (index < 0) return value + const remainder = value.slice(index + temp.length) + const segments = remainder.replace(/\\/g, '/').split('/') + const basename = segments[segments.length - 1] + return `${value.slice(0, index)}${REDACTED_TEMP}/${basename}` +} + +/** + * Project plan steps into their public, structured-output shape. Everything + * execution-internal is intentionally dropped: the command environment (may + * carry credential-bearing variables), the working directory and spawn + * identity (provenance evidence, not user-facing state), and the timeout. + * A secret such as the fallback transaction nonce never appears in step + * data, and tarball identities are not part of step output. + */ +export function publicPlanSteps (steps: readonly UpdatePlanStep[]): PublicPlanStep[] { + return steps.map((step) => { + if (step.kind === 'command') { + return { + kind: 'command' as const, + description: step.description, + executable: step.command.executable, + args: step.command.args.map(redactTempPath), + } + } + if (step.kind === 'filesystem') { + return { + kind: 'filesystem' as const, + description: step.description, + operation: step.operation, + paths: step.paths.map(redactTempPath), + } + } + return { kind: 'validation' as const, description: step.description, checks: [...step.checks] } + }) +} 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..a0dd0ff --- /dev/null +++ b/packages/core/src/update/refresh-owned-cli.ts @@ -0,0 +1,85 @@ +#!/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 { writeFallbackChildResult } from './fallback-result-protocol.js' +import { manifestDigestOf } from './fallback-journal.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) +} +// Required canonical manifest digest: the parent computes it from its trusted +// in-memory manifest; the child recomputes it from the parsed transaction file +// and rejects any disagreement BEFORE claiming the journal or mutating. +const digestIndex = args.indexOf('--manifest-digest') +const manifestDigest = digestIndex >= 0 ? args[digestIndex + 1] : undefined +if (manifestDigest === undefined || !/^[0-9a-f]{64}$/.test(manifestDigest)) { + console.error('nsolid-plugin-refresh-owned requires --manifest-digest ') + process.exit(2) +} +// Optional structured result path: newer parents plan it inside their private +// workspace; older parents never pass it and the envelope is skipped safely. +const resultIndex = args.indexOf('--result') +const resultPath = resultIndex >= 0 ? args[resultIndex + 1] : undefined + +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) +} +// Reject a tampered or replaced manifest before any journal interaction. +if (manifestDigestOf(transaction) !== manifestDigest) { + console.error('Fallback transaction manifest does not match its planned digest') + 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, + manifestDigest, + }) +} catch { + console.error('Owned refresh failed before mutation') + console.error('rollback: not-attempted') + process.exit(1) +} +if (!result.success) { + // Publish the structured, nonce-bound envelope before the legacy human + // output so the parent can surface an allowlisted code without trusting + // this process's stdout/stderr. The envelope carries no free-form text; + // preservation arrays are bounded reporting data. + if (resultPath && transaction.nonce) { + const rollback = result.rollbackAttempted === true + ? { attempted: true, succeeded: result.rollbackSucceeded === true } + : { attempted: false } + await writeFallbackChildResult(resultPath, transaction.nonce, result.error?.code ?? '', rollback, { + preservedArtifacts: result.preservedArtifacts, + preservedPaths: result.preservedPaths, + }) + } + 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/render.ts b/packages/core/src/update/render.ts new file mode 100644 index 0000000..2b2c034 --- /dev/null +++ b/packages/core/src/update/render.ts @@ -0,0 +1,192 @@ +import type { UpdatePlan, UpdatePlanItem, UpdateSummary } from './types.js' +import { C } from '../utils/format.js' + +export interface UpdatePlanDisplayOptions { + /** Machine-readable run: the plan never pollutes stdout. */ + json: boolean + /** Read-only check: always render the plan. */ + check: boolean + /** Interactive run without --yes: the plan precedes the confirmation prompt. */ + willConfirm: boolean +} + +/** + * Decide whether the human-readable update plan is rendered. The plan must be + * visible before every interactive confirmation — including items whose + * version is unknown, which are mutable (requiresConfirmation) without ever + * carrying an update-available status — while --json runs and pre-approved + * (--yes) runs stay quiet. + */ +export function shouldDisplayUpdatePlan (plan: UpdatePlan, options: UpdatePlanDisplayOptions): boolean { + if (options.json) return false + if (options.check) return true + if (!options.willConfirm) return false + return plan.items.some((item) => item.version.status === 'update-available' || item.requiresConfirmation) +} + +/** + * Deterministic order with only exactly identical commands collapsed: manual + * commands are separate recovery paths (npm, pnpm, npx, Volta), never + * variants of one another. + */ +export function uniqueCommands (commands: readonly string[]): string[] { + return [...new Set(commands)] +} + +export function humanVersionStatus (item: UpdatePlanItem): string { + if (item.source.kind === 'unsupported') return 'manual update required' + switch (item.version.status) { + case 'update-available': return 'update available' + case 'newer-than-registry': return 'up to date' + case 'current': return 'up to date' + case 'unknown': return 'version unknown' + } +} + +function formatChangeNames (names: readonly string[]): string { + if (names.length <= 3) return names.join(', ') + return `${names.slice(0, 3).join(', ')}, … (+${names.length - 3} more)` +} + +function formatChanges (changes: NonNullable): string { + const parts: string[] = [] + const noun = (count: number, singular: string, plural: string) => count === 1 ? singular : plural + if (changes.skillsAdded.length > 0) parts.push(`+${changes.skillsAdded.length} ${noun(changes.skillsAdded.length, 'skill', 'skills')} (${formatChangeNames(changes.skillsAdded)})`) + if (changes.skillsRemoved.length > 0) parts.push(`−${changes.skillsRemoved.length} ${noun(changes.skillsRemoved.length, 'skill', 'skills')} (${formatChangeNames(changes.skillsRemoved)})`) + if (changes.skillsUpdated > 0) parts.push(`${changes.skillsUpdated} ${noun(changes.skillsUpdated, 'skill', 'skills')} refreshed`) + if (changes.mcpAdded.length > 0) parts.push(`+${changes.mcpAdded.length} MCP server${changes.mcpAdded.length === 1 ? '' : 's'} (${formatChangeNames(changes.mcpAdded)})`) + if (changes.mcpRemoved.length > 0) parts.push(`−${changes.mcpRemoved.length} MCP server${changes.mcpRemoved.length === 1 ? '' : 's'} (${formatChangeNames(changes.mcpRemoved)})`) + if (changes.mcpUpdated > 0) parts.push(`${changes.mcpUpdated} MCP server${changes.mcpUpdated === 1 ? '' : 's'} reconciled`) + return parts.join(', ') +} + +function formatCommand (executable: string, args: readonly string[]): string { + return [executable, ...args].map((value) => /[\s"']/.test(value) ? JSON.stringify(value) : value).join(' ') +} + +export function printUpdatePlan (plan: UpdatePlan, color: boolean, verbose: boolean, output: NodeJS.WritableStream = process.stderr): void { + if (plan.items.length === 0) { + output.write('No installations detected.\n') + return + } + output.write(plan.checkOnly ? 'Update check:\n' : 'Updates available:\n') + for (const item of plan.items) printUpdatePlanItem(item, color, verbose, output) +} + +export function printUpdatePlanItem (item: UpdatePlanItem, color: boolean, verbose: boolean, output: NodeJS.WritableStream): void { + const paint = (value: string) => color ? C.dim(value) : value + if (item.planningError) { + output.write(` ✗ ${item.installationId} — ${item.planningError.message}\n`) + return + } + output.write(` ${item.installationId} — ${humanVersionStatus(item)}`) + if (item.version.current && item.version.latest && item.version.status !== 'current') { + if (item.version.status === 'newer-than-registry') output.write(` (${item.version.current}; registry has ${item.version.latest})`) + else 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') + if (item.changes) { + const changeText = formatChanges(item.changes) + if (changeText) output.write(` ${paint('changes:')} ${changeText}\n`) + } + for (const command of item.manualCommands ?? []) output.write(` → ${command}\n`) + if (!verbose && item.steps.length > 0) { + output.write(` ${paint('(run with --verbose to see the full technical plan)')}\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}:`)} ${formatPlanPaths(step.paths)}\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}:`)} ${formatPlanPaths(step.paths)}\n`) + if (step.kind === 'validation') output.write(` ${paint('check:')} ${step.checks.join('; ')}\n`) + } + } +} + +/** + * Human-readable plan rendering caps long path lists: the JSON output stays + * complete, but a fallback plan must not print every tracked skill path three + * times on stderr. + */ +function formatPlanPaths (paths: readonly string[], limit = 3): string { + if (paths.length <= limit) return paths.join(', ') + const remaining = paths.length - limit + const noun = remaining === 1 ? 'path' : 'paths' + return `${paths.slice(0, limit).join(', ')}, … (+${remaining} ${noun})` +} + +export function printUpdateSummary (summary: UpdateSummary, color: boolean, output: NodeJS.WritableStream = process.stdout): void { + const ok = (value: string) => color ? C.green(value) : value + const bad = (value: string) => color ? C.red(value) : value + for (const result of summary.results) { + switch (result.status) { + case 'updated': { + const span = result.currentVersion && result.resultingVersion + ? ` (${result.currentVersion} → ${result.resultingVersion})` + : result.resultingVersion ? ` (${result.resultingVersion})` : '' + output.write(`${ok('✓')} ${result.installationId} updated${span}\n`) + if (result.restartHint) output.write(` ${result.restartHint}\n`) + break + } + case 'current': { + const suffix = result.currentVersion ? ` (${result.currentVersion})` : '' + output.write(`${ok('✓')} ${result.installationId} is up to date${suffix}\n`) + break + } + case 'newer-than-registry': { + const local = result.currentVersion ?? '?' + const registry = result.latestVersion ? `; registry has ${result.latestVersion}` : '' + output.write(`${ok('✓')} ${result.installationId} is up to date (${local}${registry})\n`) + break + } + case 'unsupported': { + output.write(`${bad('✗')} ${result.installationId} cannot be updated automatically\n`) + // Every recovery path is printed: truncating to two commands hid the + // remaining exact-version commands (npx, Volta) from the user. + for (const command of uniqueCommands(result.manualCommands ?? [])) output.write(` → ${command}\n`) + break + } + case 'failed': { + const error = result.error ? ` — ${result.error.message}` : '' + output.write(`${bad('✗')} ${result.installationId} update failed${error}\n`) + if (result.rollbackCommand) output.write(` → restore: ${result.rollbackCommand}\n`) + break + } + case 'skipped': { + const error = result.error ? ` — ${result.error.message}` : '' + output.write(`${bad('✗')} ${result.installationId} skipped${error}\n`) + break + } + case 'unknown': + output.write(`${result.installationId} — installed version unknown\n`) + break + case 'not-installed': + output.write(`${result.installationId} — not installed\n`) + // A requested-but-absent harness gets concrete installable guidance, + // even though the result carries no actionable update. + for (const command of uniqueCommands(result.manualCommands ?? [])) output.write(` → ${command}\n`) + break + default: + output.write(`${result.installationId}: ${result.status}\n`) + } + } +} + +/** Capture helper for unit tests over the rendered output. */ +export function captureRender (render: (output: NodeJS.WritableStream) => void): string { + let text = '' + const stream = { + write (chunk: string | Uint8Array) { + text += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString() + }, + } as unknown as NodeJS.WritableStream + render(stream) + return text +} diff --git a/packages/core/src/update/strategies/antigravity.ts b/packages/core/src/update/strategies/antigravity.ts new file mode 100644 index 0000000..8065a7e --- /dev/null +++ b/packages/core/src/update/strategies/antigravity.ts @@ -0,0 +1,51 @@ +import path from 'node:path' +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import { resolveExecutableIdentity } from '../command-runner.js' +import { transactionCommand } from '../transaction-commands.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 manualCommands = ['agy plugin uninstall nsolid-plugin', `agy plugin install ${pinnedSource}`] + 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, + } + } + return { + ...planItem( + installation, + [ + { kind: 'filesystem', description: 'Back up the staged plugin and matching import manifest', operation: 'backup', paths }, + transactionCommand(identity, ['plugin', 'uninstall', 'nsolid-plugin'], 'Uninstall the existing Antigravity N|Solid plugin'), + transactionCommand(identity, ['plugin', 'install', pinnedSource], 'Install the fixed NodeSource GitHub plugin root at the planned commit'), + { 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, + } + }, + + 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..6b1679e --- /dev/null +++ b/packages/core/src/update/strategies/claude.ts @@ -0,0 +1,69 @@ +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import { resolveExecutableIdentity } from '../command-runner.js' +import { transactionCommand } from '../transaction-commands.js' +import { nativeExecutionGuard } from '../native-evidence.js' +import { executeClaudeTransaction } from '../claude-transaction.js' +import { failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' +import { resolveHome } from '../../utils/path.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}`, + ], + } + } + return planItem( + installation, + [ + transactionCommand(identity, ['plugin', 'marketplace', 'update', source.marketplace], `Refresh the detected ${source.marketplace} Claude marketplace`), + transactionCommand(identity, ['plugin', 'update', source.pluginId, '--scope', source.scope], `Update ${source.pluginId} in its detected ${source.scope} scope`), + ], + [], + '/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: [...new Set([ + ...(item.metadata?.nativeEvidence ?? []).map((entry) => entry.path), + resolveHome('~/.claude.json'), + ])], + 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..bd1cf69 --- /dev/null +++ b/packages/core/src/update/strategies/cli-package.ts @@ -0,0 +1,103 @@ +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import { buildGlobalUpdateCommand, formatRollbackCommand, managerArgsForIdentity, verifyGlobalPackage, verifyLocalArtifact } from '../package-manager.js' +import { isTreeTerminationUnconfirmed, isCommandSuccessful } from '../command-runner.js' +import { commandFailure, failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' +import { cleanupNpmArtifact } from '../version-source.js' +import { cliExactVersionManualCommands } from '../cli-guidance.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 launcherSource = installation.source.kind === 'unsupported' ? installation.source.source : undefined + return { + ...item, + manualCommands: cliExactVersionManualCommands(installation.version.latest, launcherSource), + } + } + 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 (isTreeTerminationUnconfirmed(result)) { + return failedResult(item, { code: 'CLI_TREE_TERMINATION_UNCONFIRMED', message: 'CLI update command ended 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..e28c231 --- /dev/null +++ b/packages/core/src/update/strategies/codex.ts @@ -0,0 +1,89 @@ +import path from 'node:path' +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import { resolveExecutableIdentity } from '../command-runner.js' +import { transactionCommand } from '../transaction-commands.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 manualCommands = [ + `codex plugin marketplace upgrade ${source.marketplace}`, + `codex plugin remove ${source.pluginId}`, + `codex plugin add ${source.pluginId}`, + ] + 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, + } + } + 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, + } + } + return { + ...planItem( + plannedInstallation, + [ + transactionCommand(identity, ['plugin', 'marketplace', 'upgrade', source.marketplace], `Refresh the detected Codex marketplace ${source.marketplace}`), + { kind: 'filesystem', description: 'Back up the exact Codex plugin registration and cached payload', operation: 'backup', paths: [configPath, cachePath] }, + transactionCommand(identity, ['plugin', 'remove', source.pluginId], `Remove the detected plugin ${source.pluginId} before reinstalling the refreshed snapshot`), + transactionCommand(identity, ['plugin', 'add', source.pluginId], `Reinstall the detected plugin ${source.pluginId} from the refreshed marketplace`), + { 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..342b5f6 --- /dev/null +++ b/packages/core/src/update/strategies/common.ts @@ -0,0 +1,93 @@ +import type { + UpdateError, + UpdateInstallation, + UpdatePlanItem, + UpdatePlanStep, + UpdateResult, + UpdateStatus, + VersionInfo, +} from '../types.js' +import { publicPlanSteps } from '../plan-projection.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, + steps: publicPlanSteps(item.steps), + // Always emitted with a stable shape so structured-output consumers see + // one schema: a non-mutating result carries an explicitly empty change set. + changes: item.changes ?? { skillsAdded: [], skillsRemoved: [], skillsUpdated: 0, mcpAdded: [], mcpRemoved: [], mcpUpdated: 0 }, + 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'], extra: Partial = {}): UpdateResult { + return resultFromPlan(item, 'failed', { changed: false, error, rollback, ...extra }) +} + +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/execution.ts b/packages/core/src/update/strategies/execution.ts new file mode 100644 index 0000000..9f36b9d --- /dev/null +++ b/packages/core/src/update/strategies/execution.ts @@ -0,0 +1,18 @@ +import type { UpdateContext, UpdatePlanItem, UpdateResult } from '../types.js' + +/** Cleanup authorization produced by an internal strategy execution. */ +export type PlanResourceDisposition = 'release' | 'preserve' + +/** + * Internal-only execution result. The coordinator consumes the disposition and + * publishes only `result`; it is deliberately not part of UpdateStrategy or + * the package's public update types. + */ +export interface InternalUpdateExecutionOutcome { + result: UpdateResult + planResources: PlanResourceDisposition +} + +export type InternalUpdateExecutor = { + executeWithOutcome: (item: UpdatePlanItem, context: UpdateContext) => Promise +} diff --git a/packages/core/src/update/strategies/fallback.ts b/packages/core/src/update/strategies/fallback.ts new file mode 100644 index 0000000..fb9a7a0 --- /dev/null +++ b/packages/core/src/update/strategies/fallback.ts @@ -0,0 +1,716 @@ +import { chmod, mkdtemp, readFile, 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 { CommandResult, UpdateContext, UpdateError, UpdateInstallation, UpdatePlanItem, UpdatePlanStep, UpdateResult, UpdateStrategy } from '../types.js' +import type { FallbackPathEvidence, FallbackTransactionIdentity } from '../types.js' +import { FALLBACK_PROTOCOL_VERSION } from '../types.js' +import { deriveFallbackFrontierLeafTargets, deriveFallbackFrontierPlan, evaluateFallbackFrontierPlatformSupport, fallbackLinkMaterialization, FallbackFrontierError, type FallbackFrontierPlan } from '../fallback-frontier.js' +import type { HarnessType, BundleDescriptor } from '../../types.js' +import { isTreeTerminationUnconfirmed, DEFAULT_COMMAND_TIMEOUT_MS, resolveExecutableIdentity, isCommandSuccessful } from '../command-runner.js' +import { failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' +import { getTrackingFilePath } from '../../utils/path.js' +import { captureFallbackPathEvidence, fallbackBundlePaths, resolveFallbackDestinations } from '../fallback-planning.js' +import { getAdapter } from '../../harnesses/index.js' +import { getHarnessSkillsPath } from '../../skills/skill-linker.js' +import { finalizeFallbackJournal, beginFallbackJournal, manifestDigestOf, markFallbackJournalMutating, isFallbackJournalLockBusyError, pathDigest, pathKind, reclaimFallbackJournalMutation, recoverFallbackJournalMutation, recoverFallbackJournal, trackingDigest, type FallbackJournalHandle } from '../fallback-journal.js' +import { managerArgsForIdentity, verifyLocalArtifact } from '../package-manager.js' +import { readTrackingFile } from '../../skills/skill-tracker.js' +import { harnessMcpKey, readMcpFieldDigests } from '../mcp-lookup.js' +import { readTarEntryText, readTarEntryTextFromBytes } from '../tarball.js' +import { validateBundle } from '../../validate.js' +import { assertSafeSkillName } from '../../utils/skill-name.js' +import { childResultArgs, containmentDirectoryMatches, fallbackChildResultMessage, readValidatedFallbackChildResult, recordContainmentDirectoryIdentity, FALLBACK_CHILD_RESULT_FILENAME, type ContainmentDirectoryIdentity, type FallbackChildResultEnvelope } from '../fallback-result-protocol.js' +import type { InternalUpdateExecutionOutcome, InternalUpdateExecutor, PlanResourceDisposition } from './execution.js' + +function executionOutcome (result: UpdateResult, planResources: PlanResourceDisposition): InternalUpdateExecutionOutcome { + return { result, planResources } +} + +/** + * The public strategy interface remains result-only. The coordinator uses + * this internal executor so cleanup authorization travels with the execution + * outcome instead of hidden result-object identity. + */ +export const fallbackStrategy: UpdateStrategy & InternalUpdateExecutor = { + 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 = await 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' }) + } + // Parse the verified bundle BEFORE the manifest is written: every planned + // destination must be derived from the verified tarball itself. A bundle + // that cannot be parsed fails the mutable plan closed. + const verifiedBundle = await parseVerifiedBundle(installation.artifact.tarballPath) + if (!verifiedBundle) { + return planItem(installation, [], [], undefined, { code: 'BUNDLE_INVALID', message: 'Fallback update could not read the verified bundle descriptor' }) + } + try { + for (const skill of verifiedBundle.skills) assertSafeSkillName(skill.name) + } catch { + return planItem(installation, [], [], undefined, { code: 'BUNDLE_INVALID', message: 'Update bundle contains an unsafe skill destination' }) + } + // Derive the complete frontier/leaf graph from the verified bundle plus + // the trusted identity BEFORE the manifest is written: planning evidence + // must fail closed before any manifest, journal, or live state exists. + const { destination, linkDir } = resolveFallbackDestinations(installation.target as HarnessType) + let frontierPlan: FallbackFrontierPlan + try { + // Derive the leaf graph once; the child consumes this exact evidence. + frontierPlan = await deriveFallbackFrontierPlan(deriveFallbackFrontierLeafTargets({ + ownedSkills: identity.ownedSkills, + ownedLinks: identity.ownedLinks, + ownedMcpConfigPaths: identity.ownedMcpConfigPaths, + trackingPath: identity.trackingPath, + destination, + linkDir, + bundleSkillNames: verifiedBundle.skills.map((skill) => skill.name), + }), fallbackLinkMaterialization(installation.target as HarnessType)) + } catch (error) { + const code = error instanceof FallbackFrontierError ? error.code : 'FALLBACK_FRONTIER_INVALID_INPUT' + return planItem(installation, [], [], undefined, { code, message: `Fallback update could not derive its destination evidence: ${(error as Error).message}` }) + } + // Publishing a planned-missing frontier is supported on Linux only for + // now; unsupported platforms fail the plan closed BEFORE the manifest + // workspace exists. Every derived frontier counts as active: conditional + // (MCP-only) inactivity cannot be proven from mutable planning state. + const frontierSupport = evaluateFallbackFrontierPlatformSupport(frontierPlan.frontiers) + if (!frontierSupport.supported) { + return { + ...planItem(installation, [], [], undefined, { + code: frontierSupport.code, + message: `This platform cannot yet create the missing parent directories (${frontierSupport.frontierPaths.join(', ')}) inside a fallback transaction. Create them manually or reinstall, then retry; no files were changed.`, + }), + manualCommands: [`nsolid-plugin install --harness ${installation.target}`], + } + } + 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 plannedIdentity: FallbackTransactionIdentity = { + ...identity, + bundleDestinations: await captureDestinationEvidence(installation.target as HarnessType, verifiedBundle), + // Exact deterministic frontier evidence derived above; the child never + // adds leaves. Explicitly present (empty when no frontier applies). + plannedMissingFrontiers: frontierPlan.frontiers, + } + const { manifestPath, manifestDigest, resultPath, resultContainment } = await createManifest(plannedIdentity) + const version = installation.version.latest! + const changes = await summarizeFallbackChanges(installation, installation.artifact.tarballPath) + const childCommand = ['nsolid-plugin-refresh-owned', '--transaction', manifestPath, '--manifest-digest', manifestDigest, ...childResultArgs(resultPath)] + const managerArgs = executor === 'npm-exec' + ? ['exec', '--yes', `--package=${installation.artifact.tarballPath}`, '--', ...childCommand] + : [`--package=${installation.artifact.tarballPath}`, 'dlx', ...childCommand] + 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: plannedIdentity }, + [ + { 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, + changes, + temporaryDirectories: [path.dirname(manifestPath)], + resultContainment: [resultContainment], + } + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + return (await fallbackStrategy.executeWithOutcome(item, context)).result + }, + + async executeWithOutcome (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return executionOutcome(failedResult(item, item.planningError), 'release') + if (!item.requiresConfirmation && item.steps.length === 0) { + return executionOutcome(resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)), 'release') + } + const mutablePlan = mutableFallbackPlan(item) + if (mutablePlan === undefined) { + // The coordinator performs the same gate before invoking this strategy. + // Preserve any planning-owned state for direct callers as well: an + // invalid plan has no authority to release resources it did not execute. + return executionOutcome(failedResult(item, { code: 'INVALID_PLAN', message: 'Mutable fallback update plan must include a transaction and command' }), 'preserve') + } + const { transaction, command } = mutablePlan + let workspace: string | undefined + let freshResultDir: string | undefined + let freshResultIdentity: ContainmentDirectoryIdentity | undefined + let journal: FallbackJournalHandle | undefined + // Snapshot directory created by this invocation; kept in memory so a + // pre-mutation rejection can report exactly this transaction's preserved + // residue without trusting the journal file for paths. + let begunSnapshotDirectory: string | undefined + type ExecutionPhase = 'before-resources' | 'execution-resources' | 'journal-open' | 'command-running' | 'command-finished' + let phase: ExecutionPhase = 'before-resources' + let planResourceDisposition: PlanResourceDisposition | undefined + const complete = (result: UpdateResult, disposition: PlanResourceDisposition = planResourceDisposition ?? 'release'): InternalUpdateExecutionOutcome => { + planResourceDisposition = disposition + return executionOutcome(result, disposition) + } + try { + workspace = await mkdtemp(path.join(tmpdir(), 'nsolid-plugin-update-')) + // Fresh per-execution result location: a same-plan retry can never replay + // a stale envelope, and the parent never deletes anything by pathname, so + // there is no check/delete race against a swapped directory. + freshResultDir = await mkdtemp(path.join(tmpdir(), 'nsolid-plugin-result-')) + phase = 'execution-resources' + await chmod(workspace, 0o700) + await chmod(freshResultDir, 0o700) + freshResultIdentity = await recordContainmentDirectoryIdentity(freshResultDir) + const freshResultPath = path.join(freshResultDir, FALLBACK_CHILD_RESULT_FILENAME) + // 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 }) + // Refuse to run when a recorded containment directory was swapped: the + // transaction manifest the child will read lives there, and the fresh + // result directory must still be the one this process created. Failing + // here precedes journal creation, so a refused execution leaves no + // journal state behind. + if ((item.resultContainment ?? []).some((identity) => !containmentDirectoryMatches(identity, identity.directory)) || + (freshResultIdentity !== undefined && !containmentDirectoryMatches(freshResultIdentity, freshResultIdentity.directory))) { + return complete(failedResult(item, { code: 'FALLBACK_COMMAND_FAILED', message: 'Fallback transaction workspace changed after planning' }, { attempted: false }), 'release') + } + if (item.artifact?.kind === 'npm' && !verifyLocalArtifact(item.artifact)) { + return complete(failedResult(item, { code: 'ARTIFACT_INTEGRITY_FAILED', message: 'The planned fallback tarball no longer matches its registry integrity' }), 'release') + } + // A journal left by an earlier run is never restored or cleaned here: + // next-run recovery is a strictly pending state that blocks the + // update until a human resolves it. + const recovery = await recoverFallbackJournal(transaction.trackingPath) + if (recovery.pending) { + return complete(failedResult(item, { code: 'FALLBACK_RECOVERY_PENDING', message: 'A previous fallback transaction is still pending. Restore or remove it manually before updating; its journal and snapshot are preserved next to the tracking file.' }, { attempted: false }), 'release') + } + try { + const begun = await beginFallbackJournal(transaction) + begunSnapshotDirectory = begun.journal.snapshotDirectory + journal = await markFallbackJournalMutating(begun.handle) + phase = 'journal-open' + } catch (error) { + if (error instanceof Error && error.message === 'FALLBACK_TRACKING_DRIFT') { + return complete(failedResult(item, { code: 'FALLBACK_TRACKING_DRIFT', message: 'Fallback tracking file changed after planning' }, { attempted: false }), 'release') + } + if (error instanceof Error && error.message === 'FALLBACK_JOURNAL_BUSY') { + return complete(failedResult(item, { code: 'FALLBACK_RECOVERY_PENDING', message: 'A previous fallback transaction is still pending. Restore or remove it manually before updating.' }, { attempted: false }), 'release') + } + return complete(failedResult(item, { code: 'FALLBACK_BACKUP_FAILED', message: 'Fallback parent snapshot could not be completed' }, { attempted: false }), 'release') + } + // Refuse to run when a recorded containment directory was swapped: the + // transaction manifest the child will read lives there, so a replaced + // directory means the child would consume a transaction this parent + // never planned. + if ((item.resultContainment ?? []).some((identity) => !containmentDirectoryMatches(identity, identity.directory))) { + return complete(failedResult(item, { code: 'FALLBACK_COMMAND_FAILED', message: 'Fallback transaction workspace changed after planning' }, { attempted: false }), 'release') + } + phase = 'command-running' + const result = await context.commandRunner.run({ + ...command, + args: resultArgsWithPath(command.args, freshResultPath), + cwd: workspace, + env: { + ...command.env, + NPM_CONFIG_USERCONFIG: path.join(workspace, '.npmrc'), + npm_config_userconfig: path.join(workspace, '.npmrc'), + }, + }) + phase = 'command-finished' + if (!isCommandSuccessful(result)) { + if (isTreeTerminationUnconfirmed(result)) { + const outcome: FallbackRecoveryOutcome = { + kind: 'tree-termination-unconfirmed', + preservation: { preservedArtifacts: [], preservedPaths: [] }, + } + return complete(projectFallbackFailure(item, command, result, undefined, undefined, outcome), preservesExecutionArtifacts(outcome) ? 'preserve' : 'release') + } + // Structured child result: read and fully validate the nonce-bound + // envelope before projecting the child-owned failure code. Raw child + // stdout/stderr is never promoted to public state or rollback state. + const structured = transaction.nonce !== undefined + ? await readValidatedFallbackChildResult(freshResultPath, transaction.nonce, { containmentDirectories: freshResultIdentity ? [freshResultIdentity] : [] }) + : undefined + const structuredMessage = structured === undefined ? undefined : fallbackChildResultMessage(structured.code, item.target) + // Validated child reporting arrays seed the preservation evidence; + // parent-side outcomes are merged in below. Child paths stay strictly + // reporting-only: no delete or overwrite is ever justified by them. + const preservation = childPreservationEvidence(structured) + // A validated FALLBACK_PROTOCOL_UNSUPPORTED is a PRE-MUTATION + // rejection: the child refused before claiming the journal or touching + // any live path. The parent must not report a rollback or restore. A + // journal-owned disposal-only operation is not available yet, so even + // an in-memory proof of the planned original state only determines the + // reporting outcome; it does not authorize releasing the journal, + // snapshot, or corresponding execution resources. This also avoids + // restoring over a concurrent change made after the asynchronous + // verification point. + if (journal !== undefined && !result.timedOut && result.spawnErrorCode !== 'ENOENT' && structured?.code === 'FALLBACK_PROTOCOL_UNSUPPORTED') { + const journalPath = journal.journalPath + const snapshotDirectory = begunSnapshotDirectory + const provablyUntouched = await liveStateMatchesPlannedEvidence(journal.manifest) + preserveRecoveryLocations(preservation, journalPath, snapshotDirectory) + if (provablyUntouched) { + const outcome: FallbackRecoveryOutcome = { kind: 'original-state-verified', preservation } + return complete(projectFallbackFailure(item, command, result, structured, structuredMessage, outcome), 'preserve') + } + const outcome: FallbackRecoveryOutcome = { kind: 'authority-unreclaimable', preservation } + return complete(projectFallbackFailure(item, command, result, structured, structuredMessage, outcome), 'preserve') + } + // Hybrid rollback: the parent reclaims owner authority (only after a + // confirmed command completion) and restores using its IN-MEMORY + // manifest as the sole authority. Owned paths are rewritten from + // backups authenticated against that manifest; planned-missing + // destinations are only ever moved to a parent-randomized quarantine, + // never rm'd. If the journal no longer matches the in-memory manifest + // or a backup fails authentication, nothing is mutated and the state + // is reported unproven. + const outcome = await recoverFallbackFailure(journal, begunSnapshotDirectory, preservation) + return complete(projectFallbackFailure(item, command, result, structured, structuredMessage, outcome), preservesExecutionArtifacts(outcome) ? 'preserve' : 'release') + } + if (journal) { + let ownerHandle: FallbackJournalHandle + try { + ownerHandle = await reclaimFallbackJournalMutation(journal) + journal = ownerHandle + } catch (error) { + // The journal API fails closed on an abandoned or contended + // transition lock; surface its message (lock path + manual recovery) + // instead of swallowing it. Reporting only: nothing is deleted. + const lockDiagnostic = isFallbackJournalLockBusyError(error) ? ` ${(error as Error).message}` : '' + return complete(failedResult(item, { code: 'FALLBACK_STATE_UNPROVEN', message: `Fallback child completed but the parent journal could not be reclaimed safely; its state was left untouched for manual recovery.${lockDiagnostic}` }, { attempted: false }), 'preserve') + } + const completion = await finalizeFallbackJournal(ownerHandle, async () => { + const tracking = await readTrackingFile() + const bundleEvidence = tracking?.bundleVersions?.[item.target as keyof typeof tracking.bundleVersions] ?? tracking?.bundleVersion + return !!tracking && bundleEvidence === item.version.latest && validateFallbackPostconditions(tracking, item.target) + }) + if (completion.failure !== undefined) { + const restored = completion.result + const validationMessage = completion.failure === 'journal' ? 'Fallback child completed without proving the planned owned-state mutation' : 'Fallback child completed without the planned bundle evidence' + return complete(failedResult(item, { code: restored.succeeded ? 'FALLBACK_VALIDATION_FAILED' : 'FALLBACK_ROLLBACK_FAILED', message: restored.succeeded ? validationMessage : 'Fallback validation failed and parent recovery was incomplete' }, { attempted: true, succeeded: restored.succeeded }, { preservedArtifacts: restored.preservedArtifacts, preservedPaths: restored.preservedPaths }), 'release') + } + const commitResult = completion.result + return complete(resultFromPlan(item, 'updated', { + resultingVersion: item.version.latest, + rollback: { attempted: false }, + preservedArtifacts: commitResult.preservedArtifacts.length > 0 ? commitResult.preservedArtifacts : undefined, + preservedPaths: commitResult.preservedPaths.length > 0 ? commitResult.preservedPaths : undefined, + }), 'release') + } + return complete(failedResult(item, { code: 'FALLBACK_STATE_UNPROVEN', message: 'Fallback child completed without a parent transaction journal; its state was left untouched for manual recovery' }, { attempted: false }), 'preserve') + } catch { + const preserve = ['journal-open', 'command-running', 'command-finished'].includes(phase as string) + return complete(failedResult(item, { code: 'UPDATE_EXECUTION_FAILED', message: 'Update strategy failed' }), preserve ? 'preserve' : 'release') + } finally { + if (planResourceDisposition !== 'preserve') { + // Execution temporaries belong exclusively to this strategy. The + // coordinator owns planning temporaries and releases them only after + // reading this internal disposition; journal backups and quarantines + // remain exclusively under journal authority. + if (workspace !== undefined) await rm(workspace, { recursive: true, force: true }).catch(() => {}) + // Pathname cleanup of a parent-created mkdtemp directory is safe by + // rm semantics: recursive removal never follows symlinks, so a + // swapped path can only delete what the swapper placed there. + if (freshResultDir !== undefined) await rm(freshResultDir, { recursive: true, force: true }).catch(() => {}) + } + } + }, +} + +/** + * Human-oriented diff of what the update will change, computed from the + * tracked state and the verified tarball's bundle descriptor. Best-effort by + * design: an unreadable tarball must never block the plan itself — the full + * technical detail remains in the steps and the structured output. The + * bundle is read in-process so the summary never executes a PATH-resolved + * binary and never blocks on an unmanaged child process. + */ +export async function summarizeFallbackChanges (installation: UpdateInstallation, tarballPath: string): Promise { + try { + return await summarizeFallbackChangesFromBytes(installation, await readFile(tarballPath)) + } catch { + return undefined + } +} + +/** + * Same summary from in-memory artifact bytes: the read-only `--check` path + * must never materialize the verified tarball on disk. + */ +export async function summarizeFallbackChangesFromBytes (installation: UpdateInstallation, bytes: Buffer): Promise { + try { + const raw = readTarEntryTextFromBytes(bytes, 'package/bundle.json') + if (raw === undefined) return undefined + const bundle = validateBundle(JSON.parse(raw)) + const trackedSkills = installation.metadata?.trackedSkills ?? [] + const trackedNames = new Set(trackedSkills.map((skill) => skill.name)) + const newNames = bundle.skills.map((skill) => skill.name) + const trackedMcp = installation.metadata?.trackedMcpNames ?? [] + const trackedMcpSet = new Set(trackedMcp) + const newMcp = bundle.mcpServers.map((server) => server.name) + const skillsAdded = newNames.filter((name) => !trackedNames.has(name)) + const skillsRemoved = [...trackedNames].filter((name) => !newNames.includes(name)) + const skillsUpdated = newNames.filter((name) => trackedNames.has(name)).length + const mcpAdded = newMcp.filter((name) => !trackedMcpSet.has(name)) + const mcpRemoved = trackedMcp.filter((name) => !newMcp.includes(name)) + const mcpUpdated = newMcp.filter((name) => trackedMcpSet.has(name)).length + return { skillsAdded, skillsRemoved, skillsUpdated, mcpAdded, mcpRemoved, mcpUpdated } + } catch { + return undefined + } +} + +export async function createFallbackIdentity (installation: UpdateInstallation): Promise { + 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 mcpConfigPaths = [...new Set([...trackedConfigPaths, ...(canonical ? [path.resolve(canonical)] : [])])] + const { destination, linkDir } = resolveFallbackDestinations(harness) + const skillPaths = skills.map((skill) => path.resolve(skill.path)) + const linkPaths = harness === 'opencode' ? [] : skills.map((skill) => path.resolve(getHarnessSkillsPath(harness), skill.name)) + let ownedSkills + let ownedLinks + let ownedMcpConfigPaths: FallbackPathEvidence[] + try { + ownedSkills = await captureFallbackPathEvidence(skillPaths) + ownedLinks = await captureFallbackPathEvidence(linkPaths) + // Whole-file kind/digest evidence for every MCP config path: this is what + // authenticates MCP backups during rollback. + ownedMcpConfigPaths = await captureFallbackPathEvidence(mcpConfigPaths) + } catch { + return undefined + } + return { + installationId: installation.installationId, + harness, + trackingPath, + trackingDigest: digest, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + digestAlgorithm: 'fallback-path-v2', + nonce: randomUUID(), + ownedSkills, + ownedLinks, + 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, + // Filled in by the planner from the verified tarball's bundle descriptor + // before the manifest is written; empty here so direct callers still get + // a shape-valid identity. + bundleDestinations: [], + // Required protocol-v3 field: the planner overwrites this with the exact + // derived graph (plannedMissingFrontiers: frontierPlan.frontiers) before + // the manifest is written; empty here only for the transient base shape. + plannedMissingFrontiers: [], + approvedDestinationRoots: [destination, ...(linkDir === undefined ? [] : [linkDir])], + } +} + +/** Read and validate package/bundle.json from the verified tarball, in-process. */ +async function parseVerifiedBundle (tarballPath: string): Promise { + try { + const raw = await readTarEntryText(tarballPath, 'package/bundle.json') + if (raw === undefined) return undefined + return validateBundle(JSON.parse(raw)) + } catch { + return undefined + } +} + +/** Capture planning-time evidence for every skill and harness-link destination the verified bundle will create. */ +async function captureDestinationEvidence (harness: HarnessType, bundle: BundleDescriptor): Promise { + const { destination, linkDir } = resolveFallbackDestinations(harness) + const paths = fallbackBundlePaths(destination, linkDir, bundle.skills.map((skill) => skill.name)) + return await captureFallbackPathEvidence(paths) +} + +async function createManifest (identity: FallbackTransactionIdentity): Promise<{ manifestPath: string; manifestDigest: string; resultPath: string; resultContainment: ContainmentDirectoryIdentity }> { + // The private 0700 staging directory is created and owned by this process; + // the structured result path lives inside it so parent validation can bind + // the envelope to workspace ownership and cleanup removes it with the + // workspace. + const directory = await mkdtemp(path.join(tmpdir(), 'nsolid-plugin-manifest-')) + // mkdtemp already applies 0700; the explicit chmod keeps the private-mode + // guarantee independent of platform defaults that could widen it. + await chmod(directory, 0o700) + const manifestPath = path.join(directory, 'transaction.json') + await writeFile(manifestPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 }) + const resultContainment = await recordContainmentDirectoryIdentity(directory) + // The canonical digest is computed from the trusted in-memory manifest and + // transported explicitly to the child; the child recomputes it from the + // parsed transaction file and rejects any disagreement before claiming. + return { manifestPath, manifestDigest: manifestDigestOf(identity), resultPath: path.join(directory, FALLBACK_CHILD_RESULT_FILENAME), resultContainment } +} + +type MutableFallbackPlan = { + transaction: FallbackTransactionIdentity + command: Extract['command'] +} + +/** Shared pre-execution contract check for the public coordinator and strategy. */ +export function isValidMutableFallbackPlan (item: UpdatePlanItem): boolean { + return mutableFallbackPlan(item) !== undefined +} + +/** Validate the execution contract before creating any process workspace. */ +function mutableFallbackPlan (item: UpdatePlanItem): MutableFallbackPlan | undefined { + if (!item.requiresConfirmation || item.ownership !== 'fallback' || item.source.kind !== 'fallback') return undefined + if (item.fallbackTransaction === undefined) return undefined + const commandStep = item.steps.find((entry): entry is Extract => entry.kind === 'command') + if (commandStep === undefined) return undefined + return { transaction: item.fallbackTransaction, command: commandStep.command } +} + +/** Point the planned child command at a fresh per-execution result path; older plans without --result gain it safely. */ +function resultArgsWithPath (args: readonly string[] | undefined, resultPath: string): string[] { + const list = args === undefined ? [] : [...args] + const index = list.indexOf('--result') + if (index >= 0 && index + 1 < list.length) { + list[index + 1] = resultPath + return list + } + return [...list, '--result', resultPath] +} + +interface PreservationEvidence { + preservedArtifacts: string[] + preservedPaths: string[] +} + +type FallbackRecoveryOutcome = + | { kind: 'tree-termination-unconfirmed'; preservation: PreservationEvidence } + | { kind: 'original-state-verified'; preservation: PreservationEvidence } + | { kind: 'authority-unreclaimable'; preservation: PreservationEvidence } + | { kind: 'restore-verified'; preservation: PreservationEvidence } + | { kind: 'restore-incomplete'; reason: 'unproven' | 'incomplete' | 'journal-finalization-pending'; preservation: PreservationEvidence; diagnostic?: string } + +/** The outcome, not a child claim, determines the public rollback projection. */ +function publicRollback (outcome: FallbackRecoveryOutcome): UpdateResult['rollback'] { + switch (outcome.kind) { + case 'tree-termination-unconfirmed': + case 'original-state-verified': + return { attempted: false } + case 'authority-unreclaimable': + return { attempted: false } + case 'restore-verified': + return { attempted: true, succeeded: true } + case 'restore-incomplete': + return { attempted: true, succeeded: false } + } +} + +/** Only an unconfirmed process tree keeps the parent execution workspace for manual recovery. */ +function preservesExecutionArtifacts (outcome: FallbackRecoveryOutcome): boolean { + return outcome.kind === 'tree-termination-unconfirmed' +} + +/** Project every public failure field once from the authoritative recovery outcome and reporting-only child data. */ +function projectFallbackFailure ( + item: UpdatePlanItem, + command: Extract['command'], + result: CommandResult, + structured: FallbackChildResultEnvelope | undefined, + structuredMessage: string | undefined, + outcome: FallbackRecoveryOutcome +): UpdateResult { + return failedResult(item, fallbackFailureError(command, result, structured, structuredMessage, outcome), publicRollback(outcome), reportedPreservation(outcome.preservation)) +} + +function fallbackFailureError ( + command: Extract['command'], + result: CommandResult, + structured: FallbackChildResultEnvelope | undefined, + structuredMessage: string | undefined, + outcome: FallbackRecoveryOutcome +): UpdateError { + if (outcome.kind === 'tree-termination-unconfirmed') { + return { + code: 'FALLBACK_TREE_TERMINATION_UNCONFIRMED', + message: 'Fallback refresh command ended and descendant termination could not be confirmed; recovery artifacts were preserved', + } + } + if (result.spawnErrorCode === 'ENOENT') return { code: 'MISSING_EXECUTABLE', message: `${command.executable} executable was not found on PATH` } + if (result.timedOut) return { code: 'FALLBACK_COMMAND_TIMEOUT', message: 'Fallback refresh command timed out' } + if (outcome.kind === 'restore-incomplete' && outcome.reason === 'unproven') { + const message = 'The fallback transaction could not prove the state of its owned files, so the automatic rollback was refused. Its journal, snapshot, and preserved artifacts were left untouched for manual recovery.' + return { + code: 'FALLBACK_STATE_UNPROVEN', + message: outcome.diagnostic === undefined ? message : `${message} ${outcome.diagnostic}`, + } + } + if (outcome.kind === 'restore-incomplete') { + return { code: 'FALLBACK_ROLLBACK_FAILED', message: 'Fallback refresh command failed and its rollback was incomplete' } + } + if (structured !== undefined && structuredMessage !== undefined) return { code: structured.code, message: structuredMessage } + return { code: 'FALLBACK_COMMAND_FAILED', message: 'Fallback refresh command failed' } +} + +/** Recover only after the caller has established confirmed tree termination. Child data is merged only as reporting evidence. */ +async function recoverFallbackFailure ( + journal: FallbackJournalHandle | undefined, + snapshotDirectory: string | undefined, + preservation: PreservationEvidence +): Promise { + if (journal === undefined) return { kind: 'authority-unreclaimable', preservation } + + const restored = await recoverFallbackJournalMutation(journal, snapshotDirectory) + mergePreservation(preservation, restored) + return restored.succeeded + ? { kind: 'restore-verified', preservation } + : { + // A physically restored frontier with pending journal bookkeeping + // remains incomplete; durable finalization is a separate proof. + kind: 'restore-incomplete', + reason: restored.unproven === true ? 'unproven' : restored.frontierJournalPending === true ? 'journal-finalization-pending' : 'incomplete', + diagnostic: restored.diagnostic, + preservation, + } +} + +function preserveRecoveryLocations (preservation: PreservationEvidence, journalPath: string | undefined, snapshotDirectory: string | undefined): void { + for (const location of [journalPath, snapshotDirectory]) { + if (location !== undefined && !preservation.preservedArtifacts.includes(location)) preservation.preservedArtifacts.push(location) + } + preservation.preservedArtifacts.sort() +} + +/** Seed preservation evidence from a validated child envelope. The arrays are reporting data only: nothing deletes or overwrites because of them. */ +function childPreservationEvidence (structured: FallbackChildResultEnvelope | undefined): PreservationEvidence { + return { + preservedArtifacts: structured?.preservedArtifacts === undefined ? [] : [...structured.preservedArtifacts], + preservedPaths: structured?.preservedPaths === undefined ? [] : [...structured.preservedPaths], + } +} + +/** Deterministically merge reporting arrays: order-stable, deduplicated union. */ +function mergePreservation (target: PreservationEvidence, source: { preservedArtifacts?: readonly string[]; preservedPaths?: readonly string[] } | undefined): void { + if (source === undefined) return + target.preservedArtifacts = [...new Set([...target.preservedArtifacts, ...(source.preservedArtifacts ?? [])])].sort() + target.preservedPaths = [...new Set([...target.preservedPaths, ...(source.preservedPaths ?? [])])].sort() +} + +/** Emit preservation arrays only when non-empty so results keep one stable shape. */ +function reportedPreservation (preservation: PreservationEvidence): Pick { + return { + preservedArtifacts: preservation.preservedArtifacts.length > 0 ? preservation.preservedArtifacts : undefined, + preservedPaths: preservation.preservedPaths.length > 0 ? preservation.preservedPaths : undefined, + } +} + +/** + * Prove, from the trusted IN-MEMORY manifest only, that every manifest-owned + * path still carries exactly its planned kind/digest and the tracking file its + * planned digest. Validation-only read (nothing is mutated); the journal file + * is never consulted because it cannot authenticate the live state. + */ +async function liveStateMatchesPlannedEvidence (manifest: FallbackTransactionIdentity): Promise { + for (const planned of [...manifest.ownedSkills, ...manifest.ownedLinks, ...manifest.ownedMcpConfigPaths]) { + const kind = await pathKind(planned.path) + if (kind !== planned.kind) return false + if (kind === 'missing') continue + if (await pathDigest(planned.path) !== planned.digest) return false + } + return trackingDigest(manifest.trackingPath) === manifest.trackingDigest +} + +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) + }) +} diff --git a/packages/core/src/update/strategies/pi.ts b/packages/core/src/update/strategies/pi.ts new file mode 100644 index 0000000..6693b85 --- /dev/null +++ b/packages/core/src/update/strategies/pi.ts @@ -0,0 +1,265 @@ +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'}`], + } + } + if (installation.artifact?.kind !== 'npm' || !installation.artifact.tarballPath) { + return planItem(installation, [], [], undefined, { code: 'ARTIFACT_IDENTITY_REQUIRED', message: 'Pi update requires the verified registry tarball captured during planning' }) + } + 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'}`], + } + } + // Pi identifies the package and its user/project caches by the installed + // source spec. The verified tarball captured during planning is not passed + // to Pi: it remains on the plan item as the expected identity that + // validatePiEvidence compares against after Pi's native update completes. + const spawn = managerArgsForIdentity(identity, ['update', source.spec, 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/tar-format.ts b/packages/core/src/update/tar-format.ts new file mode 100644 index 0000000..333c49c --- /dev/null +++ b/packages/core/src/update/tar-format.ts @@ -0,0 +1,72 @@ +interface TarEntry { + path: string + type: string + body: Buffer + linkTarget: string +} + +/** + * Iterate uncompressed TAR entries, resolving per-entry PAX/GNU paths. + * Bodies are views into the archive. Callers own decompression limits and + * payload policy. As before, a zero header ends traversal and final padding + * or a trailing partial header is not required. + */ +export function * readTarEntries (archive: Buffer): Generator { + 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') { + yield { path: paxPath ?? longPath ?? headerPath, type, body, linkTarget: tarString(header.subarray(157, 257)) } + paxPath = undefined + longPath = undefined + } + offset = bodyStart + Math.ceil(size / 512) * 512 + } +} + +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/tarball.ts b/packages/core/src/update/tarball.ts new file mode 100644 index 0000000..8b769b0 --- /dev/null +++ b/packages/core/src/update/tarball.ts @@ -0,0 +1,75 @@ +import { readFile } from 'node:fs/promises' +import { gunzipSync } from 'node:zlib' + +/** npm registry tarballs stay far below this bound; it caps both the compressed file and its decompressed output. */ +const MAX_TARBALL_BYTES = 64 * 1024 * 1024 +/** bundle.json is a small descriptor; a larger entry is treated as malformed input. */ +const MAX_ENTRY_BYTES = 1 << 20 +const HEADER_SIZE = 512 + +function isGzip (bytes: Buffer): boolean { + return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b +} + +function headerEntryName (header: Buffer): string { + const nameField = header.subarray(0, 100) + const end = nameField.indexOf(0) + return (end >= 0 ? nameField.subarray(0, end) : nameField).toString('utf8') +} + +function entrySize (header: Buffer): number { + const sizeField = header.subarray(124, 136).toString('ascii').replace(/[\0 ]+$/, '').trim() + if (!/^[0-7]*$/.test(sizeField)) return NaN + return parseInt(sizeField, 8) +} + +/** + * Extract one file entry from a tarball on disk (gzip-compressed or plain), + * entirely in-process. Reading a planned tarball must never depend on a + * PATH-resolved binary. Any unreadable, malformed, truncated, or oversized + * input yields undefined — callers treat the extraction as best-effort. + */ +export async function readTarEntryText (tarballPath: string, entryName: string): Promise { + try { + return readTarEntryTextFromBytes(await readFile(tarballPath), entryName) + } catch { + return undefined + } +} + +/** + * Extract one file entry from in-memory tar bytes (gzip-compressed or plain). + * The read-only `--check` path uses this to summarize a verified artifact + * without ever materializing it on disk. Limits are identical to the + * path-based reader: any unreadable, malformed, truncated, or oversized + * input yields undefined — callers treat the extraction as best-effort. + */ +export function readTarEntryTextFromBytes (raw: Buffer, entryName: string): string | undefined { + try { + if (raw.length === 0 || raw.length > MAX_TARBALL_BYTES) return undefined + // Bound the decompressed output too: the file-size check above only + // limits the compressed bytes, and a hostile artifact can expand far + // beyond it. zlib throws RangeError past the limit; the outer catch + // turns that into the documented best-effort undefined. + const bytes = isGzip(raw) ? gunzipSync(raw, { maxOutputLength: MAX_TARBALL_BYTES }) : raw + let offset = 0 + while (offset + HEADER_SIZE <= bytes.length) { + const header = bytes.subarray(offset, offset + HEADER_SIZE) + if (header.every((byte) => byte === 0)) return undefined + const size = entrySize(header) + const type = String.fromCharCode(header[156]) + offset += HEADER_SIZE + if (!Number.isInteger(size) || size < 0) return undefined + const body = bytes.subarray(offset, offset + size) + offset += Math.ceil(size / HEADER_SIZE) * HEADER_SIZE + if (offset > bytes.length) return undefined + if (type === '0' && headerEntryName(header) === entryName) { + if (size > MAX_ENTRY_BYTES) return undefined + return body.toString('utf8') + } + } + return undefined + } 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..ea1e2fb --- /dev/null +++ b/packages/core/src/update/transaction-commands.ts @@ -0,0 +1,29 @@ +import type { CommandResult, CommandRunner, CommandSpec, ExecutableIdentity, UpdatePlanStep } from './types.js' +import { DEFAULT_COMMAND_TIMEOUT_MS, isCommandSuccessful } from './command-runner.js' +import { managerArgsForIdentity } from './package-manager.js' + +export function transactionCommand ( + identity: Exclude, + args: string[], + description: string +): Extract { + return { kind: 'command', description, command: { ...managerArgsForIdentity(identity, args), executableIdentity: identity, timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS } } +} + +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..e1720ef --- /dev/null +++ b/packages/core/src/update/types.ts @@ -0,0 +1,550 @@ +import type { HarnessType } from '../types.js' +import type { ContainmentDirectoryIdentity } from './fallback-result-protocol.js' +import type { PayloadNormalizationProfile } from './native-payload.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 + /** Digest of the same planned payload bytes under comparisonProfile; installed-payload equivalence ONLY. */ + comparisonDigest?: string + /** Named normalization profile shared by plan and installed comparison; execution fails closed when missing or mismatched. */ + comparisonProfile?: PayloadNormalizationProfile +} + +export interface LocalArtifactIdentity { + kind: 'local-snapshot' + root: string + contentDigest: string + /** Digest of the same planned payload bytes under comparisonProfile; installed-payload equivalence ONLY. */ + comparisonDigest?: string + /** Named normalization profile shared by plan and installed comparison; execution fails closed when missing or mismatched. */ + comparisonProfile?: PayloadNormalizationProfile +} + +export type ResolvedArtifactIdentity = NpmArtifactIdentity | GitArtifactIdentity | LocalArtifactIdentity + +export interface FallbackPathEvidence { + path: string + kind: 'missing' | 'file' | 'directory' | 'symlink' | 'other' + digest?: string +} + +/** Role of an authorized fallback leaf destination; drives activation and later semantic proof. */ +export type FallbackLeafRole = 'skill' | 'link' | 'mcp-config' | 'tracking' + +/** Whether a planned fallback destination requires parent creation during this run. */ +export type FallbackLeafActivation = 'required' | 'conditional' + +/** + * One authorized leaf destination planned for a fallback update. Leaves are + * supplied by the planner from verified evidence only; the frontier module + * never widens this set on its own. + */ +export interface FallbackLeafTarget { + /** Stable planner-supplied identity retained for semantic postcondition proof. */ + id: string + role: FallbackLeafRole + activation: FallbackLeafActivation + /** Absolute canonical destination path; never a remote, relative, or `..`-spelled path. */ + path: string +} + +/** + * Immutable local filesystem identity of an existing anchor directory, + * captured with `lstat({ bigint: true })` plus `realpath`. Decimal strings + * keep the evidence JSON-stable across processes without precision loss. + * `type: 'directory'` records the observed kind; strict evidence parsing and + * identity capture both require it. + */ +export interface FallbackAnchorIdentity { + path: string + realpath: string + /** Observed anchor kind; strictly required to be `'directory'`. */ + type: 'directory' + device: string + inode: string +} + +/** + * A topmost missing directory (the frontier) whose publication a fallback + * transaction will own, attached to its authenticated existing anchor. The + * frontier path is a direct child of the anchor; everything from the frontier + * down to each leaf is missing at planning time. + */ +export interface FallbackFrontierEvidence { + /** First missing path component; a direct child of `anchor.path`. */ + frontierPath: string + /** `required` when any covered leaf requires creation this run. */ + activation: FallbackLeafActivation + anchor: FallbackAnchorIdentity + /** Every authorized leaf this frontier creates, in UTF-8 byte order. */ + leaves: readonly FallbackLeafTarget[] +} + +/** + * Current fallback journal/manifest protocol. A manifest whose protocolVersion + * differs is rejected by the child BEFORE any mutation with the stable code + * FALLBACK_PROTOCOL_UNSUPPORTED; there is intentionally no cross-version + * compatibility path (older protocol state was never deployed). + */ +export const FALLBACK_PROTOCOL_VERSION = 3 + +export interface FallbackTransactionIdentity { + installationId: string + harness: HarnessType + trackingPath: string + trackingDigest: string + /** Protocol gate: the child rejects any other value before mutation. */ + protocolVersion: number + /** Shared secret authenticating the child transaction (never authorizing restores). */ + nonce?: string + /** Digest domain of every path digest in this manifest; never reinterpret across domains. */ + digestAlgorithm?: 'fallback-path-v2' + /** Planned kind/digest evidence for every tracked skill, captured before approval. */ + ownedSkills: readonly FallbackPathEvidence[] + /** Planned kind/digest evidence for every harness link, captured before approval. */ + ownedLinks: readonly FallbackPathEvidence[] + ownedMcpFields: readonly { + configPath: string + server: string + field: string + expectedDigest: string + }[] + /** + * Whole-file kind/digest evidence for the union of tracked MCP config paths + * and the adapter canonical path, fixed at planning. Whole-path evidence is + * what authenticates MCP backups during rollback. + */ + ownedMcpConfigPaths: readonly FallbackPathEvidence[] + /** + * Every skill and harness-link destination the verified bundle will create, + * derived by the PARENT from bundle.json of the verified tarball before + * execution. The child never adds unplanned paths to the journal. Kept + * separate from the tracked owned sets so ownership semantics stay distinct. + */ + bundleDestinations: readonly FallbackPathEvidence[] + /** Canonical roots under which the planned bundle destinations may be created. */ + approvedDestinationRoots: readonly string[] + /** + * Topmost missing parent frontiers this transaction is authorized to create, + * derived and validated by the shared frontier module before the journal is + * reserved. REQUIRED in protocol v3: explicit `[]` when no frontier applies, + * with NO absent-field fallback anywhere. Journal reservation, mutation + * claims, and child preflight reject any manifest whose frontier evidence + * fails strict parsing (absent, malformed, extra keys, tampered graph) + * before any mutation, with the stable FALLBACK_FRONTIER_EVIDENCE_INVALID + * code. protocolVersion 3 has no compatibility path: journals carrying + * frontier evidence that fails strict revalidation against the live + * filesystem fail closed. + */ + plannedMissingFrontiers: readonly FallbackFrontierEvidence[] +} + +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 + /** + * Launcher path used to prove CLI installation ownership. Package-private + * seam: production defaults to `process.argv[1]`; tests inject a fixture + * launcher instead of mutating global process state. + */ + executablePath?: 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 + /** + * Whether the whole descendant tree is confirmed gone, including after + * early root exit. False always requires deferral/recovery, even with exit + * code zero. Every runner must provide explicit evidence; true is the only + * value that proves whole-tree termination. + */ + 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 +} + +/** + * Public projection of a plan step for structured output. It deliberately + * carries no command environment, working directory, spawn identity, or + * timeout: those are internal execution state, and every transient + * temporary path inside them is redacted by the projection in + * plan-projection.ts. + */ +export type PublicPlanStep = + | { kind: 'command'; description: string; executable: string; args: readonly string[] } + | { kind: 'filesystem'; description: string; operation: 'backup' | 'replace' | 'reconcile' | 'restore' | 'cleanup'; paths: readonly string[] } + | { kind: 'validation'; description: string; checks: readonly 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[] + /** + * Human-oriented summary of what the update will change (added/removed/ + * refreshed skills and MCP servers). The CLI renders this instead of the + * step-by-step technical plan; --verbose shows the full plan and the + * structured --json output keeps every step. + */ + changes?: { + skillsAdded: readonly string[] + skillsRemoved: readonly string[] + skillsUpdated: number + mcpAdded: readonly string[] + mcpRemoved: readonly string[] + mcpUpdated: number + } + /** + * Identities of the directories holding the structured fallback child + * result, recorded at creation time so the parent can reject a directory + * that was replaced (for example by a symlink) before reading the result. + * Absent on items planned by older code or by non-fallback strategies. + */ + resultContainment?: readonly ContainmentDirectoryIdentity[] + /** + * Reporting-only recovery evidence on a `fallback:recovery` plan item: + * journal/snapshot artifacts that restore-only recovery preserved next to + * the tracking file, and live paths left for manual inspection because + * their state could not be authorized. These arrays never authorize + * filesystem mutation and are omitted when empty so public shapes stay + * stable. + */ + preservedArtifacts?: readonly string[] + preservedPaths?: 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[] + /** + * Technical plan projection for the structured output (secrets and + * transient internal state redacted); steps as planned, not as executed. + */ + steps?: readonly PublicPlanStep[] + /** Human-oriented change summary of the planned update, as planned. */ + changes?: UpdatePlanItem['changes'] + rollback?: { + attempted: boolean + succeeded?: boolean + } + /** Paths/artifacts preserved because cleanup could not be authenticated. Reporting only. */ + preservedArtifacts?: readonly string[] + /** Live paths left untouched because their state could not be authorized. Reporting only. */ + preservedPaths?: readonly string[] + 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..aa86216 --- /dev/null +++ b/packages/core/src/update/version-source.ts @@ -0,0 +1,529 @@ +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 { GitArtifactIdentity, MarketplaceVersionSource, NpmArtifactIdentity, UpdateError, VersionLookupResult } from './types.js' +import { isStableVersion } from './version.js' +import { bytesMatchIntegrity } from './integrity.js' +import { redactSecrets } from './redaction.js' +import { CODEX_INSTALLED_COMPARISON_PROFILE, plannedPayloadIdentityFromArchive, plannedPayloadIdentityFromTree } 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)) + // ONE capture of the snapshot feeds both identities; a snapshot that + // already contains reserved harness metadata carries no comparison + // identity and fails closed at execution time. + const identity = plannedPayloadIdentityFromTree(payloadRoot, CODEX_INSTALLED_COMPARISON_PROFILE) + const contentDigest = identity.contentDigest + 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 } + if (identity.comparisonDigest && identity.comparisonProfile) { + result.artifact.comparisonDigest = identity.comparisonDigest + result.artifact.comparisonProfile = identity.comparisonProfile + } + } + } + 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 identity = commit && options.requireImmutable + ? await resolveGitPayloadIdentity(repository, commit, payloadPath, payloadManifest, options) + : undefined + const contentDigest = identity?.contentDigest ?? manifestDigest + if (commit && options.requireImmutable && !identity?.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') } + if (!commit) return { version } + const artifact: GitArtifactIdentity = { + kind: 'git', + repository, + commit, + contentDigest: contentDigest ?? manifestDigest, + payloadPath: payloadPath || undefined, + } + if (identity?.comparisonDigest) { + artifact.comparisonDigest = identity.comparisonDigest + artifact.comparisonProfile = CODEX_INSTALLED_COMPARISON_PROFILE + } + return { version, artifact } + } 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 resolveGitPayloadIdentity(repository, commit, '', '', options)).contentDigest + : 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) +} + +/** + * Download the immutable commit archive once and derive both identities from + * the same planned source bytes: the strict evidence digest and the + * installed-comparison digest under the Codex normalization profile. A + * planned payload that already contains reserved harness metadata carries no + * comparison identity, so execution fails closed instead of masking it. + */ +async function resolveGitPayloadIdentity (repository: string, commit: string, payloadPath: string, payloadManifest: string, options: VersionSourceOptions): Promise<{ contentDigest?: string; comparisonDigest?: string }> { + try { + const parsed = new URL(repository) + if (parsed.hostname.toLowerCase() !== 'github.com') return {} + const segments = parsed.pathname.replace(/\.git$/, '').split('/').filter(Boolean) + if (segments.length !== 2 || !isFullCommit(commit)) return {} + const response = await fetchWithTimeout(`https://codeload.github.com/${segments[0]}/${segments[1]}/tar.gz/${commit}`, options) + if (!response.ok) return {} + const archive = await readArchiveWithLimit(response) + const scope = { payloadPath, manifestPath: payloadManifest } + // ONE archive parse feeds both identities. + const identity = plannedPayloadIdentityFromArchive(archive, scope, CODEX_INSTALLED_COMPARISON_PROFILE) + return { contentDigest: identity.contentDigest, comparisonDigest: identity.comparisonDigest } + } catch { return {} } +} + +/** 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') +} + +/** + * Fetch and integrity-verify the immutable npm artifact entirely in memory. + * The read-only `--check` path summarizes from these bytes so it never + * touches the filesystem; the mutating path materializes them afterwards. + */ +export async function fetchVerifiedNpmArtifactBytes ( + artifact: Pick, + options: VersionSourceOptions = {} +): Promise { + const response = await fetchWithTimeout(artifact.tarball, options) + if (!response.ok) throw new Error(`registry tarball returned ${response.status}`) + const bytes = await readArchiveWithLimit(response) + if (!bytesMatchIntegrity(bytes, artifact.integrity)) throw new Error('registry tarball integrity mismatch') + return bytes +} + +async function downloadAndVerifyTarball ( + url: string, + integrity: string, + options: VersionSourceOptions +): Promise<{ path: string; directory: string; contentDigest: string }> { + const bytes = await fetchVerifiedNpmArtifactBytes({ tarball: url, integrity }, options) + 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/src/utils/path.ts b/packages/core/src/utils/path.ts index 6136044..dd57827 100644 --- a/packages/core/src/utils/path.ts +++ b/packages/core/src/utils/path.ts @@ -13,6 +13,12 @@ export function normalizePath (p: string): string { return path.resolve(p) } +// Lexical containment only; callers must separately authenticate filesystem paths. +export function isSameOrContained (candidate: string, parent: string): boolean { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + export function getAgentsDir (): string { return path.join(os.homedir(), '.agents') } diff --git a/packages/core/test/helpers/canonical-temp-root.ts b/packages/core/test/helpers/canonical-temp-root.ts new file mode 100644 index 0000000..2a09841 --- /dev/null +++ b/packages/core/test/helpers/canonical-temp-root.ts @@ -0,0 +1,22 @@ +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +/** + * Create a fixture temp root through its canonical (realpath-resolved) path. + * + * `mkdtempSync` seeds the directory under `os.tmpdir()`, which is a + * non-canonical alias on some CI platforms (macOS exposes `/var/...` where + * `/var` is a symlink to `/private/var`; Windows runner homes expand + * `RUNNER~1` short names). The fallback planner intentionally rejects + * non-canonical path components, so suites that derive HOME and ordinary + * destination paths from a temp root must create that root canonically. + * Hostile-path tests that build their own aliases or symlinks are unaffected. + * + * `realpathSync.native` is required: the JavaScript implementation resolves + * symlinks but leaves Windows 8.3 short-name components (`RUNNER~1`) intact, + * while the native call expands them to the final filesystem path. + */ +export function createCanonicalTempRoot (prefix: string): string { + return realpathSync.native(mkdtempSync(path.join(tmpdir(), prefix))) +} diff --git a/packages/core/test/helpers/fallback-refresh.ts b/packages/core/test/helpers/fallback-refresh.ts new file mode 100644 index 0000000..f74aa6e --- /dev/null +++ b/packages/core/test/helpers/fallback-refresh.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import type { BundleDescriptor } from '../../src/types.js' +import { readTrackingFile } from '../../src/skills/skill-tracker.js' +import { createFallbackIdentity } from '../../src/update/strategies/fallback.js' +import { refreshOwnedInstallation, type FallbackRefreshOptions, type FallbackRefreshResult } from '../../src/update/fallback-transaction.js' +import { captureFallbackPathEvidence, fallbackBundlePaths, resolveFallbackDestinations } from '../../src/update/fallback-planning.js' +import { deriveFallbackFrontierLeafTargets, deriveFallbackFrontierPlan, evaluateFallbackFrontierPlatformSupport, fallbackLinkMaterialization, FallbackFrontierError } from '../../src/update/fallback-frontier.js' +import { beginFallbackJournal, finalizeFallbackJournal, markFallbackJournalMutating, reclaimFallbackJournalMutation, recoverFallbackJournalMutation } from '../../src/update/fallback-journal.js' +import type { FallbackTransactionIdentity, UpdateInstallation } from '../../src/update/types.js' + +type ManifestObserver = (manifest: FallbackTransactionIdentity) => void | Promise + +export interface ParentRefreshTestOptions extends Omit { + /** Observe fixture planning before the parent reserves its journal. */ + observeManifest?: ManifestObserver +} + +/** Capture the same ownership evidence used by the production planner. */ +export async function createParentIdentity (options: Pick): Promise { + const tracking = await readTrackingFile() + assert.ok(tracking, 'fixture requires tracked ownership') + const skills = tracking.skills.filter((entry) => entry.harnesses.includes(options.harness)) + const mcps = tracking.mcpServers.filter((entry) => entry.harness === options.harness) + const installation: UpdateInstallation = { + installationId: `${options.harness}:fallback`, + target: options.harness, + ownership: 'fallback', + installed: true, + source: { kind: 'fallback', bundleVersion: tracking.version }, + version: { current: tracking.version, latest: '1.0.1', status: 'update-available' }, + metadata: { + trackedSkills: skills.map((entry) => ({ name: entry.name, path: entry.paths?.[options.harness] ?? entry.path })), + trackedMcpConfigPath: mcps[0]?.configPath, + trackedMcpNames: mcps.map((entry) => entry.name), + trackedMcpFields: mcps.flatMap((entry) => Object.entries(entry.fields ?? {}).map(([field, expectedDigest]) => ({ configPath: entry.configPath, server: entry.name, field, expectedDigest }))), + }, + } + const manifest = await createFallbackIdentity(installation) + assert.ok(manifest, 'fixture requires a plannable identity') + return manifest +} + +/** Same-process parent fixture: production planning and journal authority, no child process. */ +export async function refreshWithParent ( + options: ParentRefreshTestOptions, + refresh = refreshOwnedInstallation +): Promise { + const manifest = await createParentIdentity(options) + const bundle: BundleDescriptor = JSON.parse(await readFile(options.bundlePath, 'utf8')) + const { destination, linkDir } = resolveFallbackDestinations(options.harness) + try { + const plan = await deriveFallbackFrontierPlan(deriveFallbackFrontierLeafTargets({ + ownedSkills: manifest.ownedSkills, + ownedLinks: manifest.ownedLinks, + ownedMcpConfigPaths: manifest.ownedMcpConfigPaths, + trackingPath: manifest.trackingPath, + destination, + linkDir, + bundleSkillNames: bundle.skills.map((skill) => skill.name), + }), fallbackLinkMaterialization(options.harness)) + manifest.plannedMissingFrontiers = plan.frontiers + manifest.bundleDestinations = await captureFallbackPathEvidence(fallbackBundlePaths(destination, linkDir, bundle.skills.map((skill) => skill.name))) + } catch (error) { + if (!(error instanceof FallbackFrontierError)) throw error + return { success: false, error: { code: error.code, message: error.message }, rollbackAttempted: false } + } + await options.observeManifest?.(manifest) + const support = evaluateFallbackFrontierPlatformSupport(manifest.plannedMissingFrontiers) + if (!support.supported) return { success: false, error: { code: support.code, message: 'Unsupported missing parents; no files were changed' }, rollbackAttempted: false } + const begun = await beginFallbackJournal(manifest) + const { handle } = begun + await markFallbackJournalMutating(handle) + const result = await refresh({ ...options, transaction: manifest }) + if (result.success) { + const owner = await reclaimFallbackJournalMutation(handle) + const completion = await finalizeFallbackJournal(owner) + assert.equal(completion.failure, undefined, JSON.stringify(completion)) + assert.equal(completion.result.succeeded, true, JSON.stringify(completion)) + } else { + // The fixture is the waiting parent; the child never disposes its journal. + await recoverFallbackJournalMutation(handle, begun.journal.snapshotDirectory) + } + return result +} diff --git a/packages/core/test/helpers/tar.ts b/packages/core/test/helpers/tar.ts new file mode 100644 index 0000000..596a3bf --- /dev/null +++ b/packages/core/test/helpers/tar.ts @@ -0,0 +1,44 @@ +/** Minimal ustar fixture: a header plus a body padded to 512 bytes. */ +export 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]) +} + +/** Checksummed repository archive fixture, including the two end blocks. */ +export 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) +} + +export 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/integration/cli-help.test.ts b/packages/core/test/integration/cli-help.test.ts index cdcc30a..e587771 100644 --- a/packages/core/test/integration/cli-help.test.ts +++ b/packages/core/test/integration/cli-help.test.ts @@ -15,11 +15,11 @@ describe('CLI help', () => { assert.strictEqual(result.status, 0, `CLI --help failed: ${result.stderr}`) const output = result.stdout - assert.match(output, /Claude\/Codex\/Antigravity: install from the GitHub plugin root/, 'help must group Codex with root native plugin harnesses') - assert.match(output, /setup authenticates and prepares the MCP bridge runtime/, 'help must describe setup as auth + bridge runtime for native plugin harnesses') + assert.match(output, /Claude\/Codex\/Antigravity: install from the GitHub plugin root, then run setup\./, 'help must group Codex with root native plugin harnesses') + assert.match(output, /setup\s+Authenticate with NodeSource and prepare the MCP bridge/, 'help must describe setup as auth + bridge runtime for native plugin harnesses') assert.doesNotMatch(output, /setup is auth-only/, 'help must not describe setup as auth-only') - assert.match(output, /OpenCode: run setup --harness opencode — it authenticates, prepares the MCP bridge runtime, and installs skills \+ MCP config in one step\./, 'help must describe one-step OpenCode setup') - assert.match(output, /provisions the MCP bridge runtime first, then installs assets/, 'help must describe the install runtime precondition') + assert.match(output, /OpenCode: setup --harness opencode installs everything in one step\./, 'help must describe one-step OpenCode setup') + assert.match(output, /install\s+Install skills\/MCP for a harness without opening a browser/, 'help must describe install as a non-browser installer') assert.doesNotMatch(output, /OpenCode\/Codex/, 'help must not list Codex as a user-level skill harness') }) @@ -29,10 +29,10 @@ describe('CLI help', () => { }) assert.strictEqual(result.status, 0, `CLI --help failed: ${result.stderr}`) - assert.match(result.stdout, /switch-org\s+Force re-authentication to switch NodeSource organizations/, 'help must list switch-org command') + assert.match(result.stdout, /switch-org\s+Re-authenticate to switch NodeSource organizations/, 'help must list switch-org command') }) - it('documents that switch-org refreshes the selected direct-config harness and notes other direct configs need setup/install', () => { + it('documents the switch-org re-bake requirement and scopes its options', () => { const result = spawnSync(process.execPath, ['--import', 'tsx/esm', CLI_PATH, '--help'], { encoding: 'utf-8', }) @@ -40,15 +40,11 @@ describe('CLI help', () => { assert.strictEqual(result.status, 0, `CLI --help failed: ${result.stderr}`) assert.match( result.stdout, - /After switch-org, a direct-config harness passed to --harness \(OpenCode, Pi, fallback CLI installs\) has its MCP config refreshed on the spot/, - 'help must state the selected direct-config harness is refreshed immediately' + /After switch-org, re-run setup\/install per harness to re-bake the new org token\./, + 'help must state direct configs need a later setup/install after switch-org' ) - assert.match( - result.stdout, - /and other direct-config harnesses need a later setup\/install/, - 'help must state other direct configs need a later setup/install' - ) - assert.match(result.stdout, /--accounts-url \s+Explicit origin-only accounts URL override for setup\/switch-org/, 'help must scope --accounts-url to setup/switch-org') - assert.match(result.stdout, /--quiet\s+Suppress step-by-step progress output \(setup\/install\/switch-org\)/, 'help must scope --quiet to setup/install/switch-org') + assert.match(result.stdout, /--accounts-url \s+Accounts URL override for setup\/switch-org/, 'help must scope --accounts-url to setup/switch-org') + assert.match(result.stdout, /--quiet\s+Suppress step-by-step progress \(setup\/install\/switch-org\)/, 'help must scope --quiet to setup/install/switch-org') + assert.match(result.stdout, /--verbose\s+Detailed logging to stderr; shows the full technical update plan/, 'help must offer the full technical plan via --verbose') }) }) diff --git a/packages/core/test/integration/installer.test.ts b/packages/core/test/integration/installer.test.ts index 653bf7e..14febac 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -1,9 +1,9 @@ import { describe, it, beforeEach, afterEach, before, mock } from 'node:test' import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { rmSync, mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { join, sep } from 'node:path' -import { tmpdir } from 'node:os' +import { createCanonicalTempRoot } from '../helpers/canonical-temp-root.js' import http from 'node:http' import type { BundleDescriptor, BrowserLauncher } from '../../src/types.js' import type { ProgressReporter } from '../../src/utils/progress.js' @@ -121,7 +121,7 @@ let originalFetch: typeof globalThis.fetch let originalNpmExecpath: string | undefined beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'nsolid-installer-')) + tmpDir = createCanonicalTempRoot('nsolid-installer-') originalHome = process.env.HOME originalUserProfile = process.env.USERPROFILE originalProgressEnv = process.env.NSOLID_PLUGIN_PROGRESS @@ -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 { refreshWithParent } = await import('../helpers/fallback-refresh.js') + const refreshed = await refreshWithParent({ 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 { refreshWithParent } = await import('../helpers/fallback-refresh.js') + const bytesBeforeRefresh = readFileSync(claudeConfigPath, 'utf8') + const refreshed = await refreshWithParent({ 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/plugin-assets.test.ts b/packages/core/test/integration/plugin-assets.test.ts index 80f8159..165aa03 100644 --- a/packages/core/test/integration/plugin-assets.test.ts +++ b/packages/core/test/integration/plugin-assets.test.ts @@ -16,6 +16,7 @@ import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) const REPO_ROOT = join(__dirname, '..', '..', '..', '..') const SYNC_SCRIPT = join(REPO_ROOT, 'scripts', 'sync-plugin-assets.mjs') +const RELEASE_CHECK_SCRIPT = join(REPO_ROOT, 'scripts', 'check-release-version.mjs') function makeWorkspaceRoot (): string { return mkdtempSync(join(tmpdir(), 'plugin-assets-test-')) @@ -77,6 +78,11 @@ describe('plugin source hygiene', () => { rmSync(root, { recursive: true, force: true }) }) + it('makes release checks enforce the same materialized-skill hygiene gate', () => { + const source = readFileSync(RELEASE_CHECK_SCRIPT, 'utf8') + assert.match(source, /checkCommand\('scripts\/sync-plugin-assets\.mjs', '--check'\)/) + }) + it('keeps Pi as the only source package that materializes skills for pack', () => { writeSkill(root, 'ns-alpha', '# alpha\nextra line\n') writeBundle(root, ['ns-alpha'], ['ns-one']) 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..489d915 --- /dev/null +++ b/packages/core/test/integration/update-flow.test.ts @@ -0,0 +1,394 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { AddressInfo } from 'node:net' +import { checkUpdates, executeUpdatePlan, planUpdates, update } from '../../src/update/index.js' +import { resolveExecutableIdentity } from '../../src/update/command-runner.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 }) +} + +/** A positively structured npm-global CLI installation with its own launcher. */ +function cliGlobalFixture (version: string): { root: string; launcher: string } { + const root = path.join(home, 'prefix', 'lib', 'node_modules', 'nsolid-plugin') + mkdirSync(path.join(root, 'dist', 'src'), { recursive: true }) + writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'nsolid-plugin', version })) + writeFileSync(path.join(root, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version })) + const entrypoint = path.join(root, 'dist', 'src', 'cli.js') + writeFileSync(entrypoint, '#!/usr/bin/env node\n') + const launcher = path.join(home, 'prefix', 'bin', 'nsolid-plugin') + mkdirSync(path.dirname(launcher), { recursive: true }) + symlinkSync(entrypoint, launcher) + return { root, launcher } +} + +describe('update flow coordinator', () => { + it('checks a proven global CLI without probing a package manager', async () => { + const { root, launcher } = cliGlobalFixture('90.0.0') + 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: root, + executablePath: launcher, + 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(cli?.ownership, 'global-package') + assert.equal(cli?.currentVersion, '90.0.0') + assert.equal(calls, 0) + }) + + it('does not probe a package manager for a global CLI newer than the registry during a check', async () => { + const { root, launcher } = cliGlobalFixture('90.0.0') + let calls = 0 + const commandRunner = { + run: async () => { + calls++ + throw new Error('newer-than-registry check must not probe npm or pnpm') + }, + } + const plan = await planUpdates({ + packageRoot: root, + executablePath: launcher, + cwd: project, + check: true, + fetchImpl: registryFetch('0.0.1'), + commandRunner, + }) + + const cli = plan.items.find((item) => item.installationId === 'cli:global') + assert.equal(cli?.source.kind, 'global-package') + assert.equal(cli?.version.status, 'newer-than-registry') + assert.equal(cli?.requiresConfirmation, false) + assert.equal(calls, 0) + }) + + it('runs no package-manager command for a proven global CLI newer than the registry', async () => { + const { root, launcher } = cliGlobalFixture('90.0.0') + const commands: string[] = [] + const summary = await update({ + packageRoot: root, + executablePath: launcher, + cwd: project, + yes: true, + registry: 'https://registry.example', + fetchImpl: registryFetch('0.0.1'), + commandRunner: { + run: async (spec: { executable: string; args: readonly string[] }) => { + commands.push([spec.executable, ...spec.args].join(' ')) + return { exitCode: 1, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }, + }) + + const cli = summary.results.find((result) => result.installationId === 'cli:global') + assert.equal(cli?.status, 'newer-than-registry') + assert.equal(cli?.currentVersion, '90.0.0') + assert.equal(cli?.latestVersion, '0.0.1') + assert.equal(summary.exitCode, 0) + assert.deepEqual(commands, [], 'a newer-than-registry no-op must not probe a package manager') + }) + + it('runs no package-manager command for a proven global CLI already current', async () => { + const { root, launcher } = cliGlobalFixture('90.0.0') + const commands: string[] = [] + const summary = await update({ + packageRoot: root, + executablePath: launcher, + cwd: project, + yes: true, + registry: 'https://registry.example', + fetchImpl: registryFetch('90.0.0'), + commandRunner: { + run: async (spec: { executable: string; args: readonly string[] }) => { + commands.push([spec.executable, ...spec.args].join(' ')) + return { exitCode: 1, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }, + }) + + const cli = summary.results.find((result) => result.installationId === 'cli:global') + assert.equal(cli?.status, 'current') + assert.equal(cli?.currentVersion, '90.0.0') + assert.equal(cli?.latestVersion, '90.0.0') + assert.equal(summary.exitCode, 0) + assert.deepEqual(commands, [], 'a current no-op must not probe a package manager') + }) + + 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, treeTerminated: true } + }, + } + 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) + }) + + it('reports a workspace launch as unsupported from the compiled CLI', async (t) => { + const distCli = path.join(packageRoot, 'dist', 'src', 'cli.js') + // Release gates opt into requiring the built public seam; focused source + // test runs may still skip it before the build step. + if (!existsSync(distCli) && process.env.NSOLID_TEST_REQUIRE_BUILD === '1') assert.fail('compiled CLI is required but packages/core/dist/src/cli.js is missing') + if (!existsSync(distCli)) return t.skip('compiled CLI is not built; run pnpm build first') + + const server = http.createServer((request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ 'dist-tags': { latest: '90.0.2' } })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}/` + try { + const run = await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve) => { + const child = spawn(process.execPath, [distCli, 'update', '--check', '--json'], { + cwd: path.resolve(packageRoot, '..', '..'), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + npm_config_registry: registry, + NPM_CONFIG_REGISTRY: registry, + }, + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + child.on('close', (code) => resolve({ code, stdout, stderr })) + }) + + assert.equal(run.code, 0, `expected exit 0, stderr: ${run.stderr}`) + const summary = JSON.parse(run.stdout) as { + exitCode: number + results: Array<{ installationId: string; status: string; currentVersion?: string; latestVersion?: string; manualCommands?: string[] }> + } + const cli = summary.results.find((result) => result.installationId === 'cli:global') + assert.ok(cli, 'the workspace launch must produce a cli result') + assert.equal(cli.status, 'unsupported') + assert.equal(cli.currentVersion, undefined) + assert.equal(cli.latestVersion, '90.0.2') + assert.deepEqual(cli.manualCommands, [ + 'npm install --global nsolid-plugin@90.0.2', + 'pnpm add --global nsolid-plugin@90.0.2', + 'npx -y nsolid-plugin@90.0.2 ', + ]) + assert.equal(summary.exitCode, 0) + } finally { + server.close() + } + }) + + it('performs zero package-manager commands when a workspace launch mutates unsupported', async (t) => { + const distCli = path.join(packageRoot, 'dist', 'src', 'cli.js') + if (!existsSync(distCli) && process.env.NSOLID_TEST_REQUIRE_BUILD === '1') assert.fail('compiled CLI is required but packages/core/dist/src/cli.js is missing') + if (!existsSync(distCli)) return t.skip('compiled CLI is not built; run pnpm build first') + + // Sentinels on PATH: any invocation of npm or pnpm writes to the log. + // They must be identities production would actually EXECUTE, otherwise + // the zero-invocation assertion below would be vacuous: POSIX sentinels + // are executable scripts (resolveExecutableIdentity: native), Windows + // sentinels are verified cmd-shims whose entrypoint appends to the log + // (resolveExecutableIdentity: node). + const sentinelDir = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-sentinel-')) + const sentinelLog = path.join(sentinelDir, 'invoked.log') + try { + if (process.platform === 'win32') { + const entrypointDir = path.join(sentinelDir, 'node_modules', 'nsolid-plugin', 'dist', 'src') + mkdirSync(entrypointDir, { recursive: true }) + writeFileSync(path.join(entrypointDir, 'sentinel.js'), "require('fs').appendFileSync(process.env.NSOLID_SENTINEL_LOG, (process.argv.slice(2) || []).join(' ') + '\\n')\n") + writeFileSync(path.join(sentinelDir, 'node_modules', 'nsolid-plugin', 'package.json'), JSON.stringify({ name: 'nsolid-plugin', bin: { npm: 'dist/src/sentinel.js', pnpm: 'dist/src/sentinel.js' } })) + for (const name of ['npm', 'pnpm']) { + writeFileSync(path.join(sentinelDir, `${name}.cmd`), [ + '@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\\nsolid-plugin\\dist\\src\\sentinel.js" %*', + '', + ].join('\r\n')) + } + } else { + for (const name of ['npm', 'pnpm']) { + writeFileSync(path.join(sentinelDir, name), '#!/bin/sh\necho "$@" >> "$NSOLID_SENTINEL_LOG"\n', { mode: 0o755 }) + } + } + + // Self-check: each sentinel must be an identity production would invoke, + // and must have the exact shape for this platform. + for (const name of ['npm', 'pnpm']) { + const identity = resolveExecutableIdentity(name, { ...process.env, PATH: `${sentinelDir}${path.delimiter}${process.env.PATH ?? ''}`, Path: sentinelDir, PATHEXT: '.CMD' }, process.platform) + assert.equal(identity.kind, process.platform === 'win32' ? 'node' : 'native', `sentinel ${name} must be invocable as ${process.platform === 'win32' ? 'node' : 'native'}, got ${identity.kind}`) + } + + const server = http.createServer((request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ 'dist-tags': { latest: '90.0.2' } })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}/` + try { + const run = await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve) => { + const child = spawn(process.execPath, [distCli, 'update', '--yes', '--json'], { + cwd: path.resolve(packageRoot, '..', '..'), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + npm_config_registry: registry, + NPM_CONFIG_REGISTRY: registry, + NSOLID_SENTINEL_LOG: sentinelLog, + PATHEXT: process.env.PATHEXT ?? '.CMD;.BAT;.EXE;.COM', + PATH: `${sentinelDir}${path.delimiter}${process.env.PATH ?? ''}`, + }, + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + child.on('close', (code) => resolve({ code, stdout, stderr })) + }) + + assert.equal(run.code, 2, `expected exit 2 for an unsupported mutation, stderr: ${run.stderr}`) + const summary = JSON.parse(run.stdout) as { + exitCode: number + results: Array<{ installationId: string; status: string; currentVersion?: string; latestVersion?: string; manualCommands?: string[] }> + } + const cli = summary.results.find((result) => result.installationId === 'cli:global') + assert.ok(cli, 'the workspace launch must produce a cli result') + assert.equal(cli.status, 'unsupported') + assert.equal(cli.currentVersion, undefined) + assert.equal(cli.latestVersion, '90.0.2') + assert.deepEqual(cli.manualCommands, [ + 'npm install --global nsolid-plugin@90.0.2', + 'pnpm add --global nsolid-plugin@90.0.2', + 'npx -y nsolid-plugin@90.0.2 ', + ]) + assert.equal(summary.exitCode, 2) + assert.equal(existsSync(sentinelLog), false, 'no npm/pnpm command may be invoked for an unsupported mutation') + } finally { + server.close() + } + } finally { + rmSync(sentinelDir, { recursive: true, force: true }) + } + }) + + it('emits the technical plan and installable guidance in update --json from the compiled CLI', async (t) => { + const distCli = path.join(packageRoot, 'dist', 'src', 'cli.js') + if (!existsSync(distCli) && process.env.NSOLID_TEST_REQUIRE_BUILD === '1') assert.fail('compiled CLI is required but packages/core/dist/src/cli.js is missing') + if (!existsSync(distCli)) return t.skip('compiled CLI is not built; run pnpm build first') + + const run = await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve) => { + const child = spawn(process.execPath, [distCli, 'update', '--json', '--harness', 'pi'], { + cwd: path.resolve(packageRoot, '..', '..'), + env: { ...process.env, HOME: home, USERPROFILE: home }, + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + child.on('close', (code) => resolve({ code, stdout, stderr })) + }) + + // The whole stdout must parse as exactly one valid JSON document: the + // structured output carries the technical plan without human text. + assert.equal(run.code, 2, `expected exit 2 for a not-installed harness, stdout: ${run.stdout}, stderr: ${run.stderr}`) + const summary = JSON.parse(run.stdout.trim()) as { + exitCode: number + results: Array<{ installationId: string; status: string; target: string; steps?: unknown[]; changes?: unknown; manualCommands?: string[] }> + } + const pi = summary.results.find((result) => result.installationId === 'pi:none') + assert.ok(pi, 'the absent requested harness must produce a pi result') + assert.equal(pi.status, 'not-installed') + assert.equal(pi.target, 'pi') + assert.deepEqual(pi.manualCommands, ['pi install npm:nsolid-pi-plugin', 'nsolid-plugin setup --harness pi']) + assert.ok(Array.isArray(pi.steps), 'the technical plan (steps) must be present in the structured output') + assert.deepEqual(pi.changes, { skillsAdded: [], skillsRemoved: [], skillsUpdated: 0, mcpAdded: [], mcpRemoved: [], mcpUpdated: 0 }, 'the changes field must be present with a stable shape') + assert.deepEqual(pi.steps, []) + }) +}) 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-provenance.test.ts b/packages/core/test/unit/update/antigravity-provenance.test.ts new file mode 100644 index 0000000..068c3a2 --- /dev/null +++ b/packages/core/test/unit/update/antigravity-provenance.test.ts @@ -0,0 +1,110 @@ +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 { isNsolidPluginImport, isNsolidPluginImportKey } from '../../../src/update/antigravity-provenance.js' +import { detectAntigravityLayout } from '../../../src/update/inventory.js' +import { validateStagedPlugin } from '../../../src/update/antigravity-transaction.js' + +describe('Antigravity registration provenance predicate', () => { + it('accepts entries that declare the canonical identity with an accepted source', () => { + assert.equal(isNsolidPluginImport({ name: 'nsolid-plugin', source: 'antigravity' }), true) + assert.equal(isNsolidPluginImport({ name: 'nsolid-plugin', source: 'https://github.com/NodeSource/nsolid-plugin' }), true) + assert.equal(isNsolidPluginImport({ name: 'nsolid-plugin', source: 'https://github.com/NodeSource/nsolid-plugin.git' }), true) + // The `plugin` field is an accepted identity alias, as in the prior inventory contract. + assert.equal(isNsolidPluginImport({ plugin: 'nsolid-plugin', source: 'antigravity' }), true) + // Source matching stays case-insensitive, as in the prior inventory contract. + assert.equal(isNsolidPluginImport({ name: 'nsolid-plugin', source: 'HTTPS://GITHUB.COM/NodeSource/nsolid-plugin.GIT' }), true) + }) + + it('rejects a correct name with a missing, foreign, or spoofed source', () => { + assert.equal(isNsolidPluginImport({ name: 'nsolid-plugin' }), false) + assert.equal(isNsolidPluginImport({ name: 'nsolid-plugin', source: 'https://github.com/Evil/nsolid-plugin.git' }), false) + assert.equal(isNsolidPluginImport({ name: 'nsolid-plugin', source: 'https://github.com/NodeSource/nsolid-plugin.git/' }), false) + assert.equal(isNsolidPluginImport({ name: 'nsolid-plugin', source: 'git@github.com:NodeSource/nsolid-plugin.git' }), false) + assert.equal(isNsolidPluginImport({ name: 'nsolid-plugin', source: 'https://github.com/NodeSource/nsolid-plugin-extra.git' }), false) + assert.equal(isNsolidPluginImport({ name: 'nsolid-plugin', source: true }), false) + assert.equal(isNsolidPluginImport({ name: 'nsolid-plugin', source: null }), false) + }) + + it('rejects non-entries and foreign identities', () => { + assert.equal(isNsolidPluginImport(undefined), false) + assert.equal(isNsolidPluginImport(null), false) + assert.equal(isNsolidPluginImport('nsolid-plugin'), false) + assert.equal(isNsolidPluginImport(true), false) + assert.equal(isNsolidPluginImport([]), false) + assert.equal(isNsolidPluginImport({ name: 'my-nsolid-plugin-helper', source: 'antigravity' }), false) + assert.equal(isNsolidPluginImport({ source: 'antigravity' }), false) + }) + + it('recognizes only the canonical object key', () => { + assert.equal(isNsolidPluginImportKey('nsolid-plugin'), true) + assert.equal(isNsolidPluginImportKey('my-nsolid-plugin-helper'), false) + assert.equal(isNsolidPluginImportKey('nsolid-plugin '), false) + assert.equal(isNsolidPluginImportKey(''), false) + }) +}) + +describe('Antigravity provenance contract alignment', () => { + // Every accepted import shape must be recognized by both the inventory + // detection and the staged-plugin validation, and every rejected shape must + // be rejected by both. A manifest that passes one module while the other + // rejects it lets a lookalike registration through one side of the update. + const accepted = [ + { imports: [{ name: 'nsolid-plugin', source: 'antigravity' }] }, + { imports: [{ name: 'nsolid-plugin', source: 'https://github.com/NodeSource/nsolid-plugin' }] }, + { imports: [{ name: 'nsolid-plugin', source: 'https://github.com/NodeSource/nsolid-plugin.git' }] }, + { imports: [{ plugin: 'nsolid-plugin', source: 'antigravity' }] }, + { imports: { 'nsolid-plugin': { name: 'nsolid-plugin', source: 'antigravity' } } }, + { imports: { 'nsolid-plugin': { name: 'nsolid-plugin', source: 'https://github.com/NodeSource/nsolid-plugin.git' } } }, + ] + const rejected = [ + { imports: [{ name: 'nsolid-plugin' }] }, + { imports: [{ name: 'nsolid-plugin', source: 'https://github.com/Evil/nsolid-plugin.git' }] }, + { imports: [{ name: 'nsolid-plugin', source: 'https://github.com/NodeSource/nsolid-plugin.git/' }] }, + { imports: { 'nsolid-plugin': true } }, + { imports: { 'nsolid-plugin': { name: 'nsolid-plugin' } } }, + { imports: { 'my-nsolid-plugin-helper': { name: 'nsolid-plugin', source: 'antigravity' } } }, + { imports: { 'nsolid-plugin': { name: 'my-nsolid-plugin-helper', source: 'antigravity' } } }, + { imports: { other: { path: '/keep' } } }, + { }, + ] + + function setupFixture (manifestValue: unknown): { home: string; root: string; manifest: string } { + const home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-agy-provenance-')) + const root = path.join(home, '.gemini', 'config', 'plugins', 'nsolid-plugin') + 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.0', skills: [{ name: 'example', path: 'skills/example' }] })) + writeFileSync(path.join(root, 'skills', 'example', 'SKILL.md'), '# example') + const manifest = path.join(home, '.gemini', 'config', 'import_manifest.json') + writeFileSync(manifest, JSON.stringify(manifestValue)) + return { home, root, manifest } + } + + it('validates manifest import shapes identically in inventory and transaction validation', () => { + const previousHome = process.env.HOME + const previousUserProfile = process.env.USERPROFILE + try { + for (const [shape, expected] of [...accepted.map((value) => [value, true] as const), ...rejected.map((value) => [value, false] as const)]) { + const fixture = setupFixture(shape) + process.env.HOME = fixture.home + process.env.USERPROFILE = fixture.home + try { + const layout = detectAntigravityLayout() + assert.equal(layout.layout !== undefined, expected, `inventory detection for ${JSON.stringify(shape)}`) + assert.equal(layout.reason === undefined, expected, `inventory reason for ${JSON.stringify(shape)}`) + assert.equal(validateStagedPlugin(fixture.root, fixture.manifest), expected, `staged validation for ${JSON.stringify(shape)}`) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + } + } 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 + } + }) +}) 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..af774cd --- /dev/null +++ b/packages/core/test/unit/update/antigravity-transaction.test.ts @@ -0,0 +1,571 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, 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', source: 'antigravity' }] })) + 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', source: 'antigravity' } } })) + + // 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('rejects object imports whose value does not declare the plugin identity', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-agy-manifest-value-')) + 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') + + // The canonical key alone is never sufficient: a non-identifying value + // under `nsolid-plugin` must be rejected (previously accepted). + writeFileSync(manifest, JSON.stringify({ imports: { 'nsolid-plugin': true } })) + assert.equal(validateStagedPlugin(root, manifest), false) + // An object value without identity fields proves nothing either. + writeFileSync(manifest, JSON.stringify({ imports: { 'nsolid-plugin': { path: '/plugin' } } })) + assert.equal(validateStagedPlugin(root, manifest), false) + // A value carrying the canonical identity under a foreign object key is + // rejected too: key and value must agree, matching the inventory contract. + writeFileSync(manifest, JSON.stringify({ imports: { 'my-nsolid-plugin-helper': { name: 'nsolid-plugin', source: 'antigravity' } } })) + assert.equal(validateStagedPlugin(root, manifest), false) + // The value itself must declare the identity with an accepted source, + // under the canonical key (object form) or as an array entry. + writeFileSync(manifest, JSON.stringify({ imports: { 'nsolid-plugin': { name: 'nsolid-plugin', source: 'antigravity' } } })) + assert.equal(validateStagedPlugin(root, manifest), true) + writeFileSync(manifest, JSON.stringify({ imports: [{ name: 'nsolid-plugin', source: 'antigravity' }] })) + assert.equal(validateStagedPlugin(root, manifest), true) + writeFileSync(manifest, JSON.stringify({ imports: { 'nsolid-plugin': { plugin: 'nsolid-plugin', source: 'antigravity' } } })) + assert.equal(validateStagedPlugin(root, manifest), true) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects imports with missing or foreign registration source provenance', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-agy-provenance-source-')) + 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') + + // A correct name with no source proves nothing (previously accepted). + writeFileSync(manifest, JSON.stringify({ imports: [{ name: 'nsolid-plugin' }] })) + assert.equal(validateStagedPlugin(root, manifest), false) + // A foreign registration source is rejected even under the canonical name. + writeFileSync(manifest, JSON.stringify({ imports: [{ name: 'nsolid-plugin', source: 'https://github.com/Evil/nsolid-plugin.git' }] })) + assert.equal(validateStagedPlugin(root, manifest), false) + // The exact NodeSource root with an extra suffix is not the fixed root. + writeFileSync(manifest, JSON.stringify({ imports: [{ name: 'nsolid-plugin', source: 'https://github.com/NodeSource/nsolid-plugin.git/' }] })) + assert.equal(validateStagedPlugin(root, manifest), false) + // SSH shorthand is not the accepted HTTPS provenance. + writeFileSync(manifest, JSON.stringify({ imports: [{ name: 'nsolid-plugin', source: 'git@github.com:NodeSource/nsolid-plugin.git' }] })) + assert.equal(validateStagedPlugin(root, manifest), false) + // The same rejections apply to the object form. + writeFileSync(manifest, JSON.stringify({ imports: { 'nsolid-plugin': { name: 'nsolid-plugin', source: 'https://github.com/Evil/nsolid-plugin.git' } } })) + assert.equal(validateStagedPlugin(root, manifest), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('accepts exactly the canonical registration provenance sources', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-agy-provenance-accept-')) + 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') + + for (const source of ['antigravity', 'https://github.com/NodeSource/nsolid-plugin', 'https://github.com/NodeSource/nsolid-plugin.git', 'HTTPS://GITHUB.COM/NodeSource/nsolid-plugin.GIT']) { + writeFileSync(manifest, JSON.stringify({ imports: [{ name: 'nsolid-plugin', source }] })) + assert.equal(validateStagedPlugin(root, manifest), true, `array form with source ${source}`) + writeFileSync(manifest, JSON.stringify({ imports: { 'nsolid-plugin': { name: 'nsolid-plugin', source } } })) + assert.equal(validateStagedPlugin(root, manifest), true, `object form with source ${source}`) + } + } 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', source: 'antigravity' } } })) + 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('fails before mutation when the installed plugin contains a nested symlink', async (t) => { + const fixture = setupInstalledFixture() + try { + const external = path.join(fixture.home, 'external.txt') + writeFileSync(external, 'outside') + try { + symlinkSync(external, path.join(fixture.pluginRoot, 'skills', 'example', 'redirect.txt')) + } catch (error) { + if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') { + t.skip('file symlink creation requires Windows developer mode or elevation') + return + } + throw error + } + let commands = 0 + 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 () => { + commands++ + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, false) + assert.equal(result.error?.code, 'ANTIGRAVITY_BACKUP_FAILED') + assert.equal(commands, 0, 'no mutation may run for a redirected plugin tree') + assert.equal(readFileSync(external, 'utf8'), 'outside') + } finally { + restoreHome(fixture) + } + }) + + 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, treeTerminated: true } + }, + }, { 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('triggers a guarded rollback when the staged manifest claims a foreign source', 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 () => { + // The replacement commands succeed and restage a structurally valid + // plugin, but the manifest import now carries a foreign source: + // provenance validation must fail the transaction and restore. + writeFileSync(fixture.manifestPath, JSON.stringify({ imports: { 'nsolid-plugin': { name: 'nsolid-plugin', source: 'https://github.com/Evil/nsolid-plugin.git' } } })) + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'ANTIGRAVITY_VALIDATION_FAILED') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.equal(readFileSync(fixture.manifestPath, 'utf8'), JSON.stringify({ imports: { 'nsolid-plugin': { name: 'nsolid-plugin', source: 'antigravity' } } })) + 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') + } 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, treeTerminated: true } + }, + }) + + 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, treeTerminated: true } + }, + }) + + 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, treeTerminated: true } + }, + }) + + 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, treeTerminated: true } + }, + }) + + 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, treeTerminated: true } + }, + }) + + 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, treeTerminated: true }), + }) + + 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, treeTerminated: true } + }, + }) + + 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..8930371 --- /dev/null +++ b/packages/core/test/unit/update/claude-transaction.test.ts @@ -0,0 +1,922 @@ +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, symlinkSync, 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, foreignRegistrationDigest, resolveClaudePayloadRoot, restoreClaudeNativeState } from '../../../src/update/claude-transaction.js' +import type { OwnedPathKind } from '../../../src/update/owned-fs.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, treeTerminated: true } +const failedResult: CommandResult = { exitCode: 1, stdout: '', stderr: 'boom', timedOut: false, treeTerminated: true } + +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('fails before mutation when the installed payload contains a nested symlink', async (t) => { + const fixture = setupInstallation() + try { + const external = path.join(fixture.home, 'external.txt') + writeFileSync(external, 'outside') + try { + symlinkSync(external, path.join(fixture.payloadRoot, 'skills', 'example', 'redirect.txt')) + } catch (error) { + if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') { + t.skip('file symlink creation requires Windows developer mode or elevation') + return + } + throw error + } + const runnerStub = runner(() => okResult) + + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['plugin', '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, false) + assert.equal(result.error?.code, 'CLAUDE_BACKUP_FAILED') + assert.deepEqual(runnerStub.commands, [], 'no mutation may run for a redirected payload tree') + assert.equal(readFileSync(external, 'utf8'), 'outside') + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + 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('rejects an exit-zero update that removes unrelated registry and enabled-plugin entries', async () => { + const fixture = setupInstallation() + const claudeConfigPath = path.join(fixture.home, '.claude.json') + const registry = JSON.parse(readFileSync(fixture.registryPath, 'utf8')) as { plugins: Record } + registry.plugins['other-plugin@vendor'] = [{ version: '3.0.0', installPath: path.join(fixture.home, 'other'), scope: 'user' }] + const registryWithForeign = JSON.stringify(registry) + '\n' + const configWithForeign = JSON.stringify({ enabledPlugins: { 'nsolid-plugin@nodesource': true, 'other-plugin@vendor': true }, theme: 'dark' }) + '\n' + writeFileSync(fixture.registryPath, registryWithForeign) + writeFileSync(claudeConfigPath, configWithForeign) + try { + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['update'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath, claudeConfigPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.0', + }, runner(() => { + const nextRegistry = JSON.parse(readFileSync(fixture.registryPath, 'utf8')) as { plugins: Record } + delete nextRegistry.plugins['other-plugin@vendor'] + writeFileSync(fixture.registryPath, JSON.stringify(nextRegistry) + '\n') + writeFileSync(claudeConfigPath, JSON.stringify({ enabledPlugins: { 'nsolid-plugin@nodesource': true }, theme: 'dark' }) + '\n') + return okResult + })) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CLAUDE_FOREIGN_STATE_CHANGED') + assert.equal(result.rollbackSucceeded, true) + assert.equal(readFileSync(fixture.registryPath, 'utf8'), registryWithForeign) + assert.equal(readFileSync(claudeConfigPath, 'utf8'), configWithForeign) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('accepts a successful update that preserves unrelated registry and enabled-plugin entries', async () => { + const fixture = setupInstallation() + const claudeConfigPath = path.join(fixture.home, '.claude.json') + const registry = JSON.parse(readFileSync(fixture.registryPath, 'utf8')) as { plugins: Record } + registry.plugins['other-plugin@vendor'] = [{ version: '3.0.0', installPath: path.join(fixture.home, 'other'), scope: 'user' }] + writeFileSync(fixture.registryPath, JSON.stringify(registry) + '\n') + writeFileSync(claudeConfigPath, JSON.stringify({ enabledPlugins: { 'nsolid-plugin@nodesource': true, 'other-plugin@vendor': true } }) + '\n') + try { + const result = await executeClaudeTransaction({ + commands: [{ executable: 'claude', args: ['update'], timeoutMs: 1_000 }], + registrationPaths: [fixture.registryPath, fixture.marketplacesPath, claudeConfigPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user', + expectedVersion: '1.0.0', + }, runner(() => okResult)) + + assert.equal(result.success, true) + assert.match(readFileSync(fixture.registryPath, 'utf8'), /other-plugin@vendor/) + assert.match(readFileSync(claudeConfigPath, 'utf8'), /other-plugin@vendor/) + } 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('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 }) + } + }) + + for (const customWorkspace of [false, true]) { + it(`defers rollback and preserves a complete recovery bundle (custom workspace: ${customWorkspace})`, 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, customWorkspace ? deps : undefined) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CLAUDE_TREE_TERMINATION_UNCONFIRMED') + assert.equal(result.rollbackAttempted, false) + assert.equal(readFileSync(path.join(fixture.payloadRoot, 'skills/example/SKILL.md'), 'utf8'), '# mid-flight\n') + if (customWorkspace) assert.equal(result.recoveryPath, recoveryRoot) + assert.ok(result.recoveryPath) + const manifestFile = path.join(result.recoveryPath, '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(result.recoveryPath, 'registration', '0000.bin') + const backup1 = path.join(result.recoveryPath, '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.deepEqual(resolveClaudePayloadRoot(fixture.registryPath, 'nsolid-plugin@nodesource', 'user'), { status: 'ok', root: fixture.payloadRoot }) + assert.deepEqual(resolveClaudePayloadRoot(fixture.registryPath, 'nsolid-plugin@nodesource', 'project'), { status: 'absent' }) + assert.deepEqual(resolveClaudePayloadRoot(fixture.registryPath, 'other-plugin@x', 'user'), { status: 'absent' }) + assert.deepEqual(resolveClaudePayloadRoot(undefined, 'nsolid-plugin@nodesource', 'user'), { status: 'absent' }) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) +}) + +describe('foreignRegistrationDigest', () => { + const pluginId = 'nsolid-plugin@nodesource' + const target = '/tmp/installed_plugins.json' + const digest = (value: unknown): string => foreignRegistrationDigest(Buffer.from(JSON.stringify(value)), pluginId, target) + + it('changes when an unrelated empty object is removed', () => { + assert.notEqual(digest({ other: {} }), digest({})) + }) + + it('changes when an unrelated empty array is removed', () => { + assert.notEqual(digest({ other: [] }), digest({})) + }) + + it('distinguishes an empty object from an empty array at the same path', () => { + assert.notEqual(digest({ other: {} }), digest({ other: [] })) + }) + + it('distinguishes an object container from an array container holding the same leaf', () => { + assert.notEqual(digest({ other: { 0: 1 } }), digest({ other: [1] })) + }) + + it('is stable across key order', () => { + assert.equal(digest({ a: 1, b: { c: 2 } }), digest({ b: { c: 2 }, a: 1 })) + }) + + it('still ignores the owned plugin record', () => { + assert.equal(digest({ plugins: { [pluginId]: { version: '1' }, other: {} } }), digest({ plugins: { [pluginId]: { version: '2' }, other: {} } })) + }) + + it('still ignores owned records inside arrays', () => { + assert.equal(digest({ plugins: [{ id: pluginId }] }), digest({ plugins: [] })) + assert.equal(digest({ plugins: [{ id: pluginId }, { id: 'other' }] }), digest({ plugins: [{ id: 'other' }] })) + }) + + it('still counts unrelated array elements', () => { + assert.notEqual(digest({ other: [1] }), digest({ other: [1, 2] })) + }) +}) + +describe('Claude payload root approval', () => { + function baseSpec (fixture: Fixture, commands: Array<{ executable: string, args: string[], timeoutMs: number }>) { + return { + commands, + registrationPaths: [fixture.registryPath, fixture.marketplacesPath], + configPath: fixture.registryPath, + pluginId: 'nsolid-plugin@nodesource', + scope: 'user' + } + } + + function rewriteInstallPath (fixture: Fixture, installPath: string): void { + const registry = JSON.parse(readFileSync(fixture.registryPath, 'utf8')) as { plugins: Record>> } + registry.plugins['nsolid-plugin@nodesource'] = [{ version: '9.9.9', installPath, scope: 'user' }] + writeFileSync(fixture.registryPath, JSON.stringify(registry) + '\n') + } + + it('aborts before any mutation when the registry names a payload outside the cache', async () => { + const fixture = setupInstallation() + try { + const victim = path.join(fixture.home, 'victim') + mkdirSync(victim, { recursive: true }) + writeFileSync(path.join(victim, 'marker.txt'), 'do not touch') + rewriteInstallPath(fixture, victim) + const runnerStub = runner(() => okResult) + + const result = await executeClaudeTransaction(baseSpec(fixture, [{ executable: 'claude', args: ['update'], timeoutMs: 1_000 }]), runnerStub) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, false) + assert.equal(result.error?.code, 'CLAUDE_BACKUP_FAILED') + assert.deepEqual(runnerStub.commands, [], 'no command may run for a rejected payload root') + assert.equal(readFileSync(path.join(victim, 'marker.txt'), 'utf8'), 'do not touch') + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('aborts before any mutation when the registry names a sibling plugin directory inside the cache', async () => { + const fixture = setupInstallation() + try { + const sibling = path.join(fixture.home, '.claude', 'plugins', 'cache', 'other-plugin', '9.9.9') + mkdirSync(path.join(sibling, 'skills'), { recursive: true }) + writeFileSync(path.join(sibling, 'marker.txt'), 'sibling bytes') + rewriteInstallPath(fixture, sibling) + const runnerStub = runner(() => okResult) + + const result = await executeClaudeTransaction(baseSpec(fixture, [{ executable: 'claude', args: ['update'], timeoutMs: 1_000 }]), runnerStub) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, false) + assert.equal(result.error?.code, 'CLAUDE_BACKUP_FAILED') + assert.deepEqual(runnerStub.commands, []) + assert.equal(readFileSync(path.join(sibling, 'marker.txt'), 'utf8'), 'sibling bytes') + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('aborts before any mutation when a cache ancestor is a symlink', async (t) => { + const fixture = setupInstallation() + try { + const victimTree = path.join(fixture.home, 'victim-tree') + const pluginDir = path.join(fixture.home, '.claude', 'plugins', 'cache', 'nsolid-plugin') + mkdirSync(path.join(victimTree, '1.0.0', 'skills'), { recursive: true }) + writeFileSync(path.join(victimTree, 'marker.txt'), 'victim bytes') + writeFileSync(path.join(victimTree, '1.0.0', 'bundle.json'), '{"version":"1.0.0"}\n') + rmSync(pluginDir, { recursive: true, force: true }) + try { + symlinkSync(victimTree, pluginDir, 'dir') + } catch (error) { + if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') { + t.skip('directory symlink creation requires Windows developer mode or elevation') + return + } + throw error + } + const runnerStub = runner(() => okResult) + + const result = await executeClaudeTransaction(baseSpec(fixture, [{ executable: 'claude', args: ['update'], timeoutMs: 1_000 }]), runnerStub) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, false) + assert.equal(result.error?.code, 'CLAUDE_BACKUP_FAILED') + assert.deepEqual(runnerStub.commands, []) + assert.equal(readFileSync(path.join(victimTree, 'marker.txt'), 'utf8'), 'victim bytes') + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('never grants removal authority to a registry root rewritten outside the cache mid-transaction', async () => { + const fixture = setupInstallation() + try { + const victim = path.join(fixture.home, 'victim') + mkdirSync(victim, { recursive: true }) + writeFileSync(path.join(victim, 'marker.txt'), 'do not touch') + const runnerStub = runner((_spec, index) => { + if (index === 0) { + // Poison the mutable registry after the backup, then fail: the + // failure path must not resurrect the rejected root. + rewriteInstallPath(fixture, victim) + return okResult + } + return failedResult + }) + + const result = await executeClaudeTransaction(baseSpec(fixture, [ + { executable: 'claude', args: ['one'], timeoutMs: 1_000 }, + { executable: 'claude', args: ['two'], timeoutMs: 1_000 } + ]), runnerStub) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.equal(readFileSync(path.join(victim, 'marker.txt'), 'utf8'), 'do not touch') + assert.equal(existsSync(victim), true) + // The validated original payload and registration came back. + assert.equal(nativePayloadDigest(fixture.payloadRoot), fixture.payloadDigest) + assert.equal(readFileSync(fixture.registryPath, 'utf8'), fixture.registryBytes) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('reports content mismatch and still restores when the registration is legitimately removed', async () => { + const fixture = setupInstallation() + try { + const runnerStub = runner(() => { + const registry = JSON.parse(fixture.registryBytes) as { plugins: Record } + delete registry.plugins['nsolid-plugin@nodesource'] + writeFileSync(fixture.registryPath, JSON.stringify(registry) + '\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', + artifact: makeArtifact(fixture) + }, runnerStub) + + // Absent is not rejected: the artifact gate fails the transaction but + // the validated original payload is still restored. + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CLAUDE_CONTENT_MISMATCH') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.equal(nativePayloadDigest(fixture.payloadRoot), fixture.payloadDigest) + assert.equal(readFileSync(fixture.registryPath, 'utf8'), fixture.registryBytes) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }) + + it('aborts before any mutation when the marketplace segment does not match the plugin', async () => { + const fixture = setupInstallation() + try { + const foreign = path.join(fixture.home, '.claude', 'plugins', 'cache', 'evil-marketplace', 'nsolid-plugin', '9.9.9') + mkdirSync(path.join(foreign, 'skills'), { recursive: true }) + writeFileSync(path.join(foreign, 'marker.txt'), 'foreign marketplace bytes') + rewriteInstallPath(fixture, foreign) + const runnerStub = runner(() => okResult) + + const result = await executeClaudeTransaction(baseSpec(fixture, [{ executable: 'claude', args: ['update'], timeoutMs: 1_000 }]), runnerStub) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, false) + assert.equal(result.error?.code, 'CLAUDE_BACKUP_FAILED') + assert.deepEqual(runnerStub.commands, []) + assert.equal(readFileSync(path.join(foreign, 'marker.txt'), 'utf8'), 'foreign marketplace bytes') + } 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..e4d3a9d --- /dev/null +++ b/packages/core/test/unit/update/cli-package-strategy.test.ts @@ -0,0 +1,56 @@ +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, treeTerminated: true }) }, + }) + + 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 ', + ]) + }) + + it('uses the shared complete guidance for Volta and never emits placeholders', async () => { + const context = { + options: {}, + commandRunner: { run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true }) }, + } + const volta = await cliPackageStrategy.plan({ + installationId: 'cli:global', + target: 'cli', + ownership: 'none', + installed: true, + source: { kind: 'unsupported', source: '/home/test/.volta/bin/nsolid-plugin', reason: 'unsupported-manager' }, + version: { latest: '1.2.3', status: 'unknown' }, + }, context) + assert.deepEqual(volta.manualCommands, [ + 'npm install --global nsolid-plugin@1.2.3', + 'pnpm add --global nsolid-plugin@1.2.3', + 'npx -y nsolid-plugin@1.2.3 ', + 'volta install nsolid-plugin@1.2.3', + ]) + + const unresolved = await cliPackageStrategy.plan({ + installationId: 'cli:global', + target: 'cli', + ownership: 'none', + installed: true, + source: { kind: 'unsupported', source: '/workspace/cli.ts', reason: 'unsupported-manager' }, + version: { status: 'unknown' }, + }, context) + assert.deepEqual(unresolved.manualCommands, []) + }) +}) 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..4059350 --- /dev/null +++ b/packages/core/test/unit/update/codex-transaction.test.ts @@ -0,0 +1,1070 @@ +import { afterEach, beforeEach, describe, it, type TestContext } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, 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 { runCommand } from '../../../src/update/command-runner.js' +import { nativePayloadTreeDigest, type PayloadNormalizationProfile } from '../../../src/update/native-payload.js' +import type { CommandResult, 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 makeFileLink (t: TestContext, target: string, link: string): boolean { + try { + symlinkSync(target, link, 'file') + return true + } catch (error) { + if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') { + t.skip('file symlink creation requires Windows developer mode or elevation') + return false + } + throw error + } +} + +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, + } +} + +// Shared planned payload (v1.0.2) written both as the immutable plan source +// and as the faithful installed payload by the fake Codex commands. +const PLANNED_PAYLOAD_FILES: ReadonlyArray = [ + ['bundle.json', JSON.stringify({ name: 'nsolid-plugin', version: '1.0.2', skills: [] }) + '\n'], + ['skills/example/SKILL.md', '# example\n'], +] + +function writePlannedPayload (root: string): void { + for (const [relative, content] of PLANNED_PAYLOAD_FILES) { + mkdirSync(path.join(root, path.dirname(relative)), { recursive: true }) + writeFileSync(path.join(root, relative), content) + } +} + +function writeInstalledPayload (root: string): void { + writePlannedPayload(root) +} + +function comparisonArtifact (plannedRoot: string, overrides: { comparisonDigest?: string; comparisonProfile?: string } = {}): NonNullable { + return { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: nativePayloadTreeDigest(plannedRoot)!, + comparisonDigest: overrides.comparisonDigest ?? nativePayloadTreeDigest(plannedRoot, { profile: 'codex-installed-v1' })!, + comparisonProfile: (overrides.comparisonProfile ?? 'codex-installed-v1') as PayloadNormalizationProfile, + } +} + +describe('Codex update transaction', () => { + it('rejects a cache-root symlink before backup or mutation', async () => { + const external = mkdtempSync(path.join(os.tmpdir(), 'nsolid-codex-external-cache-')) + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + mkdirSync(path.dirname(cachePath), { recursive: true }) + writeFileSync(path.join(external, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + symlinkSync(external, cachePath, process.platform === 'win32' ? 'junction' : 'dir') + try { + const result = await executeCodexTransaction(item(cachePath), { + run: async () => { throw new Error('command must not run for a linked cache root') }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_CACHE_KIND_UNSUPPORTED') + assert.equal(result.rollbackAttempted, false) + assert.match(readFileSync(path.join(external, 'bundle.json'), 'utf8'), /1\.0\.0/) + } finally { + rmSync(external, { recursive: true, force: true }) + } + }) + + it('preserves backups and refuses rollback when the config becomes a symlink', async (t) => { + const external = mkdtempSync(path.join(os.tmpdir(), 'nsolid-codex-external-config-')) + const victim = path.join(external, 'victim.toml') + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(cachePath, { recursive: true }) + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + mkdirSync(path.dirname(configPath), { recursive: true }) + writeTomlFileSync(configPath, { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) + writeFileSync(victim, 'FOREIGN CONFIG\n') + try { + let linkCreated = true + const result = await executeCodexTransaction(item(cachePath), { + run: async () => { + rmSync(configPath, { force: true }) + linkCreated = makeFileLink(t, victim, configPath) + return { exitCode: 1, stdout: '', stderr: 'failed', timedOut: false, treeTerminated: true } + }, + }) + if (!linkCreated) return + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_TRANSACTION_FAILED') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, false) + assert.equal(readFileSync(victim, 'utf8'), 'FOREIGN CONFIG\n') + assert.equal(lstatSync(configPath).isSymbolicLink(), true, 'the drifted path must not be removed') + assert.match(readFileSync(path.join(cachePath, 'bundle.json'), 'utf8'), /1\.0\.0/) + assert.equal(readdirSync(path.dirname(configPath)).filter((name) => name.includes('.nsolid-config-backup-')).length, 1) + assert.equal(readdirSync(path.dirname(cachePath)).filter((name) => name.includes('.nsolid-cache-backup-')).length, 1) + } finally { + rmSync(external, { recursive: true, force: true }) + } + }) + + it('rejects a nested cache symlink before backup or mutation', async () => { + const external = mkdtempSync(path.join(os.tmpdir(), 'nsolid-codex-external-payload-')) + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + const configPath = path.join(home, '.codex', 'config.toml') + const link = path.join(cachePath, 'registered') + mkdirSync(cachePath, { recursive: true }) + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + writeFileSync(path.join(external, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + symlinkSync(external, link, process.platform === 'win32' ? 'junction' : 'dir') + mkdirSync(path.dirname(configPath), { recursive: true }) + writeTomlFileSync(configPath, { plugins: { 'nsolid-plugin@nodesource': { enabled: true, installPath: 'nsolid-plugin/registered' } } }) + let commands = 0 + try { + const result = await executeCodexTransaction(item(cachePath), { + run: async () => { + commands++ + writeFileSync(path.join(external, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '9.9.9', skills: [] })) + return { exitCode: 1, stdout: '', stderr: 'failed', timedOut: false, treeTerminated: true } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_CACHE_KIND_UNSUPPORTED') + assert.equal(result.rollbackAttempted, false) + assert.equal(result.rollbackSucceeded, undefined) + assert.equal(commands, 0) + assert.match(readFileSync(path.join(external, 'bundle.json'), 'utf8'), /1\.0\.0/) + assert.equal(lstatSync(link).isSymbolicLink(), true) + 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) + } finally { + rmSync(external, { recursive: true, force: true }) + } + }) + + 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, treeTerminated: true } + }, + }) + + 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)! + const plannedComparisonDigest = nativePayloadDigest(newPayload, { profile: 'codex-installed-v1' })! + 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, + comparisonDigest: plannedComparisonDigest, + comparisonProfile: 'codex-installed-v1', + } + + 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, treeTerminated: true } + }, + }) + + assert.equal(result.success, true) + }) + + it('accepts reinstall with Codex harness metadata when the planned comparison identity matches', 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: [] })) + + // Planned bytes come from the immutable commit (clean payload); Codex adds + // its own provenance file at the payload root during the real install. + const plannedRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-codex-f4-planned-')) + try { + const plannedFiles = new Map([ + ['bundle.json', Buffer.from(JSON.stringify({ name: 'nsolid-plugin', version: '1.0.2', skills: [] }))], + ['skills/example/SKILL.md', Buffer.from('# example\n')], + ]) + for (const [relative, content] of plannedFiles) { + mkdirSync(path.join(plannedRoot, path.dirname(relative)), { recursive: true }) + writeFileSync(path.join(plannedRoot, relative), content) + } + const candidate = item(cachePath) + candidate.version = { ...candidate.version, latest: '1.0.2' } + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: nativePayloadTreeDigest(plannedRoot)!, + comparisonDigest: nativePayloadTreeDigest(plannedRoot, { profile: 'codex-installed-v1' })!, + comparisonProfile: 'codex-installed-v1', + } + + const result = await executeCodexTransaction(candidate, { + run: async (command) => { + if (command.args.includes('add')) { + for (const [relative, content] of plannedFiles) { + mkdirSync(path.join(cachePath, path.dirname(relative)), { recursive: true }) + writeFileSync(path.join(cachePath, relative), content) + } + writeFileSync(path.join(cachePath, '.codex-marketplace-install.json'), '{"source":"marketplace"}\n') + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }) + + assert.equal(result.success, true) + assert.equal(result.rollbackAttempted, false) + assert.equal(readCodexPayloadVersion(cachePath, 'nsolid-plugin@nodesource'), '1.0.2') + assert.match(readFileSync(path.join(cachePath, '.codex-marketplace-install.json'), 'utf8'), /marketplace/) + } finally { + rmSync(plannedRoot, { recursive: true, force: true }) + } + }) + + it('fails closed when a plan carries no named comparison identity', 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 plannedRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-codex-f4-noprofile-')) + try { + const plannedBundle = JSON.stringify({ name: 'nsolid-plugin', version: '1.0.2', skills: [] }) + writeFileSync(path.join(plannedRoot, 'bundle.json'), plannedBundle) + const candidate = item(cachePath) + candidate.version = { ...candidate.version, latest: '1.0.2' } + // Legacy-style artifact: strict evidence only, no comparison identity. + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: nativePayloadTreeDigest(plannedRoot)!, + } + + const result = await executeCodexTransaction(candidate, { + run: async (command) => { + if (command.args.includes('add')) writeFileSync(path.join(cachePath, 'bundle.json'), plannedBundle) + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_CONTENT_MISMATCH') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.match(readFileSync(path.join(cachePath, 'bundle.json'), 'utf8'), /1\.0\.0/) + } finally { + rmSync(plannedRoot, { recursive: true, force: true }) + } + }) + + it('rejects real payload tampering under the comparison profile and rolls back', 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 plannedRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-codex-f4-tamper-')) + try { + const plannedFiles = new Map([ + ['bundle.json', Buffer.from(JSON.stringify({ name: 'nsolid-plugin', version: '1.0.2', skills: [] }))], + ['skills/example/SKILL.md', Buffer.from('# example\n')], + ]) + for (const [relative, content] of plannedFiles) { + mkdirSync(path.join(plannedRoot, path.dirname(relative)), { recursive: true }) + writeFileSync(path.join(plannedRoot, relative), content) + } + const candidate = item(cachePath) + candidate.version = { ...candidate.version, latest: '1.0.2' } + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: nativePayloadTreeDigest(plannedRoot)!, + comparisonDigest: nativePayloadTreeDigest(plannedRoot, { profile: 'codex-installed-v1' })!, + comparisonProfile: 'codex-installed-v1', + } + + // The version matches, the harness metadata matches, but a real payload + // byte was substituted: the normalized comparison must still reject it. + const result = await executeCodexTransaction(candidate, { + run: async (command) => { + if (command.args.includes('add')) { + for (const [relative, content] of plannedFiles) { + mkdirSync(path.join(cachePath, path.dirname(relative)), { recursive: true }) + writeFileSync(path.join(cachePath, relative), content) + } + writeFileSync(path.join(cachePath, '.codex-marketplace-install.json'), '{"source":"marketplace"}\n') + writeFileSync(path.join(cachePath, 'skills/example/SKILL.md'), '# substituted\n') + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_CONTENT_MISMATCH') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.match(readFileSync(path.join(cachePath, 'bundle.json'), 'utf8'), /1\.0\.0/) + assert.equal(existsSync(path.join(cachePath, 'skills/example/SKILL.md')), false) + } finally { + rmSync(plannedRoot, { recursive: true, force: true }) + } + }) + + it('rejects a symlink installed at the reserved harness metadata path and rolls back', async (t) => { + 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 plannedRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-codex-f4-symlink-')) + try { + writePlannedPayload(plannedRoot) + const candidate = item(cachePath) + candidate.version = { ...candidate.version, latest: '1.0.2' } + candidate.artifact = comparisonArtifact(plannedRoot) + + let linkCreated = true + const result = await executeCodexTransaction(candidate, { + run: async (command) => { + if (command.args.includes('add')) { + writeInstalledPayload(cachePath) + // A crafted symlink must not hide behind the normalization profile. + linkCreated = makeFileLink(t, '../../shared/meta.json', path.join(cachePath, '.codex-marketplace-install.json')) + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }) + if (!linkCreated) return + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_TRANSACTION_FAILED') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, false) + assert.match(readFileSync(path.join(cachePath, 'bundle.json'), 'utf8'), /1\.0\.2/) + // Post-command kind drift is never removed. The crafted symlink and both + // authenticated backups remain available for manual recovery. + assert.equal(lstatSync(path.join(cachePath, '.codex-marketplace-install.json')).isSymbolicLink(), true) + assert.equal(readdirSync(path.dirname(path.join(home, '.codex', 'config.toml'))).filter((name) => name.includes('.nsolid-config-backup-')).length, 1) + assert.equal(readdirSync(path.dirname(cachePath)).filter((name) => name.includes('.nsolid-cache-backup-')).length, 1) + } finally { + rmSync(plannedRoot, { recursive: true, force: true }) + } + }) + + it('rejects a directory installed at the reserved harness metadata path and rolls back', 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 plannedRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-codex-f4-dir-')) + try { + writePlannedPayload(plannedRoot) + const candidate = item(cachePath) + candidate.version = { ...candidate.version, latest: '1.0.2' } + candidate.artifact = comparisonArtifact(plannedRoot) + + const result = await executeCodexTransaction(candidate, { + run: async (command) => { + if (command.args.includes('add')) { + writeInstalledPayload(cachePath) + mkdirSync(path.join(cachePath, '.codex-marketplace-install.json')) + writeFileSync(path.join(cachePath, '.codex-marketplace-install.json', 'nested.txt'), 'payload-ish\n') + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_CONTENT_MISMATCH') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.match(readFileSync(path.join(cachePath, 'bundle.json'), 'utf8'), /1\.0\.0/) + assert.equal(existsSync(path.join(cachePath, '.codex-marketplace-install.json')), false) + } finally { + rmSync(plannedRoot, { recursive: true, force: true }) + } + }) + + it('rejects unrecognized comparison profiles even when strict bytes would match', 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 plannedRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-codex-f4-unknown-profile-')) + try { + writePlannedPayload(plannedRoot) + const candidate = item(cachePath) + candidate.version = { ...candidate.version, latest: '1.0.2' } + // A silently-strict implementation would accept this plan: the installed + // bytes below match the strict digest exactly. The unknown profile must + // be rejected instead of digested as strict evidence. + candidate.artifact = comparisonArtifact(plannedRoot, { + comparisonDigest: nativePayloadTreeDigest(plannedRoot)!, + comparisonProfile: 'codex-installed-v2-future', + }) + + const result = await executeCodexTransaction(candidate, { + run: async (command) => { + if (command.args.includes('add')) writeInstalledPayload(cachePath) + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_CONTENT_MISMATCH') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.match(readFileSync(path.join(cachePath, 'bundle.json'), 'utf8'), /1\.0\.0/) + } finally { + rmSync(plannedRoot, { recursive: true, force: true }) + } + }) + + it('rejects a comparison digest that does not match its named profile and rolls back', 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 plannedRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-codex-f4-wrong-identity-')) + try { + writePlannedPayload(plannedRoot) + const candidate = item(cachePath) + candidate.version = { ...candidate.version, latest: '1.0.2' } + // The digest was computed over different bytes than the plan carries, so + // no faithful install can produce it under the named profile. + writeFileSync(path.join(plannedRoot, 'skills', 'extra.txt'), 'stray\n') + const forgedDigest = nativePayloadTreeDigest(plannedRoot, { profile: 'codex-installed-v1' })! + rmSync(path.join(plannedRoot, 'skills', 'extra.txt')) + candidate.artifact = comparisonArtifact(plannedRoot, { comparisonDigest: forgedDigest }) + + const result = await executeCodexTransaction(candidate, { + run: async (command) => { + if (command.args.includes('add')) { + writeInstalledPayload(cachePath) + writeFileSync(path.join(cachePath, '.codex-marketplace-install.json'), '{"source":"marketplace"}\n') + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_CONTENT_MISMATCH') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.match(readFileSync(path.join(cachePath, 'bundle.json'), 'utf8'), /1\.0\.0/) + } finally { + rmSync(plannedRoot, { recursive: true, force: true }) + } + }) + + it('restores prior harness metadata byte-exact through strict backup digests', 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 } } }) + const priorMetadata = '{"source":"marketplace","prior":true}\n' + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + writeFileSync(path.join(cachePath, '.codex-marketplace-install.json'), priorMetadata) + + const plannedRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-codex-f4-rollback-')) + try { + const candidate = item(cachePath) + candidate.version = { ...candidate.version, latest: '1.0.2' } + candidate.artifact = { + kind: 'git', + repository: 'https://github.com/NodeSource/nsolid-plugin.git', + commit: 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c', + contentDigest: nativePayloadTreeDigest(plannedRoot)!, + comparisonDigest: nativePayloadTreeDigest(plannedRoot, { profile: 'codex-installed-v1' })!, + comparisonProfile: 'codex-installed-v1', + } + + // A wrong payload version forces a post-mutation rollback; the prior + // cache (including its harness metadata) must be restored byte-exact. + const result = await executeCodexTransaction(candidate, { + run: async (command) => { + if (command.args.includes('add')) { + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '0.9.0', skills: [] })) + writeFileSync(path.join(cachePath, '.codex-marketplace-install.json'), '{"source":"marketplace","rewritten":true}\n') + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }) + + assert.equal(result.error?.code, 'CODEX_VERSION_MISMATCH') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.match(readFileSync(path.join(cachePath, 'bundle.json'), 'utf8'), /1\.0\.0/) + assert.equal(readFileSync(path.join(cachePath, '.codex-marketplace-install.json'), 'utf8'), priorMetadata) + } finally { + rmSync(plannedRoot, { recursive: true, force: 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, treeTerminated: true } + }, + }) + + 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, treeTerminated: true } + }, + }) + + 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, treeTerminated: true }), + }) + + 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, treeTerminated: true } + }, + }) + + 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, treeTerminated: true } + }, + }) + + 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, treeTerminated: true } + }, + }) + + 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, treeTerminated: true }), + }) + + 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('authenticates and restores an originally empty cache', 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 }) + writeTomlFileSync(configPath, { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) + const candidate = item(cachePath) + candidate.steps = [ + { kind: 'command', description: 'upgrade', command: { executable: 'codex', args: ['plugin', 'update'], timeoutMs: 1000 } }, + ] + + const result = await executeCodexTransaction(candidate, { + run: async () => { + writeFileSync(path.join(cachePath, 'partial.txt'), 'partial') + return { exitCode: 1, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_COMMAND_FAILED') + assert.equal(result.rollbackAttempted, true) + assert.equal(result.rollbackSucceeded, true) + assert.deepEqual(readdirSync(cachePath), []) + 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) + }) + + for (const exitCode of [null, 0, 1]) { + it(`preserves backups on unconfirmed tree termination (exit: ${exitCode})`, 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, stdout: '', stderr: '', timedOut: exitCode === null, 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) + }) + } + + it('preserves backups when the injected runner omits termination evidence', 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 }) + writeTomlFileSync(configPath, { 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: 0, stdout: '', stderr: '', timedOut: false } as unknown as CommandResult), + }) + + assert.equal(result.success, false) + assert.equal(result.rollbackAttempted, false) + assert.equal(result.error?.code, 'CODEX_TREE_TERMINATION_UNCONFIRMED') + assert.equal(readdirSync(path.dirname(configPath)).filter((name) => name.includes('.nsolid-config-backup-')).length, 1) + assert.equal(readdirSync(path.dirname(cachePath)).filter((name) => name.includes('.nsolid-cache-backup-')).length, 1) + }) + + it('prevents detached writes after rollback when the command exits before timeout', { skip: process.platform !== 'linux' }, async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + mkdirSync(cachePath, { recursive: true }) + const configPath = path.join(home, '.codex', 'config.toml') + writeTomlFileSync(configPath, { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) + const bundle = path.join(cachePath, 'bundle.json') + const original = JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] }) + writeFileSync(bundle, original) + const pidFile = path.join(home, 'descendant.pid') + const trigger = path.join(home, 'write-now') + const worker = `const fs=require('node:fs');fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));setInterval(()=>{if(fs.existsSync(${JSON.stringify(trigger)}))fs.writeFileSync(${JSON.stringify(bundle)},${JSON.stringify(original.replace('1.0.0', '9.9.9'))})},10)` + const parent = `const fs=require('node:fs');fs.writeFileSync(${JSON.stringify(bundle)},${JSON.stringify(original.replace('1.0.0', '1.0.1'))});require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(worker)}],{detached:true,stdio:'ignore'}).unref();setInterval(()=>{if(fs.existsSync(${JSON.stringify(pidFile)}))process.exit(1)},10)` + let pid: number | undefined + try { + const result = await executeCodexTransaction(item(cachePath), { + run: () => runCommand({ executable: process.execPath, args: ['-e', parent], timeoutMs: 10_000 }), + }) + pid = Number(readFileSync(pidFile, 'utf8')) + if (result.rollbackAttempted) { + assert.equal(result.rollbackSucceeded, true) + assert.equal(readFileSync(bundle, 'utf8'), original) + } else { + // Concurrent unrelated process activity can make ancestry ambiguous. + // Deferral is safe only when explicit and both backups survive. + assert.equal(result.error?.code, 'CODEX_TREE_TERMINATION_UNCONFIRMED') + assert.ok(readdirSync(path.dirname(cachePath)).some((name) => name.includes('.nsolid-cache-backup-'))) + assert.ok(readdirSync(path.dirname(configPath)).some((name) => name.includes('.nsolid-config-backup-'))) + } + const beforeTrigger = readFileSync(bundle, 'utf8') + writeFileSync(trigger, 'go') + await new Promise((resolve) => setTimeout(resolve, 100)) + assert.equal(readFileSync(bundle, 'utf8'), beforeTrigger, 'the accounted descendant must be gone before returning') + } finally { + if (pid !== undefined) { try { process.kill(pid, 'SIGKILL') } catch { /* already gone */ } } + } + }) + + 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, treeTerminated: true } + }, + }) + + 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, treeTerminated: true } + }, + }) + + 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 recreated config after the command removed it', async () => { + const fixture = setupFixture() + const drifted = `${fixture.originalConfig}# concurrent user edit\n` + let stateCaptureHooks = 0 + const result = await executeCodexTransaction(failedUpgradeItem(fixture.cachePath), { + run: async () => { + rmSync(fixture.configPath) + return { exitCode: 1, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }, { + // Recreate the file only after the transaction has authorized the + // missing state. The awaited seam makes this drift deterministic and + // proves explicit null never falls back to a fresh digest. + afterAuthorizedStateCaptured: async () => { + stateCaptureHooks++ + writeFileSync(fixture.configPath, drifted) + }, + }) + + assert.equal(stateCaptureHooks, 1) + 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, treeTerminated: true } + }, + }) + + 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, treeTerminated: true } + }, + }) + + 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..554b71f --- /dev/null +++ b/packages/core/test/unit/update/command-runner.test.ts @@ -0,0 +1,858 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createHmac, randomBytes } from 'node:crypto' +import { once } from 'node:events' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' + +/** + * Fixture-local control server for the escaped-grandchild test below. It is + * deliberately NOT a general process-control framework. It exists so that one + * fixture can: + * - establish a private, nonce-authenticated loopback connection from the + * escaped grandchild BEFORE the intermediate exits (the grandchild writes + * its pid file only after its first authenticated response, and the + * intermediate exits only after seeing that file); + * - prove, with a fresh challenge-response over the SAME connection after + * `runCommand` resolves, that the escaped process is still the original, + * live grandchild — application-level liveness evidence, not a bare + * `kill(pid, 0)` probe that can neither establish identity nor exclude a + * recycled PID; + * - shut the grandchild down over that same channel without ever signaling an + * unverified or potentially recycled PID (the grandchild exits when its + * control socket closes; the child-side self-exit TTL is leak backup only). + * + * Protocol: the server sends a fresh random challenge; the peer answers with + * HMAC-SHA256(nonce, challenge) and its own PID. Only the fixture-spawned + * grandchild knows the nonce. + */ +interface EscapeControlFixture { + /** Resolves with the loopback port once the server is listening. */ + readonly portReady: Promise + /** The nonce the fixture-spawned grandchild must answer challenges with. */ + readonly nonceHex: string + /** Resolves with the authenticated peer's self-reported PID. */ + readonly ready: Promise + /** Fresh challenge-response over the SAME established connection. */ + challenge (): Promise + /** Bounded, idempotent teardown that is safe on every path. */ + shutdown (): Promise +} + +function createEscapeControlFixture (): EscapeControlFixture { + const nonce = randomBytes(32) + const server = net.createServer() + // Every socket the server accepted. net.Server has no closeAllConnections + // (that API belongs to http.Server), so teardown tracks and destroys the + // server-owned sockets explicitly instead of waiting on remote peers. + const ownedSockets = new Set() + let mode: 'accepting' | 'closing' = 'accepting' + let control: net.Socket | undefined + let expectedMac: string | undefined + let handshakeTimer: NodeJS.Timeout | undefined + let responseTimer: NodeJS.Timeout | undefined + let readyTimer: NodeJS.Timeout | undefined + let readySettled = false + let readyResolve: ((pid: number) => void) | undefined + let readyReject: ((error: Error) => void) | undefined + let challengeResolve: ((pid: number) => void) | undefined + let challengeReject: ((error: Error) => void) | undefined + let shutdownPromise: Promise | undefined + + const ready = new Promise((resolve, reject) => { + readyResolve = resolve + readyReject = reject + }) + // Every `ready` rejection is either awaited by the escape test or triggered + // by teardown after the test already failed elsewhere (for example on + // `portReady`); this handler keeps the latter case from becoming an + // unhandled rejection while the awaiting caller still observes the error. + ready.catch(() => { /* observed by the awaiting fixture or by shutdown */ }) + + const portReady = new Promise((resolve, reject) => { + server.once('listening', () => { + const address = server.address() + if (address === null || typeof address === 'string') { + reject(new Error('the escape fixture control server has no loopback port')) + return + } + // Bounded wait for the grandchild's authenticated connection so a + // fixture that never connects fails instead of hanging. + readyTimer = setTimeout(() => { + failReady(new Error('no grandchild authenticated with the escape fixture control server')) + }, 10_000) + resolve(address.port) + }) + server.once('error', reject) + }) + // Awaited by the escape test; teardown also settles `ready` on this path. + portReady.catch(() => { /* observed by the awaiting fixture */ }) + + const failReady = (error: Error) => { + if (readySettled) return + readySettled = true + if (readyTimer !== undefined) { + clearTimeout(readyTimer) + readyTimer = undefined + } + readyReject?.(error) + } + + const clearResponseTimer = () => { + if (responseTimer !== undefined) { + clearTimeout(responseTimer) + responseTimer = undefined + } + expectedMac = undefined + } + + const settleChallenge = (error: Error | undefined, pid?: number) => { + clearResponseTimer() + const resolve = challengeResolve + const reject = challengeReject + challengeResolve = undefined + challengeReject = undefined + if (resolve === undefined || reject === undefined) return + if (error !== undefined) reject(error) + else resolve(pid as number) + } + + const destroySocket = (socket: net.Socket) => { + ownedSockets.delete(socket) + socket.removeAllListeners() + socket.destroy() + } + + const sendChallenge = (socket: net.Socket, timeoutMs: number) => { + const challenge = randomBytes(16).toString('hex') + expectedMac = createHmac('sha256', nonce).update(challenge).digest('hex') + socket.write(challenge) + responseTimer = setTimeout(() => { + destroySocket(socket) + settleChallenge(new Error('the authenticated peer stopped responding to challenges')) + }, timeoutMs) + } + + server.on('connection', (socket) => { + ownedSockets.add(socket) + // Closing mode handles late handshakes by shutting them down immediately. + if (mode === 'closing') { + destroySocket(socket) + return + } + handshakeTimer = setTimeout(() => { + destroySocket(socket) + failReady(new Error('the connecting peer never completed the nonce handshake')) + }, 5_000) + socket.on('error', () => { /* 'close' follows and handles the failure */ }) + socket.on('close', () => { + ownedSockets.delete(socket) + if (mode === 'closing') return + failReady(new Error('the peer disconnected before authenticating')) + settleChallenge(new Error('the authenticated control connection closed unexpectedly')) + const wasControl = control + if (wasControl === socket) { + control = undefined + } + }) + socket.on('data', (chunk: Buffer) => { + const [mac, pidText] = chunk.toString('utf8').trim().split(':') + if (expectedMac === undefined || mac !== expectedMac || !/^\d+$/.test(pidText ?? '')) { + destroySocket(socket) + failReady(new Error('the connected peer does not know the fixture nonce')) + return + } + if (handshakeTimer !== undefined) { + clearTimeout(handshakeTimer) + handshakeTimer = undefined + } + clearResponseTimer() + const pid = Number(pidText) + if (!readySettled) { + readySettled = true + if (readyTimer !== undefined) { + clearTimeout(readyTimer) + readyTimer = undefined + } + // Exactly one authenticated peer owns the control channel. + control = socket + readyResolve?.(pid) + return + } + settleChallenge(undefined, pid) + }) + // The first challenge goes out immediately: the grandchild must answer + // with the nonce-derived MAC before it is trusted at all. + sendChallenge(socket, 5_000) + }) + + server.on('error', (error) => { + failReady(error instanceof Error ? error : new Error(String(error))) + settleChallenge(new Error('the escape fixture control server errored')) + }) + + server.listen(0, '127.0.0.1') + + return { + portReady, + nonceHex: nonce.toString('hex'), + ready, + challenge () { + const peer = control + if (mode === 'closing' || peer === undefined) { + return Promise.reject(new Error('the escape fixture control connection is not available')) + } + return new Promise((resolve, reject) => { + challengeResolve = resolve + challengeReject = reject + sendChallenge(peer, 2_000) + }) + }, + shutdown () { + if (shutdownPromise !== undefined) return shutdownPromise + mode = 'closing' + // Settle every pending promise immediately so teardown can neither hang + // nor produce unhandled rejections, on any failure path. + failReady(new Error('the escape fixture shut down before a peer authenticated')) + settleChallenge(new Error('the escape fixture shut down before a response arrived')) + if (handshakeTimer !== undefined) { + clearTimeout(handshakeTimer) + handshakeTimer = undefined + } + if (control !== undefined) { + destroySocket(control) + control = undefined + } + shutdownPromise = new Promise((resolve) => { + // Bounded: resolve even if the server's close callback stalls. + const deadline = setTimeout(() => resolve(), 2_000) + server.close(() => { + clearTimeout(deadline) + resolve() + }) + // The server owns its sockets: destroy every one of them (late or + // stray handshakes included) instead of waiting on remote peers. + for (const socket of ownedSockets) destroySocket(socket) + }) + return shutdownPromise + }, + } +} +import { deriveShimEntrypoint, isCommandSuccessful, isTreeTerminationUnconfirmed, resolveExecutableIdentity, runCommand, terminationVerdict, windowsTaskkillPath } from '../../../src/update/command-runner.js' +import type { CommandResult } from '../../../src/update/types.js' + +describe('update command runner', () => { + for (const [exitCode, stripToken] of [[0, false], [0, true], [1, false], [1, true]] as const) { + it(`accounts for reparented descendants after early exit (code: ${exitCode}, strip token: ${stripToken})`, { skip: process.platform !== 'linux' }, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-early-exit-')) + const pidFile = path.join(root, 'descendant.pid') + const trigger = path.join(root, 'write-now') + const output = path.join(root, 'late.txt') + const worker = `const fs=require('node:fs');fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));setInterval(()=>{if(fs.existsSync(${JSON.stringify(trigger)}))fs.writeFileSync(${JSON.stringify(output)},'late')},10)` + // The parent waits for the final descendant's readiness, then exits. + // No timeout or fixed startup delay is needed to produce reparenting. + const parent = `const fs=require('node:fs');const env={...process.env};${stripToken ? 'delete env.NSOLID_COMMAND_TREE_TOKEN;' : ''}require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(worker)}],{detached:true,stdio:'ignore',env}).unref();const timer=setInterval(()=>{if(fs.existsSync(${JSON.stringify(pidFile)})){clearInterval(timer);process.exit(${exitCode})}},10)` + let pid: number | undefined + try { + const result = await runCommand({ executable: process.execPath, args: ['-e', parent], timeoutMs: 10_000 }) + pid = Number(readFileSync(pidFile, 'utf8')) + assert.equal(result.timedOut, false) + assert.equal(result.exitCode, exitCode) + if (stripToken) { + assert.equal(result.treeTerminated, false) + assert.equal(result.spawnErrorCode, 'TREE_TERMINATION_UNCONFIRMED') + assert.doesNotThrow(() => process.kill(pid!, 0), 'unattributed processes must not be killed') + } else { + assert.equal(result.treeTerminated, true) + writeFileSync(trigger, 'go') + await new Promise((resolve) => setTimeout(resolve, 100)) + assert.equal(existsSync(output), false, 'no descendant may write after termination was confirmed') + } + } finally { + if (pid !== undefined) { try { process.kill(pid, 'SIGKILL') } catch { /* already gone */ } } + rmSync(root, { recursive: true, force: true }) + } + }) + } + + 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('requires explicit tree termination evidence at runtime', () => { + for (const exitCode of [0, 1]) { + // Deliberately bypass the required TypeScript field to model a legacy + // injected runner returning an incomplete result at runtime. + const result = { exitCode, stdout: '', stderr: '', timedOut: false } as unknown as CommandResult + assert.equal(isTreeTerminationUnconfirmed(result), true, `exit ${exitCode} must remain unconfirmed`) + assert.equal(isCommandSuccessful(result), false, `exit ${exitCode} must not authorize success`) + } + }) + + 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 where the platform can prove it', async () => { + const result = await runCommand({ + executable: process.execPath, + args: ['-e', 'setInterval(() => {}, 10_000)'], + timeoutMs: 50, + }) + + assert.equal(result.timedOut, true) + if (process.platform === 'linux' || process.platform === 'win32') { + // Linux proves whole-tree termination from pre-spawn /proc identity + // snapshots; Windows reports taskkill's own /T success. + assert.equal(result.treeTerminated, true) + } else { + // Without pre-spawn identity evidence (macOS) a detached, token-stripped, + // reparented descendant is unattributable, so the verdict must refuse + // confirmation even though the observable tree was terminated. + assert.equal(result.treeTerminated, false) + assert.equal(result.spawnErrorCode, 'TREE_TERMINATION_UNCONFIRMED') + } + }) + + it('terminates a detached descendant on timeout; confirmation follows platform evidence', { skip: process.platform === 'win32' }, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-detached-descendant-')) + const lateWrite = path.join(root, 'late-write.txt') + const childCode = `const fs=require('node:fs');setTimeout(()=>fs.writeFileSync(${JSON.stringify(lateWrite)},'late'),400);setInterval(()=>{},10000)` + const parentCode = `const {spawn}=require('node:child_process');spawn(process.execPath,['-e',${JSON.stringify(childCode)}],{detached:true,stdio:'ignore'}).unref();setInterval(()=>{},10000)` + try { + const result = await runCommand({ executable: process.execPath, args: ['-e', parentCode], timeoutMs: 100 }) + await new Promise((resolve) => setTimeout(resolve, 600)) + + assert.equal(result.timedOut, true) + if (process.platform === 'linux') { + assert.equal(result.treeTerminated, true) + } else { + // Without pre-spawn identity evidence (macOS) the termination of the + // observable tree cannot be proven whole; the verdict fails closed. + assert.equal(result.treeTerminated, false) + assert.equal(result.spawnErrorCode, 'TREE_TERMINATION_UNCONFIRMED') + } + // The descendant itself was still terminated: the late write it would + // have performed must never appear, independent of the verdict. + assert.equal(existsSync(lateWrite), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('terminates a detached descendant that was reparented before timeout', { skip: process.platform !== 'linux' }, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-reparented-descendant-')) + const pidFile = path.join(root, 'descendant.pid') + const intermediateExit = path.join(root, 'intermediate-exit.json') + const lateWrite = path.join(root, 'late-write.txt') + const timeoutMs = 1000 + const descendantCode = `const fs=require('node:fs');fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));setTimeout(()=>fs.writeFileSync(${JSON.stringify(lateWrite)},'late'),2000);setInterval(()=>{},10000)` + const intermediateCode = `const {spawn}=require('node:child_process');spawn(process.execPath,['-e',${JSON.stringify(descendantCode)}],{detached:true,stdio:'ignore'}).unref()` + // Only the final descendant escapes the group. Detaching the short-lived + // intermediate too introduces a separate /proc evidence-loss scenario + // during its startup/exit; the token-stripped escape test covers refusal. + const parentCode = `const {spawn}=require('node:child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(intermediateCode)}],{stdio:'ignore'});child.once('exit',(code,signal)=>require('node:fs').writeFileSync(${JSON.stringify(intermediateExit)},JSON.stringify({code,signal,at:Date.now()})));child.unref();setInterval(()=>{},10000)` + let descendantPid: number | undefined + try { + const startedAt = Date.now() + const result = await runCommand({ executable: process.execPath, args: ['-e', parentCode], timeoutMs }) + await new Promise((resolve) => setTimeout(resolve, 700)) + descendantPid = existsSync(pidFile) ? Number(readFileSync(pidFile, 'utf8')) : undefined + + assert.ok(descendantPid !== undefined, 'the descendant must have started; a missing fixture cannot prove termination') + const intermediate = JSON.parse(readFileSync(intermediateExit, 'utf8')) as { code: number | null; signal: string | null; at: number } + assert.equal(intermediate.code, 0) + assert.equal(intermediate.signal, null) + assert.ok(intermediate.at < startedAt + timeoutMs, 'the intermediate must exit naturally and reparent its descendant before timeout') + assert.equal(result.timedOut, true) + assert.equal(result.treeTerminated, true) + assert.equal(existsSync(lateWrite), false) + const terminatedPid = descendantPid + assert.throws(() => process.kill(terminatedPid, 0), 'the descendant must be gone, so it cannot write later') + } finally { + if (descendantPid !== undefined) { + try { process.kill(descendantPid, 'SIGKILL') } catch { /* already terminated */ } + } + rmSync(root, { recursive: true, force: true }) + } + }) + + it('refuses to confirm termination when a token-stripped detached grandchild survives', { skip: process.platform === 'win32' }, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-escaped-grandchild-')) + const pidFile = path.join(root, 'escaped.pid') + const exitMarker = path.join(root, 'escaped-exit.json') + const intermediateExit = path.join(root, 'intermediate-exit.json') + const timeoutMs = 2_000 + // Private, nonce-authenticated control channel for this fixture alone, + // created before anything spawns (see the helper's contract). + const fixture = createEscapeControlFixture() + try { + const controlPort = await fixture.portReady + // The grandchild strips the tree token and stays alive on the control + // connection. Its self-exit TTL is pure leak backup (the observation + // window below is a fraction of it) and never part of the assertions: + // it exits when the fixture closes the control socket. + const grandchildCode = `const net=require('node:net'),crypto=require('node:crypto'),fs=require('node:fs');const socket=net.connect(${controlPort},'127.0.0.1');let announced=false;socket.on('data',(d)=>{const c=d.toString('utf8').trim();if(!c)return;const mac=crypto.createHmac('sha256',Buffer.from(${JSON.stringify(fixture.nonceHex)},'hex')).update(c).digest('hex');socket.write(mac+':'+process.pid);if(!announced){announced=true;fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid))}});const bail=(why)=>{try{fs.writeFileSync(${JSON.stringify(exitMarker)},JSON.stringify({pid:process.pid,why}))}catch{}process.exit(0)};socket.on('close',()=>bail('disconnected'));socket.on('error',()=>bail('error'));setTimeout(()=>bail('ttl'),30000)` + // The intermediate waits for the grandchild's pid announcement — which + // the grandchild only writes after its first authenticated control + // response — and only then exits, so the control connection is + // established before the intermediate exits and the grandchild is + // reparented before the command can time out (proven below from the + // recorded exit time). Its environment is rebuilt without + // NSOLID_COMMAND_TREE_TOKEN, so nothing identifies the grandchild as + // part of the command tree: it is invisible to the process group, PPID + // ancestry, session, and token enumeration, termination cannot be + // proven, and the runner must fail closed instead of claiming a + // terminated tree. + const intermediateCode = `const {spawn}=require('node:child_process');const fs=require('node:fs');const env={...process.env};delete env.NSOLID_COMMAND_TREE_TOKEN;const child=spawn(process.execPath,['-e',${JSON.stringify(grandchildCode)}],{detached:true,stdio:'ignore',env});child.unref();const bail=setTimeout(()=>process.exit(1),15000);const timer=setInterval(()=>{if(fs.existsSync(${JSON.stringify(pidFile)})){clearTimeout(bail);clearInterval(timer);process.exit(0)}},10)` + const parentCode = `const {spawn}=require('node:child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(intermediateCode)}],{detached:true,stdio:'ignore'});child.once('exit',(code,signal)=>require('node:fs').writeFileSync(${JSON.stringify(intermediateExit)},JSON.stringify({code,signal,at:Date.now()})));child.unref();setInterval(()=>{},10000)` + try { + const startedAt = Date.now() + const result = await runCommand({ executable: process.execPath, args: ['-e', parentCode], timeoutMs }) + // The grandchild announced its pid only after its first authenticated + // control response, so this resolves with the live peer's own + // self-reported identity on the private connection. + const authenticatedPid = await fixture.ready + assert.equal(Number(readFileSync(pidFile, 'utf8')), authenticatedPid, 'the authenticated peer must be the process that announced the pid file') + const intermediate = JSON.parse(readFileSync(intermediateExit, 'utf8')) as { code: number | null; signal: string | null; at: number } + assert.equal(intermediate.code, 0) + assert.equal(intermediate.signal, null) + // Bounded startup evidence: the intermediate exited (and therefore + // the authenticated control connection readied) before the runner's + // timeout fired. There is no runCommand cancellation seam, so the + // fixed timeout is not gated on readiness; on a pathologically slow + // machine this assertion fails honestly instead of passing silently. + assert.ok(intermediate.at < startedAt + timeoutMs, 'the intermediate must exit and reparent the grandchild before the timeout') + // Fresh challenge-response over the SAME connection, before the + // verdict assertions: proves the escaped process is still the + // original live grandchild executing fixture protocol code, not + // merely that some pid answers a probe. + assert.equal(await fixture.challenge(), authenticatedPid) + + assert.equal(result.timedOut, true) + assert.equal(result.treeTerminated, false) + assert.equal(result.spawnErrorCode, 'TREE_TERMINATION_UNCONFIRMED') + + // Assertion-independent shutdown over the same channel: closing the + // control socket makes the grandchild exit (EOF backup; the TTL is + // leak backup only). No unverified or recycled PID is ever signaled. + await fixture.shutdown() + // Bounded closure evidence from the owned child itself: the exit + // marker is written by the grandchild process when its control + // socket closes, so the fixture never equates "socket closed" with + // "OS process gone" without the child's own confirmation. + const closureDeadline = Date.now() + 2_000 + while (!existsSync(exitMarker) && Date.now() < closureDeadline) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + assert.ok(existsSync(exitMarker), 'the escaped grandchild must exit when the fixture closes the control connection') + } finally { + // Teardown on every path: stops accepting, settles pending promises + // immediately, destroys every server-owned socket, and closes the + // server under a bounded deadline. Idempotent with the shutdown + // above; on failure paths the same mechanism still runs even though + // the exit-marker assertion is not re-evaluated there. + await fixture.shutdown() + rmSync(root, { recursive: true, force: true }) + } + } finally { + // Covers a `portReady` rejection (setup failure) before the inner try + // exists; shutdown is idempotent. + await fixture.shutdown() + rmSync(root, { recursive: true, force: true }) + } + }) + + it('escape fixture teardown rejects pending challenges and shuts down stray and late peers', async () => { + const fixture = createEscapeControlFixture() + const port = await fixture.portReady + // A challenge before any authenticated peer exists must reject + // immediately, never hang and never become an unhandled rejection. + await assert.rejects(fixture.challenge(), /control connection is not available/) + // A peer that connects but never completes the nonce handshake is a + // server-owned socket that teardown must destroy, not leave to the + // grandchild's TTL. + const stray = net.connect(port, '127.0.0.1') + // Teardown destroys the server-owned socket, so this peer sees a reset; + // an unhandled 'error' event would crash the run instead of closing. + stray.on('error', () => { /* expected: the fixture destroyed the connection */ }) + // The fixture sends a nonce challenge on every connection. Consuming it + // drains the receive buffer so teardown's EOF reaches this socket — an + // unread receive buffer defers the client-side 'close' event indefinitely. + stray.on('data', () => { /* the fixture challenge; a stray never answers it */ }) + await once(stray, 'connect') + // Attach the close observation BEFORE teardown: the socket can already be + // closed by the time shutdown resolves, and a late-attached observer never + // settles on an event that fired before it attached. The observer is + // resolve-only on purpose: teardown's expected reset must not fail the + // close-wait (an attached 'error' listener only prevents the unhandled + // event; it does not stop once() from rejecting on 'error'). + const strayClosed = new Promise((resolve) => { stray.once('close', () => resolve()) }) + await fixture.shutdown() + await strayClosed + // After teardown the server no longer accepts: late arrivals are + // refused instead of being left half-open. + const late = net.connect(port, '127.0.0.1') + late.on('error', () => { /* expected: the server socket is closed */ }) + await assert.rejects(once(late, 'connect'), /ECONNREFUSED/) + // Teardown is idempotent and bounded. + await fixture.shutdown() + }) + + it('refuses to confirm timeout termination when pre-spawn identity evidence is absent', () => { + // Deterministic regression for the macOS CI discrepancy (PR 56): without + // a pre-spawn /proc identity snapshot, PPID/token enumeration cannot + // attribute a detached, token-stripped, reparented descendant, so the + // timeout verdict must refuse confirmation instead of defaulting to + // success. A missing pid announcement in the macOS CI run also proved + // execution, not survival; missing evidence is not proof of termination. + assert.equal(terminationVerdict(undefined, undefined, 4242, undefined, undefined), false) + }) + + 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 the entrypoint of a node_modules/.bin cmd shim (npm dependency layout, cross-platform)', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-bin-')) + const entrypoint = path.join(root, 'node_modules', 'pnpm', 'bin', 'pnpm.cjs') + const shim = path.join(root, 'node_modules', '.bin', 'pnpm.cmd') + mkdirSync(path.dirname(entrypoint), { recursive: true }) + mkdirSync(path.dirname(shim), { recursive: true }) + writeFileSync(entrypoint, '#!/usr/bin/env node\n') + writeFileSync(path.join(root, 'node_modules', 'pnpm', 'package.json'), JSON.stringify({ name: 'pnpm', bin: { pnpm: 'bin/pnpm.cjs' } })) + // npm's cmd-shim writes the target relative to the shim directory, which + // for a node_modules/.bin shim is one `..` level up (this is the layout + // pnpm/action-setup produces on Windows runners, where only pnpm.cmd — + // no pnpm.exe — exists on PATH). + 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% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" "%dp0%\\..\\pnpm\\bin\\pnpm.cjs" %*', + '', + ].join('\r\n')) + try { + assert.equal(deriveShimEntrypoint(shim, 'win32'), entrypoint) + assert.deepEqual(resolveExecutableIdentity('pnpm', { Path: path.join(root, 'node_modules', '.bin'), PATHEXT: '.cmd' }, 'win32'), { + kind: 'node', + executable: process.execPath, + entrypoint, + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects a .bin cmd shim whose target escapes the node_modules root', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-shim-bin-')) + const evil = path.join(root, 'evil', 'dummy.js') + const shim = path.join(root, 'node_modules', '.bin', 'pnpm.cmd') + mkdirSync(path.dirname(evil), { recursive: true }) + mkdirSync(path.dirname(shim), { recursive: true }) + writeFileSync(evil, '#!/usr/bin/env node\n') + writeFileSync(shim, 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\..\\..\\evil\\dummy.js" %*\r\n') + try { + assert.equal(deriveShimEntrypoint(shim, 'win32'), undefined) + } 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..f93e888 --- /dev/null +++ b/packages/core/test/unit/update/coordinator.test.ts @@ -0,0 +1,1479 @@ +import { tarEntry } from '../../helpers/tar.js' +import { createCanonicalTempRoot } from '../../helpers/canonical-temp-root.js' +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createHash, randomUUID } from 'node:crypto' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { gzipSync } from 'node:zlib' +import path from 'node:path' +import { checkUpdates, executeUpdatePlan, planUpdates, update, withPinnedMarketplaceCommit } from '../../../src/update/coordinator.js' +import { beginFallbackJournal, fallbackJournalPath, pathDigest, pathKind, trackingDigest } from '../../../src/update/fallback-journal.js' +import { cliPackageStrategy } from '../../../src/update/strategies/cli-package.js' +import { fallbackStrategy } from '../../../src/update/strategies/fallback.js' +import { getHarnessSkillsPath } from '../../../src/skills/skill-linker.js' +import { getTrackingFilePath } from '../../../src/utils/path.js' +import type { CommandSpec, FallbackTransactionIdentity, ResolvedArtifactIdentity, UpdatePlanItem, UpdateResult, UpdateSource } from '../../../src/update/types.js' +import { FALLBACK_PROTOCOL_VERSION } from '../../../src/update/types.js' + +let home: string +let previousHome: string | undefined +let previousUserProfile: string | undefined + +beforeEach(() => { + home = createCanonicalTempRoot('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') +} + +/** + * Build a genuine journal via beginFallbackJournal, then drift the live skill + * past its snapshotted state — the exact state in which next-run restore-only + * recovery succeeds (recovered: true) and mutates tracked paths. + */ +async function writeFallbackExecutionFixture (): Promise<{ identity: FallbackTransactionIdentity; skillPath: string; trackingPath: string }> { + const skillsDir = path.join(home, '.config', 'opencode', 'skills') + const skillPath = path.join(skillsDir, 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old tracked') + const trackingPath = getTrackingFilePath() + mkdirSync(path.dirname(trackingPath), { 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: [], + }, null, 2)}\n`) + const evidence = async (target: string) => { + const kind = await pathKind(target) + return { path: path.resolve(target), kind, digest: kind === 'missing' ? undefined : await pathDigest(target) } + } + const identity: FallbackTransactionIdentity = { + installationId: 'opencode:fallback', + harness: 'opencode', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await evidence(skillPath)], + ownedLinks: [], + ownedMcpFields: [], + ownedMcpConfigPaths: [await evidence(path.join(home, '.config', 'opencode', 'opencode.jsonc'))], + bundleDestinations: [await evidence(skillPath)], + approvedDestinationRoots: [skillsDir], + } + return { identity, skillPath, trackingPath } +} + +async function writeRestorableJournal (): Promise<{ skillPath: string; journalPath: string; snapshotDirectory: string }> { + const skillsDir = path.join(home, '.config', 'opencode', 'skills') + const skillPath = path.join(skillsDir, 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old tracked') + const trackingPath = getTrackingFilePath() + mkdirSync(path.dirname(trackingPath), { 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: [], + }, null, 2)}\n`) + const evidence = async (target: string) => { + const kind = await pathKind(target) + return { path: path.resolve(target), kind, digest: kind === 'missing' ? undefined : await pathDigest(target) } + } + const { journal } = await beginFallbackJournal({ + installationId: 'opencode:fallback', + harness: 'opencode', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await evidence(skillPath)], + ownedLinks: [], + ownedMcpFields: [], + ownedMcpConfigPaths: [await evidence(path.join(home, '.config', 'opencode', 'opencode.jsonc'))], + bundleDestinations: [await evidence(skillPath)], + approvedDestinationRoots: [skillsDir], + }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'drifted') + writeFileSync(path.join(skillPath, 'stray.txt'), 'half-applied') + return { skillPath, journalPath: journal.journalPath, snapshotDirectory: journal.snapshotDirectory! } +} + +/** + * Read-only recursive byte snapshot of a directory tree (relative path to + * utf8 contents). Directories are detected by a successful readdir; anything + * else is read as a file. Fixture trees contain only text bytes. + */ +function snapshotTree (root: string): Record { + const files: Record = {} + const walk = (directory: string): void => { + for (const name of readdirSync(directory)) { + const target = path.join(directory, name) + let children: string[] | undefined + try { + children = readdirSync(target) + } catch { + children = undefined + } + if (children === undefined) files[path.relative(root, target)] = readFileSync(target, 'utf8') + else walk(target) + } + } + walk(root) + return files +} + +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, treeTerminated: true } + }, + }, + }) + + 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('keeps a check-only plan byte-identical for a restorable pending journal', async () => { + const { skillPath, journalPath, snapshotDirectory } = await writeRestorableJournal() + const trackingPath = getTrackingFilePath() + let fetchCalls = 0 + let runnerCalls = 0 + const spies = () => ({ + fetchImpl: async () => { + fetchCalls++ + return new Response('{}', { status: 200 }) + }, + commandRunner: { + run: async () => { + runnerCalls++ + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }, + }) + // Every live byte a restore would touch, plus the journal, tracking, and + // snapshot backups: a check must leave all of them identical. + const skillFile = path.join(skillPath, 'SKILL.md') + const strayFile = path.join(skillPath, 'stray.txt') + const skillBefore = readFileSync(skillFile, 'utf8') + const strayBefore = readFileSync(strayFile, 'utf8') + const trackingBefore = readFileSync(trackingPath, 'utf8') + const journalBefore = readFileSync(journalPath, 'utf8') + const snapshotBefore = snapshotTree(snapshotDirectory) + const skillParentBefore = readdirSync(path.dirname(skillPath)).sort() + const trackingDirBefore = readdirSync(path.dirname(trackingPath)).sort() + assert.equal(skillBefore, 'drifted') + + const plan = await planUpdates({ all: true, check: true, ...spies() }) + + assert.equal(plan.items.length, 1) + assert.equal(plan.items[0]?.installationId, 'fallback:recovery') + assert.equal(plan.items[0]?.planningError?.code, 'FALLBACK_RECOVERY_PENDING') + const message = plan.items[0]?.planningError?.message ?? '' + // A check never restores, so claiming the state was "restored to its + // tracked state" would be false; the guidance must say it was preserved + // untouched for manual resolution. + assert.match(message, /preserved untouched/) + assert.doesNotMatch(message, /restored to its tracked state/) + assert.equal(fetchCalls, 0) + assert.equal(runnerCalls, 0) + // The drifted skill, the stray file, the tracking file, the journal, and + // every snapshot backup are byte-identical... + assert.equal(readFileSync(skillFile, 'utf8'), skillBefore) + assert.equal(readFileSync(strayFile, 'utf8'), strayBefore) + assert.equal(readFileSync(trackingPath, 'utf8'), trackingBefore) + assert.equal(readFileSync(journalPath, 'utf8'), journalBefore) + assert.deepEqual(snapshotTree(snapshotDirectory), snapshotBefore) + // ...and no recovery storage was created beside any live target. + assert.deepEqual(readdirSync(path.dirname(skillPath)).sort(), skillParentBefore) + assert.deepEqual(readdirSync(path.dirname(trackingPath)).sort(), trackingDirBefore) + // The journal and snapshot survive for deliberate manual cleanup. + assert.equal(existsSync(journalPath), true) + assert.equal(existsSync(snapshotDirectory), true) + + // A second read-only pass through the public summary reports the same + // pending state, still without running or writing anything. + const checkSummary = await checkUpdates({ all: true, ...spies() }) + assert.equal(checkSummary.results.length, 1) + assert.equal(checkSummary.results[0]?.status, 'failed') + assert.equal(checkSummary.results[0]?.error?.code, 'FALLBACK_RECOVERY_PENDING') + assert.match(checkSummary.results[0]?.error?.message ?? '', /preserved untouched/) + assert.equal(fetchCalls, 0) + assert.equal(runnerCalls, 0) + assert.equal(readFileSync(skillFile, 'utf8'), skillBefore) + assert.equal(readFileSync(strayFile, 'utf8'), strayBefore) + assert.equal(readFileSync(trackingPath, 'utf8'), trackingBefore) + assert.equal(readFileSync(journalPath, 'utf8'), journalBefore) + assert.deepEqual(readdirSync(path.dirname(skillPath)).sort(), skillParentBefore) + assert.deepEqual(readdirSync(path.dirname(trackingPath)).sort(), trackingDirBefore) + }) + + it('restores a restorable pending journal on a non-check plan', async () => { + const { skillPath, journalPath, snapshotDirectory } = await writeRestorableJournal() + let fetchCalls = 0 + let runnerCalls = 0 + + const plan = await planUpdates({ + all: true, + fetchImpl: async () => { + fetchCalls++ + return new Response('{}', { status: 200 }) + }, + commandRunner: { + run: async () => { + runnerCalls++ + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }, + }) + + assert.equal(plan.items.length, 1) + assert.equal(plan.items[0]?.installationId, 'fallback:recovery') + assert.equal(plan.items[0]?.planningError?.code, 'FALLBACK_RECOVERY_PENDING') + const message = plan.items[0]?.planningError?.message ?? '' + // Restore-only recovery rewrote tracked paths, so claiming the state was + // "preserved untouched" would be false; the message must say it restored. + assert.doesNotMatch(message, /preserved untouched/) + assert.match(message, /restored to its tracked state/) + assert.ok(message.includes(path.resolve(skillPath)), 'the restored path is named in the guidance') + const expectedPreserved = [path.resolve(journalPath), path.resolve(snapshotDirectory)].sort() + assert.deepEqual(plan.items[0]?.preservedArtifacts, expectedPreserved) + assert.equal(plan.items[0]?.preservedPaths, undefined) + assert.equal(fetchCalls, 0) + assert.equal(runnerCalls, 0) + // The drifted skill was rewritten from the authenticated snapshot... + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + assert.equal(existsSync(path.join(skillPath, 'stray.txt')), false) + // ...while the journal and snapshot survive for deliberate manual cleanup. + assert.equal(existsSync(journalPath), true) + assert.equal(existsSync(snapshotDirectory), true) + + // And the public mutation result, still without running any mutation. + const summary = await update({ + all: true, + yes: true, + fetchImpl: async () => { + fetchCalls++ + return new Response('{}', { status: 200 }) + }, + commandRunner: { + run: async () => { + runnerCalls++ + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }, + }) + assert.equal(summary.results.length, 1) + assert.equal(summary.results[0]?.status, 'failed') + assert.equal(summary.results[0]?.error?.code, 'FALLBACK_RECOVERY_PENDING') + assert.deepEqual(summary.results[0]?.preservedArtifacts, expectedPreserved) + assert.equal(summary.results[0]?.preservedPaths, undefined) + assert.equal(fetchCalls, 0) + assert.equal(runnerCalls, 0) + assert.equal(existsSync(journalPath), true) + assert.equal(existsSync(snapshotDirectory), true) + }) + + it('does not execute mutable targets while recovery remains unresolved', async () => { + writeInvalidJournal() + const journalPath = fallbackJournalPath(getTrackingFilePath()) + let runnerCalls = 0 + let fetchCalls = 0 + const summary = await update({ + all: true, + yes: true, + commandRunner: { + run: async () => { + runnerCalls++ + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }, + fetchImpl: async () => { + fetchCalls++ + return new Response('{}', { status: 200 }) + }, + }) + + // Pending recovery fails closed with the preserved-journal guidance, not + // the legacy FAILED mapping. + assert.equal(summary.results.length, 1) + assert.equal(summary.results[0]?.error?.code, 'FALLBACK_RECOVERY_PENDING') + assert.match(summary.results[0]?.error?.message ?? '', /preserved untouched/) + assert.equal(summary.results[0]?.status, 'failed') + // A malformed journal cannot be authenticated, so only the journal + // artifact is reported as preserved and no live path evidence exists. + assert.deepEqual(summary.results[0]?.preservedArtifacts, [journalPath]) + assert.equal(summary.results[0]?.preservedPaths, undefined) + // No package-manager command and no inventory fetch may run while + // recovery is unresolved. + assert.equal(runnerCalls, 0) + assert.equal(fetchCalls, 0) + // The pending journal must remain untouched on disk. + assert.equal(existsSync(journalPath), true) + }) + + 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, treeTerminated: true } + }, + }, + }) + + assert.equal(runnerCalls, 0) + assert.equal(summary.results[1]?.status, 'failed') + assert.equal(summary.results[1]?.error?.code, 'FALLBACK_RECOVERY_FAILED') + // Externally constructed items without preservation evidence keep the + // stable shape: the reporting fields stay absent instead of becoming + // empty arrays. + assert.equal(summary.results[0]?.preservedArtifacts, undefined) + assert.equal(summary.results[0]?.preservedPaths, undefined) + assert.equal(summary.results[1]?.preservedArtifacts, undefined) + assert.equal(summary.results[1]?.preservedPaths, undefined) + }) + + it('preserves planning-owned state when the public seam rejects an incomplete fallback plan', async () => { + const fixture = await writeFallbackExecutionFixture() + const transactionDirectory = mkdtempSync(path.join(home, 'transaction-')) + const manifestPath = path.join(transactionDirectory, 'transaction.json') + writeFileSync(manifestPath, 'planned manifest\n') + const plannedArtifact = artifact() + const artifactBefore = readFileSync(plannedArtifact.tarballPath) + const transactionBefore = snapshotTree(transactionDirectory) + const skillBefore = snapshotTree(fixture.skillPath) + const trackingBefore = readFileSync(fixture.trackingPath) + let runnerCalls = 0 + 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 () => { + runnerCalls++ + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }, + }) + + assert.equal(summary.results[0]?.status, 'failed') + assert.equal(summary.results[0]?.error?.code, 'INVALID_PLAN') + assert.equal(runnerCalls, 0) + assert.equal(existsSync(plannedArtifact.tempDirectory), true) + assert.deepEqual(readFileSync(plannedArtifact.tarballPath), artifactBefore) + assert.equal(existsSync(transactionDirectory), true) + assert.deepEqual(snapshotTree(transactionDirectory), transactionBefore) + assert.deepEqual(snapshotTree(fixture.skillPath), skillBefore) + assert.deepEqual(readFileSync(fixture.trackingPath), trackingBefore) + }) + + it('preserves fallback artifacts and transaction state when tree termination is unconfirmed', async () => { + const fixture = await writeFallbackExecutionFixture() + const transactionDirectory = mkdtempSync(path.join(home, 'transaction-')) + const manifestPath = path.join(transactionDirectory, 'transaction.json') + writeFileSync(manifestPath, '{}') + const plannedArtifact = artifact() + let workspace = '' + let resultDirectory = '' + 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, + fallbackTransaction: fixture.identity, + 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 ?? '' + const resultIndex = command.args?.indexOf('--result') ?? -1 + const resultPath = resultIndex >= 0 ? command.args?.[resultIndex + 1] : undefined + resultDirectory = resultPath === undefined ? '' : path.dirname(resultPath) + 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) + assert.equal(existsSync(resultDirectory), true) + rmSync(plannedArtifact.tempDirectory, { recursive: true, force: true }) + rmSync(transactionDirectory, { recursive: true, force: true }) + rmSync(workspace, { recursive: true, force: true }) + rmSync(resultDirectory, { recursive: true, force: true }) + }) + + it('releases planning temporaries when update confirmation is cancelled', async () => { + const transactionDirectory = mkdtempSync(path.join(home, 'cancelled-transaction-')) + writeFileSync(path.join(transactionDirectory, 'transaction.json'), 'planned manifest\n') + 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: [], timeoutMs: 1000 } }], + rollbackSteps: [], + requiresConfirmation: true, + temporaryDirectories: [transactionDirectory], + } + + const summary = await executeUpdatePlan({ checkOnly: false, items: [item] }, { + confirm: async () => false, + commandRunner: { run: async () => { throw new Error('cancelled updates must not execute') } }, + }) + + assert.equal(summary.results[0]?.status, 'skipped') + assert.equal(existsSync(plannedArtifact.tempDirectory), false) + assert.equal(existsSync(transactionDirectory), false) + }) + + it('preserves all fallback resources when execution raises before a completion verdict', async () => { + const fixture = await writeFallbackExecutionFixture() + const transactionDirectory = mkdtempSync(path.join(home, 'exception-transaction-')) + const manifestPath = path.join(transactionDirectory, 'transaction.json') + writeFileSync(manifestPath, '{}') + const plannedArtifact = artifact() + const journalPath = fallbackJournalPath(fixture.trackingPath) + let workspace = '' + let resultDirectory = '' + let snapshotDirectory = '' + 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, + fallbackTransaction: fixture.identity, + steps: [{ kind: 'command', description: 'refresh', command: { executable: process.execPath, args: ['--transaction', manifestPath], timeoutMs: 1000 } }], + rollbackSteps: [], + requiresConfirmation: true, + temporaryDirectories: [transactionDirectory], + } + + try { + const summary = await executeUpdatePlan({ checkOnly: false, items: [item] }, { + yes: true, + commandRunner: { + run: async (command) => { + workspace = command.cwd ?? '' + const resultIndex = command.args?.indexOf('--result') ?? -1 + const resultPath = resultIndex >= 0 ? command.args?.[resultIndex + 1] : undefined + resultDirectory = resultPath === undefined ? '' : path.dirname(resultPath) + throw new Error('runner failed before completion evidence') + }, + }, + }) + + assert.equal(summary.results[0]?.error?.code, 'UPDATE_EXECUTION_FAILED') + assert.equal(existsSync(plannedArtifact.tempDirectory), true) + assert.equal(existsSync(transactionDirectory), true) + assert.equal(existsSync(workspace), true) + assert.notEqual(resultDirectory, '') + assert.equal(existsSync(resultDirectory), true) + assert.equal(existsSync(journalPath), true) + snapshotDirectory = (JSON.parse(readFileSync(journalPath, 'utf8')) as { snapshotDirectory: string }).snapshotDirectory + assert.equal(existsSync(snapshotDirectory), true) + } finally { + rmSync(plannedArtifact.tempDirectory, { recursive: true, force: true }) + rmSync(transactionDirectory, { recursive: true, force: true }) + if (workspace !== '') rmSync(workspace, { recursive: true, force: true }) + if (resultDirectory !== '') rmSync(resultDirectory, { recursive: true, force: true }) + if (snapshotDirectory !== '') rmSync(snapshotDirectory, { recursive: true, force: true }) + rmSync(journalPath, { force: true }) + } + }) + + it('keeps plan cleanup independent from public child error presentation', async () => { + const fixture = await writeFallbackExecutionFixture() + const transactionDirectory = mkdtempSync(path.join(home, 'presentation-transaction-')) + const manifestPath = path.join(transactionDirectory, 'transaction.json') + writeFileSync(manifestPath, '{}') + const plannedArtifact = artifact() + const journalPath = fallbackJournalPath(fixture.trackingPath) + let workspace = '' + let resultDirectory = '' + let snapshotDirectory = '' + 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, + fallbackTransaction: fixture.identity, + steps: [{ kind: 'command', description: 'refresh', command: { executable: process.execPath, args: ['--transaction', manifestPath], timeoutMs: 1000 } }], + rollbackSteps: [], + requiresConfirmation: true, + temporaryDirectories: [transactionDirectory], + } + const originalExecuteWithOutcome = fallbackStrategy.executeWithOutcome + let returnedResult: UpdateResult | undefined + fallbackStrategy.executeWithOutcome = async (planItem, context) => { + const outcome = await originalExecuteWithOutcome(planItem, context) + assert.equal(outcome.result.error?.code, 'FALLBACK_TREE_TERMINATION_UNCONFIRMED') + // Return a distinct public result object. Cleanup must follow the + // explicit disposition, never an identity lookup on the result. + const result = { ...outcome.result, error: { code: 'FALLBACK_COMMAND_FAILED', message: 'public presentation changed' } } + returnedResult = result + assert.equal(JSON.stringify(result).includes('planResources'), false) + return { result, planResources: outcome.planResources } + } + + try { + const summary = await executeUpdatePlan({ checkOnly: false, items: [item] }, { + yes: true, + commandRunner: { + run: async (command) => { + workspace = command.cwd ?? '' + const resultIndex = command.args?.indexOf('--result') ?? -1 + const resultPath = resultIndex >= 0 ? command.args?.[resultIndex + 1] : undefined + resultDirectory = resultPath === undefined ? '' : path.dirname(resultPath) + return { exitCode: null, stdout: '', stderr: '', timedOut: true, treeTerminated: false } + }, + }, + }) + + assert.equal(summary.results[0], returnedResult, 'the coordinator must consume the same result object whose public error was changed') + assert.equal(summary.results[0]?.error?.code, 'FALLBACK_COMMAND_FAILED') + assert.equal(existsSync(plannedArtifact.tempDirectory), true) + assert.equal(existsSync(transactionDirectory), true) + assert.equal(existsSync(workspace), true) + assert.notEqual(resultDirectory, '') + assert.equal(existsSync(resultDirectory), true) + assert.equal(existsSync(journalPath), true) + snapshotDirectory = (JSON.parse(readFileSync(journalPath, 'utf8')) as { snapshotDirectory: string }).snapshotDirectory + assert.equal(existsSync(snapshotDirectory), true) + } finally { + fallbackStrategy.executeWithOutcome = originalExecuteWithOutcome + rmSync(plannedArtifact.tempDirectory, { recursive: true, force: true }) + rmSync(transactionDirectory, { recursive: true, force: true }) + if (workspace !== '') rmSync(workspace, { recursive: true, force: true }) + if (resultDirectory !== '') rmSync(resultDirectory, { recursive: true, force: true }) + if (snapshotDirectory !== '') rmSync(snapshotDirectory, { recursive: true, force: true }) + rmSync(journalPath, { force: true }) + } + }) + + it('releases resources for a cloned confirmed outcome despite preservation-looking public error', async () => { + const fixture = await writeFallbackExecutionFixture() + const transactionDirectory = mkdtempSync(path.join(home, 'cloned-release-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, + fallbackTransaction: fixture.identity, + steps: [{ kind: 'command', description: 'refresh', command: { executable: process.execPath, args: ['--transaction', manifestPath], timeoutMs: 1000 } }], + rollbackSteps: [], + requiresConfirmation: true, + temporaryDirectories: [transactionDirectory], + } + const originalExecuteWithOutcome = fallbackStrategy.executeWithOutcome + let originalResult: UpdateResult | undefined + let returnedResult: UpdateResult | undefined + fallbackStrategy.executeWithOutcome = async (planItem, context) => { + const outcome = await originalExecuteWithOutcome(planItem, context) + assert.equal(outcome.planResources, 'release') + originalResult = outcome.result + // Clone the public result while retaining the internal release decision. + // A presentation that resembles preservation must not override it. + const result = { ...outcome.result, error: { code: 'CLI_TREE_TERMINATION_UNCONFIRMED', message: 'public preservation presentation' } } + assert.notEqual(result, outcome.result) + assert.equal(JSON.stringify(result).includes('planResources'), false) + returnedResult = result + return { result, planResources: outcome.planResources } + } + + try { + 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], returnedResult) + assert.notEqual(summary.results[0], originalResult) + assert.equal(summary.results[0]?.error?.code, 'CLI_TREE_TERMINATION_UNCONFIRMED') + assert.equal(existsSync(plannedArtifact.tempDirectory), false) + assert.equal(existsSync(transactionDirectory), false) + assert.equal(JSON.stringify(summary).includes('planResources'), false) + } finally { + fallbackStrategy.executeWithOutcome = originalExecuteWithOutcome + rmSync(plannedArtifact.tempDirectory, { recursive: true, force: true }) + rmSync(transactionDirectory, { recursive: true, force: true }) + } + }) + + it('preserves plan resources when a fallback outcome lacks its internal disposition', async () => { + const fixture = await writeFallbackExecutionFixture() + const transactionDirectory = mkdtempSync(path.join(home, 'missing-disposition-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, + fallbackTransaction: fixture.identity, + steps: [{ kind: 'command', description: 'refresh', command: { executable: process.execPath, args: ['--transaction', manifestPath], timeoutMs: 1000 } }], + rollbackSteps: [], + requiresConfirmation: true, + temporaryDirectories: [transactionDirectory], + } + const resultWithoutDisposition: UpdateResult = { + installationId: item.installationId, + target: item.target, + ownership: item.ownership, + status: 'failed', + changed: false, + error: { code: 'FALLBACK_COMMAND_FAILED', message: 'untrusted result' }, + } + const originalExecuteWithOutcome = fallbackStrategy.executeWithOutcome + fallbackStrategy.executeWithOutcome = async () => ({ result: resultWithoutDisposition } as unknown as Awaited>) + + try { + const summary = await executeUpdatePlan({ checkOnly: false, items: [item] }, { + yes: true, + commandRunner: { run: async () => { throw new Error('a missing-disposition result must not execute the command') } }, + }) + + assert.equal(summary.results[0], resultWithoutDisposition) + assert.equal(JSON.stringify(summary).includes('planResources'), false) + assert.equal(existsSync(plannedArtifact.tempDirectory), true) + assert.equal(existsSync(transactionDirectory), true) + } finally { + fallbackStrategy.executeWithOutcome = originalExecuteWithOutcome + rmSync(plannedArtifact.tempDirectory, { recursive: true, force: true }) + rmSync(transactionDirectory, { recursive: true, force: true }) + } + }) + + it('preserves plan resources and returns a failed result for malformed fallback result enums', async () => { + const malformedFields: Array<['status' | 'target' | 'ownership', string]> = [ + ['status', 'invalid'], + ['target', 'invalid'], + ['ownership', 'invalid'], + ] + const originalExecuteWithOutcome = fallbackStrategy.executeWithOutcome + + for (const [field, value] of malformedFields) { + const fixture = await writeFallbackExecutionFixture() + const transactionDirectory = mkdtempSync(path.join(home, `malformed-${field}-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, + fallbackTransaction: fixture.identity, + steps: [{ kind: 'command', description: 'refresh', command: { executable: process.execPath, args: ['--transaction', manifestPath], timeoutMs: 1000 } }], + rollbackSteps: [], + requiresConfirmation: true, + temporaryDirectories: [transactionDirectory], + } + const malformedResult: Record = { + installationId: item.installationId, + target: item.target, + ownership: item.ownership, + status: 'failed', + changed: false, + } + malformedResult[field] = value + fallbackStrategy.executeWithOutcome = async () => ({ + result: malformedResult as unknown as UpdateResult, + planResources: 'release', + }) + + try { + const summary = await executeUpdatePlan({ checkOnly: false, items: [item] }, { + yes: true, + commandRunner: { run: async () => { throw new Error('malformed outcomes must not execute the command') } }, + }) + + const result = summary.results[0] + assert.equal(result?.status, 'failed') + assert.equal(result?.error?.code, 'UPDATE_EXECUTION_FAILED') + assert.equal(result?.installationId, item.installationId) + assert.equal(result?.target, item.target) + assert.equal(result?.ownership, item.ownership) + assert.equal(result?.changed, false) + assert.equal(summary.counts.failed, 1) + assert.equal(Object.prototype.hasOwnProperty.call(summary.counts, 'invalid'), false) + assert.equal(existsSync(plannedArtifact.tempDirectory), true) + assert.equal(existsSync(transactionDirectory), true) + } finally { + fallbackStrategy.executeWithOutcome = originalExecuteWithOutcome + rmSync(plannedArtifact.tempDirectory, { recursive: true, force: true }) + rmSync(transactionDirectory, { recursive: true, force: true }) + } + } + }) + + it('cleans fallback artifacts and transaction state after a confirmed failure', async () => { + const fixture = await writeFallbackExecutionFixture() + 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, + fallbackTransaction: fixture.identity, + 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 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, treeTerminated: true }) }, + }) + + 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 + } + }) +}) + +describe('unsupported CLI provenance', () => { + function cliPackageRoot (root: string, version: string): string { + const manifest = path.join(root, 'package.json') + mkdirSync(path.dirname(manifest), { recursive: true }) + writeFileSync(manifest, JSON.stringify({ name: 'nsolid-plugin', version })) + writeFileSync(path.join(root, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version })) + return root + } + + function cliLauncher (root: string): string { + const launcher = path.join(root, 'dist', 'src', 'cli.js') + mkdirSync(path.dirname(launcher), { recursive: true }) + writeFileSync(launcher, '#!/usr/bin/env node\n') + return launcher + } + + function registryFetch (version: string): typeof fetch { + return async () => new Response(JSON.stringify({ 'dist-tags': { latest: version } }), { status: 200 }) + } + + const exactVersionGuidance = (version: string) => [ + `npm install --global nsolid-plugin@${version}`, + `pnpm add --global nsolid-plugin@${version}`, + `npx -y nsolid-plugin@${version} `, + ] + + it('reports a workspace CLI launch as unsupported with exact-version guidance during a check', async () => { + const root = cliPackageRoot(path.join(home, 'repo', 'packages', 'core'), '1.0.3') + const launcher = cliLauncher(root) + let probes = 0 + + const summary = await checkUpdates({ + packageRoot: root, + executablePath: launcher, + cwd: path.join(home, 'scratch'), + fetchImpl: registryFetch('90.0.2'), + commandRunner: { + run: async () => { + probes++ + throw new Error('read-only check must not probe package managers') + }, + }, + }) + + const cli = summary.results.find((result) => result.installationId === 'cli:global') + assert.ok(cli, 'a cli result must exist') + assert.equal(cli.status, 'unsupported') + assert.equal(cli.ownership, 'none') + assert.equal(cli.currentVersion, undefined) + assert.equal(cli.latestVersion, '90.0.2') + assert.deepEqual(cli.manualCommands, exactVersionGuidance('90.0.2')) + assert.equal(summary.exitCode, 0) + assert.equal(summary.success, true) + assert.equal(probes, 0) + }) + + it('appends wrapper guidance for a detected Volta launcher during a check', async () => { + const root = cliPackageRoot(path.join(home, '.volta', 'tools', 'image', 'packages', 'nsolid-plugin'), '90.0.0') + const launcher = path.join(root, 'bin', 'nsolid-plugin') + mkdirSync(path.dirname(launcher), { recursive: true }) + writeFileSync(launcher, '#!/bin/sh\n') + + const summary = await checkUpdates({ + packageRoot: root, + executablePath: launcher, + cwd: path.join(home, 'scratch'), + fetchImpl: registryFetch('90.0.2'), + commandRunner: { run: async () => { throw new Error('check must not probe package managers') } }, + }) + + const cli = summary.results.find((result) => result.installationId === 'cli:global') + assert.ok(cli) + assert.equal(cli.status, 'unsupported') + assert.equal(cli.currentVersion, undefined) + const strategyItem = await cliPackageStrategy.plan({ + installationId: 'cli:global', + target: 'cli', + ownership: 'none', + installed: true, + source: { kind: 'unsupported', source: launcher, reason: 'unsupported-manager' }, + version: { latest: '90.0.2', status: 'unknown' }, + }, { + options: {}, + commandRunner: { run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true }) }, + }) + assert.deepEqual(cli.manualCommands, strategyItem.manualCommands, 'check and mutation planning must use the identical shared guidance') + assert.deepEqual(cli.manualCommands, [...exactVersionGuidance('90.0.2'), 'volta install nsolid-plugin@90.0.2']) + assert.equal(summary.exitCode, 0) + }) + + it('exits 2 with exact-version guidance and runs no package-manager update for a workspace CLI mutation', async () => { + const root = cliPackageRoot(path.join(home, 'repo', 'packages', 'core'), '1.0.3') + const launcher = cliLauncher(root) + const tarballBytes = Buffer.from('verified artifact') + const integrity = `sha512-${createHash('sha512').update(tarballBytes).digest('base64')}` + const commands: string[] = [] + + const summary = await update({ + packageRoot: root, + executablePath: launcher, + cwd: path.join(home, 'scratch'), + yes: true, + registry: 'https://registry.example', + fetchImpl: async (url: RequestInfo | URL) => { + const text = String(url) + if (text.endsWith('/nsolid-plugin')) { + return new Response(JSON.stringify({ + 'dist-tags': { latest: '90.0.2' }, + versions: { + '90.0.2': { + name: 'nsolid-plugin', + version: '90.0.2', + dist: { tarball: 'https://registry.example/nsolid-plugin-90.0.2.tgz', integrity }, + }, + }, + }), { status: 200 }) + } + if (text.endsWith('.tgz')) return new Response(new Uint8Array(tarballBytes), { status: 200 }) + throw new Error(`unexpected fetch ${text}`) + }, + commandRunner: { + run: async (spec: CommandSpec) => { + commands.push([spec.executable, ...spec.args].join(' ')) + return { exitCode: 1, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }, + }) + + const cli = summary.results.find((result) => result.installationId === 'cli:global') + assert.ok(cli) + assert.equal(cli.status, 'unsupported') + assert.equal(cli.currentVersion, undefined) + assert.equal(cli.latestVersion, '90.0.2') + assert.deepEqual(cli.manualCommands, exactVersionGuidance('90.0.2')) + assert.equal(summary.exitCode, 2) + assert.equal(summary.success, false) + assert.deepEqual(commands, [], 'an unsupported launcher must not invoke any package manager') + }) +}) + +describe('read-only check stays off the filesystem', () => { + it('summarizes fallback changes in memory without requiring a writable temporary directory', { skip: process.platform === 'win32' }, async () => { + // A tracked opencode fallback installation with one tracked skill. + const destination = path.join(home, '.config', 'opencode', 'skills') + mkdirSync(path.join(destination, 'tracked'), { recursive: true }) + writeFileSync(path.join(destination, 'tracked', 'SKILL.md'), 'old') + const trackingPath = getTrackingFilePath() + mkdirSync(path.dirname(trackingPath), { recursive: true }) + writeFileSync(trackingPath, JSON.stringify({ + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersions: { opencode: '1.0.0' }, + skills: [{ + name: 'tracked', + path: path.join(destination, 'tracked'), + paths: { opencode: path.join(destination, 'tracked') }, + installedAt: new Date().toISOString(), + harnesses: ['opencode'], + }], + mcpServers: [], + })) + + // A verified registry artifact whose bundle adds one skill and one MCP + // server; it is served over the fetch seam and never written to disk. + const bundle = { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'added', path: 'skills/added', description: 'added' }], + mcpServers: [{ name: 'brand-new-mcp', url: 'https://example.com/mcp', headers: {} }], + } + const bundleJson = Buffer.from(JSON.stringify(bundle)) + const tar = Buffer.concat([ + tarEntry('package/', undefined, '5'), + tarEntry('package/bundle.json', bundleJson, '0'), + Buffer.alloc(1024), + ]) + const tarballBytes = gzipSync(tar) + const integrity = `sha512-${createHash('sha512').update(tarballBytes).digest('base64')}` + + // Route os.tmpdir() into a watched scratch directory for the duration of + // the check: the former implementation mkdtemp'd + // `nsolid-plugin-artifact-*` here on every fallback check. + const scratch = mkdtempSync(path.join(home, 'check-tmp-')) + const previousTmpdir = process.env.TMPDIR + const previousTemp = process.env.TEMP + const previousTmp = process.env.TMP + process.env.TMPDIR = scratch + process.env.TEMP = scratch + process.env.TMP = scratch + chmodSync(scratch, 0o500) + try { + const summary = await checkUpdates({ + harness: 'opencode', + fetchImpl: async (url: RequestInfo | URL) => { + const text = String(url) + if (text.endsWith('/nsolid-plugin')) { + return new Response(JSON.stringify({ + 'dist-tags': { latest: '1.0.1' }, + versions: { + '1.0.1': { version: '1.0.1', dist: { tarball: 'https://registry.example/a.tgz', integrity } }, + }, + }), { status: 200 }) + } + if (text.endsWith('.tgz')) return new Response(new Uint8Array(tarballBytes), { status: 200 }) + throw new Error(`unexpected fetch ${text}`) + }, + }) + + const fallback = summary.results.find((result) => result.installationId === 'opencode:fallback') + assert.ok(fallback, JSON.stringify(summary.results)) + assert.equal(fallback.status, 'update-available') + assert.deepEqual(fallback.changes, { + skillsAdded: ['added'], + skillsRemoved: ['tracked'], + skillsUpdated: 0, + mcpAdded: ['brand-new-mcp'], + mcpRemoved: [], + mcpUpdated: 0, + }) + assert.deepEqual(readdirSync(scratch), [], 'the check path must not create any temporary artifact directory') + } finally { + chmodSync(scratch, 0o700) + if (previousTmpdir === undefined) delete process.env.TMPDIR + else process.env.TMPDIR = previousTmpdir + if (previousTemp === undefined) delete process.env.TEMP + else process.env.TEMP = previousTemp + if (previousTmp === undefined) delete process.env.TMP + else process.env.TMP = previousTmp + } + }) +}) + +describe('update coordinator recovery message branches', () => { + const planOptions = () => ({ + all: true, + check: true, + fetchImpl: async () => new Response('{}', { status: 200 }), + commandRunner: { + run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true }), + }, + }) + + /** A two-skill pending journal whose snapshot backups can be selectively tampered with. */ + async function writeTwoSkillJournal (): Promise<{ skillAPath: string; skillBPath: string; journalPath: string }> { + const skillsDir = path.join(home, '.config', 'opencode', 'skills') + const skillAPath = path.join(skillsDir, 'alpha') + const skillBPath = path.join(skillsDir, 'beta') + mkdirSync(skillAPath, { recursive: true }) + writeFileSync(path.join(skillAPath, 'SKILL.md'), 'alpha tracked') + mkdirSync(skillBPath, { recursive: true }) + writeFileSync(path.join(skillBPath, 'SKILL.md'), 'beta tracked') + const trackingPath = getTrackingFilePath() + mkdirSync(path.dirname(trackingPath), { recursive: true }) + writeFileSync(trackingPath, `${JSON.stringify({ + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + skills: [ + { name: 'alpha', path: skillAPath, paths: { opencode: skillAPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }, + { name: 'beta', path: skillBPath, paths: { opencode: skillBPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }, + ], + mcpServers: [], + }, null, 2)}\n`) + const evidence = async (target: string) => { + const kind = await pathKind(target) + return { path: path.resolve(target), kind, digest: kind === 'missing' ? undefined : await pathDigest(target) } + } + const { journal } = await beginFallbackJournal({ + installationId: 'opencode:fallback', + harness: 'opencode', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await evidence(skillAPath), await evidence(skillBPath)], + ownedLinks: [], + ownedMcpFields: [], + ownedMcpConfigPaths: [await evidence(path.join(home, '.config', 'opencode', 'opencode.jsonc'))], + bundleDestinations: [await evidence(skillAPath), await evidence(skillBPath)], + approvedDestinationRoots: [skillsDir], + }) + return { skillAPath, skillBPath, journalPath: journal.journalPath } + } + + /** Break one snapshot backup's authentication by smuggling a stray child into it. */ + function tamperBackupOf (journalPath: string, entryPath: string): void { + const disk = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries: Array<{ path: string, backup?: string }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(entryPath)) + if (entry?.backup === undefined) throw new Error(`no backup for ${entryPath}`) + writeFileSync(path.join(entry.backup, 'tampered.txt'), 'tampered backup bytes') + } + + it('reports unrestorable and drifted paths as preserved untouched on a check plan', async () => { + const { skillAPath, skillBPath, journalPath } = await writeTwoSkillJournal() + writeFileSync(path.join(skillAPath, 'SKILL.md'), 'alpha drifted') + writeFileSync(path.join(skillBPath, 'SKILL.md'), 'beta drifted') + tamperBackupOf(journalPath, skillBPath) + + const plan = await planUpdates(planOptions()) + const message = plan.items[0]?.planningError?.message ?? '' + // A check never restores: even though skillA is restorable and skillB is + // not, the guidance must say the state was preserved untouched and name + // both paths for manual inspection. + assert.match(message, /preserved untouched/) + assert.doesNotMatch(message, /restored to its tracked state/) + assert.match(message, new RegExp(`Live paths left for manual inspection: ${skillAPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)) + assert.match(message, new RegExp(`${skillBPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)) + assert.deepEqual(plan.items[0]?.preservedPaths, [path.resolve(skillAPath), path.resolve(skillBPath)].sort()) + // Both live trees are byte-identical: the drifted skill was not + // rewritten and the tampered backup was never installed. + assert.equal(readFileSync(path.join(skillAPath, 'SKILL.md'), 'utf8'), 'alpha drifted') + assert.equal(readFileSync(path.join(skillBPath, 'SKILL.md'), 'utf8'), 'beta drifted') + }) + + it('reports drifted paths as preserved untouched on a check plan without restoring', async () => { + const { skillAPath, skillBPath } = await writeTwoSkillJournal() + writeFileSync(path.join(skillAPath, 'SKILL.md'), 'alpha drifted') + writeFileSync(path.join(skillBPath, 'SKILL.md'), 'beta drifted') + + const plan = await planUpdates(planOptions()) + const message = plan.items[0]?.planningError?.message ?? '' + assert.match(message, /preserved untouched/) + assert.match(message, new RegExp(`Live paths left for manual inspection: ${skillAPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)) + assert.match(message, new RegExp(`${skillBPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)) + assert.doesNotMatch(message, /restored to its tracked state/) + assert.deepEqual(plan.items[0]?.preservedPaths, [path.resolve(skillAPath), path.resolve(skillBPath)].sort()) + // The drifted skills were not rewritten from the snapshot. + assert.equal(readFileSync(path.join(skillAPath, 'SKILL.md'), 'utf8'), 'alpha drifted') + assert.equal(readFileSync(path.join(skillBPath, 'SKILL.md'), 'utf8'), 'beta drifted') + }) + + it('partially restores drifted skills on a non-check plan while preserving tampered backups', async () => { + const { skillAPath, skillBPath, journalPath } = await writeTwoSkillJournal() + writeFileSync(path.join(skillAPath, 'SKILL.md'), 'alpha drifted') + writeFileSync(path.join(skillBPath, 'SKILL.md'), 'beta drifted') + tamperBackupOf(journalPath, skillBPath) + + const plan = await planUpdates({ ...planOptions(), check: false }) + const message = plan.items[0]?.planningError?.message ?? '' + // Skill A was restorable and rewritten while skill B stayed preserved: + // the guidance must say PARTIALLY, name the restored path and the path + // left for manual inspection, and never claim preserved untouched after + // a restoration happened. + assert.match(message, /PARTIALLY restored/) + assert.match(message, /Restored to tracked state:/) + assert.ok(message.includes(path.resolve(skillAPath)), 'the restored path is named in the guidance') + assert.match(message, /Live paths left for manual inspection:/) + assert.ok(message.includes(path.resolve(skillBPath)), 'the preserved path is named in the guidance') + assert.doesNotMatch(message, /preserved untouched/) + assert.deepEqual(plan.items[0]?.preservedPaths, [path.resolve(skillBPath)]) + // Skill A was rewritten from the authenticated snapshot while skill B + // keeps its drifted bytes: the tampered backup was never installed. + assert.equal(readFileSync(path.join(skillAPath, 'SKILL.md'), 'utf8'), 'alpha tracked') + assert.equal(readFileSync(path.join(skillBPath, 'SKILL.md'), 'utf8'), 'beta drifted') + }) + + it('announces that nothing needed restoration when every owned path is already tracked', async () => { + await writeTwoSkillJournal() + + const plan = await planUpdates(planOptions()) + const message = plan.items[0]?.planningError?.message ?? '' + assert.match(message, /needs no restoration: every owned path is already at its tracked state/) + assert.doesNotMatch(message, /Restored to tracked state:/) + assert.doesNotMatch(message, /preserved untouched\./) + }) + + it('names preserved harness links outside tracking ownership when nothing needed restoration', async () => { + // Journaled harness links are deliberately outside restore scope: the + // current tracking file never names the link path, so next-run recovery + // preserves it for inspection while every owned path already matches. + const skillsDir = path.join(home, '.agents', 'skills') + const skillPath = path.join(skillsDir, 'tracked') + const linkPath = path.join(getHarnessSkillsPath('claude'), 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'tracked') + mkdirSync(path.dirname(linkPath), { recursive: true }) + writeFileSync(linkPath, 'link\n') + const trackingPath = getTrackingFilePath() + 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: [], + }, null, 2)}\n`) + const evidence = async (target: string) => { + const kind = await pathKind(target) + return { path: path.resolve(target), kind, digest: kind === 'missing' ? undefined : await pathDigest(target) } + } + await beginFallbackJournal({ + installationId: 'claude:fallback', + harness: 'claude', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await evidence(skillPath)], + ownedLinks: [await evidence(linkPath)], + ownedMcpFields: [], + ownedMcpConfigPaths: [await evidence(path.join(home, '.claude.json'))], + bundleDestinations: [], + approvedDestinationRoots: [path.resolve(skillsDir), path.resolve(getHarnessSkillsPath('claude'))], + }) + + const plan = await planUpdates(planOptions()) + const message = plan.items[0]?.planningError?.message ?? '' + assert.match(message, /needs no restoration: every owned path is already at its tracked state/) + assert.match(message, new RegExp(`Live paths left for manual inspection: ${linkPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)) + assert.doesNotMatch(message, /Restored to tracked state:/) + assert.doesNotMatch(message, /preserved untouched\./) + }) +}) diff --git a/packages/core/test/unit/update/fallback-frontier.test.ts b/packages/core/test/unit/update/fallback-frontier.test.ts new file mode 100644 index 0000000..c69e9b0 --- /dev/null +++ b/packages/core/test/unit/update/fallback-frontier.test.ts @@ -0,0 +1,1098 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import type { TestContext } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, lstatSync, mkdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { + assertFallbackFrontierEvidenceList, + compareUtf8, + deriveFallbackFrontierLeafTargets, + deriveFallbackFrontierPlan, + deriveFallbackJournalPhysicalTargets, + deriveFrontierTreeExpectation, + evaluateFallbackFrontierPlatformSupport, + FALLBACK_FRONTIER_BOUNDS, + FALLBACK_FRONTIER_ERROR_CODES, + FallbackFrontierError, + fallbackLinkMaterialization, + revalidateFallbackFrontierEvidence, + selectActiveFallbackFrontierLeaves, + type FallbackFrontierLeafInputs, +} from '../../../src/update/fallback-frontier.js' +import type { FallbackAnchorIdentity, FallbackFrontierEvidence, FallbackLeafActivation, FallbackLeafRole, FallbackLeafTarget, FallbackPathEvidence, FallbackTransactionIdentity } from '../../../src/update/types.js' +import type { HarnessType } from '../../../src/types.js' +import { FALLBACK_PROTOCOL_VERSION } from '../../../src/update/types.js' +import { createCanonicalTempRoot } from '../../helpers/canonical-temp-root.js' + +let root: string + +beforeEach(() => { + root = createCanonicalTempRoot('nsolid-plugin-frontier-') +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function leaf (leafPath: string, role: FallbackLeafRole = 'skill', activation: FallbackLeafActivation = 'required', id = path.basename(leafPath)): FallbackLeafTarget { + return { id, role, activation, path: leafPath } +} + +function structuredCloneFrontiers (frontiers: readonly FallbackFrontierEvidence[]): FallbackFrontierEvidence[] { + return JSON.parse(JSON.stringify(frontiers)) as FallbackFrontierEvidence[] +} + +function withPlatform (platform: NodeJS.Platform, run: () => T): T { + const original = process.platform + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + try { + return run() + } finally { + Object.defineProperty(process, 'platform', { value: original, configurable: true }) + } +} + +function assertErrorCode (operation: () => unknown, code: string): void { + try { + operation() + } catch (error) { + assert.ok(error instanceof FallbackFrontierError, `expected FallbackFrontierError, got ${String(error)}`) + assert.equal(error.code, code) + return + } + assert.fail(`expected a ${code} rejection`) +} + +/** + * Create a symlink for a test. Only the Windows-specific EPERM denial (no + * symlink privilege) may skip the case; every other failure is rethrown. + */ +function createSymlinkForTest (context: TestContext, target: string, linkPath: string): boolean { + try { + symlinkSync(target, linkPath) + return true + } catch (error) { + if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') { + context.skip(`symlink creation denied on this host: ${linkPath}`) + return false + } + throw error + } +} + +describe('fallback frontier derivation', () => { + it('derives one frontier per topmost missing directory below a shared existing anchor with anchor identity', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + const added = leaf(path.join(anchor, 'added')) + const deep = leaf(path.join(anchor, 'zoo', 'deep'), 'link', 'conditional') + const plan = await deriveFallbackFrontierPlan([deep, added]) + assert.equal(plan.frontiers.length, 2) + assert.deepEqual(plan.frontiers.map((entry) => entry.frontierPath), [added.path, path.join(anchor, 'zoo')].sort(compareUtf8)) + const [addedFrontier, zooFrontier] = plan.frontiers + assert.equal(addedFrontier.activation, 'required') + assert.deepEqual(addedFrontier.leaves, [added]) + assert.equal(zooFrontier.activation, 'conditional') + assert.deepEqual(zooFrontier.leaves, [deep]) + const expectedIdentity = (entry: FallbackAnchorIdentity): void => { + assert.equal(entry.path, anchor) + assert.equal(entry.realpath, anchor) + assert.equal(entry.type, 'directory') + const stats = lstatSync(anchor, { bigint: true }) + assert.equal(entry.device, stats.dev.toString(10)) + assert.equal(entry.inode, stats.ino.toString(10)) + } + expectedIdentity(addedFrontier.anchor) + expectedIdentity(zooFrontier.anchor) + assert.deepEqual(plan.existingLeaves, []) + assert.deepEqual(plan.missingLeaves, []) + }) + + it('groups leaves sharing a frontier and keeps disjoint missing components separate', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + const packA = leaf(path.join(anchor, 'pack-a')) + const packB = leaf(path.join(anchor, 'pack-b')) + const nested = leaf(path.join(anchor, 'nested', 'x', 'y')) + const plan = await deriveFallbackFrontierPlan([packB, nested, packA]) + assert.deepEqual(plan.frontiers.map((entry) => entry.frontierPath), [packA.path, packB.path, path.join(anchor, 'nested')].sort(compareUtf8)) + const nestedFrontier = plan.frontiers.find((entry) => entry.frontierPath === path.join(anchor, 'nested')) + assert.ok(nestedFrontier) + assert.deepEqual(nestedFrontier.leaves, [nested]) + }) + + it('classifies existing leaves without frontiers and covers every authorized leaf exactly once', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(path.join(anchor, 'tracked'), { recursive: true }) + writeFileSync(path.join(anchor, 'tracked', 'SKILL.md'), 'tracked\n') + const tracked = leaf(path.join(anchor, 'tracked')) + const added = leaf(path.join(anchor, 'added')) + const plan = await deriveFallbackFrontierPlan([tracked, added]) + const covered = [ + ...plan.frontiers.flatMap((entry) => entry.leaves.map((entryLeaf) => entryLeaf.path)), + ...plan.existingLeaves.map((entryLeaf) => entryLeaf.path), + ...plan.missingLeaves.map((entryLeaf) => entryLeaf.path), + ] + assert.deepEqual(plan.existingLeaves, [tracked]) + assert.equal(plan.frontiers.length, 1) + assert.deepEqual(plan.frontiers[0].leaves, [added]) + assert.deepEqual(plan.missingLeaves, []) + assert.deepEqual(covered.sort(compareUtf8), [tracked.path, added.path].sort(compareUtf8)) + }) + + it('keeps file and link leaves independent: existing files are existing leaves and missing files under existing parents never become frontiers', async () => { + const anchor = path.join(root, 'agents') + mkdirSync(anchor) + const trackingLeaf = leaf(path.join(root, 'installed.json'), 'tracking', 'required', 'tracking') + writeFileSync(trackingLeaf.path, '{}\n') + const mcpLeaf = leaf(path.join(anchor, 'opencode.json'), 'mcp-config', 'required', 'config') + writeFileSync(mcpLeaf.path, '{}\n') + const missingMcpLeaf = leaf(path.join(anchor, 'missing.json'), 'mcp-config', 'conditional', 'missing-config') + const missingLinkLeaf = leaf(path.join(anchor, 'skill-link'), 'link', 'conditional', 'missing-link') + const plan = await deriveFallbackFrontierPlan([trackingLeaf, missingLinkLeaf, missingMcpLeaf, mcpLeaf]) + const covered = [ + ...plan.frontiers.flatMap((entry) => entry.leaves.map((entryLeaf) => entryLeaf.path)), + ...plan.existingLeaves.map((entryLeaf) => entryLeaf.path), + ...plan.missingLeaves.map((entryLeaf) => entryLeaf.path), + ] + assert.deepEqual(plan.existingLeaves, [mcpLeaf, trackingLeaf]) + assert.deepEqual(plan.missingLeaves, [missingMcpLeaf, missingLinkLeaf]) + assert.deepEqual(plan.frontiers, []) + assert.deepEqual(covered.sort(compareUtf8), [trackingLeaf.path, missingLinkLeaf.path, missingMcpLeaf.path, mcpLeaf.path].sort(compareUtf8)) + // No frontier means no platform restriction anywhere: an existing-parent + // update must not be blocked on non-Linux hosts. + assert.deepEqual(withPlatform('win32', () => evaluateFallbackFrontierPlatformSupport(plan.frontiers)), { supported: true }) + }) + + it('treats an existing managed symlink as a legitimate link leaf but rejects it for directory roles', async (t) => { + const anchor = path.join(root, 'skills') + mkdirSync(path.join(anchor, 'real-skill'), { recursive: true }) + const linkPath = path.join(anchor, 'managed-link') + if (!createSymlinkForTest(t, path.join(anchor, 'real-skill'), linkPath)) return + const linkLeaf = leaf(linkPath, 'link') + const plan = await deriveFallbackFrontierPlan([linkLeaf]) + assert.deepEqual(plan.existingLeaves, [linkLeaf]) + assert.deepEqual(plan.frontiers, []) + assert.deepEqual(plan.missingLeaves, []) + await assert.rejects( + deriveFallbackFrontierPlan([leaf(linkPath)]), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.LEAF_KIND_MISMATCH + ) + }) + + it('is deterministic: derivation order never changes the resulting graph', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(path.join(anchor, 'kept'), { recursive: true }) + const inputs = [ + leaf(path.join(anchor, 'kept')), + leaf(path.join(anchor, 'b-second'), 'skill', 'conditional'), + leaf(path.join(anchor, 'a-first')), + leaf(path.join(anchor, 'c-nested', 'inner'), 'mcp-config', 'required', 'config'), + ] + const forward = await deriveFallbackFrontierPlan(inputs) + const backward = await deriveFallbackFrontierPlan([...inputs].reverse()) + assert.equal(JSON.stringify(backward), JSON.stringify(forward)) + assert.equal(JSON.stringify(await deriveFallbackFrontierPlan(inputs)), JSON.stringify(forward)) + }) + + it('aggregates activation: any required leaf makes the frontier required', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + const mixed = await deriveFallbackFrontierPlan([ + leaf(path.join(anchor, 'mixed', 'a'), 'skill', 'conditional'), + leaf(path.join(anchor, 'mixed', 'b'), 'skill', 'required'), + ]) + assert.equal(mixed.frontiers[0].activation, 'required') + const conditional = await deriveFallbackFrontierPlan([ + leaf(path.join(anchor, 'cond', 'a'), 'mcp-config', 'conditional', 'config'), + leaf(path.join(anchor, 'cond', 'b'), 'link', 'conditional'), + ]) + assert.equal(conditional.frontiers[0].activation, 'conditional') + }) +}) + +describe('fallback frontier rejection', () => { + it('rejects duplicate leaf paths and conflicting identities for the same path', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + const target = path.join(anchor, 'added') + await assert.rejects( + deriveFallbackFrontierPlan([leaf(target), leaf(target)]), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.COLLISION + ) + await assert.rejects( + deriveFallbackFrontierPlan([leaf(target, 'skill', 'required', 'one'), leaf(target, 'link', 'required', 'two')]), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.COLLISION + ) + }) + + it('rejects a leaf that is an ancestor of another leaf (file-as-ancestor collision)', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(path.join(anchor, 'parent'), { recursive: true }) + const parentLeaf = leaf(path.join(anchor, 'parent')) + const childLeaf = leaf(path.join(anchor, 'parent', 'child')) + await assert.rejects( + deriveFallbackFrontierPlan([parentLeaf, childLeaf]), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.COLLISION + ) + }) + + it('rejects symlink components without following them, even when the link dangles', async (t) => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + const linkDir = path.join(anchor, 'link-dir') + if (!createSymlinkForTest(t, path.join(root, 'does-not-exist-anywhere'), linkDir)) return + const plan = leaf(path.join(linkDir, 'child')) + await assert.rejects( + deriveFallbackFrontierPlan([plan]), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.COMPONENT_NOT_DIRECTORY + ) + assert.ok(lstatSync(linkDir).isSymbolicLink()) + assert.equal(existsSync(path.join(root, 'does-not-exist-anywhere')), false) + }) + + it('rejects file components in the chain and kind-conflicting existing symlink leaves', async (t) => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + writeFileSync(path.join(anchor, 'blocker'), 'file\n') + await assert.rejects( + deriveFallbackFrontierPlan([leaf(path.join(anchor, 'blocker', 'child'))]), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.COMPONENT_NOT_DIRECTORY + ) + const linkedLeaf = path.join(anchor, 'linked-leaf') + if (!createSymlinkForTest(t, path.join(root, 'nowhere'), linkedLeaf)) return + // A skill destination that exists as a symlink is a kind conflict, not a + // traversable component: lstat inspects the link itself without following. + await assert.rejects( + deriveFallbackFrontierPlan([leaf(linkedLeaf)]), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.LEAF_KIND_MISMATCH + ) + assert.ok(lstatSync(linkedLeaf).isSymbolicLink()) + }) + + it('rejects existing destinations whose live kind contradicts the kind their role requires', async () => { + const anchor = path.join(root, 'mixed') + mkdirSync(path.join(anchor, 'dir-leaf'), { recursive: true }) + const fileLeaf = path.join(anchor, 'file-leaf') + writeFileSync(fileLeaf, '{}\n') + await assert.rejects( + deriveFallbackFrontierPlan([leaf(path.join(anchor, 'dir-leaf'), 'tracking')]), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.LEAF_KIND_MISMATCH + ) + await assert.rejects( + deriveFallbackFrontierPlan([leaf(path.join(anchor, 'dir-leaf'), 'mcp-config')]), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.LEAF_KIND_MISMATCH + ) + await assert.rejects( + deriveFallbackFrontierPlan([leaf(fileLeaf)]), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.LEAF_KIND_MISMATCH + ) + }) + + it('enforces the missing-segment bound and canonical path shapes', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + const deepName = Array.from({ length: FALLBACK_FRONTIER_BOUNDS.maxMissingSegmentsPerLeaf + 1 }, (_, index) => `level-${index}`).join('/') + const deepLeaf = leaf(path.join(anchor, deepName)) + const traversal = leaf(`${root}/escape/../target`) + await assert.rejects( + deriveFallbackFrontierPlan([deepLeaf]), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT + ) + await assert.rejects(deriveFallbackFrontierPlan([leaf('relative/path')]), (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + await assert.rejects(deriveFallbackFrontierPlan([traversal]), (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + }) + + it('enforces the frontier bound during derivation: exactly 16 accepted, 17 rejected', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + const frontierLeaves = (count: number): FallbackLeafTarget[] => + Array.from({ length: count }, (_, index) => leaf(path.join(anchor, `frontier-${String(index).padStart(2, '0')}`))) + const accepted = await deriveFallbackFrontierPlan(frontierLeaves(FALLBACK_FRONTIER_BOUNDS.maxFrontiers)) + assert.equal(accepted.frontiers.length, FALLBACK_FRONTIER_BOUNDS.maxFrontiers) + assertFallbackFrontierEvidenceList(structuredCloneFrontiers(accepted.frontiers)) + await assert.rejects( + deriveFallbackFrontierPlan(frontierLeaves(FALLBACK_FRONTIER_BOUNDS.maxFrontiers + 1)), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT + ) + }) +}) + +describe('fallback frontier platform preflight', () => { + it('rejects active frontiers on non-linux platforms with the stable code', () => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + return (async () => { + const plan = await deriveFallbackFrontierPlan([leaf(path.join(anchor, 'added'))]) + for (const platform of ['win32', 'darwin'] as const) { + const support = withPlatform(platform, () => evaluateFallbackFrontierPlatformSupport(plan.frontiers)) + assert.equal(support.supported, false) + if (!support.supported) { + assert.equal(support.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + assert.deepEqual(support.frontierPaths, [path.join(anchor, 'added')]) + } + } + })() + }) + + it('keeps existing-parent plans and inactive conditional frontiers supported everywhere', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(path.join(anchor, 'tracked'), { recursive: true }) + const existingPlan = await deriveFallbackFrontierPlan([leaf(path.join(anchor, 'tracked'))]) + assert.deepEqual(withPlatform('win32', () => evaluateFallbackFrontierPlatformSupport(existingPlan.frontiers)), { supported: true }) + + const conditionalPlan = await deriveFallbackFrontierPlan([leaf(path.join(anchor, 'cond'), 'skill', 'conditional')]) + assert.deepEqual( + withPlatform('win32', () => evaluateFallbackFrontierPlatformSupport(conditionalPlan.frontiers, { inactiveFrontierPaths: [path.join(anchor, 'cond')] })), + { supported: true } + ) + assert.deepEqual(withPlatform('linux', () => evaluateFallbackFrontierPlatformSupport(conditionalPlan.frontiers)), { supported: true }) + }) + + it('fails closed when inactive marks reference required or unknown frontiers', () => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + return (async () => { + const plan = await deriveFallbackFrontierPlan([leaf(path.join(anchor, 'added'))]) + assertErrorCode(() => withPlatform('win32', () => evaluateFallbackFrontierPlatformSupport(plan.frontiers, { inactiveFrontierPaths: [path.join(anchor, 'added')] })), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + assertErrorCode(() => withPlatform('win32', () => evaluateFallbackFrontierPlatformSupport(plan.frontiers, { inactiveFrontierPaths: [path.join(anchor, 'unknown')] })), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + })() + }) +}) + +describe('fallback frontier strict parsing and revalidation', () => { + it('round-trips derived evidence through the strict parser unchanged', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + const plan = await deriveFallbackFrontierPlan([ + leaf(path.join(anchor, 'a')), + leaf(path.join(anchor, 'n', 'x'), 'link', 'conditional'), + ]) + const parsed = structuredCloneFrontiers(plan.frontiers) + assertFallbackFrontierEvidenceList(parsed) + assert.deepEqual(parsed, plan.frontiers) + }) + + it('rejects tampered evidence: reordering, identity changes, extra fields, forged anchors, stray leaves', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + const grouped = await deriveFallbackFrontierPlan([ + leaf(path.join(anchor, 'group', 'a')), + leaf(path.join(anchor, 'group', 'b')), + ]) + assert.equal(grouped.frontiers[0].leaves.length, 2) + const reordered = structuredCloneFrontiers(grouped.frontiers) + reordered[0] = { ...reordered[0], leaves: [...reordered[0].leaves].reverse() } + assertErrorCode(() => assertFallbackFrontierEvidenceList(reordered), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + + const plan = await deriveFallbackFrontierPlan([ + leaf(path.join(anchor, 'two-a')), + leaf(path.join(anchor, 'two-b')), + ]) + const extraField = structuredCloneFrontiers(plan.frontiers) + const extraRecord = extraField[0] as unknown as Record + extraRecord.applied = true + assertErrorCode(() => assertFallbackFrontierEvidenceList(extraField), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + + // Nested leaves must reject unknown fields through the production parser. + const nestedExtraField = structuredCloneFrontiers(plan.frontiers) + ;(nestedExtraField[0].leaves[0] as unknown as Record).tampered = true + assertErrorCode(() => assertFallbackFrontierEvidenceList(nestedExtraField), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + + // Anchors must carry exactly the recorded directory identity fields. + const anchorMissingType = structuredCloneFrontiers(plan.frontiers) + delete (anchorMissingType[0].anchor as unknown as Record).type + assertErrorCode(() => assertFallbackFrontierEvidenceList(anchorMissingType), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + const anchorWrongType = structuredCloneFrontiers(plan.frontiers) + anchorWrongType[0].anchor = { ...anchorWrongType[0].anchor, type: 'file' } as unknown as FallbackAnchorIdentity + assertErrorCode(() => assertFallbackFrontierEvidenceList(anchorWrongType), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + + const forgedAnchor = structuredCloneFrontiers(plan.frontiers) + forgedAnchor[0].anchor = { ...forgedAnchor[0].anchor, path: path.dirname(anchor) } + assertErrorCode(() => assertFallbackFrontierEvidenceList(forgedAnchor), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + + const strayLeaf = structuredCloneFrontiers(plan.frontiers) + strayLeaf[0].leaves = [leaf(path.join(root, 'elsewhere', 'leaf'))] + assertErrorCode(() => assertFallbackFrontierEvidenceList(strayLeaf), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + + const unsortedFrontiers = structuredCloneFrontiers(plan.frontiers) + const second = await deriveFallbackFrontierPlan([leaf(path.join(anchor, 'aaa-second'))]) + const combined = [...structuredCloneFrontiers(second.frontiers), ...unsortedFrontiers] + combined.sort((a, b) => compareUtf8(b.frontierPath, a.frontierPath)) + assertErrorCode(() => assertFallbackFrontierEvidenceList(combined), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + }) + + it('rejects globally impossible graphs no derivation could ever produce', async () => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + const frontierPath = path.join(anchor, 'impossible-frontier') + const synthetic = (leaves: FallbackLeafTarget[], activation: FallbackLeafActivation = 'required', frontierOverride = frontierPath): FallbackFrontierEvidence => ({ + frontierPath: frontierOverride, + activation, + anchor: { path: path.dirname(frontierOverride), realpath: path.dirname(frontierOverride), device: '1', inode: '1', type: 'directory' }, + leaves, + }) + + // Leaf–leaf nesting inside a single well-formed frontier. + const nestedLeaves = synthetic([ + leaf(path.join(frontierPath, 'a')), + leaf(path.join(frontierPath, 'a', 'b')), + ]) + assertErrorCode(() => assertFallbackFrontierEvidenceList([nestedLeaves]), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + + // Nested frontiers (the inner anchor is the outer frontier path). + const nestedFrontiers = [ + synthetic([leaf(path.join(anchor, 'nest'))], 'required', path.join(anchor, 'nest')), + synthetic([leaf(path.join(anchor, 'nest', 'inner'))], 'required', path.join(anchor, 'nest', 'inner')), + ] + assertErrorCode(() => assertFallbackFrontierEvidenceList(nestedFrontiers), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + + // Duplicate leaf ids across frontiers. + const duplicateIds = [ + synthetic([leaf(path.join(anchor, 'za-frontier', 'x'), 'skill', 'required', 'dup')], 'required', path.join(anchor, 'za-frontier')), + synthetic([leaf(path.join(anchor, 'zb-frontier', 'y'), 'skill', 'required', 'dup')], 'required', path.join(anchor, 'zb-frontier')), + ] + assertErrorCode(() => assertFallbackFrontierEvidenceList(duplicateIds), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + + // Activation must aggregate: all-conditional leaves cannot claim required + // and any required leaf cannot hide behind a conditional frontier. + const activationLie = synthetic([leaf(path.join(frontierPath, 'cfg'), 'mcp-config', 'conditional', 'cfg')], 'required') + assertErrorCode(() => assertFallbackFrontierEvidenceList([activationLie]), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + const activationLieReverse = synthetic([leaf(path.join(frontierPath, 'a'), 'skill', 'required', 'req')], 'conditional') + assertErrorCode(() => assertFallbackFrontierEvidenceList([activationLieReverse]), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + + // A file/link leaf can never equal a directory frontier path. + const linkSelfLeaf = synthetic([leaf(frontierPath, 'link', 'required', 'link-self')]) + assertErrorCode(() => assertFallbackFrontierEvidenceList([linkSelfLeaf]), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + + // The leaf sum is globally bounded even when each frontier is within the + // per-frontier bound: every leaf lives directly beneath its own declared + // frontier, so per-frontier containment and shape both pass and only the + // whole-graph aggregate (2 * (maxLeaves / 2 + 1) = maxLeaves + 2 leaves) + // can reject. + const overflowLeaves = (prefix: string, frontierDirectory: string): FallbackLeafTarget[] => Array.from( + { length: Math.floor(FALLBACK_FRONTIER_BOUNDS.maxLeaves / 2) + 1 }, + (_, index) => leaf(path.join(frontierDirectory, `${prefix}-${String(index).padStart(2, '0')}`), 'skill', 'required', `${prefix}-${index}`) + ) + const aaOverflow = synthetic(overflowLeaves('aa', path.join(anchor, 'aa-frontier')), 'required', path.join(anchor, 'aa-frontier')) + const bbOverflow = synthetic(overflowLeaves('bb', path.join(anchor, 'bb-frontier')), 'required', path.join(anchor, 'bb-frontier')) + // Each frontier alone is within every per-frontier bound... + assertFallbackFrontierEvidenceList([aaOverflow]) + assertFallbackFrontierEvidenceList([bbOverflow]) + // ...so the combined rejection must come from the global leaf-sum rule, + // not from containment, shape, ids, order, or activation. + assert.throws( + () => assertFallbackFrontierEvidenceList([aaOverflow, bbOverflow]), + (error: unknown) => error instanceof FallbackFrontierError && + error.code === FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT && + error.message.includes(`carries ${FALLBACK_FRONTIER_BOUNDS.maxLeaves + 2} leaves across frontiers; the bound is ${FALLBACK_FRONTIER_BOUNDS.maxLeaves}`), + 'expected the global leaf-sum bound rejection' + ) + }) + + it('accepts the legitimate self-referencing directory skill leaf its own frontier publishes', () => { + const anchor = path.join(root, 'skills') + mkdirSync(anchor) + const frontierPath = path.join(anchor, 'published-frontier') + const legitimate = { + frontierPath, + activation: 'required', + anchor: { path: anchor, realpath: anchor, device: '1', inode: '1', type: 'directory' }, + leaves: [leaf(frontierPath)], + } + assertFallbackFrontierEvidenceList([legitimate]) + }) +}) + +describe('fallback frontier shared leaf derivation', () => { + const destination = '/planned/dest' + const linkDir = '/planned/links' + const trackingPath = '/planned/home/.agents/nodesource-installed.json' + + function baseInputs (overrides: Partial = {}): FallbackFrontierLeafInputs { + return { + ownedSkills: [], + ownedLinks: [], + ownedMcpConfigPaths: [], + trackingPath, + destination, + linkDir, + bundleSkillNames: [], + ...overrides, + } + } + + function evidence (evidencePath: string): FallbackPathEvidence { + return { path: evidencePath, kind: 'directory' } + } + + it('derives the complete one-to-one leaf set from bundle, tracked, MCP, and tracking inputs', () => { + const leaves = deriveFallbackFrontierLeafTargets(baseInputs({ + bundleSkillNames: ['alpha', 'beta'], + ownedSkills: [evidence(`${destination}/removed-skill`), evidence(`${destination}/alpha`)], + ownedLinks: [evidence(`${linkDir}/removed-link`)], + ownedMcpConfigPaths: [evidence('/planned/cfg/b.json'), evidence('/planned/cfg/a.json'), evidence('/planned/cfg/b.json')], + })) + assert.deepEqual(leaves, [ + { id: 'skill:alpha', role: 'skill', activation: 'required', path: path.resolve(destination, 'alpha') }, + { id: 'link:alpha', role: 'link', activation: 'required', path: path.resolve(linkDir, 'alpha') }, + { id: 'skill:beta', role: 'skill', activation: 'required', path: path.resolve(destination, 'beta') }, + { id: 'link:beta', role: 'link', activation: 'required', path: path.resolve(linkDir, 'beta') }, + { id: 'owned-skill:removed-skill', role: 'skill', activation: 'conditional', path: path.resolve(`${destination}/removed-skill`) }, + { id: 'owned-link:removed-link', role: 'link', activation: 'conditional', path: path.resolve(`${linkDir}/removed-link`) }, + { id: 'mcp-config:0', role: 'mcp-config', activation: 'conditional', path: path.resolve('/planned/cfg/a.json') }, + { id: 'mcp-config:1', role: 'mcp-config', activation: 'conditional', path: path.resolve('/planned/cfg/b.json') }, + { id: 'tracking', role: 'tracking', activation: 'required', path: path.resolve(trackingPath) }, + ]) + }) + + it('is deterministic: equal inputs produce deep-equal leaf graphs', () => { + const inputs = baseInputs({ bundleSkillNames: ['one'], ownedSkills: [evidence(`${destination}/kept`)] }) + assert.deepEqual(deriveFallbackFrontierLeafTargets(inputs), deriveFallbackFrontierLeafTargets(baseInputs({ bundleSkillNames: ['one'], ownedSkills: [evidence(`${destination}/kept`)] }))) + }) + + it('bundle evidence wins over tracked evidence for the same destination path', () => { + const leaves = deriveFallbackFrontierLeafTargets(baseInputs({ + bundleSkillNames: ['alpha'], + ownedSkills: [evidence(`${destination}/alpha`)], + ownedLinks: [evidence(`${linkDir}/alpha`)], + })) + assert.deepEqual(leaves, [ + { id: 'skill:alpha', role: 'skill', activation: 'required', path: path.resolve(destination, 'alpha') }, + { id: 'link:alpha', role: 'link', activation: 'required', path: path.resolve(linkDir, 'alpha') }, + { id: 'tracking', role: 'tracking', activation: 'required', path: path.resolve(trackingPath) }, + ]) + }) + + it('keeps conditional leaves for tracked destinations the bundle no longer contains', () => { + const leaves = deriveFallbackFrontierLeafTargets(baseInputs({ + ownedSkills: [evidence(`${destination}/stale-skill`)], + ownedLinks: [evidence(`${linkDir}/stale-link`)], + })) + assert.deepEqual(leaves, [ + { id: 'owned-skill:stale-skill', role: 'skill', activation: 'conditional', path: path.resolve(`${destination}/stale-skill`) }, + { id: 'owned-link:stale-link', role: 'link', activation: 'conditional', path: path.resolve(`${linkDir}/stale-link`) }, + { id: 'tracking', role: 'tracking', activation: 'required', path: path.resolve(trackingPath) }, + ]) + }) + + it('assigns stable mcp-config ids after deduplication and UTF-8 sorting regardless of input order', () => { + const leaves = deriveFallbackFrontierLeafTargets(baseInputs({ + ownedMcpConfigPaths: [evidence('/planned/cfg/zeta.json'), evidence('/planned/cfg/alpha.json'), evidence('/planned/cfg/mid.json'), evidence('/planned/cfg/zeta.json')], + })) + const mcpLeaves = leaves.filter((entry) => entry.role === 'mcp-config') + assert.deepEqual(mcpLeaves, [ + { id: 'mcp-config:0', role: 'mcp-config', activation: 'conditional', path: path.resolve('/planned/cfg/alpha.json') }, + { id: 'mcp-config:1', role: 'mcp-config', activation: 'conditional', path: path.resolve('/planned/cfg/mid.json') }, + { id: 'mcp-config:2', role: 'mcp-config', activation: 'conditional', path: path.resolve('/planned/cfg/zeta.json') }, + ]) + }) + + it('rejects basename collisions among distinct tracked destinations instead of collapsing them', () => { + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ + ownedSkills: [evidence('/planned/one/dup-name'), evidence('/planned/two/dup-name')], + })), FALLBACK_FRONTIER_ERROR_CODES.COLLISION) + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ + ownedLinks: [evidence('/planned/one/dup-link'), evidence('/planned/two/dup-link')], + })), FALLBACK_FRONTIER_ERROR_CODES.COLLISION) + }) + + it('rejects cross-role claims on the same destination instead of silently dropping one', () => { + // A tracked MCP config equal to the tracking file: the tracking leaf is + // added later and the roles differ. + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ + ownedMcpConfigPaths: [evidence(trackingPath)], + })), FALLBACK_FRONTIER_ERROR_CODES.COLLISION) + // Destination root equal to the link root: skill and link leaves collide. + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ + destination, + linkDir: destination, + bundleSkillNames: ['alpha'], + })), FALLBACK_FRONTIER_ERROR_CODES.COLLISION) + // A bundle skill destination claimed simultaneously as a tracked link. + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ + bundleSkillNames: ['alpha'], + ownedLinks: [evidence(path.resolve(destination, 'alpha'))], + })), FALLBACK_FRONTIER_ERROR_CODES.COLLISION) + // A tracked link claimed simultaneously as a tracked MCP config. + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ + ownedLinks: [evidence('/planned/cfg/shared.json')], + ownedMcpConfigPaths: [evidence('/planned/cfg/shared.json')], + })), FALLBACK_FRONTIER_ERROR_CODES.COLLISION) + }) + + it('collapses only compatible same-role evidence: identical duplicates merge and bundle evidence wins', () => { + const leaves = deriveFallbackFrontierLeafTargets(baseInputs({ + bundleSkillNames: ['alpha'], + ownedSkills: [evidence(`${destination}/alpha`), evidence(`${destination}/alpha`)], + ownedLinks: [evidence(`${linkDir}/alpha`)], + })) + assert.deepEqual(leaves.filter((entry) => entry.path === path.resolve(destination, 'alpha')), [ + { id: 'skill:alpha', role: 'skill', activation: 'required', path: path.resolve(destination, 'alpha') }, + ]) + assert.deepEqual(leaves.filter((entry) => entry.path === path.resolve(linkDir, 'alpha')), [ + { id: 'link:alpha', role: 'link', activation: 'required', path: path.resolve(linkDir, 'alpha') }, + ]) + }) + + it('rejects duplicate bundle skill names and unsafe bundle skill names', () => { + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ bundleSkillNames: ['dup', 'dup'] })), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ bundleSkillNames: ['../escape'] })), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + }) + + it('omits link leaves entirely when the harness has no link root', () => { + const leaves = deriveFallbackFrontierLeafTargets(baseInputs({ linkDir: undefined, bundleSkillNames: ['solo'] })) + assert.deepEqual(leaves, [ + { id: 'skill:solo', role: 'skill', activation: 'required', path: path.resolve(destination, 'solo') }, + { id: 'tracking', role: 'tracking', activation: 'required', path: path.resolve(trackingPath) }, + ]) + assert.ok(leaves.every((entry) => entry.role !== 'link')) + }) + + it('rejects relative roots and malformed evidence inputs', () => { + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ destination: 'relative/dest' })), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ linkDir: 'relative/links' })), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ trackingPath: 'relative/tracking.json' })), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ ownedSkills: [{ kind: 'directory' } as unknown as FallbackPathEvidence] })), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + assertErrorCode(() => deriveFallbackFrontierLeafTargets(baseInputs({ ownedSkills: 'not-an-array' as unknown as FallbackPathEvidence[] })), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + assertErrorCode(() => deriveFallbackFrontierLeafTargets({ ...baseInputs(), unexpected: true } as unknown as FallbackFrontierLeafInputs), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + assertErrorCode(() => deriveFallbackFrontierLeafTargets({ ...baseInputs(), trackingPath: undefined } as unknown as FallbackFrontierLeafInputs), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + }) +}) + +describe('fallback journal physical target derivation', () => { + // Synthetic fixture roots must satisfy the strict canonical-path validation + // on every platform: derive them from the host's canonical temp directory + // instead of a POSIX literal like `/home/u`, which win32 does not treat as + // an absolute path. No directory is created; anchor identity is faked. + const syntheticHome = path.join(realpathSync.native(tmpdir()), 'u') + const fakeAnchorIdentity = (anchorPath: string): FallbackAnchorIdentity => ({ + path: anchorPath, + realpath: anchorPath, + type: 'directory', + device: '1', + inode: '1', + }) + const frontierEvidence = (frontierPath: string, leafPaths: readonly string[]): FallbackFrontierEvidence => ({ + frontierPath, + activation: 'required', + anchor: fakeAnchorIdentity(path.dirname(frontierPath)), + leaves: leafPaths + .map((leafPath, index) => ({ id: `skill:${path.basename(leafPath)}-${index}`, role: 'skill' as const, activation: 'required' as const, path: leafPath })) + .sort((left, right) => compareUtf8(left.path, right.path)), + }) + const missingEvidence = (target: string): FallbackPathEvidence => ({ path: target, kind: 'missing' }) + const manifestWith = (frontiers: readonly FallbackFrontierEvidence[], extra: Partial = {}): FallbackTransactionIdentity => ({ + installationId: 'opencode:fallback', + harness: 'opencode', + trackingPath: path.join(syntheticHome, '.agents', '.nodesource-installed.json'), + trackingDigest: 'a'.repeat(64), + protocolVersion: FALLBACK_PROTOCOL_VERSION, + nonce: 'nonce', + plannedMissingFrontiers: frontiers, + ownedSkills: [], + ownedLinks: [], + ownedMcpFields: [], + ownedMcpConfigPaths: [], + bundleDestinations: [], + approvedDestinationRoots: [path.join(syntheticHome, '.config', 'opencode', 'skills')], + ...extra, + }) + + it('replaces every covered leaf with exactly one planned-missing frontier entry and retains uncovered targets', () => { + const frontierPath = path.join(syntheticHome, '.config', 'opencode', 'skills') + const manifest = manifestWith([frontierEvidence(frontierPath, [path.join(frontierPath, 'alpha'), path.join(frontierPath, 'beta')])], { + ownedSkills: [missingEvidence(path.join(frontierPath, 'alpha'))], + bundleDestinations: [missingEvidence(path.join(frontierPath, 'alpha')), missingEvidence(path.join(frontierPath, 'beta'))], + ownedMcpConfigPaths: [missingEvidence(path.join(syntheticHome, 'opencode.json'))], + }) + assert.deepEqual(deriveFallbackJournalPhysicalTargets(manifest), [ + { path: manifest.trackingPath, frontier: false }, + { path: path.join(syntheticHome, 'opencode.json'), frontier: false }, + { path: frontierPath, frontier: true }, + ]) + }) + + it('keeps separate frontiers as separate non-overlapping entries in UTF-8 byte order', () => { + const frontierA = path.join(syntheticHome, 'one', 'skills') + const frontierB = path.join(syntheticHome, 'two', 'skills') + // Manifest evidence must already be strictly sorted; the helper keeps + // that order instead of reordering it. + const manifest = manifestWith( + [frontierEvidence(frontierA, [path.join(frontierA, 'a')]), frontierEvidence(frontierB, [path.join(frontierB, 'b')])], + { bundleDestinations: [missingEvidence(path.join(frontierA, 'a')), missingEvidence(path.join(frontierB, 'b'))] } + ) + assert.deepEqual(deriveFallbackJournalPhysicalTargets(manifest), [ + { path: manifest.trackingPath, frontier: false }, + { path: frontierA, frontier: true }, + { path: frontierB, frontier: true }, + ]) + }) + + it('represents a self-referencing frontier leaf by the frontier entry alone and dedupes retained evidence', () => { + const frontierPath = path.join(syntheticHome, '.config', 'opencode', 'skills', 'added') + const manifest = manifestWith([frontierEvidence(frontierPath, [frontierPath])], { + ownedSkills: [missingEvidence(frontierPath)], + bundleDestinations: [missingEvidence(frontierPath)], + }) + assert.deepEqual(deriveFallbackJournalPhysicalTargets(manifest), [ + { path: manifest.trackingPath, frontier: false }, + { path: frontierPath, frontier: true }, + ]) + }) + + it('rejects a manifest candidate hidden under a frontier without being one of its authorized leaves', () => { + const frontierPath = '/home/u/.config/opencode/skills' + const hidden = manifestWith([frontierEvidence(frontierPath, [path.join(frontierPath, 'alpha')])], { + bundleDestinations: [missingEvidence(path.join(frontierPath, 'alpha')), missingEvidence(path.join(frontierPath, 'hidden'))], + }) + assertErrorCode(() => deriveFallbackJournalPhysicalTargets(hidden), FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + }) + + it('rejects a graph whose leaf is covered by two different frontiers at the strict parse layer', () => { + // A candidate covered by two frontiers requires the same leaf path in + // both frontiers, which the strict parser already refuses; the helper + // therefore never even sees such a graph. + const shared = '/home/u/shared' + const graph = [ + frontierEvidence('/home/u/one', [path.join('/home/u/one', 'a'), shared]), + frontierEvidence('/home/u/two', [shared, path.join('/home/u/two', 'b')]), + ] + assert.throws(() => assertFallbackFrontierEvidenceList(graph), (error: unknown) => error instanceof FallbackFrontierError) + }) +}) + +describe('fallback frontier journal revalidation', () => { + function realAnchorIdentity (anchorPath: string): FallbackAnchorIdentity { + const stats = lstatSync(anchorPath, { bigint: true }) + const real = realpathSync(anchorPath) + if (real !== anchorPath || !stats.isDirectory()) throw new Error(`fixture anchor ${anchorPath} must be a real directory`) + return { path: anchorPath, realpath: real, type: 'directory', device: stats.dev.toString(10), inode: stats.ino.toString(10) } + } + + function evidenceFor (anchor: string, frontierPath: string): FallbackFrontierEvidence { + return { + frontierPath, + activation: 'required', + anchor: realAnchorIdentity(anchor), + leaves: [{ id: 'skill:alpha', role: 'skill', activation: 'required', path: path.join(frontierPath, 'alpha') }], + } + } + + const driftRejection = (error: unknown): boolean => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.DRIFT + + it('accepts evidence whose anchor identity, component chain, missing frontier, and leaf graph all still hold', async () => { + const anchor = path.join(root, 'anchor') + mkdirSync(anchor) + const frontierPath = path.join(anchor, 'new-root') + const evidence = evidenceFor(anchor, frontierPath) + await revalidateFallbackFrontierEvidence([evidence]) + assert.ok(true) + }) + + it('rejects drift when the frontier path appears before begin or claim', async () => { + const anchor = path.join(root, 'anchor') + mkdirSync(anchor) + const frontierPath = path.join(anchor, 'new-root') + const evidence = evidenceFor(anchor, frontierPath) + mkdirSync(frontierPath) + // The frontier-exists guard fires before leaf-graph rederivation, so + // this case covers the guard's exact reason — not graph disagreement. + await assert.rejects(revalidateFallbackFrontierEvidence([evidence]), (error: unknown) => { + return error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.DRIFT && + /the frontier path exists/.test(error.message) + }) + }) + + it('rejects drift when the anchor inode is replaced', async () => { + const anchor = path.join(root, 'anchor') + mkdirSync(anchor) + const frontierPath = path.join(anchor, 'new-root') + const evidence = evidenceFor(anchor, frontierPath) + // Replace the anchor's directory entry with a freshly created directory: + // both existed at the same time, so the replacement is guaranteed to + // carry a new inode. Rename the original aside first (preserving its + // inode and avoiding deletion/inode reuse): rename-over-existing is not + // permitted on every platform. + const replacement = path.join(root, 'anchor-replacement') + mkdirSync(replacement) + renameSync(anchor, `${anchor}-replaced`) + renameSync(replacement, anchor) + await assert.rejects(revalidateFallbackFrontierEvidence([evidence]), driftRejection) + }) + + it('rejects drift when an anchor component becomes a symlink instead of a real directory', async (t) => { + const realDir = path.join(root, 'real-anchor-parent') + mkdirSync(realDir) + const anchor = path.join(realDir, 'anchor') + mkdirSync(anchor) + const evidence = evidenceFor(anchor, path.join(anchor, 'new-root')) + // Replace the real parent directory with a symlink to an existing + // directory: every component of the anchor chain must be a real + // directory, never a link. + rmSync(realDir, { recursive: true, force: true }) + if (!createSymlinkForTest(t, root, realDir)) return + await assert.rejects(revalidateFallbackFrontierEvidence([evidence]), driftRejection) + }) +}) + +describe('deriveFrontierTreeExpectation', () => { + const frontierFor = (leaves: FallbackLeafTarget[]): FallbackFrontierEvidence => ({ + frontierPath: path.join(root, 'anchor', 'new-root'), + activation: 'required', + anchor: { path: path.join(root, 'anchor'), realpath: path.join(root, 'anchor'), type: 'directory', device: '1', inode: '2' }, + leaves, + }) + + it('derives exact directories, leaf kinds, and link targets from trusted evidence', () => { + const frontierPath = path.join(root, 'anchor', 'new-root') + const skillPath = path.join(root, 'dest-root', 'gamma') + const frontier = frontierFor([ + leaf(path.join(frontierPath, 'alpha'), 'skill', 'required', 'skill:alpha'), + leaf(path.join(frontierPath, 'nested', 'config.json'), 'mcp-config', 'required', 'mcp-config:0'), + leaf(path.join(frontierPath, 'nested', 'deep', 'tracking.json'), 'tracking', 'conditional', 'tracking'), + leaf(path.join(frontierPath, 'gamma'), 'link', 'required', 'link:gamma'), + ]) + const allLeaves = [leaf(skillPath, 'skill', 'required', 'skill:gamma'), ...frontier.leaves] + const expectation = deriveFrontierTreeExpectation(frontier, allLeaves) + assert.deepEqual(expectation.directories, ['nested', path.join('nested', 'deep')]) + assert.deepEqual([...expectation.leafKinds.entries()].sort(([a], [b]) => compareUtf8(a, b)), [ + ['alpha', 'directory'], + ['gamma', 'symlink'], + [path.join('nested', 'config.json'), 'file'], + [path.join('nested', 'deep', 'tracking.json'), 'file'], + ]) + assert.deepEqual(expectation.linkTargets, new Map([['gamma', skillPath]])) + }) + + it('rejects a link leaf without a corresponding skill: leaf', () => { + const frontier = frontierFor([leaf(path.join(frontierFor([]).frontierPath, 'gamma'), 'link', 'required', 'link:gamma')]) + assert.throws(() => deriveFrontierTreeExpectation(frontier, [...frontier.leaves]), (error: unknown) => { + return error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT && + /skill:/.test(error.message) + }) + }) + + it('rejects a leaf outside the frontier', () => { + const frontier = frontierFor([leaf(path.join(root, 'elsewhere', 'alpha'), 'skill', 'required', 'skill:alpha')]) + assert.throws(() => deriveFrontierTreeExpectation(frontier, [...frontier.leaves]), FallbackFrontierError) + }) + + it('rejects two leaves collapsing into one relative path', () => { + const frontierPath = path.join(root, 'anchor', 'new-root') + const frontier = frontierFor([ + leaf(path.join(frontierPath, 'alpha'), 'skill', 'required', 'skill:alpha'), + leaf(path.join(frontierPath, 'alpha'), 'skill', 'required', 'owned-skill:alpha'), + ]) + assert.throws(() => deriveFrontierTreeExpectation(frontier, [...frontier.leaves]), FallbackFrontierError) + }) + + it('rejects a non-canonical frontier path', () => { + const frontier = { ...frontierFor([]), frontierPath: `${frontierFor([]).frontierPath}${path.sep}..${path.sep}other` } + assert.throws(() => deriveFrontierTreeExpectation(frontier, [...frontier.leaves]), FallbackFrontierError) + }) + + it('supports a directory skill leaf that exactly equals the frontier root', () => { + const frontierPath = path.join(root, 'anchor', 'new-root') + const frontier = frontierFor([leaf(frontierPath, 'skill', 'required', 'skill:whole')]) + const expectation = deriveFrontierTreeExpectation(frontier, [...frontier.leaves]) + assert.deepEqual([...expectation.leafKinds.entries()], [['', 'directory']]) + assert.deepEqual(expectation.directories, []) + assert.deepEqual(expectation.linkTargets, new Map()) + }) + + it('rejects a non-directory leaf that equals the frontier root', () => { + const frontierPath = path.join(root, 'anchor', 'new-root') + const frontier = frontierFor([leaf(frontierPath, 'link', 'required', 'link:whole')]) + assert.throws(() => deriveFrontierTreeExpectation(frontier, [...frontier.leaves]), (error: unknown) => { + return error instanceof FallbackFrontierError && /only a directory skill leaf may equal the frontier root/.test(error.message) + }) + }) + + it('binds a link leaf to an existing skill destination through the resolver option', () => { + const frontierPath = path.join(root, 'anchor', 'new-root') + const existingSkillPath = path.join(root, 'dest-root', 'gamma') + const frontier = frontierFor([leaf(path.join(frontierPath, 'gamma'), 'link', 'required', 'link:gamma')]) + // No skill:gamma leaf anywhere in allLeaves; the resolver (built from + // trusted manifest evidence by the journal) supplies the binding. + const expectation = deriveFrontierTreeExpectation(frontier, [...frontier.leaves], { + skillDestinationResolver: (bound) => bound.id === 'link:gamma' ? existingSkillPath : undefined, + }) + assert.deepEqual(expectation.linkTargets, new Map([['gamma', existingSkillPath]])) + // Without the resolver the same evidence fails closed. + assert.throws(() => deriveFrontierTreeExpectation(frontier, [...frontier.leaves]), /skill:/) + }) +}) + +describe('selectActiveFallbackFrontierLeaves', () => { + const frontierWithConditional = (): FallbackFrontierEvidence => ({ + frontierPath: path.join(root, 'anchor', 'new-root'), + activation: 'required', + anchor: { path: path.join(root, 'anchor'), realpath: path.join(root, 'anchor'), type: 'directory', device: '1', inode: '2' }, + leaves: [ + leaf(path.join(root, 'anchor', 'new-root', 'alpha'), 'skill', 'required', 'skill:alpha'), + leaf(path.join(root, 'anchor', 'new-root', 'beta'), 'skill', 'conditional', 'skill:beta'), + leaf(path.join(root, 'anchor', 'new-root', 'gamma'), 'skill', 'conditional', 'skill:gamma'), + ], + }) + + it('always includes required leaves and omits conditionals by default', () => { + const frontier = frontierWithConditional() + const selected = selectActiveFallbackFrontierLeaves(frontier) + assert.deepEqual([...selected], [frontier.leaves[0]]) + }) + + it('adds exactly the selected conditional leaves and keeps UTF-8 path order', () => { + const frontier = frontierWithConditional() + const selected = selectActiveFallbackFrontierLeaves(frontier, ['skill:gamma']) + assert.deepEqual([...selected], [frontier.leaves[0], frontier.leaves[2]]) + }) + + it('is deterministic and never mutates the frontier evidence', () => { + const frontier = frontierWithConditional() + const snapshot = JSON.stringify(frontier) + const first = selectActiveFallbackFrontierLeaves(frontier, ['skill:beta', 'skill:gamma']) + const second = selectActiveFallbackFrontierLeaves(frontier, ['skill:gamma', 'skill:beta']) + assert.deepEqual([...first], [...second]) + assert.equal(JSON.stringify(frontier), snapshot) + assert.ok(Object.isFrozen(first)) + }) + + it('rejects unknown, duplicate, required-id, malformed, and overflow selectors', () => { + const frontier = frontierWithConditional() + const invalidSelector = (selector: unknown): void => { + assert.throws(() => selectActiveFallbackFrontierLeaves(frontier, selector as readonly string[]), (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + } + invalidSelector(['skill:nope']) + invalidSelector(['skill:beta', 'skill:beta']) + invalidSelector(['skill:alpha']) + invalidSelector(['']) + invalidSelector([42]) + invalidSelector([null]) + invalidSelector(Array.from({ length: FALLBACK_FRONTIER_BOUNDS.maxLeaves + 1 }, (_, index) => `skill:x${index}`)) + }) + + it('rejects malformed frontier evidence before selection', () => { + const malformed = frontierWithConditional() as unknown as Record + malformed.leaves = [] + assert.throws(() => selectActiveFallbackFrontierLeaves(malformed as unknown as FallbackFrontierEvidence), (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.INVALID_INPUT) + }) + + it('accepts an empty selector for an all-conditional frontier and yields an empty selection', () => { + const frontier = frontierWithConditional() + const allConditional: FallbackFrontierEvidence = { + ...frontier, + activation: 'conditional', + leaves: frontier.leaves.map((entry) => ({ ...entry, activation: 'conditional' as const })), + } + const selected = selectActiveFallbackFrontierLeaves(allConditional) + assert.deepEqual([...selected], []) + }) +}) + +describe('trusted link materialization policy', () => { + it('derives the policy only from the trusted harness plus the runtime platform', () => { + // Pi always copies skill directories, on every platform. + assert.deepEqual([...fallbackLinkMaterialization('pi').allowedKinds], ['directory']) + if (process.platform === 'win32') { + assert.deepEqual([...fallbackLinkMaterialization('claude').allowedKinds], ['symlink', 'directory']) + } else { + // Every other harness on POSIX requires real symlinks. + assert.deepEqual([...fallbackLinkMaterialization('claude').allowedKinds], ['symlink']) + assert.deepEqual([...fallbackLinkMaterialization('codex').allowedKinds], ['symlink']) + assert.deepEqual([...fallbackLinkMaterialization('opencode').allowedKinds], ['symlink']) + // On Windows the same harnesses may fall back to copied directories. + assert.deepEqual(withPlatform('win32', () => [...fallbackLinkMaterialization('claude').allowedKinds]), ['symlink', 'directory']) + assert.deepEqual(withPlatform('win32', () => [...fallbackLinkMaterialization('antigravity').allowedKinds]), ['symlink', 'directory']) + assert.deepEqual(withPlatform('win32', () => [...fallbackLinkMaterialization('pi').allowedKinds]), ['directory']) + } + assert.throws( + () => fallbackLinkMaterialization('unknown' as HarnessType), + (error: unknown) => error instanceof FallbackFrontierError + ) + }) + + it('accepts an existing Pi copied-directory link under the Pi policy and rejects the same shape under the strict POSIX policy', async () => { + const anchor = path.join(root, 'pi-skills') + mkdirSync(path.join(anchor, 'real-skill'), { recursive: true }) + writeFileSync(path.join(anchor, 'real-skill', 'SKILL.md'), '# real\n') + // Pi materializes managed links as copied directories: the "link" leaf is + // a real directory on disk. + mkdirSync(path.join(anchor, 'copied-link'), { recursive: true }) + writeFileSync(path.join(anchor, 'copied-link', 'SKILL.md'), '# copied\n') + const linkLeaf = leaf(path.join(anchor, 'copied-link'), 'link', 'conditional', 'link:tracked') + const piPlan = await deriveFallbackFrontierPlan([linkLeaf], fallbackLinkMaterialization('pi')) + assert.deepEqual(piPlan.existingLeaves, [linkLeaf]) + assert.deepEqual(piPlan.frontiers, []) + assert.deepEqual(piPlan.missingLeaves, []) + // The default (strict symlink) policy — what every non-Pi harness derives + // on POSIX — must reject the very same live directory shape. + await assert.rejects( + deriveFallbackFrontierPlan([linkLeaf]), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.LEAF_KIND_MISMATCH + ) + }) + + it('accepts both directory and symlink link leaves under the Windows permissive policy regardless of host platform', async (t) => { + const anchor = path.join(root, 'win-skills') + mkdirSync(path.join(anchor, 'real-skill'), { recursive: true }) + const winPolicy = withPlatform('win32', () => fallbackLinkMaterialization('claude')) + assert.deepEqual([...winPolicy.allowedKinds], ['symlink', 'directory']) + // Copied-directory form. + const copied = leaf(path.join(anchor, 'copied'), 'link', 'conditional', 'link:copied') + mkdirSync(copied.path, { recursive: true }) + const copiedPlan = await deriveFallbackFrontierPlan([copied], winPolicy) + assert.deepEqual(copiedPlan.existingLeaves, [copied]) + assert.deepEqual(copiedPlan.frontiers, []) + // Real-symlink form stays equally legitimate under the permissive policy. + const linked = leaf(path.join(anchor, 'linked'), 'link', 'conditional', 'link:linked') + if (!createSymlinkForTest(t, path.join(anchor, 'real-skill'), linked.path)) return + const linkedPlan = await deriveFallbackFrontierPlan([linked], winPolicy) + assert.deepEqual(linkedPlan.existingLeaves, [linked]) + }) + + it('keeps a missing Pi terminal link an independent missing leaf, never a frontier', async () => { + const anchor = path.join(root, 'pi-links') + mkdirSync(anchor) + const missingLink = leaf(path.join(anchor, 'skill-link'), 'link', 'conditional', 'link:missing') + const plan = await deriveFallbackFrontierPlan([missingLink], fallbackLinkMaterialization('pi')) + assert.deepEqual(plan.missingLeaves, [missingLink]) + assert.deepEqual(plan.existingLeaves, []) + assert.deepEqual(plan.frontiers, []) + }) + + it('still rejects kind-drifting link leaves under permissive policies', async (t) => { + const anchor = path.join(root, 'pi-negatives') + mkdirSync(anchor) + // A regular file is never a legitimate managed link, even for Pi. + const fileLink = leaf(path.join(anchor, 'file-link'), 'link', 'conditional', 'link:file') + writeFileSync(fileLink.path, 'not a link\n') + await assert.rejects( + deriveFallbackFrontierPlan([fileLink], fallbackLinkMaterialization('pi')), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.LEAF_KIND_MISMATCH + ) + // And a symlink is never a legitimate Pi copied-directory link. + mkdirSync(path.join(root, 'pi-target'), { recursive: true }) + const symlinkLink = leaf(path.join(root, 'symlink-link'), 'link', 'conditional', 'link:symlink') + if (!createSymlinkForTest(t, path.join(root, 'pi-target'), symlinkLink.path)) return + await assert.rejects( + deriveFallbackFrontierPlan([symlinkLink], fallbackLinkMaterialization('pi')), + (error: unknown) => error instanceof FallbackFrontierError && error.code === FALLBACK_FRONTIER_ERROR_CODES.LEAF_KIND_MISMATCH + ) + }) + + it('revalidates a Pi link-leaf frontier under the Pi policy with unchanged protocol v3 evidence', async () => { + const anchor = path.join(root, 'pi-anchor') + mkdirSync(anchor) + const frontierPath = path.join(anchor, 'new-root') + const skillLeaf = leaf(path.join(frontierPath, 'alpha'), 'skill', 'required', 'skill:alpha') + const linkLeaf = leaf(path.join(frontierPath, 'tracked'), 'link', 'conditional', 'link:tracked') + const plan = await deriveFallbackFrontierPlan([skillLeaf, linkLeaf], fallbackLinkMaterialization('pi')) + assert.equal(plan.frontiers.length, 1) + // The strict parser must accept the derived evidence unchanged, and + // revalidation under the same trusted harness policy must hold. + assertFallbackFrontierEvidenceList(plan.frontiers) + await assert.doesNotReject(revalidateFallbackFrontierEvidence(plan.frontiers, fallbackLinkMaterialization('pi'))) + }) +}) 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..385bbcb --- /dev/null +++ b/packages/core/test/unit/update/fallback-journal.test.ts @@ -0,0 +1,4356 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { closeSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, openSync } from 'node:fs' +import { spawn, type ChildProcess } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import os from 'node:os' +import path from 'node:path' +import { applyFallbackEntry, beginFallbackJournal, canonicalJsonString, claimFallbackJournalMutation, clearFallbackFrontierPublicationStateForTests, commitFallbackJournal, fallbackJournalPath, inspectFallbackJournal, manifestDigestOf, markFallbackJournalMutating, pathDigest, pathKind, publishedFallbackFrontiers, reclaimFallbackJournalMutation, recoverFallbackJournal, recoverFallbackJournalMutation, registerFallbackFrontierStage, registerFallbackStage, reloadFallbackJournal, restoreFallbackJournal, setFallbackArtifactSwapSeamForTests, setFallbackFrontierPublicationSeamForTests, setFallbackFrontierRollbackSeamForTests, setFallbackJournalTransitionLockTimeoutForTests, trackingDigest, type FallbackJournalHandle } from '../../../src/update/fallback-journal.js' +import { assertFallbackFrontierEvidenceList, compareUtf8, FallbackFrontierError } from '../../../src/update/fallback-frontier.js' +import { getHarnessSkillsPath } from '../../../src/skills/skill-linker.js' +import { getSkillsDir, getTrackingFilePath } from '../../../src/utils/path.js' +import { createCanonicalTempRoot } from '../../helpers/canonical-temp-root.js' +import { FALLBACK_PROTOCOL_VERSION, type FallbackAnchorIdentity, type FallbackFrontierEvidence, type FallbackTransactionIdentity } from '../../../src/update/types.js' + +let home: string +let previousHome: string | undefined +let previousUserProfile: string | undefined + +beforeEach(() => { + home = createCanonicalTempRoot('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 +}) + +// --- Live-process fixture helpers ------------------------------------------------- +// The historical fixtures parsed the FIRST stdout chunk of a spawned child with +// parseInt and then read /proc//stat. Whenever that chunk was not a +// bare integer (banner text, fragmented output, runtime-specific formatting) +// the parsed value was NaN or truncated and the /proc read failed with +// ENOENT /proc/NaN/stat, failing fixture construction instead of exercising +// the production behavior under test. The helpers below own a child's full +// lifecycle instead: the pid comes from child.pid (validated after the spawn +// event), the Linux starttime identity is read exactly once with no polling +// or retries, and termination is awaited under a bounded deadline (a truthy +// kill() return only means the signal was sent, not that the child exited). + +const MUTATOR_CHILD_TTL_MS = 30_000 +const CHILD_SPAWN_DEADLINE_MS = 10_000 +const CHILD_STOP_DEADLINE_MS = 5_000 + +interface LiveProcessFixture { + child: ChildProcess + pid: number + startIdentity: string + stop: () => Promise +} + +function readStatStartIdentity (stat: string, expectedPid: number): string { + // Field 22 of /proc//stat is starttime. comm may itself contain ') ', + // so split after the LAST ')': the remaining fields start at field 3 (state), + // which puts starttime at index 22 - 3 = 19. Read exactly once — no polling, + // no retries — so a vanished or malformed identity fails the fixture + // explicitly instead of silently sampling whatever process later reused the + // pid. + const closeParen = stat.lastIndexOf(')') + const afterComm = closeParen === -1 ? '' : stat.slice(closeParen + 1).trim() + const fields = afterComm.split(' ') + const recordedPid = Number.parseInt(stat, 10) + const starttime = fields[19] + if (!Number.isSafeInteger(recordedPid) || recordedPid !== expectedPid || starttime === undefined || !/^\d+$/.test(starttime)) { + throw new Error(`malformed or vanished /proc/${expectedPid}/stat identity: ${JSON.stringify(stat.slice(0, 64))}`) + } + return starttime +} + +async function terminateFixtureChild (child: ChildProcess, onError: (error: Error) => void): Promise { + // A failed spawn has no pid and nothing to terminate (some runtimes set a + // negative exitCode, others may leave it null); an already-exited child + // needs only listener disposal. Both resolve immediately instead of + // attempting a bogus kill or waiting out the stop deadline. + if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) { + child.removeListener('error', onError) + return + } + // Named listener references: only the listeners this helper attaches are + // removed — no broad removeAllListeners(), which would also strip unrelated + // runtime-installed stream listeners and does not express ownership. + let onExit: () => void = () => {} + let onStopError: (error: Error) => void = () => {} + const exited = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`fixture child ${String(child.pid)} did not exit within ${CHILD_STOP_DEADLINE_MS}ms of SIGKILL`)), CHILD_STOP_DEADLINE_MS) + onExit = () => { clearTimeout(timer); resolve() } + onStopError = (error: Error) => { clearTimeout(timer); reject(error) } + child.once('exit', onExit) + child.once('error', onStopError) + }) + child.kill('SIGKILL') + try { + await exited + } finally { + child.removeListener('exit', onExit) + child.removeListener('error', onStopError) + child.removeListener('error', onError) + } +} + +async function spawnLiveProcessFixture (childArgv?: string[], statPathFor?: (pid: number) => string): Promise { + const argv = childArgv ?? [process.execPath, '-e', `setTimeout(() => {}, ${MUTATOR_CHILD_TTL_MS})`] + const statPath = statPathFor ?? ((pid: number) => `/proc/${pid}/stat`) + // No stdout pid protocol: the fixtures need a live process with a Linux + // starttime, not a JavaScript readiness handshake. 'spawn' confirms process + // creation, not execution of the child's code, and Linux starttime survives + // exec, so no extra handshake is required. + const child = spawn(argv[0], argv.slice(1), { stdio: 'ignore' }) + // Persistent `on` error listener for the child's lifetime (removed in + // stop()) so a late error event is observed instead of crashing the test + // runner; a once-listener would stop observing after the first error. + let childError: Error | null = null + const onError = (error: Error) => { childError = error } + child.on('error', onError) + // Ownership starts at creation: stop() exists BEFORE the spawn event is + // awaited, so a spawn timeout or spawn error terminates (or disposes) the + // child instead of leaking it. Cached, so repeated and concurrent calls + // share one bounded termination. + let exitPromise: Promise | null = null + const stop = (): Promise => { + if (exitPromise === null) exitPromise = terminateFixtureChild(child, onError) + return exitPromise + } + // Every setup failure terminates the owned child and surfaces BOTH the + // original diagnostic and any cleanup failure — cleanup errors are never + // swallowed. + const failWithCleanup = async (setupError: Error): Promise => { + let cleanupNote = '' + try { + await stop() + } catch (cleanupError) { + cleanupNote = `; fixture cleanup also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}` + } + const pidNote = child.pid === undefined ? 'child pid unknown (spawn failed)' : `child pid ${child.pid}` + return new Error(`fixture setup failed (${pidNote}): ${setupError.message}${cleanupNote}`) + } + const spawned = await new Promise<{ error: Error | null }>((resolve) => { + const cleanup = () => { + clearTimeout(timer) + child.removeListener('error', onChildError) + child.removeListener('spawn', onSpawn) + } + const timer = setTimeout(() => { + cleanup() + resolve({ error: new Error(`fixture child did not spawn within ${CHILD_SPAWN_DEADLINE_MS}ms`) }) + }, CHILD_SPAWN_DEADLINE_MS) + const onSpawn = () => { cleanup(); resolve({ error: null }) } + const onChildError = (error: Error) => { cleanup(); resolve({ error }) } + child.once('error', onChildError) + child.once('spawn', onSpawn) + }) + if (spawned.error !== null) throw await failWithCleanup(spawned.error) + try { + const pid = child.pid + if (pid === undefined || !Number.isSafeInteger(pid) || pid <= 0) { + throw new Error(`fixture child reported invalid pid: ${String(pid)}`) + } + if (childError !== null) throw childError + // Single /proc read through the stat seam: production fixtures read the + // real /proc//stat; failure-path tests inject a controlled path. + const startIdentity = readStatStartIdentity(readFileSync(statPath(pid), 'utf8'), pid) + return { child, pid, startIdentity, stop } + } catch (error) { + throw await failWithCleanup(error instanceof Error ? error : new Error(String(error))) + } +} + +// Bounded confirmation that a fixture child is really gone from /proc (a +// truthy kill() only means the signal was sent, not that the process exited). +async function assertProcGone (pid: number, context: string): Promise { + for (let waited = 0; waited < 100; waited++) { + if (!existsSync(`/proc/${pid}`)) return + await new Promise((resolve) => setTimeout(resolve, 50)) + } + assert.fail(`${context}: fixture child ${pid} still present in /proc after ${100 * 50}ms`) +} + +describe('fallback journal canonical manifest digest', () => { + it('is stable across object key order and sensitive to array order and content', async () => { + const { manifest } = await setupValidFixture() + const reordered = { + approvedDestinationRoots: manifest.approvedDestinationRoots, + bundleDestinations: manifest.bundleDestinations, + ownedMcpConfigPaths: manifest.ownedMcpConfigPaths, + ownedMcpFields: manifest.ownedMcpFields, + ownedLinks: manifest.ownedLinks, + ownedSkills: manifest.ownedSkills, + nonce: manifest.nonce, + digestAlgorithm: manifest.digestAlgorithm, + protocolVersion: manifest.protocolVersion, + plannedMissingFrontiers: manifest.plannedMissingFrontiers, + trackingDigest: manifest.trackingDigest, + trackingPath: manifest.trackingPath, + harness: manifest.harness, + installationId: manifest.installationId, + } + assert.equal(manifestDigestOf(reordered as unknown as FallbackTransactionIdentity), manifestDigestOf(manifest)) + // Array-order sensitivity needs at least two elements: reversing a + // single-element array is a no-op and would prove nothing. + const twoSkills = { + ...manifest, + ownedSkills: [...manifest.ownedSkills, { path: path.join(home, 'second-skill'), kind: 'missing' }], + } as unknown as FallbackTransactionIdentity + const arrayShuffled = { + ...twoSkills, + ownedSkills: [...twoSkills.ownedSkills].reverse(), + } as unknown as FallbackTransactionIdentity + assert.notEqual(manifestDigestOf(arrayShuffled), manifestDigestOf(twoSkills)) + const contentChanged = { ...manifest, trackingDigest: '0'.repeat(64) } as unknown as FallbackTransactionIdentity + assert.notEqual(manifestDigestOf(contentChanged), manifestDigestOf(manifest)) + }) + + it('rejects undefined or sparse array elements and non-finite numbers in canonical JSON', () => { + assert.throws(() => canonicalJsonString([undefined])) + const sparse: unknown[] = new Array(3) + sparse[1] = 'x' + assert.throws(() => canonicalJsonString(sparse)) + assert.throws(() => canonicalJsonString({ a: Number.NaN })) + assert.equal(canonicalJsonString({ a: undefined, b: 1 }), '{"b":1}') + assert.equal(canonicalJsonString(['a', 1, true, null]), '["a",1,true,null]') + }) +}) + +describe('fallback journal ownership validation', () => { + it('refuses rollback paths that are not covered by the manifest evidence', async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const victim = path.join(home, 'user-owned.txt') + writeFileSync(victim, 'keep') + const malicious = { + ...JSON.parse(readFileSync(fallbackJournalPath(manifest.trackingPath), 'utf8')), + entries: [ + ...JSON.parse(readFileSync(fallbackJournalPath(manifest.trackingPath), 'utf8')).entries, + { path: victim, backup: path.join(home, 'attacker'), existed: false, kind: 'file' }, + ], + } + writeFileSync(fallbackJournalPath(manifest.trackingPath), JSON.stringify(malicious)) + + const result = await restoreFallbackJournal(handle) + assert.equal(result.succeeded, false) + assert.equal(readFileSync(victim, 'utf8'), 'keep') + }) + + it('rejects owned skill bytes that change after planning but before journaling', async () => { + const { manifest, skillPath } = await setupValidFixture() + writeFileSync(path.join(skillPath, 'SKILL.md'), '# user edit after approval\n') + + await assert.rejects(beginFallbackJournal(manifest), /FALLBACK_TRACKING_DRIFT/) + assert.equal(existsSync(fallbackJournalPath(manifest.trackingPath)), false) + }) + + it('rejects a planned-missing destination that appears before the transaction creates it', async () => { + const { manifest } = await setupValidFixture() + const destination = path.join(getSkillsDir(), 'new-skill') + manifest.bundleDestinations = [{ path: destination, kind: 'missing' }] + mkdirSync(destination, { recursive: true }) + writeFileSync(path.join(destination, 'user-file.txt'), 'keep\n') + + await assert.rejects(beginFallbackJournal(manifest), /FALLBACK_BACKUP_FAILED/) + assert.equal(readFileSync(path.join(destination, 'user-file.txt'), 'utf8'), 'keep\n') + // The failed begin removed both its reservation and its snapshot. + assert.equal(existsSync(fallbackJournalPath(manifest.trackingPath)), false) + const trackingDir = path.dirname(manifest.trackingPath) + assert.deepEqual(readdirSync(trackingDir).filter((name) => name.startsWith('.nsolid-plugin-update-')), []) + }) + + it('validates equal, malformed, and unexpected nested frontier evidence before reserving a journal', async () => { + const fixture = await setupFrontierFixture() + const malformed = { ...fixture.manifest, plannedMissingFrontiers: [{ nope: true }] } as unknown as FallbackTransactionIdentity + const nestedExtra = JSON.parse(JSON.stringify(fixture.manifest)) as FallbackTransactionIdentity + ;(nestedExtra.plannedMissingFrontiers[0].leaves[0] as unknown as Record).unexpected = true + + await assert.rejects(beginFallbackJournal(malformed), /FALLBACK_FRONTIER_EVIDENCE_INVALID/) + await assert.rejects(beginFallbackJournal(nestedExtra), /FALLBACK_FRONTIER_EVIDENCE_INVALID/) + assert.equal(existsSync(fallbackJournalPath(fixture.trackingPath)), false) + + // The unchanged graph follows the real journal path and is accepted. + const begun = await beginFallbackJournal(fixture.manifest) + assert.ok(begun.handle) + const restored = await restoreFallbackJournal(begun.handle) + assert.equal(restored.succeeded, true) + }) + + it('compares shape-valid frontier roles and destination lists with the trusted journal manifest', async () => { + const fixture = await setupFrontierFixture() + const begun = await beginFallbackJournal(fixture.manifest) + const mutating = await markFallbackJournalMutating(begun.handle) + const trustedDigest = manifestDigestOf(fixture.manifest) + const tamperedManifests: FallbackTransactionIdentity[] = [] + + const roleTampered = JSON.parse(JSON.stringify(fixture.manifest)) as FallbackTransactionIdentity + roleTampered.plannedMissingFrontiers[0].leaves[0].role = 'link' + tamperedManifests.push(roleTampered) + + const destinationRemoved = JSON.parse(JSON.stringify(fixture.manifest)) as FallbackTransactionIdentity + destinationRemoved.plannedMissingFrontiers[0].leaves = destinationRemoved.plannedMissingFrontiers[0].leaves.slice(0, 1) + tamperedManifests.push(destinationRemoved) + + const destinationAdded = JSON.parse(JSON.stringify(fixture.manifest)) as FallbackTransactionIdentity + const addedLeaf = { + id: 'skill:gamma', + role: 'skill' as const, + activation: 'conditional' as const, + path: path.join(fixture.frontierPath, 'gamma'), + } + destinationAdded.plannedMissingFrontiers[0].leaves = [...destinationAdded.plannedMissingFrontiers[0].leaves, addedLeaf] + .sort((left, right) => compareUtf8(left.path, right.path)) + tamperedManifests.push(destinationAdded) + + const frontierRemoved = JSON.parse(JSON.stringify(fixture.manifest)) as FallbackTransactionIdentity + frontierRemoved.plannedMissingFrontiers = [] + tamperedManifests.push(frontierRemoved) + + const secondAnchorPath = path.join(home, 'second-frontier-anchor') + mkdirSync(secondAnchorPath) + const frontierAdded = JSON.parse(JSON.stringify(fixture.manifest)) as FallbackTransactionIdentity + frontierAdded.plannedMissingFrontiers = [...frontierAdded.plannedMissingFrontiers, { + frontierPath: path.join(secondAnchorPath, 'new-root'), + activation: 'required', + anchor: await frontierAnchorIdentity(secondAnchorPath), + leaves: [{ id: 'skill:gamma', role: 'skill', activation: 'required', path: path.join(secondAnchorPath, 'new-root', 'gamma') }], + }] + frontierAdded.plannedMissingFrontiers = [...frontierAdded.plannedMissingFrontiers] + .sort((left, right) => compareUtf8(left.frontierPath, right.frontierPath)) + tamperedManifests.push(frontierAdded) + + // Every tamper above is strict-parser-valid and self-consistent for live + // revalidation. Only the journal's trusted manifest comparison rejects it. + for (const tampered of tamperedManifests) { + assert.equal(await claimFallbackJournalMutation(tampered, trustedDigest), null) + } + + const owner = await reclaimFallbackJournalMutation(mutating) + const restored = await restoreFallbackJournal(owner) + assert.equal(restored.succeeded, true) + }) + + it('rejects a changed frontier anchor inode through the real journal preflight', async () => { + const fixture = await setupFrontierFixture() + // Replace the anchor's inode without deleting the original directory and + // without rename-over-existing (not permitted on every platform): rename + // the original aside preserving its inode, then move the freshly created + // replacement into the anchor path. + const replacement = path.join(home, 'frontier-anchor-replacement') + mkdirSync(replacement) + renameSync(fixture.anchorPath, `${fixture.anchorPath}-replaced`) + renameSync(replacement, fixture.anchorPath) + + await assert.rejects(beginFallbackJournal(fixture.manifest), /FALLBACK_FRONTIER_DRIFT/) + assert.equal(existsSync(fallbackJournalPath(fixture.trackingPath)), false) + }) + + it('serializes two concurrent begins in the same process: exactly one succeeds', async () => { + const { manifest } = await setupValidFixture() + const [first, second] = await Promise.allSettled([ + beginFallbackJournal(manifest), + beginFallbackJournal(manifest), + ]) + const fulfilled = [first, second].filter((outcome) => outcome.status === 'fulfilled') + const rejected = [first, second].filter((outcome) => outcome.status === 'rejected') + assert.equal(fulfilled.length, 1) + assert.equal(rejected.length, 1) + assert.match(String((rejected[0] as PromiseRejectedResult).reason), /FALLBACK_JOURNAL_BUSY/) + }) + + it('serializes concurrent begins across processes: exactly one succeeds', { skip: process.platform === 'win32' }, async () => { + const { manifest } = await setupValidFixture() + // Both the imported module URL and the child's cwd (needed to resolve the + // `tsx/esm` loader specifier) are derived from THIS test file's location, + // never from process.cwd: the suite must behave identically whether it is + // run from packages/core or the repository root. + const packageRoot = fileURLToPath(new URL('../../..', import.meta.url)) + const journalModule = new URL('../../../src/update/fallback-journal.js', import.meta.url).href + // True overlap: both children park on a filesystem barrier and only call + // beginFallbackJournal once BOTH are alive, so the wx reservation race is + // genuinely concurrent instead of two sequential spawns. + const readyPrefix = path.join(home, 'begin-ready.') + const goPath = path.join(home, 'begin-go') + const script = [ + 'const { writeFileSync, existsSync } = require(\'node:fs\')', + `writeFileSync(${JSON.stringify(readyPrefix)} + process.pid, '')`, + 'const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)', + `while (!existsSync(${JSON.stringify(goPath)})) sleep(5)`, + `import(${JSON.stringify(journalModule)}).then(async (m) => {`, + ' try {', + ` await m.beginFallbackJournal(${JSON.stringify(manifest)})`, + ' console.log(\'BEGIN_OK\')', + ' } catch (error) {', + ' console.log(error.message)', + ' }', + '})', + ].join('\n') + const children = [0, 1].map(() => spawn(process.execPath, ['--import', 'tsx/esm', '-e', script], { + cwd: packageRoot, + env: { ...process.env, HOME: home, USERPROFILE: home }, + stdio: ['ignore', 'pipe', 'pipe'], + })) + try { + const deadline = Date.now() + 60_000 + while (Date.now() < deadline && readdirSync(home).filter((name) => name.startsWith('begin-ready.')).length < 2) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + assert.equal(readdirSync(home).filter((name) => name.startsWith('begin-ready.')).length, 2, 'both racers must be parked at the barrier before the begin') + writeFileSync(goPath, 'go') + const outcomes = await Promise.all(children.map((child) => new Promise((resolve, reject) => { + let output = '' + child.stdout!.on('data', (chunk: Buffer) => { output += chunk.toString() }) + child.stderr!.on('data', (chunk: Buffer) => { output += chunk.toString() }) + child.on('error', reject) + child.on('close', () => resolve(output)) + }))) + assert.equal(outcomes.filter((text) => text.includes('BEGIN_OK')).length, 1, outcomes.join('\n---\n')) + assert.equal(outcomes.filter((text) => text.includes('FALLBACK_JOURNAL_BUSY')).length, 1, outcomes.join('\n---\n')) + } finally { + for (const child of children) child.kill('SIGKILL') + } + }) + + // Discriminating cross-process regression for the claim/reclaim mutation + // authority race: the historical read-check-casWrite sequence has no + // inter-process exclusion, so N barrier-synced claimants that all read the + // same mutator-free journal before any of them writes ALL pass the CAS + // (last-writer-wins with the SAME incremented revision) and N children hold + // genuine mutator handles simultaneously. Exactly one live claimant may win. + it('serializes concurrent cross-process mutation claims: exactly one live claimant wins', async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const mutating = await markFallbackJournalMutating(handle) + assert.equal(mutating.role, 'owner') + + const journalModule = new URL('../../../src/update/fallback-journal.js', import.meta.url).href + const packageRoot = fileURLToPath(new URL('../../..', import.meta.url)) + const readyPrefix = path.join(home, 'claim-ready.') + const goPath = path.join(home, 'claim-go') + const resultPrefix = path.join(home, 'claim-result.') + const racers = 8 + const script = [ + 'const { writeFileSync, existsSync, readFileSync } = require(\'node:fs\')', + 'const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)', + `writeFileSync(${JSON.stringify(readyPrefix)} + process.pid, '')`, + `while (!existsSync(${JSON.stringify(goPath)})) sleep(5)`, + `import(${JSON.stringify(journalModule)}).then(async (journal) => {`, + ' const manifest = JSON.parse(readFileSync(process.argv[1], \'utf8\'))', + ' let result', + ' try {', + ' const claimed = await journal.claimFallbackJournalMutation(manifest, journal.manifestDigestOf(manifest))', + ' result = { success: claimed !== null, pid: process.pid, revision: claimed === null ? null : claimed.revision }', + ' } catch (error) {', + ' result = { success: false, pid: process.pid, error: String(error instanceof Error ? error.message : error) }', + ' }', + ` writeFileSync(${JSON.stringify(resultPrefix)} + process.pid, JSON.stringify(result))`, + ' // A winner stays alive so every later claimant observes a live mutator.', + ' if (result.success) { while (true) sleep(50) }', + '})', + ].join('\n') + const manifestFile = path.join(home, 'claim-manifest.json') + writeFileSync(manifestFile, JSON.stringify(manifest)) + const children = Array.from({ length: racers }, () => spawn(process.execPath, ['--import', 'tsx/esm', '-e', script, manifestFile], { + cwd: packageRoot, + env: { ...process.env, HOME: home, USERPROFILE: home }, + stdio: ['ignore', 'pipe', 'pipe'], + })) + try { + const deadline = Date.now() + 60_000 + while (Date.now() < deadline && readdirSync(home).filter((name) => name.startsWith('claim-ready.')).length < racers) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + assert.equal(readdirSync(home).filter((name) => name.startsWith('claim-ready.')).length, racers, 'all racers must be parked at the barrier before the claims') + writeFileSync(goPath, 'go') + + const resultDeadline = Date.now() + 60_000 + while (Date.now() < resultDeadline && readdirSync(home).filter((name) => name.startsWith('claim-result.')).length < racers) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + const resultFiles = readdirSync(home).filter((name) => name.startsWith('claim-result.')) + assert.equal(resultFiles.length, racers, 'every racer must report a claim result') + interface ClaimResult { success: boolean; pid: number; revision: number | null; error?: string } + const results: ClaimResult[] = resultFiles.map((name) => JSON.parse(readFileSync(path.join(home, name), 'utf8')) as ClaimResult) + const winners = results.filter((result) => result.success) + assert.equal(winners.length, 1, `exactly one live claimant may win: ${JSON.stringify(results)}`) + assert.equal(results.filter((result) => !result.success && result.revision === null).length, racers - 1) + const winner = winners[0]! + assert.equal(winner.revision, 3, 'the winner must hold the revision it wrote (prepared=1, mutating=2, claimed=3)') + // The on-disk journal must name exactly the one live winner as mutator. + const disk = JSON.parse(readFileSync(mutating.journalPath, 'utf8')) as { revision: number; mutator?: { pid: number } } + assert.equal(disk.revision, 3) + assert.equal(disk.mutator?.pid, winner.pid) + } finally { + for (const child of children) child.kill('SIGKILL') + } + }) + + // Shared record writer mirroring the internal transition-lock record shape + // (white-box, like the crafted mutator records above): if the internal + // format changes, these tests are updated with it. + function writeTransitionLockFixture (lockPath: string, holder: { pid: number; startIdentity?: string }): string { + const content = JSON.stringify({ + kind: 'fallback-journal-transition-lock', + journalPath: lockPath.replace(/\.mutation-lock$/, ''), + token: randomUUID(), + holder, + acquiredAt: new Date().toISOString(), + }, null, 2) + '\n' + writeFileSync(lockPath, content) + return content + } + + it('fails closed while the mutation transition lock is held by a live foreign process, and reclaim names the lock path', { skip: process.platform !== 'linux' }, async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const mutating = await markFallbackJournalMutating(handle) + const journalBefore = readFileSync(mutating.journalPath, 'utf8') + const lockPath = `${mutating.journalPath}.mutation-lock` + const live = await spawnLiveProcessFixture() + try { + const content = writeTransitionLockFixture(lockPath, { pid: live.pid, startIdentity: live.startIdentity }) + setFallbackJournalTransitionLockTimeoutForTests(50) + try { + assert.equal(await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)), null) + await assert.rejects(reclaimFallbackJournalMutation(mutating), (error: unknown) => { + assert.ok(error instanceof Error) + assert.match(error.message, /FALLBACK_JOURNAL_LOCK_BUSY/) + assert.ok(error.message.includes(lockPath), `actionable lock path expected in: ${error.message}`) + assert.match(error.message, /manual recovery/) + return true + }) + // Fail-closed preservation: journal bytes identical, foreign lock untouched (no takeover). + assert.equal(readFileSync(mutating.journalPath, 'utf8'), journalBefore) + assert.ok(existsSync(lockPath)) + assert.equal(readFileSync(lockPath, 'utf8'), content) + } finally { + setFallbackJournalTransitionLockTimeoutForTests(undefined) + } + } finally { + await live.stop() + } + // The documented MANUAL recovery (removing the abandoned/foreign lock) + // restores transitions; nothing else was changed. + rmSync(lockPath, { force: true }) + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.notEqual(claimed, null) + }) + + it('never deletes or takes over an abandoned or corrupt mutation transition lock: fail closed with the journal preserved', { skip: process.platform !== 'linux' }, async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const mutating = await markFallbackJournalMutating(handle) + const journalBefore = readFileSync(mutating.journalPath, 'utf8') + const lockPath = `${mutating.journalPath}.mutation-lock` + // A real process that REALLY died: its recorded identity is provably stale, + // yet check-then-delete recovery stays forbidden (TOCTOU on replacement locks). + const dead = await spawnLiveProcessFixture() + const deadIdentity = { pid: dead.pid, startIdentity: dead.startIdentity } + await dead.stop() + const variants: string[] = [ + writeTransitionLockFixture(lockPath, deadIdentity), + '', + 'corrupt', + JSON.stringify({ kind: 'fallback-journal-transition-lock' }), + ] + setFallbackJournalTransitionLockTimeoutForTests(50) + try { + for (const content of variants) { + writeFileSync(lockPath, content) + assert.equal(await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)), null, `claim must fail closed on lock content: ${content.slice(0, 40)}`) + await assert.rejects(reclaimFallbackJournalMutation(mutating), /FALLBACK_JOURNAL_LOCK_BUSY/) + assert.ok(existsSync(lockPath), 'a failed transition must never delete the lock (no auto recovery)') + assert.equal(readFileSync(lockPath, 'utf8'), content) + assert.equal(readFileSync(mutating.journalPath, 'utf8'), journalBefore) + } + } finally { + setFallbackJournalTransitionLockTimeoutForTests(undefined) + rmSync(lockPath, { force: true }) + } + }) + + it('recoverFallbackJournalMutation surfaces an abandoned transition lock as a reporting-only diagnostic while preserving journal and backups', { skip: process.platform !== 'linux' }, async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const mutating = await markFallbackJournalMutating(handle) + const journalBefore = readFileSync(mutating.journalPath, 'utf8') + const journal = JSON.parse(journalBefore) as { entries: Array<{ backup?: string }> } + const fileBackups = journal.entries.map((entry) => entry.backup).filter((backup): backup is string => backup !== undefined && statSync(backup).isFile()) + const backupBefore = new Map(fileBackups.map((backup) => [backup, readFileSync(backup, 'utf8')])) + assert.ok(backupBefore.size > 0, 'the fixture journal must hold at least one file backup') + const lockPath = `${mutating.journalPath}.mutation-lock` + const lockContent = 'abandoned by a crashed process' + writeFileSync(lockPath, lockContent, { mode: 0o600 }) + setFallbackJournalTransitionLockTimeoutForTests(50) + try { + const result = await recoverFallbackJournalMutation(mutating) + assert.equal(result.succeeded, false) + assert.equal(result.unproven, true) + assert.ok(result.diagnostic !== undefined, 'the swallowed lock-busy failure must surface a diagnostic') + assert.match(result.diagnostic, /FALLBACK_JOURNAL_LOCK_BUSY/) + assert.ok(result.diagnostic.includes(lockPath), `actionable lock path expected in: ${result.diagnostic}`) + assert.match(result.diagnostic, /manual recovery/) + // Fail-closed preservation: journal bytes, every file backup, and the + // foreign lock are all untouched; the diagnostic is reporting-only. + assert.equal(readFileSync(mutating.journalPath, 'utf8'), journalBefore) + for (const [backup, content] of backupBefore) assert.equal(readFileSync(backup, 'utf8'), content) + assert.ok(existsSync(lockPath)) + assert.equal(readFileSync(lockPath, 'utf8'), lockContent) + } finally { + setFallbackJournalTransitionLockTimeoutForTests(undefined) + rmSync(lockPath, { force: true }) + } + }) + + it('releases its mutation transition lock after success, after refused claims, and after errors inside the critical section', async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const mutating = await markFallbackJournalMutating(handle) + const lockPath = `${mutating.journalPath}.mutation-lock` + // Successful claim releases the lock. + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.notEqual(claimed, null) + assert.equal(existsSync(lockPath), false) + // Refused claim (wrong nonce) releases the lock. + assert.equal(await claimFallbackJournalMutation({ ...manifest, nonce: randomUUID() }), null) + assert.equal(existsSync(lockPath), false) + // Error inside the critical section (journal deleted under the claimant) + // releases the lock too: a crash-or-error path never leaves our own lock. + rmSync(claimed!.journalPath, { force: true }) + await assert.rejects(reclaimFallbackJournalMutation(claimed!)) + assert.equal(existsSync(lockPath), false) + // Successful reclaim releases the lock as well. + rmSync(mutating.journalPath, { force: true }) + const fresh = await beginFallbackJournal(manifest) + const freshMutating = await markFallbackJournalMutating(fresh.handle) + const freshClaimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.notEqual(freshClaimed, null) + assert.equal(existsSync(lockPath), false) + const owner = await reclaimFallbackJournalMutation(freshClaimed!) + assert.equal(owner.role, 'owner') + assert.equal(existsSync(lockPath), false) + assert.equal(freshMutating.role, 'owner') + }) + + it('keeps owner and mutator authorities separate across a mutation claim handoff (stale handles fail closed)', async () => { + const { manifest, skillPath } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const ownerHandle = await markFallbackJournalMutating(handle) + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.notEqual(claimed, null) + // The owner's stale pre-claim handle (old revision) can no longer advance + // the journal or register stages: every CAS reloads against the handle's + // revision and fails closed on the drifted disk state. + await assert.rejects(markFallbackJournalMutating(ownerHandle), /Invalid fallback journal/) + await assert.rejects(registerFallbackStage(ownerHandle, skillPath, { bytes: Buffer.from('x') }), /Invalid fallback journal/) + // The issued mutator handle is the only authority that can register. + const staged = await registerFallbackStage(claimed!, skillPath, { bytes: Buffer.from('staged') }) + assert.equal(staged.role, 'mutator') + // The owner reclaims through its genuine handle (adopts the latest disk + // revision by design); the child's now-stale mutator handle is dead authority. + const reclaimed = await reclaimFallbackJournalMutation(ownerHandle) + assert.equal(reclaimed.role, 'owner') + await assert.rejects(registerFallbackStage(claimed!, skillPath, { bytes: Buffer.from('x') }), /Invalid fallback journal/) + }) + + it('reserves the journal path before any snapshot work: an existing reservation is busy', async () => { + const { manifest } = await setupValidFixture() + // A reservation created by "another process" (only the file existence + // matters for wx) makes the begin fail closed before any snapshot exists. + const reservation = openSync(fallbackJournalPath(manifest.trackingPath), 'wx', 0o600) + writeFileSync(fallbackJournalPath(manifest.trackingPath), '{}\n') + closeSync(reservation) + const trackingDir = path.dirname(manifest.trackingPath) + const snapshotsBefore = readdirSync(trackingDir).filter((name) => name.startsWith('.nsolid-plugin-update-')) + await assert.rejects(beginFallbackJournal(manifest), /FALLBACK_JOURNAL_BUSY/) + assert.deepEqual(readdirSync(trackingDir).filter((name) => name.startsWith('.nsolid-plugin-update-')), snapshotsBefore) + }) + + it('refuses to claim the mutation without an existing journal, without the nonce, or with a wrong digest', async () => { + const { manifest } = await setupValidFixture() + // Missing journal: the claim must fail closed (never succeed vacuously). + assert.equal(await claimFallbackJournalMutation(manifest), null) + const { handle } = await beginFallbackJournal(manifest) + const mutating = await markFallbackJournalMutating(handle) + const impostor = { ...manifest, nonce: randomUUID() } + assert.equal(await claimFallbackJournalMutation(impostor), null) + assert.equal(await claimFallbackJournalMutation({ ...manifest, nonce: undefined }), null) + assert.equal(await claimFallbackJournalMutation(manifest, 'f'.repeat(64)), null) + assert.notEqual(await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)), null) + // A claim on a prepared (not mutating) journal fails too: dispose this + // transaction and begin a fresh one, which stays prepared. + rmSync(fallbackJournalPath(manifest.trackingPath), { force: true }) + await beginFallbackJournal(manifest) + assert.equal(await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)), null) + assert.ok(mutating) + }) + + it('fails closed on stale revisions and on deleted or replaced journals between API calls', async () => { + const { manifest, skillPath } = await setupValidFixture() + let { handle } = await beginFallbackJournal(manifest) + handle = await markFallbackJournalMutating(handle) + const disk = JSON.parse(readFileSync(handle.journalPath, 'utf8')) + disk.revision += 5 + writeFileSync(handle.journalPath, JSON.stringify(disk)) + await assert.rejects(registerFallbackStage(handle, skillPath, { bytes: Buffer.from('x') }), /Invalid fallback journal/) + + // The tampered first journal must be disposed before a fresh begin: any + // existing journal (even a stale one) makes begin fail closed as busy. + rmSync(handle.journalPath, { force: true }) + const fresh = await beginFallbackJournal(manifest) + let live = await markFallbackJournalMutating(fresh.handle) + rmSync(live.journalPath, { force: true }) + // A deleted or malformed journal must fail closed; the thrown value is the + // raw fs/parse error in the current implementation, so accept any + // rejection here — the contract is "never return stale state". + await assert.rejects(reloadFallbackJournal(live)) + live = await markFallbackJournalMutating(live).catch(() => live) + rmSync(live.journalPath, { force: true }) + writeFileSync(live.journalPath, '{"not":"a journal"}\n') + await assert.rejects(reloadFallbackJournal(live)) + }) + + it('refuses parent reclaim while a different live process mutates, allows it after exit, and never lets PID reuse grant ownership', { skip: process.platform !== 'linux' }, async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const owner = await markFallbackJournalMutating(handle) + + // Craft a mutator record for a real live process (a sleeping child). The + // fixture helper owns the child lifecycle: no stdout pid protocol, a + // validated child.pid, and a single /proc starttime read. + const live = await spawnLiveProcessFixture() + const childPid: number = live.pid + try { + const disk = JSON.parse(readFileSync(owner.journalPath, 'utf8')) + disk.mutator = { pid: childPid, startIdentity: live.startIdentity, claimedAt: new Date().toISOString() } + writeFileSync(owner.journalPath, JSON.stringify(disk)) + + await assert.rejects(reclaimFallbackJournalMutation(owner), /FALLBACK_MUTATOR_LIVE/) + } finally { + // Explicitly stop and await the child BEFORE the reclaim-success + // assertions: a truthy kill() return only means the signal was sent, + // not that the process exited. Idempotent if already stopped. + await live.stop() + } + const reclaimed = await reclaimFallbackJournalMutation(owner) + assert.equal(reclaimed.role, 'owner') + assert.equal(JSON.parse(readFileSync(reclaimed.journalPath, 'utf8')).mutator, undefined) + + // PID reuse: a record pointing at OUR pid with someone else's start + // identity is provably a recycled pid, not a live mutator. + const recycled = JSON.parse(readFileSync(reclaimed.journalPath, 'utf8')) + recycled.mutator = { pid: process.pid, startIdentity: '1', claimedAt: new Date().toISOString() } + writeFileSync(reclaimed.journalPath, JSON.stringify(recycled)) + // A spread copy of a genuine handle was never issued: the immutable + // issuance metadata is bound to the original object only, so the copy + // must be rejected before any successor authority is issued. + await assert.rejects(reclaimFallbackJournalMutation({ ...reclaimed }), /Invalid fallback journal/) + // The original genuine handle still reclaims the stale same-revision + // disk state successfully. + const again = await reclaimFallbackJournalMutation(reclaimed) + assert.equal(again.role, 'owner') + }) + + it('live-process fixture helper uses the real pid and a valid starttime even when child stdout carries a banner and a numeric decoy', { skip: process.platform !== 'linux' }, async () => { + // Controlled parser-mechanism discrimination plus real-helper identity + // verification — NOT captured-output equivalence and NOT a reconstruction + // of the unknown historical trigger. The historical fixtures parsed the + // FIRST stdout chunk with Number.parseInt(chunk.trim(), 10) and then read + // /proc//stat. This regression feeds that exact expression a + // FIXED controlled first-chunk string beginning with a nonnumeric banner + // and shows the failure mechanism end to end: the parse yields NaN and + // the /proc/NaN/stat read fails with ENOENT. The real shared helper is + // then spawned with a child script configured to emit that same banner + // plus a numeric decoy before sleeping, proving the helper ignores + // hostile stdout entirely and uses the actual child pid with a real + // single-read starttime. + const bannerLine = 'fixture child banner: pid follows' + // Fixed controlled first-chunk payload: begins with a nonnumeric banner. + const controlledFirstChunk = `${bannerLine}\n` + // The exact legacy parser expression on the controlled parser input: + // NaN, then the historical ENOENT /proc/NaN/stat failure. + const legacyPid = Number.parseInt(controlledFirstChunk.trim(), 10) + assert.ok(Number.isNaN(legacyPid), `legacy first-chunk parser must yield NaN on a banner-led chunk, got ${String(legacyPid)}`) + assert.throws(() => readFileSync(`/proc/${legacyPid}/stat`, 'utf8'), /ENOENT/) + + // Real helper against a child emitting the same fixed banner plus a + // numeric decoy (a truncated fragment of its own pid) and then sleeping. + const bannerScript = `console.log(${JSON.stringify(bannerLine)}); console.log(String(process.pid).slice(0, 2)); setTimeout(() => {}, ${MUTATOR_CHILD_TTL_MS})` + const live = await spawnLiveProcessFixture([process.execPath, '-e', bannerScript]) + try { + // Authoritative identity: the helper pid must be the actual spawned + // child's pid, and the recorded identity must be that exact child's + // /proc starttime — not merely a well-formed number. + assert.ok(live.child.pid !== undefined, 'spawned child must expose its pid') + assert.equal(live.pid, live.child.pid) + assert.match(live.startIdentity, /^\d+$/) + assert.equal(live.startIdentity, readStatStartIdentity(readFileSync(`/proc/${live.child.pid}/stat`, 'utf8'), live.child.pid)) + } finally { + await live.stop() + } + }) + + it('fixture helper terminates the owned child when setup fails after a successful spawn', { skip: process.platform !== 'linux' }, async () => { + // Deterministic setup failure: the stat-path seam makes the single + // identity read return a malformed record while the child is already + // alive. The helper must fail setup, terminate the child it owns, and + // surface the setup diagnostic — never leak the child or retry the read. + const malformedStatPath = path.join(os.tmpdir(), `nsolid-journal-fixture-stat-${randomUUID()}`) + writeFileSync(malformedStatPath, '999 (decoy) S 0 0 0') + let setupError: Error | null = null + try { + await spawnLiveProcessFixture(undefined, () => malformedStatPath) + } catch (error) { + setupError = error as Error + } + rmSync(malformedStatPath, { force: true }) + assert.ok(setupError !== null, 'malformed identity must fail fixture setup') + assert.match(setupError.message, /malformed or vanished \/proc\/\d+\/stat identity/) + // The diagnostic must identify the owned child so termination can be + // confirmed — kill() alone is not confirmation. + const match = /child pid (\d+)/.exec(setupError.message) + assert.ok(match !== null, `setup failure must identify the owned child pid: ${setupError.message}`) + await assertProcGone(Number(match[1]), 'setup failure') + }) + + it('fixture helper rejects promptly on spawn failure with the setup diagnostic retained', { skip: process.platform !== 'linux' }, async () => { + const startedAt = Date.now() + await assert.rejects( + spawnLiveProcessFixture(['nonexistent-fixture-binary-9x7']), + (error: Error) => { + assert.match(error.message, /ENOENT/) + assert.match(error.message, /fixture setup failed/) + assert.match(error.message, /child pid unknown/) + return true + } + ) + // Promptly: the rejection is the spawn error itself, not a waited-out + // spawn deadline. + assert.ok(Date.now() - startedAt < CHILD_SPAWN_DEADLINE_MS, 'spawn failure must reject without waiting out the spawn deadline') + }) + + it('fixture helper fails explicitly when the child vanishes before its identity is read', { skip: process.platform !== 'linux' }, async () => { + // Deterministic stand-in for an early-exiting child: by the time the + // single identity read happens, /proc//stat is already gone. The + // helper must fail explicitly — no polling, no retry that could sample a + // reused pid — and still terminate the child it owns. + let setupError: Error | null = null + try { + await spawnLiveProcessFixture(undefined, () => '/proc/nonexistent-fixture-pid/stat') + } catch (error) { + setupError = error as Error + } + assert.ok(setupError !== null, 'vanished identity must fail fixture setup explicitly') + assert.match(setupError.message, /ENOENT/) + const match = /child pid (\d+)/.exec(setupError.message) + assert.ok(match !== null, `setup failure must identify the owned child pid: ${setupError.message}`) + await assertProcGone(Number(match[1]), 'vanished identity') + }) + + it('fixture helper stop is idempotent, confirms real termination, and disposes listeners', { skip: process.platform !== 'linux' }, async () => { + const live = await spawnLiveProcessFixture() + try { + const { child } = live + assert.ok(child.pid !== undefined) + assert.equal(live.pid, child.pid) + // Concurrent repeated stops share one bounded termination. + await Promise.all([live.stop(), live.stop(), live.stop()]) + // Real termination, not merely a signalled child. + assert.ok(child.exitCode !== null || child.signalCode !== null, 'child must have really exited, not merely been signalled') + // Listener disposal: nothing stays attached after termination, so no + // late event work or deadline timer survives the stop. + assert.equal(child.listenerCount('error'), 0) + assert.equal(child.listenerCount('exit'), 0) + assert.equal(child.listenerCount('close'), 0) + // Stop after completion stays idempotent. + await live.stop() + } finally { + // An assertion failure before stop() must not leak the owned child; + // stop is cached and idempotent, so this joins the earlier termination. + await live.stop() + } + }) + + it('terminateFixtureChild resolves without killing an already-exited or failed-spawn child', async () => { + // Already-exited child: listener disposal only, no kill needed. The probe + // is owned from creation: the error listener is attached immediately, the + // close wait is bounded, and terminateFixtureChild runs in finally so an + // assertion failure cannot leak the child. + const dead = spawn(process.execPath, ['-e', 'process.exit(0)'], { stdio: 'ignore' }) + const deadOnError = () => {} + dead.once('error', deadOnError) + // Named reference for this probe's own error listener: it never fires on + // a clean exit, so the probe disposes it itself — helper removal is + // limited to listeners the helper owns. + let onDeadError: (error: Error) => void = () => {} + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`probe child did not close within ${CHILD_SPAWN_DEADLINE_MS}ms`)), CHILD_SPAWN_DEADLINE_MS) + dead.once('close', () => { clearTimeout(timer); resolve() }) + onDeadError = (error: Error) => { clearTimeout(timer); reject(error) } + dead.once('error', onDeadError) + }) + assert.equal(dead.exitCode, 0) + } finally { + dead.removeListener('error', onDeadError) + await terminateFixtureChild(dead, deadOnError) + } + assert.equal(dead.listenerCount('error'), 0) + + // Failed spawn: no pid exists, so termination must resolve without a + // bogus kill and without waiting out the stop deadline. The error wait is + // bounded and terminateFixtureChild runs in finally for disposal. + const failed = spawn('nonexistent-fixture-binary-9x7', [], { stdio: 'ignore' }) + const failures: Error[] = [] + const failedOnError = (error: Error) => { failures.push(error) } + failed.once('error', failedOnError) + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`failed-spawn probe reported no error within ${CHILD_SPAWN_DEADLINE_MS}ms`)), CHILD_SPAWN_DEADLINE_MS) + failed.once('error', () => { clearTimeout(timer); resolve() }) + }) + } finally { + await terminateFixtureChild(failed, failedOnError) + } + assert.equal(failures.length, 1) + assert.equal(failed.pid, undefined) + assert.equal(failed.listenerCount('error'), 0) + }) + + it('refuses spread or plain handles that were never issued for reload, reclaim, and commit', async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + // Structurally identical copies (spread/plain objects) carry no WeakMap + // issuance metadata: every handle-based transition must reject them + // before reading or clearing any authority. + await assert.rejects(reloadFallbackJournal({ ...handle }), /Invalid fallback journal/) + await assert.rejects(reclaimFallbackJournalMutation({ ...handle }), /Invalid fallback journal/) + await assert.rejects(commitFallbackJournal({ ...handle }), /Invalid fallback journal/) + // The genuine handle still works end to end. + const owner = await markFallbackJournalMutating(handle) + assert.equal((await reloadFallbackJournal(owner)).phase, 'mutating') + const reclaimed = await reclaimFallbackJournalMutation(owner) + assert.equal(reclaimed.role, 'owner') + }) + + it('refuses an in-place retargeted genuine handle from laundering another transaction reclaim or restore', async () => { + // Transaction A: a genuine owner handle that later gets retargeted in + // place. Its journal is disposed so transaction B can begin normally. + const aFixture = await setupValidFixture() + const { handle: genuineA } = await beginFallbackJournal(aFixture.manifest) + rmSync(genuineA.journalPath, { force: true }) + // Transaction B: a real frontier publication this process legitimately + // owns and applies (same lifecycle as the frontier publication tests). + const bFixture = await setupFrontierFixture() + const { handle: bRaw } = await beginFallbackJournal(bFixture.manifest) + await markFallbackJournalMutating(bRaw) + const bOwner = await claimFallbackJournalMutation(bFixture.manifest, manifestDigestOf(bFixture.manifest)) + if (!bOwner) throw new Error('mutation claim failed') + const staged = path.join(home, `staged-frontier-${randomUUID().slice(0, 8)}`) + mkdirSync(path.join(staged, 'alpha'), { recursive: true }) + writeFileSync(path.join(staged, 'alpha', 'SKILL.md'), '# alpha staged\n') + mkdirSync(path.join(staged, 'beta'), { recursive: true }) + writeFileSync(path.join(staged, 'beta', 'SKILL.md'), '# beta staged\n') + const registered = await registerFallbackFrontierStage(bOwner, bFixture.frontierPath, staged, { activeConditionalLeafIds: ['skill:beta'] }) + const currentB = await applyFallbackEntry(registered, bFixture.frontierPath) + assert.equal(publishedFallbackFrontiers(currentB).length, 1) + + // Laundering attempt: overwrite A's fields in place with B's identity. + // The WeakMap issuance metadata bound to A's object still describes A, + // so every transition must reject BEFORE issuing successor authority. + const retargetedA = Object.assign(genuineA, currentB) + await assert.rejects(reclaimFallbackJournalMutation(retargetedA), /Invalid fallback journal/) + // Restore through the laundered chain consumes nothing: B's publication + // capability and live frontier content stay intact. + const launderedRestore = await restoreFallbackJournal(retargetedA) + assert.equal(launderedRestore.succeeded, false) + assert.equal(launderedRestore.unproven, true) + assert.equal(publishedFallbackFrontiers(currentB).length, 1) + assert.equal(readFileSync(path.join(bFixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(readFileSync(path.join(bFixture.frontierPath, 'beta', 'SKILL.md'), 'utf8'), '# beta staged\n') + // The genuine B handle still owns its publication. + const bRestore = await restoreFallbackJournal(currentB) + assert.equal(bRestore.succeeded, true) + assert.equal(existsSync(bFixture.frontierPath), false) + }) + + it('reclaims authority and restores a failed parent transaction as one journal operation', async () => { + const { skillPath, manifest } = await setupValidFixture() + const begun = await beginFallbackJournal(manifest) + const mutating = await markFallbackJournalMutating(begun.handle) + writeFileSync(path.join(skillPath, 'SKILL.md'), '# mutated\n') + + const result = await recoverFallbackJournalMutation(mutating, begun.journal.snapshotDirectory) + assert.equal(result.succeeded, true) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(existsSync(mutating.journalPath), false) + assert.equal(begun.journal.snapshotDirectory !== undefined && existsSync(begun.journal.snapshotDirectory), false) + }) + + it('restores the snapshotted bytes of owned state after a mutation and disposes the journal as owner', async () => { + const { trackingPath, skillPath, linkPath, manifest, trackingJson } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.ok(claimed) + const staged = await registerFallbackStage(claimed, trackingPath, { bytes: Buffer.from(JSON.stringify({ ...JSON.parse(trackingJson), installedAt: 'mutated' })) }) + await applyFallbackEntry(staged, trackingPath) + const owner = await reclaimFallbackJournalMutation(handle) + writeFileSync(path.join(skillPath, 'SKILL.md'), '# mutated\n') + writeFileSync(linkPath, 'mutated\n') + + const result = await restoreFallbackJournal(owner) + assert.equal(result.succeeded, true) + assert.equal(result.unproven, undefined) + assert.deepEqual(result.preservedPaths, []) + 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(owner.journalPath), false) + const trackingDir = path.dirname(trackingPath) + assert.deepEqual(readdirSync(trackingDir).filter((name) => name.includes('.nsolid-')), []) + }) + + it('overwrites a concurrent edit to an owned path with the authenticated original (hybrid parent authority)', async () => { + const { skillPath, manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const owner = await markFallbackJournalMutating(handle) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'child mutation\n') + writeFileSync(path.join(skillPath, 'user-edit.txt'), 'concurrent user edit\n') + + const result = await restoreFallbackJournal(owner) + assert.equal(result.succeeded, true) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(existsSync(path.join(skillPath, 'user-edit.txt')), false) + assert.equal(existsSync(owner.journalPath), false) + }) + + it('preserves tracking added by another harness after the snapshot and defers restore', async () => { + const { manifest, trackingPath, skillPath } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const owner = await markFallbackJournalMutating(handle) + const tracking = JSON.parse(readFileSync(trackingPath, 'utf8')) + const foreignPath = path.join(home, '.config', 'opencode', 'skills', 'foreign') + mkdirSync(foreignPath, { recursive: true }) + writeFileSync(path.join(foreignPath, 'SKILL.md'), 'KEEP') + tracking.skills.push({ name: 'foreign', path: foreignPath, paths: { opencode: foreignPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }) + const concurrentBytes = JSON.stringify(tracking, null, 2) + '\r\n' + writeFileSync(trackingPath, concurrentBytes) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'child mutation') + + const result = await restoreFallbackJournal(owner) + + assert.equal(readFileSync(trackingPath, 'utf8'), concurrentBytes) + assert.equal(readFileSync(path.join(foreignPath, 'SKILL.md'), 'utf8'), 'KEEP') + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'child mutation', 'drift is detected before any restore') + assert.equal(result.succeeded, false) + assert.equal(result.unproven, true) + assert.ok(result.preservedPaths.includes(trackingPath)) + assert.equal(existsSync(owner.journalPath), true) + const inspection = await inspectFallbackJournal(trackingPath) + assert.equal(inspection.recovered, false) + assert.ok(inspection.preservedPaths.includes(trackingPath)) + const recovery = await recoverFallbackJournal(trackingPath) + assert.equal(recovery.pending, true) + assert.equal(recovery.recovered, false) + assert.ok(recovery.preservedPaths.includes(trackingPath)) + assert.equal(readFileSync(trackingPath, 'utf8'), concurrentBytes, 'next-run recovery must preserve the concurrent records too') + }) + + it('does not promote forged applied tracking digests to restore authority', async () => { + const { manifest, trackingPath, trackingJson } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.ok(claimed) + const staged = await registerFallbackStage(claimed, trackingPath, { bytes: Buffer.from(JSON.stringify({ ...JSON.parse(trackingJson), installedAt: 'published' })) }) + await applyFallbackEntry(staged, trackingPath) + const owner = await reclaimFallbackJournalMutation(handle) + const concurrentBytes = JSON.stringify({ ...JSON.parse(trackingJson), installedAt: 'concurrent' }) + writeFileSync(trackingPath, concurrentBytes) + const disk = JSON.parse(readFileSync(owner.journalPath, 'utf8')) + const entry = disk.entries.find((entry: { path: string }) => entry.path === trackingPath) + entry.applied = true + entry.stageDigest = await pathDigest(trackingPath) + writeFileSync(owner.journalPath, JSON.stringify(disk)) + + const result = await restoreFallbackJournal(owner) + assert.equal(result.succeeded, false) + assert.equal(result.unproven, true) + assert.equal(readFileSync(trackingPath, 'utf8'), concurrentBytes) + assert.equal(existsSync(owner.journalPath), true) + }) + + it('reports FALLBACK_STATE_UNPROVEN and preserves everything when a backup was tampered with', async () => { + const { skillPath, manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const owner = await markFallbackJournalMutating(handle) + const disk = JSON.parse(readFileSync(owner.journalPath, 'utf8')) + const skillEntry = disk.entries.find((entry: { path: string }) => path.resolve(entry.path) === path.resolve(skillPath)) + writeFileSync(path.join(skillEntry.backup, 'SKILL.md'), '# tampered\n') + + const result = await restoreFallbackJournal(owner) + assert.equal(result.succeeded, false) + assert.equal(result.unproven, true) + // 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(owner.journalPath), true) + }) + + it('reports unproven when the journal was replaced after begin', async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const owner = await markFallbackJournalMutating(handle) + const forged = JSON.parse(readFileSync(owner.journalPath, 'utf8')) + forged.transactionId = randomUUID() + writeFileSync(owner.journalPath, JSON.stringify(forged)) + + const result = await restoreFallbackJournal(owner) + assert.equal(result.succeeded, false) + assert.equal(result.unproven, true) + assert.equal(existsSync(owner.journalPath), true) + }) + + it('rolls back a created planned-missing destination into a preserved quarantine without deleting it', async () => { + const { manifest, trackingPath } = await setupValidFixture() + const destination = path.join(getSkillsDir(), 'added') + manifest.bundleDestinations = [{ path: destination, kind: 'missing' }] + const { handle: fresh } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(fresh) + // Mutator-role operations require a claimed mutator handle (and the + // journal's mutator record): the owner handle from + // markFallbackJournalMutating can neither stage nor apply. + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.ok(claimed) + const stageSource = mkdtempSync(path.join(path.dirname(destination), '.stage-src-')) + mkdirSync(path.join(stageSource, 'payload'), { recursive: true }) + writeFileSync(path.join(stageSource, 'payload', 'SKILL.md'), 'new skill\n') + let handle = await registerFallbackStage(claimed, destination, { directory: path.join(stageSource, 'payload') }) + handle = await applyFallbackEntry(handle, destination) + assert.equal(readFileSync(path.join(destination, 'SKILL.md'), 'utf8'), 'new skill\n') + + const result = await restoreFallbackJournal(handle) + assert.equal(result.succeeded, true) + // The created destination was moved into a quarantine that SURVIVES. + assert.equal(existsSync(destination), false) + const quarantines = readdirSync(path.dirname(destination)).filter((name) => name.includes('.nsolid-quarantine-')) + assert.equal(quarantines.length, 1) + assert.equal(readFileSync(path.join(path.dirname(destination), quarantines[0], 'added', 'SKILL.md'), 'utf8'), 'new skill\n') + assert.ok(result.preservedArtifacts.some((artifact) => artifact.includes('.nsolid-quarantine-'))) + assert.equal(existsSync(handle.journalPath), true, 'a mutator leaves journal disposition to its owner') + assert.equal(existsSync(trackingPath), true) + rmSync(stageSource, { recursive: true, force: true }) + }) + + it('never deletes a journal-recorded stage container without its in-memory capability during commit', async () => { + const { manifest, skillPath } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const owner = await markFallbackJournalMutating(handle) + // A decoy container that passes strict structural validation (exact + // sibling name shape + payload child) but was created OUTSIDE this + // process: the journal record alone must never authorize its removal. + const decoy = path.join(path.dirname(skillPath), `.${path.basename(skillPath)}.nsolid-stage-abc123`) + mkdirSync(path.join(decoy, 'payload'), { recursive: true }) + writeFileSync(path.join(decoy, 'payload', 'decoy.txt'), 'KEEP') + const journalPath = fallbackJournalPath(manifest.trackingPath) + const forged = JSON.parse(readFileSync(journalPath, 'utf8')) + const skillEntry = forged.entries.find((entry: { path: string }) => path.resolve(entry.path) === path.resolve(skillPath)) + skillEntry.stage = path.join(decoy, 'payload') + writeFileSync(journalPath, JSON.stringify(forged)) + + const result = await commitFallbackJournal(owner) + assert.equal(result.succeeded, true) + assert.equal(readFileSync(path.join(decoy, 'payload', 'decoy.txt'), 'utf8'), 'KEEP') + assert.ok(result.preservedArtifacts.some((artifact) => artifact === decoy)) + }) + + it('cleans authenticated stage and quarantine containers during commit and reports none', async () => { + const { manifest, skillPath } = await setupValidFixture() + const { handle: fresh } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(fresh) + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.ok(claimed) + const stageSource = mkdtempSync(path.join(path.dirname(skillPath), '.stage-src-')) + mkdirSync(path.join(stageSource, 'payload'), { recursive: true }) + writeFileSync(path.join(stageSource, 'payload', 'SKILL.md'), 'new bundle\n') + let handle = await registerFallbackStage(claimed, skillPath, { directory: path.join(stageSource, 'payload') }) + handle = await applyFallbackEntry(handle, skillPath) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'new bundle\n') + // The creator cleaned its own containers right after the apply. + const siblings = readdirSync(path.dirname(skillPath)).filter((name) => name.includes('.nsolid-')) + assert.deepEqual(siblings, []) + + // The owner reclaims (fresh revision, mutator released) before committing. + const owner = await reclaimFallbackJournalMutation(fresh) + const result = await commitFallbackJournal(owner) + assert.equal(result.succeeded, true) + assert.deepEqual(result.preservedArtifacts, []) + assert.equal(existsSync(handle.journalPath), false) + rmSync(stageSource, { recursive: true, force: true }) + }) + + it('applies a deletion-only entry by moving the owned path into quarantine', async () => { + const { manifest, skillPath } = await setupValidFixture() + const { handle: fresh } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(fresh) + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.ok(claimed) + // Deletion-only apply: v3 performs a deletion by swapping in nothing, so + // the owned path moves to quarantine while the journal still records it. + await applyFallbackEntry(claimed, skillPath) + assert.equal(existsSync(skillPath), false) + const quarantines = readdirSync(path.dirname(skillPath)).filter((name) => name.includes('.nsolid-quarantine-')) + assert.equal(quarantines.length, 1, 'deletion preserves bytes in quarantine until cleanup') + }) + + it('aborts the swap when the live path drifted after registration', async () => { + const { linkPath, manifest } = await setupValidFixture() + const { handle: fresh } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(fresh) + const handle = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.ok(handle) + const staged = await registerFallbackStage(handle, linkPath, { bytes: Buffer.from('# new bundle\n') }) + writeFileSync(linkPath, 'concurrent user edit\n') + + await assert.rejects(applyFallbackEntry(staged, linkPath), /drifted after journaling/) + assert.equal(readFileSync(linkPath, 'utf8'), 'concurrent user edit\n') + assert.equal(existsSync(fallbackJournalPath(manifest.trackingPath)), true) + const reloaded = JSON.parse(readFileSync(fallbackJournalPath(manifest.trackingPath), 'utf8')) + const entry = reloaded.entries.find((candidate: { path: string }) => candidate.path === linkPath) + assert.ok(entry.stage) + assert.equal(readFileSync(entry.stage, 'utf8'), '# new bundle\n') + }) + + it('aborts the swap when a journaled-missing destination was created concurrently', async () => { + const { manifest } = await setupValidFixture() + const destination = path.join(getSkillsDir(), 'fresh-skill') + manifest.bundleDestinations = [{ path: destination, kind: 'missing' }] + const { handle: fresh } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(fresh) + const handle = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.ok(handle) + const stageSource = mkdtempSync(path.join(path.dirname(destination), '.stage-src-')) + mkdirSync(path.join(stageSource, 'payload'), { recursive: true }) + writeFileSync(path.join(stageSource, 'payload', 'SKILL.md'), 'fresh\n') + const staged = await registerFallbackStage(handle, destination, { directory: path.join(stageSource, 'payload') }) + mkdirSync(destination, { recursive: true }) + writeFileSync(path.join(destination, 'user-file.txt'), 'precious\n') + + await assert.rejects(applyFallbackEntry(staged, destination), /drifted after journaling/) + assert.equal(readFileSync(path.join(destination, 'user-file.txt'), 'utf8'), 'precious\n') + rmSync(stageSource, { recursive: true, force: true }) + }) + + it('refuses a no-stage apply for a planned-missing destination it never owned', async () => { + const { manifest } = await setupValidFixture() + const destination = path.join(getSkillsDir(), 'unauthorized-fresh-skill') + manifest.bundleDestinations = [{ path: destination, kind: 'missing' }] + const { handle: fresh } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(fresh) + // A planned-missing bundle destination is creation authority only: without + // a registered stage there is nothing authenticated to swap in, and the + // manifest's owned evidence (ownedSkills/ownedLinks) never authorizes + // deleting it. An owned no-stage apply is the valid deletion-only path + // covered by the test above; this refusal is genuinely unauthorized. + const handle = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.ok(handle) + await assert.rejects(applyFallbackEntry(handle, destination), /No registered stage capability/) + assert.equal(existsSync(destination), false, 'the unauthorized target must stay untouched') + assert.equal( + readdirSync(path.dirname(destination)).filter((name) => name.includes('.nsolid-quarantine-')).length, + 0, + 'a refused no-stage apply must not create any quarantine container' + ) + }) + + it('keeps next-run recovery strictly restore-only, scoped to tracking-owned paths', async () => { + const { trackingPath, skillPath, manifest } = await setupValidFixture() + const destination = path.join(getSkillsDir(), 'added') + manifest.bundleDestinations = [{ path: destination, kind: 'missing' }] + const { handle: fresh } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(fresh) + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.ok(claimed) + const stageSource = mkdtempSync(path.join(path.dirname(destination), '.stage-src-')) + mkdirSync(path.join(stageSource, 'payload'), { recursive: true }) + writeFileSync(path.join(stageSource, 'payload', 'SKILL.md'), 'new skill\n') + let handle = await registerFallbackStage(claimed, destination, { directory: path.join(stageSource, 'payload') }) + handle = await applyFallbackEntry(handle, destination) + writeFileSync(path.join(skillPath, 'SKILL.md'), '# mutated by crashed child\n') + assert.equal(existsSync(handle.journalPath), true) + + const recovery = await recoverFallbackJournal(trackingPath) + assert.equal(recovery.pending, true, 'next-run recovery never claims completion') + assert.equal(recovery.recovered, true, 'the tracking-owned skill was restored') + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(existsSync(destination), true, 'a non-owned destination is preserved, not restored or deleted') + // Journal and snapshot survive next-run recovery untouched. + assert.equal(existsSync(handle.journalPath), true) + assert.ok(recovery.preservedArtifacts.includes(handle.journalPath)) + assert.ok(existsSync(handle.journalPath)) + rmSync(stageSource, { recursive: true, force: true }) + }) + + it('refuses next-run recovery when the tracking backup disagrees with the planned manifest digest', async () => { + const { trackingPath, skillPath, manifest, trackingJson } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + // Drift an owned skill so any live mutation ordered before the tracking + // install would be observable: the preflight must leave it drifted. + writeFileSync(path.join(skillPath, 'SKILL.md'), '# mutated by crashed child\n') + // Forge ONLY the tracking backup and its journaled entry digest: valid + // tracking JSON carrying an extra ownership declaration, while the + // manifest and its hash stay exactly as planned. + const disk = JSON.parse(readFileSync(handle.journalPath, 'utf8')) as { manifest: FallbackTransactionIdentity; manifestDigest: string; entries: Array<{ path: string; backup?: string; digest?: string }> } + const trackingEntry = disk.entries.find((entry) => path.resolve(entry.path) === path.resolve(manifest.trackingPath)) + assert.ok(trackingEntry?.backup) + const forged = JSON.parse(trackingJson) as { skills: Array<{ name: string; path: string; paths: Record; installedAt: string; harnesses: string[] }> } + forged.skills.push({ name: 'forged', path: path.join(home, 'forged-skill'), paths: { claude: path.join(home, 'forged-skill') }, installedAt: new Date().toISOString(), harnesses: ['claude'] }) + writeFileSync(trackingEntry.backup, `${JSON.stringify(forged, null, 2)}\n`) + trackingEntry.digest = await pathDigest(trackingEntry.backup) + writeFileSync(handle.journalPath, `${JSON.stringify(disk, null, 2)}\n`) + // Only the tracking backup and its entry digest changed: the manifest + // and its hash are exactly as planned. + const reloaded = JSON.parse(readFileSync(handle.journalPath, 'utf8')) as { manifest: FallbackTransactionIdentity; manifestDigest: string } + assert.equal(canonicalJsonString(reloaded.manifest), canonicalJsonString(manifest)) + assert.equal(reloaded.manifestDigest, manifestDigestOf(manifest)) + const snapshotDirectory = (JSON.parse(readFileSync(handle.journalPath, 'utf8')) as { snapshotDirectory: string }).snapshotDirectory + + // Fresh restart: the original handle is never handed to recovery. + const recovery = await recoverFallbackJournal(trackingPath) + assert.equal(recovery.pending, true, 'next-run recovery never claims completion') + assert.equal(recovery.recovered, false, 'a tracking backup that disagrees with the manifest digest must fail closed') + assert.deepEqual(recovery.restoredPaths, [], 'nothing may be restored when tracking evidence is tampered') + assert.equal(readFileSync(trackingPath, 'utf8'), trackingJson, 'the forged tracking backup must not be installed') + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# mutated by crashed child\n', 'no live path may be mutated before the tampered tracking is detected') + // Journal and snapshot survive for deliberate manual cleanup. + assert.equal(existsSync(handle.journalPath), true) + assert.equal(existsSync(snapshotDirectory), true) + assert.ok(recovery.preservedArtifacts.includes(handle.journalPath)) + }) + + it('restores owned bytes on untampered next-run recovery', async () => { + const { trackingPath, skillPath, manifest, trackingJson } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + writeFileSync(path.join(skillPath, 'SKILL.md'), '# mutated by crashed child\n') + + // Fresh restart: the original handle is never handed to recovery. + const recovery = await recoverFallbackJournal(trackingPath) + assert.equal(recovery.pending, true, 'next-run recovery never claims completion') + assert.equal(recovery.recovered, true, 'the untampered tracking-owned skill was restored') + assert.ok(recovery.restoredPaths.includes(path.resolve(skillPath))) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(readFileSync(trackingPath, 'utf8'), trackingJson, 'untampered tracking stays byte-identical') + // Journal and snapshot survive next-run recovery untouched. + assert.equal(existsSync(handle.journalPath), true) + assert.ok(recovery.preservedArtifacts.includes(handle.journalPath)) + }) + + it('keeps next-run recovery preserve-only when an owned skill parent was replaced by a link to a foreign directory', { skip: process.platform !== 'linux' }, async () => { + const { trackingPath, skillPath, manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + // The owned skill drifts so recovery must attempt its restore instead of + // skipping an already-matching path. + writeFileSync(path.join(skillPath, 'SKILL.md'), '# mutated by crashed child\n') + + // A foreign directory tree whose same-named skill carries sentinel bytes: + // a substituted ancestor must never redirect the recovery copy into it. + const foreignRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-foreign-')) + const foreignSkills = path.join(foreignRoot, 'skills') + const sentinel = '# foreign sentinel bytes\n' + mkdirSync(path.join(foreignSkills, 'tracked'), { recursive: true }) + writeFileSync(path.join(foreignSkills, 'tracked', 'SKILL.md'), sentinel) + + const skillParent = path.dirname(skillPath) + const movedParent = path.join(path.dirname(skillParent), `skills.moved-${randomUUID()}`) + renameSync(skillParent, movedParent) + symlinkSync(foreignSkills, skillParent) + + try { + const recovery = await recoverFallbackJournal(trackingPath) + assert.equal(recovery.pending, true, 'next-run recovery never claims completion') + assert.equal(recovery.recovered, false, 'the linked ancestor makes the restore unprovable') + assert.ok(recovery.preservedPaths.includes(path.resolve(skillPath)), 'the owned target is reported preserved') + // The foreign tree is byte-identical and structurally untouched. + assert.equal(readFileSync(path.join(foreignSkills, 'tracked', 'SKILL.md'), 'utf8'), sentinel) + assert.deepEqual(readdirSync(foreignSkills).sort(), ['tracked']) + assert.deepEqual(readdirSync(foreignRoot).sort(), ['skills']) + // No recovery storage was created beside the real parent or through the + // substituted link into the foreign tree. + assert.deepEqual(readdirSync(path.dirname(skillParent)).filter((name) => name.startsWith('.nsolid-recovery-')), []) + // Journal and snapshot survive next-run recovery untouched. + assert.equal(existsSync(handle.journalPath), true) + assert.ok(recovery.preservedArtifacts.includes(handle.journalPath)) + const journal = JSON.parse(readFileSync(handle.journalPath, 'utf8')) as { snapshotDirectory?: string } + assert.equal(existsSync(journal.snapshotDirectory!), true) + } finally { + rmSync(skillParent, { force: true }) + rmSync(movedParent, { recursive: true, force: true }) + rmSync(foreignRoot, { recursive: true, force: true }) + } + }) + + it('reports a byte-identical skill under a substituted parent as preserved for manual inspection on check', { skip: process.platform !== 'linux' }, async () => { + const { trackingPath, skillPath, manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + // No drift: the live skill stays byte-identical to the tracked backup. + // A foreign directory tree carries the SAME bytes under the same name, + // so digest equality alone must never reassure. + const foreignRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-foreign-')) + const foreignSkills = path.join(foreignRoot, 'skills') + mkdirSync(path.join(foreignSkills, 'tracked'), { recursive: true }) + writeFileSync(path.join(foreignSkills, 'tracked', 'SKILL.md'), '# tracked\n') + const journalBefore = readFileSync(handle.journalPath, 'utf8') + + const skillParent = path.dirname(skillPath) + const movedParent = path.join(path.dirname(skillParent), `skills.moved-${randomUUID()}`) + renameSync(skillParent, movedParent) + symlinkSync(foreignSkills, skillParent) + + try { + const inspection = await inspectFallbackJournal(trackingPath) + assert.equal(inspection.pending, true, 'inspection never claims completion') + assert.equal(inspection.recovered, false, 'byte-identical bytes under a substituted ancestor are unproven') + assert.ok(inspection.preservedPaths.includes(path.resolve(skillPath)), 'the owned target is named for manual inspection') + assert.deepEqual(inspection.restoredPaths, [], 'an inspection never restores') + // Read-only: the live bytes, the foreign tree, and the journal are untouched... + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(readFileSync(path.join(foreignSkills, 'tracked', 'SKILL.md'), 'utf8'), '# tracked\n') + assert.deepEqual(readdirSync(foreignSkills).sort(), ['tracked']) + assert.deepEqual(readdirSync(foreignRoot).sort(), ['skills']) + assert.equal(readFileSync(handle.journalPath, 'utf8'), journalBefore) + // ...and no recovery storage was created beside the real parent or + // through the substituted link into the foreign tree. + assert.deepEqual(readdirSync(path.dirname(skillParent)).filter((name) => name.startsWith('.nsolid-recovery-')), []) + } finally { + rmSync(skillParent, { force: true }) + rmSync(movedParent, { recursive: true, force: true }) + rmSync(foreignRoot, { recursive: true, force: true }) + } + }) + + it('keeps next-run recovery pending and untouched for forged committed and legacy v2 journals', async () => { + const { trackingPath, skillPath, manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + // A forged committed phase is not a valid v3 phase: strict load fails and + // nothing is cleaned. + const forged = JSON.parse(readFileSync(handle.journalPath, 'utf8')) + forged.phase = 'committed' + writeFileSync(handle.journalPath, JSON.stringify(forged)) + let recovery = await recoverFallbackJournal(trackingPath) + assert.equal(recovery.pending, true) + assert.equal(recovery.recovered, false) + assert.equal(existsSync(handle.journalPath), true) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + + // Journal version 2 was never deployed: preserved pending, no migration. + rmSync(handle.journalPath, { force: true }) + const legacy = { version: 2, phase: 'mutating', manifest, journalPath: handle.journalPath, snapshotDirectory: handle.journalPath.replace(/json$/, 'snapshot'), entries: [] } + writeFileSync(handle.journalPath, JSON.stringify(legacy)) + recovery = await recoverFallbackJournal(trackingPath) + assert.equal(recovery.pending, true) + assert.equal(recovery.recovered, false) + assert.equal(existsSync(handle.journalPath), true) + // And a begin over any pending journal is busy. + await assert.rejects(beginFallbackJournal(manifest), /FALLBACK_JOURNAL_BUSY/) + }) + + it('keeps next-run recovery pending while the recorded mutator is a live different process', { skip: process.platform !== 'linux' }, async () => { + const { trackingPath, manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const live = await spawnLiveProcessFixture() + const childPid: number = live.pid + try { + const disk = JSON.parse(readFileSync(handle.journalPath, 'utf8')) + disk.phase = 'mutating' + disk.mutator = { pid: childPid, startIdentity: live.startIdentity, claimedAt: new Date().toISOString() } + writeFileSync(handle.journalPath, JSON.stringify(disk)) + + const recovery = await recoverFallbackJournal(trackingPath) + assert.equal(recovery.pending, true) + assert.equal(recovery.recovered, false) + } finally { + await live.stop() + } + }) + + it('rejects begin for a manifest with a missing or unknown protocol version before creating anything', async () => { + const { manifest } = await setupValidFixture() + const trackingDir = path.dirname(manifest.trackingPath) + for (const protocolVersion of [undefined, 2, FALLBACK_PROTOCOL_VERSION + 1]) { + const broken = { ...manifest, protocolVersion } as unknown as FallbackTransactionIdentity + await assert.rejects(beginFallbackJournal(broken), /FALLBACK_PROTOCOL_UNSUPPORTED/) + // The gate fires before the reservation and before any snapshot work. + assert.equal(existsSync(fallbackJournalPath(manifest.trackingPath)), false) + assert.deepEqual(readdirSync(trackingDir).filter((name) => name.startsWith('.nsolid-plugin-update-')), []) + } + }) + + it('refuses a mutation claim for an unsupported protocol version and leaves the journal byte-identical', async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const owner = await markFallbackJournalMutating(handle) + const before = readFileSync(owner.journalPath, 'utf8') + for (const protocolVersion of [undefined, FALLBACK_PROTOCOL_VERSION + 1]) { + const broken = { ...manifest, protocolVersion } as unknown as FallbackTransactionIdentity + await assert.rejects(claimFallbackJournalMutation(broken), /FALLBACK_PROTOCOL_UNSUPPORTED/) + } + assert.equal(readFileSync(owner.journalPath, 'utf8'), before, 'a refused claim must not mutate the journal') + }) + + it('rejects begin for absent, malformed, or tampered frontier evidence before creating anything', async () => { + const { manifest } = await setupValidFixture() + const trackingDir = path.dirname(manifest.trackingPath) + const absent: Record = { ...manifest } + delete absent.plannedMissingFrontiers + const anchorPath = path.dirname(manifest.trackingPath) + const frontierPath = path.join(anchorPath, 'missing-destination') + const forgedFrontier = { + frontierPath, + activation: 'required', + anchor: { path: anchorPath, realpath: anchorPath, device: '1', inode: '1', type: 'directory' }, + leaves: [{ id: 'skill:forged', role: 'skill', activation: 'required', path: path.join(frontierPath, 'skill') }], + } + const cases: unknown[] = [ + // Absent field: protocol v3 has no absent-field fallback. + absent, + // Non-array evidence. + { ...manifest, plannedMissingFrontiers: 'none' }, + // Extra key on the frontier evidence object. + { ...manifest, plannedMissingFrontiers: [{ ...forgedFrontier, extraKey: true }] }, + // Anchor missing a required identity field. + { ...manifest, plannedMissingFrontiers: [{ ...forgedFrontier, anchor: { path: anchorPath, realpath: anchorPath, device: '1', inode: '1' } }] }, + // Extra key nested inside a leaf. + { ...manifest, plannedMissingFrontiers: [{ ...forgedFrontier, leaves: [{ ...forgedFrontier.leaves[0], extra: 1 }] }] }, + // Tampered graph: frontier evidence must stay strictly sorted. + { ...manifest, plannedMissingFrontiers: [forgedFrontier, { ...forgedFrontier, frontierPath: path.join(anchorPath, 'a-missing-destination') }] }, + ] + for (const [index, broken] of cases.entries()) { + await assert.rejects(beginFallbackJournal(broken as unknown as FallbackTransactionIdentity), /FALLBACK_FRONTIER_EVIDENCE_INVALID/, `frontier case ${index} must fail closed`) + // The gate fires before the reservation and before any snapshot work. + assert.equal(existsSync(fallbackJournalPath(manifest.trackingPath)), false) + assert.deepEqual(readdirSync(trackingDir).filter((name) => name.startsWith('.nsolid-plugin-update-')), []) + } + }) + + it('refuses a mutation claim whose frontier evidence fails strict parsing and leaves the journal byte-identical', async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + const before = readFileSync(handle.journalPath, 'utf8') + const absent: Record = { ...manifest } + delete absent.plannedMissingFrontiers + await assert.rejects(claimFallbackJournalMutation(absent as unknown as FallbackTransactionIdentity), /FALLBACK_FRONTIER_EVIDENCE_INVALID/) + assert.equal(readFileSync(handle.journalPath, 'utf8'), before, 'a refused claim must not mutate the journal') + }) + + it('rejects begin for self-consistent but globally impossible frontier graphs before creating anything', async () => { + const { manifest } = await setupValidFixture() + const trackingDir = path.dirname(manifest.trackingPath) + const anchorPath = path.dirname(manifest.trackingPath) + const anchor = { path: anchorPath, realpath: anchorPath, device: '1', inode: '1', type: 'directory' } + // Every frontier below is individually well-formed for the strict parser; + // only the whole-graph rules can reject them. + const nestedLeaves = { + frontierPath: path.join(anchorPath, 'impossible-frontier'), + activation: 'required', + anchor, + leaves: [ + { id: 'skill:nested-parent', role: 'skill', activation: 'required', path: path.join(anchorPath, 'impossible-frontier', 'a') }, + { id: 'link:nested-child', role: 'link', activation: 'conditional', path: path.join(anchorPath, 'impossible-frontier', 'a', 'b') }, + ], + } + const duplicateLeafId = [ + { + frontierPath: path.join(anchorPath, 'za-frontier'), + activation: 'required', + anchor, + leaves: [{ id: 'dup', role: 'skill', activation: 'required', path: path.join(anchorPath, 'za-frontier', 'x') }], + }, + { + frontierPath: path.join(anchorPath, 'zb-frontier'), + activation: 'required', + anchor, + leaves: [{ id: 'dup', role: 'skill', activation: 'required', path: path.join(anchorPath, 'zb-frontier', 'y') }], + }, + ] + const activationLie = { + frontierPath: path.join(anchorPath, 'activation-lie'), + activation: 'required', + anchor, + leaves: [{ id: 'config:conditional', role: 'mcp-config', activation: 'conditional', path: path.join(anchorPath, 'activation-lie', 'cfg.json') }], + } + const linkSelfLeaf = { + frontierPath: path.join(anchorPath, 'self-frontier'), + activation: 'required', + anchor, + leaves: [{ id: 'link:self', role: 'link', activation: 'required', path: path.join(anchorPath, 'self-frontier') }], + } + // The manifest field is a frontier list: the three singleton graphs are + // wrapped in arrays so each one reaches the whole-graph validator instead + // of being rejected by array-shape parsing. duplicateLeafId is already a + // list and stays unchanged. + const cases = [[nestedLeaves], duplicateLeafId, [activationLie], [linkSelfLeaf]] + for (const [index, frontiers] of cases.entries()) { + // Prove the rejection layer directly: the strict frontier parser — the + // same whole-graph validator begin applies before reserving anything — + // refuses every one of these graphs on its own. + assert.throws( + () => assertFallbackFrontierEvidenceList(frontiers), + (error: unknown) => error instanceof FallbackFrontierError, + `impossible graph case ${index} must be refused by the strict frontier parser itself` + ) + const broken = { ...manifest, plannedMissingFrontiers: frontiers } as unknown as FallbackTransactionIdentity + await assert.rejects(beginFallbackJournal(broken), /FALLBACK_FRONTIER_EVIDENCE_INVALID/, `impossible graph case ${index} must fail closed`) + // The gate fires before the reservation and before any snapshot work. + assert.equal(existsSync(fallbackJournalPath(manifest.trackingPath)), false) + assert.deepEqual(readdirSync(trackingDir).filter((name) => name.startsWith('.nsolid-plugin-update-')), []) + } + }) + + it('refuses a mutation claim carrying a globally impossible frontier graph and leaves the journal byte-identical', async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + const before = readFileSync(handle.journalPath, 'utf8') + const anchorPath = path.dirname(manifest.trackingPath) + // The frontier gate runs before any journal read or digest comparison, so + // the impossible graph itself must refuse the claim. + const impossible = [{ + frontierPath: path.join(anchorPath, 'impossible-frontier'), + activation: 'required', + anchor: { path: anchorPath, realpath: anchorPath, device: '1', inode: '1', type: 'directory' }, + leaves: [ + { id: 'skill:nested-parent', role: 'skill', activation: 'required', path: path.join(anchorPath, 'impossible-frontier', 'a') }, + { id: 'link:nested-child', role: 'link', activation: 'conditional', path: path.join(anchorPath, 'impossible-frontier', 'a', 'b') }, + ], + }] + const broken = { ...manifest, plannedMissingFrontiers: impossible } as unknown as FallbackTransactionIdentity + await assert.rejects(claimFallbackJournalMutation(broken), /FALLBACK_FRONTIER_EVIDENCE_INVALID/) + assert.equal(readFileSync(handle.journalPath, 'utf8'), before, 'a refused claim must not mutate the journal') + }) + + it('refuses next-run recovery of an on-disk v3 journal whose manifest lacks frontier evidence', { skip: process.platform !== 'linux' }, async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + const live = await spawnLiveProcessFixture() + const childPid: number = live.pid + try { + // Re-serialize the disk journal WITHOUT the frontier field and re-sign + // its manifest digest, with a live mutator: every other recovery check + // would pass, so only the strict isSafeJournal frontier gate can refuse + // this journal as v3 transaction state. (The previous comment said + // "dead mutator", but the fixture child was alive the whole time; the + // refusal comes from the frontier gate, not from mutator liveness.) + const disk = JSON.parse(readFileSync(handle.journalPath, 'utf8')) as { manifest: Record; manifestDigest: string; phase: string; mutator: unknown } + disk.phase = 'mutating' + disk.mutator = { pid: childPid, startIdentity: live.startIdentity, claimedAt: new Date().toISOString() } + delete disk.manifest.plannedMissingFrontiers + disk.manifestDigest = manifestDigestOf(disk.manifest as unknown as FallbackTransactionIdentity) + writeFileSync(handle.journalPath, JSON.stringify(disk)) + const recovery = await recoverFallbackJournal(manifest.trackingPath) + assert.equal(recovery.pending, true) + assert.equal(recovery.recovered, false) + // strictLoad fails before any snapshot evidence is trusted: only the + // unauthenticated journal artifact is reported preserved. + assert.deepEqual(recovery.preservedArtifacts, [handle.journalPath]) + assert.deepEqual(recovery.preservedPaths, []) + } finally { + await live.stop() + } + }) + + it('refuses a plain self-consistent journal as restore authority and preserves everything', async () => { + const { skillPath, manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + writeFileSync(path.join(skillPath, 'SKILL.md'), '# mutated while plain\n') + // The exact bytes on disk, read back WITHOUT the owner's handle: even a + // perfectly self-consistent v3 journal must never be destructive authority + // on its own. Only the in-process handle (with its trusted manifest and + // capabilities) restores; next-run recovery's restricted tracking-scoped + // path lives in recoverFallbackJournal. + const plain = JSON.parse(readFileSync(handle.journalPath, 'utf8')) as unknown as FallbackJournalHandle + const result = await restoreFallbackJournal(plain) + assert.equal(result.succeeded, false) + assert.equal(result.unproven, true) + assert.deepEqual(result.preservedPaths, []) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# mutated while plain\n', 'no restore may run from plain disk state') + assert.equal(existsSync(handle.journalPath), true, 'the journal must survive a refused plain restore') + const snapshot = JSON.parse(readFileSync(handle.journalPath, 'utf8')).snapshotDirectory as string + assert.equal(existsSync(snapshot), true, 'the snapshot must survive a refused plain restore') + }) + + it('refuses a plain self-consistent journal as commit authority and disposes nothing', async () => { + const { skillPath, manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + const plain = JSON.parse(readFileSync(handle.journalPath, 'utf8')) as unknown as FallbackJournalHandle + await assert.rejects(commitFallbackJournal(plain), /Invalid fallback journal/) + assert.equal(existsSync(handle.journalPath), true, 'the journal must survive a refused plain commit') + const snapshot = JSON.parse(readFileSync(handle.journalPath, 'utf8')).snapshotDirectory as string + assert.equal(existsSync(snapshot), true, 'the snapshot must survive a refused plain commit') + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + }) + + it('pins the fallback digest framing with fixed vectors', async () => { + const { createHash } = await import('node:crypto') + const file = path.join(home, 'vector.txt') + writeFileSync(file, 'vector') + assert.equal(await pathDigest(file), createHash('sha256').update('file\0vector').digest('hex')) + const target = path.join(home, 'target.txt') + writeFileSync(target, 't') + const link = path.join(home, 'link.txt') + if (process.platform !== 'win32') { + const { symlinkSync } = await import('node:fs') + symlinkSync(target, link, 'file') + assert.equal(await pathDigest(link), createHash('sha256').update(`symlink\0${target}`).digest('hex')) + } + assert.equal(await pathKind(file), 'file') + assert.equal(await pathKind(path.join(home, 'absent')), 'missing') + }) +}) + +describe('fallback journal authenticated artifact cleanup', () => { + it('preserves a stage container that carries unexpected sentinel content instead of deleting it', async () => { + const { manifest, skillPath } = await setupValidFixture() + const { handle: fresh } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(fresh) + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.ok(claimed) + const stageSource = mkdtempSync(path.join(path.dirname(skillPath), '.stage-src-')) + mkdirSync(path.join(stageSource, 'payload'), { recursive: true }) + writeFileSync(path.join(stageSource, 'payload', 'SKILL.md'), 'new bundle\n') + const staged = await registerFallbackStage(claimed, skillPath, { directory: path.join(stageSource, 'payload') }) + // Stuff an unexpected sentinel into the stage container after registration: + // authenticated cleanup must refuse to recursively delete it. + const journaled = JSON.parse(readFileSync(fresh.journalPath, 'utf8')) + const entry = journaled.entries.find((candidate: { path: string }) => path.resolve(candidate.path) === path.resolve(skillPath)) + const stageContainer = path.dirname(entry.stage) + writeFileSync(path.join(stageContainer, '.user-sentinel'), 'KEEP') + + await applyFallbackEntry(staged, skillPath) + // The swap itself still succeeded: the staged payload moved into the live + // path, out of its container... + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'new bundle\n') + // ...but the sentinel-bearing container survived intact and was reported. + const siblings = readdirSync(path.dirname(skillPath)).filter((name) => name.includes('.nsolid-stage-')) + assert.equal(siblings.length, 1) + const container = path.join(path.dirname(skillPath), siblings[0]) + assert.equal(existsSync(path.join(container, 'payload')), false, 'the payload legitimately moved into the live path') + assert.equal(readFileSync(path.join(container, '.user-sentinel'), 'utf8'), 'KEEP') + const residues = JSON.parse(readFileSync(fresh.journalPath, 'utf8')) + assert.ok((residues.preservedArtifacts ?? []).some((artifact: string) => path.resolve(artifact) === path.resolve(container))) + rmSync(stageSource, { recursive: true, force: true }) + }) + + it('preserves a swap quarantine when unexpected content appears inside the removal window', async () => { + const { manifest, skillPath } = await setupValidFixture() + const { handle: fresh } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(fresh) + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.ok(claimed) + const stageSource = mkdtempSync(path.join(path.dirname(skillPath), '.stage-src-')) + mkdirSync(path.join(stageSource, 'payload'), { recursive: true }) + writeFileSync(path.join(stageSource, 'payload', 'SKILL.md'), 'new bundle\n') + let injected = false + setFallbackArtifactSwapSeamForTests(async (artifact) => { + // Deterministic awaited seam: the quarantine (the replaced bytes) gets + // an unexpected sentinel after validation but before the tombstone + // rename, so the whole container must be preserved, not deleted. + if (artifact.role === 'quarantine' && !injected) { + injected = true + writeFileSync(path.join(artifact.container, '.user-sentinel'), 'KEEP') + } + }) + try { + const staged = await registerFallbackStage(claimed, skillPath, { directory: path.join(stageSource, 'payload') }) + const applied = await applyFallbackEntry(staged, skillPath) + assert.ok(applied) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'new bundle\n') + const quarantines = readdirSync(path.dirname(skillPath)).filter((name) => name.includes('.nsolid-quarantine-')) + assert.equal(quarantines.length, 1) + const container = path.join(path.dirname(skillPath), quarantines[0]) + assert.equal(readFileSync(path.join(container, 'tracked', 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(readFileSync(path.join(container, '.user-sentinel'), 'utf8'), 'KEEP') + const residues = JSON.parse(readFileSync(fresh.journalPath, 'utf8')) + assert.ok((residues.preservedArtifacts ?? []).some((artifact: string) => path.resolve(artifact) === path.resolve(container))) + } finally { + setFallbackArtifactSwapSeamForTests(undefined) + rmSync(stageSource, { recursive: true, force: true }) + } + }) + + it('preserves a restore quarantine when unexpected content appears inside the removal window', async () => { + const { skillPath, manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const owner = await markFallbackJournalMutating(handle) + writeFileSync(path.join(skillPath, 'SKILL.md'), '# mutated\n') + writeFileSync(path.join(skillPath, 'user-edit.txt'), 'concurrent user edit\n') + let injected = false + setFallbackArtifactSwapSeamForTests(async (artifact) => { + if (artifact.role === 'restore' && !injected) { + injected = true + writeFileSync(path.join(artifact.container, '.user-sentinel'), 'KEEP') + } + }) + try { + const result = await restoreFallbackJournal(owner) + assert.equal(result.succeeded, true) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), '# tracked\n') + // The drifted bytes were quarantined; the sentinel-bearing container + // survived and was reported instead of being destroyed. + const quarantines = readdirSync(path.dirname(skillPath)).filter((name) => name.includes('.nsolid-quarantine-')) + assert.equal(quarantines.length, 1) + const container = path.join(path.dirname(skillPath), quarantines[0]) + assert.ok(result.preservedArtifacts.includes(container)) + assert.equal(readFileSync(path.join(container, 'tracked', 'user-edit.txt'), 'utf8'), 'concurrent user edit\n') + assert.equal(readFileSync(path.join(container, '.user-sentinel'), 'utf8'), 'KEEP') + assert.equal(existsSync(owner.journalPath), false) + } finally { + setFallbackArtifactSwapSeamForTests(undefined) + } + }) + + it('preserves the snapshot when it carries unexpected sentinel content during commit', async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const owner = await markFallbackJournalMutating(handle) + const disk = JSON.parse(readFileSync(owner.journalPath, 'utf8')) + const snapshot = disk.snapshotDirectory as string + writeFileSync(path.join(snapshot, '.user-sentinel'), 'KEEP') + + const result = await commitFallbackJournal(owner) + assert.equal(result.succeeded, true) + assert.ok(result.preservedArtifacts.includes(snapshot)) + assert.equal(existsSync(snapshot), true) + assert.equal(readFileSync(path.join(snapshot, '.user-sentinel'), 'utf8'), 'KEEP') + assert.equal(readFileSync(path.join(snapshot, '1', 'SKILL.md'), 'utf8'), '# tracked\n') + assert.equal(existsSync(owner.journalPath), false) + }) + + it('preserves the snapshot when a backup child was replaced with a symlink', async () => { + const { manifest, linkPath } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const owner = await markFallbackJournalMutating(handle) + const disk = JSON.parse(readFileSync(owner.journalPath, 'utf8')) + const snapshot = disk.snapshotDirectory as string + const linkEntry = disk.entries.find((entry: { path: string }) => path.resolve(entry.path) === path.resolve(linkPath)) + rmSync(linkEntry.backup, { recursive: true, force: true }) + symlinkSync(path.join(home, 'elsewhere'), linkEntry.backup, 'file') + + const result = await commitFallbackJournal(owner) + assert.equal(result.succeeded, true) + assert.ok(result.preservedArtifacts.includes(snapshot)) + assert.equal(existsSync(snapshot), true) + assert.equal(readlinkSync(linkEntry.backup), path.join(home, 'elsewhere')) + }) + + it('never deletes a replacement swapped into the container path before the tombstone rename', async () => { + const { manifest } = await setupValidFixture() + const { handle } = await beginFallbackJournal(manifest) + const owner = await markFallbackJournalMutating(handle) + const disk = JSON.parse(readFileSync(owner.journalPath, 'utf8')) + const snapshot = disk.snapshotDirectory as string + const stash = path.join(home, 'attacker-stash') + mkdirSync(stash, { recursive: true }) + setFallbackArtifactSwapSeamForTests(async (artifact) => { + if (artifact.role === 'snapshot' && artifact.container === path.resolve(snapshot)) { + // Deterministic swap inside the validation→rename window: the real + // snapshot is moved aside and a decoy takes its place at the path. + renameSync(snapshot, path.join(stash, 'real-snapshot')) + mkdirSync(path.join(stash, 'decoy'), { recursive: true }) + writeFileSync(path.join(stash, 'decoy', 'decoy.txt'), 'decoy') + renameSync(path.join(stash, 'decoy'), snapshot) + } + }) + try { + const result = await commitFallbackJournal(owner) + assert.equal(result.succeeded, true) + assert.ok(result.preservedArtifacts.includes(snapshot)) + // The decoy was renamed back to the container path, fully preserved. + assert.equal(readFileSync(path.join(snapshot, 'decoy.txt'), 'utf8'), 'decoy') + // The real authenticated snapshot survived at the stash untouched. + assert.equal(existsSync(path.join(stash, 'real-snapshot', '1', 'SKILL.md')), true) + assert.equal(existsSync(owner.journalPath), false) + } finally { + setFallbackArtifactSwapSeamForTests(undefined) + } + }) + + it('preserves the whole container when content is smuggled in after validation but before the rename', async () => { + const { manifest, skillPath } = await setupValidFixture() + const { handle: fresh } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(fresh) + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + assert.ok(claimed) + const stageSource = mkdtempSync(path.join(path.dirname(skillPath), '.stage-src-')) + mkdirSync(path.join(stageSource, 'payload'), { recursive: true }) + writeFileSync(path.join(stageSource, 'payload', 'SKILL.md'), 'new bundle\n') + let smuggled = false + setFallbackArtifactSwapSeamForTests(async (artifact) => { + if (artifact.role === 'stage' && !smuggled) { + smuggled = true + writeFileSync(path.join(artifact.container, '.smuggled'), 'KEEP') + } + }) + try { + const staged = await registerFallbackStage(claimed, skillPath, { directory: path.join(stageSource, 'payload') }) + const applied = await applyFallbackEntry(staged, skillPath) + assert.ok(applied) + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'new bundle\n') + const siblings = readdirSync(path.dirname(skillPath)).filter((name) => name.includes('.nsolid-stage-')) + assert.equal(siblings.length, 1) + const container = path.join(path.dirname(skillPath), siblings[0]) + assert.equal(existsSync(path.join(container, 'payload')), false, 'the payload legitimately moved into the live path') + assert.equal(readFileSync(path.join(container, '.smuggled'), 'utf8'), 'KEEP') + const residues = JSON.parse(readFileSync(fresh.journalPath, 'utf8')) + assert.ok((residues.preservedArtifacts ?? []).some((artifact: string) => path.resolve(artifact) === path.resolve(container))) + } finally { + setFallbackArtifactSwapSeamForTests(undefined) + rmSync(stageSource, { recursive: true, force: true }) + } + }) +}) + +describe('fallback journal frontier entries', () => { + it('journals one planned-missing frontier entry that replaces multiple covered leaves and keeps them out of the snapshot', async () => { + const fixture = await setupFrontierFixture() + const { journal } = await beginFallbackJournal(fixture.manifest) + const paths = journal.entries.map((entry) => path.resolve(entry.path)) + assert.deepEqual(paths.sort(), [ + fixture.trackingPath, + fixture.keptSkillPath, + fixture.keptLinkPath, + path.join(home, '.claude.json'), + fixture.frontierPath, + ].map((value) => path.resolve(value)).sort()) + const frontierEntry = journal.entries.find((entry) => path.resolve(entry.path) === path.resolve(fixture.frontierPath)) + assert.ok(frontierEntry) + assert.equal(frontierEntry.existed, false) + assert.equal(frontierEntry.kind, 'missing') + assert.equal(frontierEntry.digest, undefined) + assert.equal(frontierEntry.backup, undefined) + for (const covered of ['alpha', 'beta']) { + assert.equal(journal.entries.some((entry) => entry.path === path.join(fixture.frontierPath, covered)), false, `covered leaf ${covered} must have no entry`) + } + // The snapshot carries backups only for retained existing targets. + assert.deepEqual(readdirSync(journal.snapshotDirectory!).sort(), ['0', '1', '2']) + // Existing directory anchors are validation-only: never entries. + assert.equal(paths.includes(path.resolve(fixture.anchorPath)), false) + }) + + it('rejects begin for a manifest candidate hidden under a frontier without being an authorized leaf', async () => { + const fixture = await setupFrontierFixture() + const hidden = path.join(fixture.frontierPath, 'hidden.json') + const broken = { + ...fixture.manifest, + bundleDestinations: [...fixture.manifest.bundleDestinations, await pathEvidence(hidden)], + } as FallbackTransactionIdentity + const trackingDir = path.dirname(fixture.trackingPath) + await assert.rejects(beginFallbackJournal(broken), /FALLBACK_FRONTIER_EVIDENCE_INVALID/) + assert.equal(existsSync(fallbackJournalPath(fixture.trackingPath)), false) + assert.deepEqual(readdirSync(trackingDir).filter((name) => name.startsWith('.nsolid-plugin-update-')), []) + }) + + it('rejects begin on frontier or anchor drift before any journal state exists', async () => { + const fixture = await setupFrontierFixture() + const trackingDir = path.dirname(fixture.trackingPath) + // The frontier path appeared before begin. + mkdirSync(fixture.frontierPath) + await assert.rejects(beginFallbackJournal(fixture.manifest), /FALLBACK_FRONTIER_DRIFT/) + assert.equal(existsSync(fallbackJournalPath(fixture.trackingPath)), false) + assert.deepEqual(readdirSync(trackingDir).filter((name) => name.startsWith('.nsolid-plugin-update-')), []) + rmSync(fixture.frontierPath, { recursive: true, force: true }) + // The anchor identity changed: rename the original anchor aside + // (preserving its inode, avoiding rename-over-existing), then move a + // freshly created directory into the anchor path so the replacement is + // guaranteed to carry a new inode. + const replacement = path.join(home, 'anchor-replacement') + mkdirSync(replacement) + renameSync(fixture.anchorPath, `${fixture.anchorPath}-replaced`) + renameSync(replacement, fixture.anchorPath) + await assert.rejects(beginFallbackJournal(fixture.manifest), /FALLBACK_FRONTIER_DRIFT/) + assert.equal(existsSync(fallbackJournalPath(fixture.trackingPath)), false) + assert.deepEqual(readdirSync(trackingDir).filter((name) => name.startsWith('.nsolid-plugin-update-')), []) + }) + + it('refuses a mutation claim on frontier drift and leaves the journal byte-identical', async () => { + const fixture = await setupFrontierFixture() + const { handle } = await beginFallbackJournal(fixture.manifest) + await markFallbackJournalMutating(handle) + const before = readFileSync(handle.journalPath, 'utf8') + mkdirSync(fixture.frontierPath) + writeFileSync(path.join(fixture.frontierPath, 'user.txt'), 'KEEP') + await assert.rejects(claimFallbackJournalMutation(fixture.manifest, manifestDigestOf(fixture.manifest)), /FALLBACK_FRONTIER_DRIFT/) + assert.equal(readFileSync(handle.journalPath, 'utf8'), before) + }) + + it('never lets forged applied or stageDigest fields on a frontier entry grant parent restore authority', async () => { + const fixture = await setupFrontierFixture() + const begin = await beginFallbackJournal(fixture.manifest) + const owner = await markFallbackJournalMutating(begin.handle) + mkdirSync(fixture.frontierPath) + writeFileSync(path.join(fixture.frontierPath, 'user.txt'), 'KEEP\n') + const forged = JSON.parse(readFileSync(owner.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean; stageDigest?: string }> } + const frontierEntry = forged.entries.find((entry) => path.resolve(entry.path) === path.resolve(fixture.frontierPath)) + assert.ok(frontierEntry) + frontierEntry.applied = true + frontierEntry.stageDigest = await pathDigest(fixture.frontierPath) + writeFileSync(owner.journalPath, JSON.stringify(forged, null, 2) + '\n') + const restore = await restoreFallbackJournal(owner) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.deepEqual(restore.preservedPaths, [path.resolve(fixture.frontierPath)]) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'user.txt'), 'utf8'), 'KEEP\n') + assert.equal(existsSync(owner.journalPath), true) + }) + + it('restores independently owned bytes while preserving a live frontier and reporting the rollback incomplete', async () => { + const fixture = await setupFrontierFixture() + const { journal, handle } = await beginFallbackJournal(fixture.manifest) + const owner = await markFallbackJournalMutating(handle) + writeFileSync(path.join(fixture.keptSkillPath, 'SKILL.md'), 'drifted\n') + mkdirSync(fixture.frontierPath) + writeFileSync(path.join(fixture.frontierPath, 'user.txt'), 'KEEP\n') + const restore = await restoreFallbackJournal(owner) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.equal(readFileSync(path.join(fixture.keptSkillPath, 'SKILL.md'), 'utf8'), '# kept\n') + assert.equal(readFileSync(path.join(fixture.frontierPath, 'user.txt'), 'utf8'), 'KEEP\n') + assert.deepEqual(restore.preservedPaths, [path.resolve(fixture.frontierPath)]) + assert.equal(restore.preservedArtifacts.length, 1) + assert.ok(restore.preservedArtifacts[0].includes('.nsolid-quarantine-')) + assert.equal(existsSync(owner.journalPath), true) + assert.equal(existsSync(journal.snapshotDirectory!), true) + }) + + it('completes the owner restore and disposes the journal when a planned frontier is still absent', async () => { + const fixture = await setupFrontierFixture() + const { journal, handle } = await beginFallbackJournal(fixture.manifest) + const owner = await markFallbackJournalMutating(handle) + writeFileSync(path.join(fixture.keptSkillPath, 'SKILL.md'), 'drifted\n') + const restore = await restoreFallbackJournal(owner) + assert.equal(restore.succeeded, true) + assert.equal(restore.frontierJournalPending, undefined) + assert.deepEqual(restore.preservedArtifacts, []) + assert.deepEqual(restore.preservedPaths, []) + assert.equal(existsSync(owner.journalPath), false) + assert.equal(existsSync(journal.snapshotDirectory!), false) + assert.equal(readFileSync(path.join(fixture.keptSkillPath, 'SKILL.md'), 'utf8'), '# kept\n') + assert.equal(existsSync(fixture.frontierPath), false) + }) + + it('keeps next-run recovery strictly preserve-only for a live frontier and its descendants', async () => { + const fixture = await setupFrontierFixture() + const { journal, handle } = await beginFallbackJournal(fixture.manifest) + await markFallbackJournalMutating(handle) + mkdirSync(path.join(fixture.frontierPath, 'nested'), { recursive: true }) + writeFileSync(path.join(fixture.frontierPath, 'nested', 'user.txt'), 'KEEP\n') + const recovery = await recoverFallbackJournal(fixture.trackingPath) + assert.equal(recovery.pending, true) + assert.equal(recovery.recovered, false) + // The unmanaged kept link is outside current tracking ownership and the + // live frontier is never restored: both stay preserved and reported. + assert.deepEqual(recovery.preservedPaths, [fixture.keptLinkPath, path.resolve(fixture.frontierPath)].map((value) => path.resolve(value)).sort()) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'nested', 'user.txt'), 'utf8'), 'KEEP\n') + assert.deepEqual(recovery.preservedArtifacts, [handle.journalPath, path.resolve(journal.snapshotDirectory!)].map((value) => path.resolve(value)).sort()) + }) + + it('treats an absent planned frontier as satisfying the missing state during recovery', async () => { + const fixture = await setupFrontierFixture() + const { handle } = await beginFallbackJournal(fixture.manifest) + await markFallbackJournalMutating(handle) + const recovery = await recoverFallbackJournal(fixture.trackingPath) + assert.equal(recovery.pending, true) + assert.equal(recovery.recovered, true) + // Only the tracking-unowned kept link is reported; the absent frontier + // satisfies the original missing state without any reporting inference. + assert.deepEqual(recovery.preservedPaths, [path.resolve(fixture.keptLinkPath)]) + }) + + it('rejects exact immutable-entry tampering: insertion, removal, kind, and digest', async () => { + const fixture = await setupFrontierFixture() + const { handle } = await beginFallbackJournal(fixture.manifest) + interface EditableEntry { path: string; kind: string; existed: boolean; digest?: string } + interface EditableJournal { entries: EditableEntry[] } + const read = (): EditableJournal => JSON.parse(readFileSync(handle.journalPath, 'utf8')) as EditableJournal + const write = (disk: EditableJournal): void => { writeFileSync(handle.journalPath, JSON.stringify(disk, null, 2) + '\n') } + const expectRejection = async (label: string): Promise => { + await assert.rejects(reloadFallbackJournal(handle), /Invalid fallback journal/, `${label} must be rejected`) + } + // Insertion of an unmanifested entry. + const inserted = read() + inserted.entries.push({ path: path.join(home, 'unmanifested'), kind: 'missing', existed: false }) + write(inserted) + await expectRejection('entry insertion') + // Removal of a required entry. + const removed = read() + removed.entries.splice(0, 1) + write(removed) + await expectRejection('entry removal') + // Kind tampering on an evidence-backed entry. + const kindTampered = read() + const skillEntry = kindTampered.entries.find((entry) => path.resolve(entry.path) === path.resolve(fixture.keptSkillPath)) + assert.ok(skillEntry) + skillEntry.kind = 'file' + write(kindTampered) + await expectRejection('entry kind tampering') + // Digest tampering on an evidence-backed entry. + const digestTampered = read() + const skillEntry2 = digestTampered.entries.find((entry) => path.resolve(entry.path) === path.resolve(fixture.keptSkillPath)) + assert.ok(skillEntry2) + skillEntry2.digest = '0'.repeat(64) + write(digestTampered) + await expectRejection('entry digest tampering') + // A frontier entry must stay a pure missing publication unit. + const frontierTampered = read() + const frontierEntry = frontierTampered.entries.find((entry) => path.resolve(entry.path) === path.resolve(fixture.frontierPath)) + assert.ok(frontierEntry) + frontierEntry.digest = '0'.repeat(64) + write(frontierTampered) + await expectRejection('frontier entry digest tampering') + }) +}) + +async function setupValidFixture (): Promise<{ 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)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + digestAlgorithm: 'fallback-path-v2', + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await pathEvidence(skillPath)], + ownedLinks: [await pathEvidence(linkPath)], + ownedMcpFields: [], + ownedMcpConfigPaths: [await pathEvidence(path.join(home, '.claude.json'))], + bundleDestinations: [], + approvedDestinationRoots: [path.resolve(path.join(home, '.agents', 'skills')), path.resolve(getHarnessSkillsPath('claude'))], + } + return { trackingPath, skillPath, linkPath, manifest, trackingJson } +} + +async function pathEvidence (target: string) { + const kind = await pathKind(target) + const digest = kind === 'missing' ? undefined : await pathDigest(target) + return { path: path.resolve(target), kind, digest } +} + +async function frontierAnchorIdentity (anchorPath: string): Promise { + const stats = lstatSync(anchorPath, { bigint: true }) + const real = realpathSync(anchorPath) + if (real !== anchorPath || !stats.isDirectory()) throw new Error(`fixture anchor ${anchorPath} must be a real directory`) + return { path: anchorPath, realpath: real, type: 'directory', device: stats.dev.toString(10), inode: stats.ino.toString(10) } +} + +/** + * Valid claude fixture plus one planned-missing frontier: a topmost missing + * directory `frontier-anchor/new-root` covering two planned skill leaves + * (alpha, beta), alongside retained existing tracked skill/link evidence and + * a missing canonical MCP config outside the frontier. + */ +async function setupFrontierFixture (): Promise<{ + trackingPath: string + anchorPath: string + frontierPath: string + keptSkillPath: string + keptLinkPath: string + frontierEvidence: FallbackFrontierEvidence + manifest: FallbackTransactionIdentity +}> { + const trackingPath = getTrackingFilePath() + const keptSkillPath = path.join(home, '.agents', 'skills', 'kept') + const keptLinkPath = path.join(getHarnessSkillsPath('claude'), 'kept') + mkdirSync(keptSkillPath, { recursive: true }) + writeFileSync(path.join(keptSkillPath, 'SKILL.md'), '# kept\n') + mkdirSync(path.dirname(keptLinkPath), { recursive: true }) + writeFileSync(keptLinkPath, 'kept-link\n') + mkdirSync(path.dirname(trackingPath), { recursive: true }) + const trackingJson = JSON.stringify({ + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + skills: [{ name: 'kept', path: keptSkillPath, paths: { claude: keptSkillPath }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [], + }) + writeFileSync(trackingPath, trackingJson) + const anchorPath = path.join(home, 'frontier-anchor') + mkdirSync(anchorPath) + const frontierPath = path.join(anchorPath, 'new-root') + const frontierEvidence: FallbackFrontierEvidence = { + frontierPath, + activation: 'required', + anchor: await frontierAnchorIdentity(anchorPath), + leaves: [ + { id: 'skill:alpha', role: 'skill', activation: 'required', path: path.join(frontierPath, 'alpha') }, + { id: 'skill:beta', role: 'skill', activation: 'conditional', path: path.join(frontierPath, 'beta') }, + ], + } + const manifest: FallbackTransactionIdentity = { + installationId: 'claude:fallback', + harness: 'claude', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + digestAlgorithm: 'fallback-path-v2', + nonce: randomUUID(), + plannedMissingFrontiers: [frontierEvidence], + ownedSkills: [await pathEvidence(keptSkillPath)], + ownedLinks: [await pathEvidence(keptLinkPath)], + ownedMcpFields: [], + ownedMcpConfigPaths: [await pathEvidence(path.join(home, '.claude.json'))], + bundleDestinations: [await pathEvidence(path.join(frontierPath, 'alpha')), await pathEvidence(path.join(frontierPath, 'beta'))], + approvedDestinationRoots: [path.dirname(keptSkillPath), anchorPath].map((value) => path.resolve(value)), + } + return { trackingPath, anchorPath, frontierPath, keptSkillPath, keptLinkPath, frontierEvidence, manifest } +} + +/** Registers the shared frontier fixture with its intentional conditional skill:beta leaf active. */ +async function registerFrontierStageWithBeta (handle: FallbackJournalHandle, frontierPath: string, staged: string): Promise { + return registerFallbackFrontierStage(handle, frontierPath, staged, { activeConditionalLeafIds: ['skill:beta'] }) +} + +type FrontierFixture = Awaited> + +async function buildStagedFrontier (options: { alphaContent?: string; betaContent?: string } = {}): Promise { + const staged = path.join(home, `staged-frontier-${randomUUID().slice(0, 8)}`) + mkdirSync(path.join(staged, 'alpha'), { recursive: true }) + writeFileSync(path.join(staged, 'alpha', 'SKILL.md'), options.alphaContent ?? '# alpha staged\n') + mkdirSync(path.join(staged, 'beta'), { recursive: true }) + writeFileSync(path.join(staged, 'beta', 'SKILL.md'), options.betaContent ?? '# beta staged\n') + return staged +} + +async function beginMutatingFrontier (fixture: FrontierFixture): Promise { + const { handle } = await beginFallbackJournal(fixture.manifest) + await markFallbackJournalMutating(handle) + // The local transaction lifecycle: the same process self-claims the + // mutator role from the manifest it just planned. + const claimed = await claimFallbackJournalMutation(fixture.manifest, manifestDigestOf(fixture.manifest)) + if (!claimed) throw new Error('mutation claim failed') + return claimed +} + +async function setupPublishedFrontier (): Promise<{ fixture: FrontierFixture; applied: FallbackJournalHandle }> { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + const applied = await applyFallbackEntry(registered, fixture.frontierPath) + return { fixture, applied } +} + +describe('fallback journal frontier publication', () => { + const stageContainers = (fixture: FrontierFixture): string[] => + readdirSync(fixture.anchorPath).filter((name) => name.includes('.nsolid-stage-')) + + /** setupFrontierFixture for a second fixture in one test: the anchor mkdir is not recursive and a pending journal blocks begin. */ + async function freshFrontierFixture (): Promise { + rmSync(fallbackJournalPath(getTrackingFilePath()), { force: true }) + const trackingDir = path.dirname(getTrackingFilePath()) + if (existsSync(trackingDir)) { + for (const name of readdirSync(trackingDir)) { + if (name.startsWith('.nsolid-plugin-update-')) rmSync(path.join(trackingDir, name), { recursive: true, force: true }) + } + } + rmSync(path.join(home, 'frontier-anchor'), { recursive: true, force: true }) + return await setupFrontierFixture() + } + + it('publishes a complete multi-leaf frontier exactly once, in place', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + const before = JSON.parse(readFileSync(registered.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean; stageDigest?: string }> } + const registeredEntry = before.entries.find((entry) => path.resolve(entry.path) === path.resolve(fixture.frontierPath)) + assert.ok(registeredEntry?.stageDigest) + assert.equal(registeredEntry.applied, false) + + let reservedIno = 0 + setFallbackFrontierPublicationSeamForTests((event) => { + if (event.phase === 'reserved-identity') reservedIno = lstatSync(fixture.frontierPath).ino + }) + try { + const applied = await applyFallbackEntry(registered, fixture.frontierPath) + // The reserved directory was populated in place: same inode, no rename. + assert.equal(lstatSync(fixture.frontierPath).ino, reservedIno) + const stageDigest = registeredEntry.stageDigest! + assert.equal(await pathDigest(fixture.frontierPath), stageDigest) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(readFileSync(path.join(fixture.frontierPath, 'beta', 'SKILL.md'), 'utf8'), '# beta staged\n') + const after = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean; stage?: string }> } + const appliedEntry = after.entries.find((entry) => path.resolve(entry.path) === path.resolve(fixture.frontierPath)) + assert.equal(appliedEntry?.applied, true) + assert.equal(appliedEntry?.stage, undefined) + // The authenticated stage container was cleaned after publication. + assert.deepEqual(stageContainers(fixture), []) + assert.equal(existsSync(applied.journalPath), true) + // The completion record is process-local and carries the reserved identity. + const publications = publishedFallbackFrontiers(applied) + assert.equal(publications.length, 1) + assert.equal(path.resolve(publications[0].frontierPath), path.resolve(fixture.frontierPath)) + const liveIdentity = lstatSync(fixture.frontierPath) + assert.equal(publications[0].dev, liveIdentity.dev) + assert.equal(publications[0].ino, liveIdentity.ino) + assert.equal(publications[0].stageDigest, stageDigest) + // Replying a published frontier fails closed without touching bytes. + const digestBeforeReplay = await pathDigest(fixture.frontierPath) + await assert.rejects(applyFallbackEntry(applied, fixture.frontierPath), /No registered stage capability/) + assert.equal(await pathDigest(fixture.frontierPath), digestBeforeReplay) + } finally { + setFallbackFrontierPublicationSeamForTests() + } + }) + + it('never replaces a foreign destination at the frontier path, even byte-identical', async () => { + for (const mode of ['byte-identical', 'empty'] as const) { + const fixture = await freshFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier(mode === 'byte-identical' ? {} : { alphaContent: 'different\n' }) + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + mkdirSync(fixture.frontierPath, { recursive: true }) + if (mode === 'byte-identical') { + mkdirSync(path.join(fixture.frontierPath, 'alpha'), { recursive: true }) + writeFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), '# alpha staged\n') + mkdirSync(path.join(fixture.frontierPath, 'beta'), { recursive: true }) + writeFileSync(path.join(fixture.frontierPath, 'beta', 'SKILL.md'), '# beta staged\n') + } + const foreignIno = lstatSync(fixture.frontierPath).ino + await assert.rejects(applyFallbackEntry(registered, fixture.frontierPath), /FALLBACK_FRONTIER_COLLISION/) + // The existing destination is preserved untouched: same inode, same bytes. + assert.equal(lstatSync(fixture.frontierPath).ino, foreignIno) + if (mode === 'byte-identical') { + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + } else { + assert.deepEqual(readdirSync(fixture.frontierPath), []) + } + // The journal stays pending and the stage artifacts are preserved. + const disk = JSON.parse(readFileSync(registered.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.applied, false) + assert.equal(stageContainers(fixture).length, 1) + } + }) + + it('preserves a foreign destination created inside the reservation window', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + setFallbackFrontierPublicationSeamForTests((event) => { + if (event.phase === 'before-reserve') { + mkdirSync(fixture.frontierPath, { recursive: true }) + writeFileSync(path.join(fixture.frontierPath, 'user.txt'), 'KEEP') + } + }) + try { + await assert.rejects(applyFallbackEntry(registered, fixture.frontierPath), /FALLBACK_FRONTIER_COLLISION/) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'user.txt'), 'utf8'), 'KEEP') + assert.equal(stageContainers(fixture).length, 1) + } finally { + setFallbackFrontierPublicationSeamForTests() + } + }) + + it('rejects arbitrary symlinks in the staged tree before anything goes live', async () => { + for (const [label, place] of [ + ['inside skill content', (staged: string): string => path.join(staged, 'alpha', 'evil-link')], + ['at an unplanned frontier-level path', (staged: string): string => path.join(staged, 'stray-link')], + ] as const) { + const fixture = await freshFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + symlinkSync('/etc', place(staged)) + await assert.rejects(registerFrontierStageWithBeta(owner, fixture.frontierPath, staged), /symlink|unplanned content/, `${label} must be rejected`) + // Nothing live was created and the fresh stage container was contained. + assert.equal(existsSync(fixture.frontierPath), false) + assert.deepEqual(stageContainers(fixture), []) + } + }) + + it('publishes a managed link leaf to the exact final skill path without dereferencing', async () => { + const fixture = await setupFrontierFixture() + const destAnchor = path.join(home, 'dest-anchor') + const linkAnchor = path.join(home, 'link-anchor') + mkdirSync(destAnchor) + mkdirSync(linkAnchor) + const destRoot = path.join(destAnchor, 'skills') + const linkRoot = path.join(linkAnchor, 'skills') + const destGammaPath = path.join(destRoot, 'gamma') + const linkGammaPath = path.join(linkRoot, 'gamma') + const skillFrontier = { + frontierPath: destRoot, + activation: 'required' as const, + anchor: await frontierAnchorIdentity(destAnchor), + leaves: [{ id: 'skill:gamma', role: 'skill' as const, activation: 'required' as const, path: destGammaPath }], + } + const linkFrontier = { + frontierPath: linkRoot, + activation: 'required' as const, + anchor: await frontierAnchorIdentity(linkAnchor), + leaves: [{ id: 'link:gamma', role: 'link' as const, activation: 'required' as const, path: linkGammaPath }], + } + const manifest = { + ...fixture.manifest, + plannedMissingFrontiers: [skillFrontier, linkFrontier], + bundleDestinations: [await pathEvidence(destGammaPath), await pathEvidence(linkGammaPath)], + approvedDestinationRoots: [...fixture.manifest.approvedDestinationRoots, destAnchor, linkAnchor], + } + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + const owner = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + if (!owner) throw new Error('mutation claim failed') + // Staged destination tree and staged link tree (the link text is the + // absolute final skill path; the destination does not exist yet). + const stagedDest = path.join(home, `staged-dest-${randomUUID().slice(0, 8)}`) + mkdirSync(path.join(stagedDest, 'gamma'), { recursive: true }) + writeFileSync(path.join(stagedDest, 'gamma', 'SKILL.md'), '# gamma staged\n') + const stagedLink = path.join(home, `staged-link-${randomUUID().slice(0, 8)}`) + mkdirSync(stagedLink, { recursive: true }) + symlinkSync(destGammaPath, path.join(stagedLink, 'gamma')) + const withDest = await registerFallbackFrontierStage(owner, destRoot, stagedDest) + const withLink = await registerFallbackFrontierStage(withDest, linkRoot, stagedLink) + // The link frontier publishes first, while its target is still missing: + // publication must never dereference the managed link. + const withLinkApplied = await applyFallbackEntry(withLink, linkRoot) + assert.equal(lstatSync(linkGammaPath).isSymbolicLink(), true) + assert.equal(readlinkSync(linkGammaPath), destGammaPath) + assert.equal(existsSync(destGammaPath), false) + const withDestApplied = await applyFallbackEntry(withLinkApplied, destRoot) + assert.equal(readFileSync(path.join(destGammaPath, 'SKILL.md'), 'utf8'), '# gamma staged\n') + assert.equal(readlinkSync(linkGammaPath), destGammaPath) + assert.deepEqual(publishedFallbackFrontiers(withDestApplied).map((publication) => path.resolve(publication.frontierPath)).sort(), [destRoot, linkRoot].map((value) => path.resolve(value)).sort()) + }) + + it('preserves a partially populated frontier and stays pending when population fails', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + // The seam emits op.relative joined with the platform-native separator + // (walkFrontierTree), so match with the native separator: a POSIX literal + // never fires on Windows and the injected fault would be silently skipped. + let injectorReached = false + setFallbackFrontierPublicationSeamForTests((event) => { + if (event.phase === 'before-leaf' && event.leaf === path.join('beta', 'SKILL.md')) { + injectorReached = true + throw new Error('injected failure') + } + }) + try { + await assert.rejects(applyFallbackEntry(registered, fixture.frontierPath), /FALLBACK_FRONTIER_PUBLICATION_INCOMPLETE/) + assert.equal(injectorReached, true) + // The partial population is preserved, never rolled back or removed. + assert.equal(existsSync(fixture.frontierPath), true) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(existsSync(path.join(fixture.frontierPath, 'beta', 'SKILL.md')), false) + // The authenticated stage artifacts and pending journal are preserved. + assert.equal(stageContainers(fixture).length, 1) + const disk = JSON.parse(readFileSync(registered.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean; stageDigest?: string }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.applied, false) + assert.ok(entry?.stageDigest) + assert.equal(existsSync(registered.journalPath), true) + } finally { + setFallbackFrontierPublicationSeamForTests() + } + }) + + it('rejects a foreign nested insertion during exclusive population and preserves everything', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + // Native-separator leaf match as above; the stray write must actually be + // injected for the preservation assertions below to mean anything. + let injectorReached = false + setFallbackFrontierPublicationSeamForTests((event) => { + if (event.phase === 'before-leaf' && event.leaf === path.join('beta', 'SKILL.md')) { + injectorReached = true + writeFileSync(path.join(fixture.frontierPath, 'alpha', 'stray.txt'), 'FOREIGN') + } + }) + try { + await assert.rejects(applyFallbackEntry(registered, fixture.frontierPath), /FALLBACK_FRONTIER_PUBLICATION_INCOMPLETE/) + assert.equal(injectorReached, true) + // The foreign insertion is preserved, not deleted. + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'stray.txt'), 'utf8'), 'FOREIGN') + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(stageContainers(fixture).length, 1) + const disk = JSON.parse(readFileSync(registered.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.applied, false) + } finally { + setFallbackFrontierPublicationSeamForTests() + } + }) + + it('detects a reserved-directory replacement between mkdir and identity observation', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + // Deterministic replacement: rename the reserved directory away and move + // a pre-existing directory (guaranteed different inode) into the path. + const replacement = path.join(home, 'replacement-dir') + mkdirSync(replacement) + writeFileSync(path.join(replacement, 'replacement.txt'), 'REPLACED') + setFallbackFrontierPublicationSeamForTests((event) => { + if (event.phase === 'reserved-identity') { + renameSync(fixture.frontierPath, path.join(home, 'stashed-reserved')) + renameSync(replacement, fixture.frontierPath) + } + }) + try { + await assert.rejects(applyFallbackEntry(registered, fixture.frontierPath), /replaced between reservation and identity observation/) + // Both the stashed original reservation and the replacement are + // preserved, never deleted. + assert.equal(readFileSync(path.join(fixture.frontierPath, 'replacement.txt'), 'utf8'), 'REPLACED') + assert.equal(existsSync(path.join(home, 'stashed-reserved')), true) + assert.equal(stageContainers(fixture).length, 1) + const disk = JSON.parse(readFileSync(registered.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.applied, false) + } finally { + setFallbackFrontierPublicationSeamForTests() + } + }) + + it('detects post-population drift and preserves the published bytes', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + setFallbackFrontierPublicationSeamForTests((event) => { + if (event.phase === 'post-population') { + writeFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), '# alpha staged\n tampered') + } + }) + try { + await assert.rejects(applyFallbackEntry(registered, fixture.frontierPath), /FALLBACK_FRONTIER_PUBLICATION_INCOMPLETE/) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n tampered') + assert.equal(existsSync(path.join(fixture.frontierPath, 'beta', 'SKILL.md')), true) + assert.equal(stageContainers(fixture).length, 1) + const disk = JSON.parse(readFileSync(registered.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.applied, false) + } finally { + setFallbackFrontierPublicationSeamForTests() + } + }) + + it('never lets forged journal fields fake publication completion', async () => { + // (a) A live foreign destination plus forged applied/stageDigest fields: + // the payload digest check fails first and the live bytes stay untouched. + const forgedFixture = await setupFrontierFixture() + const forgedOwner = await beginMutatingFrontier(forgedFixture) + const forgedStaged = await buildStagedFrontier() + const forgedRegistered = await registerFrontierStageWithBeta(forgedOwner, forgedFixture.frontierPath, forgedStaged) + mkdirSync(forgedFixture.frontierPath, { recursive: true }) + writeFileSync(path.join(forgedFixture.frontierPath, 'foreign.txt'), 'FOREIGN') + const foreignIno = lstatSync(forgedFixture.frontierPath).ino + const forged = JSON.parse(readFileSync(forgedRegistered.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean }> } + const forgedEntry = forged.entries.find((entry) => path.resolve(entry.path) === path.resolve(forgedFixture.frontierPath)) + assert.ok(forgedEntry) + forgedEntry.applied = true + writeFileSync(forgedRegistered.journalPath, JSON.stringify(forged, null, 2) + '\n') + await assert.rejects(applyFallbackEntry(forgedRegistered, forgedFixture.frontierPath), /FALLBACK_FRONTIER_COLLISION/) + assert.equal(lstatSync(forgedFixture.frontierPath).ino, foreignIno) + assert.equal(readFileSync(path.join(forgedFixture.frontierPath, 'foreign.txt'), 'utf8'), 'FOREIGN') + // Forged disk fields never create process-local completion authority. + assert.deepEqual(publishedFallbackFrontiers(forgedRegistered), []) + assert.equal(existsSync(forgedRegistered.journalPath), true) + + // (b) A forged digest matching a live foreign tree still fails the + // missing-destination check before any reservation. + const driftedFixture = await freshFrontierFixture() + const driftedOwner = await beginMutatingFrontier(driftedFixture) + const driftedStaged = await buildStagedFrontier() + const driftedRegistered = await registerFrontierStageWithBeta(driftedOwner, driftedFixture.frontierPath, driftedStaged) + mkdirSync(driftedFixture.frontierPath, { recursive: true }) + const driftedDisk = JSON.parse(readFileSync(driftedRegistered.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean; stageDigest?: string }> } + const driftedEntry = driftedDisk.entries.find((entry) => path.resolve(entry.path) === path.resolve(driftedFixture.frontierPath)) + assert.ok(driftedEntry) + driftedEntry.applied = true + driftedEntry.stageDigest = await pathDigest(driftedFixture.frontierPath) + writeFileSync(driftedRegistered.journalPath, JSON.stringify(driftedDisk, null, 2) + '\n') + await assert.rejects(applyFallbackEntry(driftedRegistered, driftedFixture.frontierPath), /capability digest/) + assert.deepEqual(publishedFallbackFrontiers(driftedRegistered), []) + assert.equal(existsSync(driftedRegistered.journalPath), true) + }) + + it('rejects a tampered stage payload before any reservation', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + const container = stageContainers(fixture)[0] + assert.ok(container) + writeFileSync(path.join(fixture.anchorPath, container, 'payload', 'alpha', 'SKILL.md'), 'tampered\n') + await assert.rejects(applyFallbackEntry(registered, fixture.frontierPath), /no longer matches its registered digest/) + assert.equal(existsSync(fixture.frontierPath), false) + assert.equal(existsSync(registered.journalPath), true) + const disk = JSON.parse(readFileSync(registered.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.applied, false) + }) + + it('refuses frontier registration for a path that is not a planned frontier', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + await assert.rejects(registerFallbackFrontierStage(owner, path.join(fixture.anchorPath, 'other-root'), staged), /No planned fallback frontier/) + assert.equal(existsSync(fixture.frontierPath), false) + assert.deepEqual(stageContainers(fixture), []) + }) + + it('rejects simultaneous payload and journal digest tampering against the immutable registered digest', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + const container = stageContainers(fixture)[0] + assert.ok(container) + // Tamper the staged payload AND update the disk journal digest to the + // tampered tree's digest, leaving the revision untouched: only the + // process-local registered digest must stand in the way. + const payloadAlpha = path.join(fixture.anchorPath, container, 'payload', 'alpha', 'SKILL.md') + writeFileSync(payloadAlpha, 'EVIL\n') + const disk = JSON.parse(readFileSync(registered.journalPath, 'utf8')) as { entries: Array<{ path: string; stageDigest?: string }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.ok(entry) + entry.stageDigest = await pathDigest(path.join(fixture.anchorPath, container, 'payload')) + writeFileSync(registered.journalPath, JSON.stringify(disk, null, 2) + '\n') + await assert.rejects(applyFallbackEntry(registered, fixture.frontierPath), /capability digest/) + // Zero live mutation and no completion authority from the tampered pair. + assert.equal(existsSync(fixture.frontierPath), false) + assert.equal(stageContainers(fixture).length, 1) + assert.deepEqual(publishedFallbackFrontiers(registered), []) + }) + + it('never lets a replaced destination ancestor redirect writes outside the reservation', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + const external = path.join(home, 'external-redirect') + mkdirSync(external) + const stashed = path.join(home, 'stashed-alpha') + // Native-separator leaf match as above; the ancestor replacement must + // actually be injected for the redirect-containment assertions to run. + let injectorReached = false + setFallbackFrontierPublicationSeamForTests((event) => { + if (event.phase === 'before-leaf' && event.leaf === path.join('alpha', 'SKILL.md')) { + injectorReached = true + // Deterministically rename the created `alpha` directory aside and + // replace its path with a symlink to an external directory. + renameSync(path.join(fixture.frontierPath, 'alpha'), stashed) + symlinkSync(external, path.join(fixture.frontierPath, 'alpha')) + } + }) + try { + await assert.rejects(applyFallbackEntry(registered, fixture.frontierPath), /destination directory alpha was replaced during publication/) + assert.equal(injectorReached, true) + // The external redirect target was never written and nothing was deleted. + assert.deepEqual(readdirSync(external), []) + // `alpha` was stashed before its SKILL.md was copied: an empty managed dir. + assert.equal(existsSync(stashed), true) + assert.deepEqual(readdirSync(stashed), []) + assert.equal(lstatSync(path.join(fixture.frontierPath, 'alpha')).isSymbolicLink(), true) + assert.equal(stageContainers(fixture).length, 1) + const disk = JSON.parse(readFileSync(registered.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.applied, false) + } finally { + setFallbackFrontierPublicationSeamForTests() + } + }) + + it('publishes a directory skill leaf that exactly equals the frontier root', async () => { + const trackingPath = getTrackingFilePath() + const keptSkillPath = path.join(home, '.agents', 'skills', 'kept') + const keptLinkPath = path.join(getHarnessSkillsPath('claude'), 'kept') + mkdirSync(keptSkillPath, { recursive: true }) + writeFileSync(path.join(keptSkillPath, 'SKILL.md'), '# kept\n') + mkdirSync(path.dirname(keptLinkPath), { recursive: true }) + writeFileSync(keptLinkPath, 'kept-link\n') + mkdirSync(path.dirname(trackingPath), { recursive: true }) + const trackingJson = JSON.stringify({ + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + skills: [{ name: 'kept', path: keptSkillPath, paths: { claude: keptSkillPath }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [], + }) + writeFileSync(trackingPath, trackingJson) + const anchorPath = path.join(home, 'root-skill-anchor') + mkdirSync(anchorPath) + const frontierPath = path.join(anchorPath, 'whole-skill') + const frontierEvidence: FallbackFrontierEvidence = { + frontierPath, + activation: 'required', + anchor: await frontierAnchorIdentity(anchorPath), + leaves: [{ id: 'skill:whole', role: 'skill', activation: 'required', path: frontierPath }], + } + const manifest: FallbackTransactionIdentity = { + installationId: 'claude:fallback', + harness: 'claude', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + digestAlgorithm: 'fallback-path-v2', + nonce: randomUUID(), + plannedMissingFrontiers: [frontierEvidence], + ownedSkills: [await pathEvidence(keptSkillPath)], + ownedLinks: [await pathEvidence(keptLinkPath)], + ownedMcpFields: [], + ownedMcpConfigPaths: [await pathEvidence(path.join(home, '.claude.json'))], + bundleDestinations: [await pathEvidence(frontierPath)], + approvedDestinationRoots: [path.resolve(path.join(home, '.agents', 'skills')), anchorPath], + } + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + const owner = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + if (!owner) throw new Error('mutation claim failed') + // The whole staged tree is the skill's managed interior. + const staged = path.join(home, `staged-root-${randomUUID().slice(0, 8)}`) + mkdirSync(staged, { recursive: true }) + writeFileSync(path.join(staged, 'SKILL.md'), '# whole skill\n') + mkdirSync(path.join(staged, 'interior')) + writeFileSync(path.join(staged, 'interior', 'note.txt'), 'interior\n') + const registered = await registerFallbackFrontierStage(owner, frontierPath, staged) + const applied = await applyFallbackEntry(registered, frontierPath) + assert.equal(readFileSync(path.join(frontierPath, 'SKILL.md'), 'utf8'), '# whole skill\n') + assert.equal(readFileSync(path.join(frontierPath, 'interior', 'note.txt'), 'utf8'), 'interior\n') + const publications = publishedFallbackFrontiers(applied) + assert.equal(publications.length, 1) + assert.equal(path.resolve(publications[0].frontierPath), frontierPath) + assert.equal(publications[0].dev, lstatSync(frontierPath).dev) + assert.equal(publications[0].ino, lstatSync(frontierPath).ino) + assert.deepEqual(stageContainers({ anchorPath } as never), []) + }) + + it('registers a link frontier whose final skill destination already exists', async () => { + const fixture = await setupFrontierFixture() + const destAnchor = path.join(home, 'existing-dest-anchor') + const linkAnchor = path.join(home, 'existing-link-anchor') + mkdirSync(destAnchor) + mkdirSync(linkAnchor) + const destRoot = path.join(destAnchor, 'skills') + const linkRoot = path.join(linkAnchor, 'skills') + const destGammaPath = path.join(destRoot, 'gamma') + const linkGammaPath = path.join(linkRoot, 'gamma') + // The final skill destination already exists, so it is an existing leaf — + // not a frontier leaf. Only the link's parent is a planned-missing + // frontier, and registration must still bind the link to its skill via + // the trusted manifest resolver. + mkdirSync(destGammaPath, { recursive: true }) + writeFileSync(path.join(destGammaPath, 'SKILL.md'), '# gamma existing\n') + const linkFrontier: FallbackFrontierEvidence = { + frontierPath: linkRoot, + activation: 'required', + anchor: await frontierAnchorIdentity(linkAnchor), + leaves: [{ id: 'link:gamma', role: 'link', activation: 'required', path: linkGammaPath }], + } + const manifest = { + ...fixture.manifest, + plannedMissingFrontiers: [linkFrontier], + bundleDestinations: [await pathEvidence(destGammaPath), await pathEvidence(linkGammaPath)], + approvedDestinationRoots: [...fixture.manifest.approvedDestinationRoots, destAnchor, linkAnchor], + } + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + const owner = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + if (!owner) throw new Error('mutation claim failed') + const stagedLink = path.join(home, `staged-link-${randomUUID().slice(0, 8)}`) + mkdirSync(stagedLink, { recursive: true }) + symlinkSync(destGammaPath, path.join(stagedLink, 'gamma')) + const registered = await registerFallbackFrontierStage(owner, linkRoot, stagedLink) + const applied = await applyFallbackEntry(registered, linkRoot) + assert.equal(lstatSync(linkGammaPath).isSymbolicLink(), true) + assert.equal(readlinkSync(linkGammaPath), destGammaPath) + assert.equal(readFileSync(path.join(destGammaPath, 'SKILL.md'), 'utf8'), '# gamma existing\n') + assert.equal(publishedFallbackFrontiers(applied).length, 1) + }) + + it('preserves a foreign replacement of the stage container when registration fails', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const foreign = path.join(home, 'foreign-stage') + mkdirSync(foreign) + writeFileSync(path.join(foreign, 'foreign.txt'), 'FOREIGN') + setFallbackFrontierPublicationSeamForTests((event) => { + if (event.phase === 'stage-ready') { + // Move the allocated container aside, put a foreign directory in its + // pathname, and corrupt the disk revision so the durable CAS fails. + const containers = readdirSync(fixture.anchorPath).filter((name) => name.includes('.nsolid-stage-')) + const containerPath = path.join(fixture.anchorPath, containers[0]) + renameSync(containerPath, path.join(home, 'stashed-stage')) + renameSync(foreign, containerPath) + const disk = JSON.parse(readFileSync(owner.journalPath, 'utf8')) as { revision: number } + disk.revision += 1000 + writeFileSync(owner.journalPath, JSON.stringify(disk, null, 2) + '\n') + } + }) + try { + await assert.rejects(registerFrontierStageWithBeta(owner, fixture.frontierPath, staged), /Invalid fallback journal/) + // The foreign replacement is preserved (authenticated cleanup refuses + // to destroy an unproven container) and the stashed original too. + const containers = readdirSync(fixture.anchorPath).filter((name) => name.includes('.nsolid-stage-')) + assert.equal(containers.length, 1) + assert.equal(readFileSync(path.join(fixture.anchorPath, containers[0], 'foreign.txt'), 'utf8'), 'FOREIGN') + assert.equal(existsSync(path.join(home, 'stashed-stage', 'payload', 'alpha', 'SKILL.md')), true) + assert.equal(existsSync(fixture.frontierPath), false) + } finally { + setFallbackFrontierPublicationSeamForTests() + } + }) + + it('reports a completed publication when only post-apply bookkeeping fails', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + setFallbackFrontierPublicationSeamForTests((event) => { + if (event.phase === 'after-applied') { + // The first CAS has already durably recorded applied:true and stage + // cleanup succeeds right after; corrupt the disk revision so the + // post-apply bookkeeping CAS fails. + const disk = JSON.parse(readFileSync(registered.journalPath, 'utf8')) as { revision: number } + disk.revision += 1000 + writeFileSync(registered.journalPath, JSON.stringify(disk, null, 2) + '\n') + } + }) + try { + // The publication IS complete: a bookkeeping failure must never become + // FALLBACK_FRONTIER_PUBLICATION_INCOMPLETE and must never promise a + // pending journal or a preserved stage. + const completed = await applyFallbackEntry(registered, fixture.frontierPath) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(readFileSync(path.join(fixture.frontierPath, 'beta', 'SKILL.md'), 'utf8'), '# beta staged\n') + const publications = publishedFallbackFrontiers(completed) + assert.equal(publications.length, 1) + assert.equal(publications[0].cleanupPending, true) + // Stage cleanup already succeeded before the bookkeeping failure. + assert.deepEqual(stageContainers(fixture), []) + const disk = JSON.parse(readFileSync(completed.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.applied, true) + } finally { + setFallbackFrontierPublicationSeamForTests() + } + }) + + it('rejects staged frontier trees exceeding the structural bounds before allocation', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = path.join(home, `staged-deep-${randomUUID().slice(0, 8)}`) + mkdirSync(path.join(staged, 'alpha'), { recursive: true }) + writeFileSync(path.join(staged, 'alpha', 'SKILL.md'), '# alpha\n') + // The deep chain lives inside the planned directory leaf `beta` (its + // interior is managed content), keeping the leaf graph exact so the + // failure is proven by the depth bound, not by classification. + let deep = path.join(staged, 'beta') + for (let depth = 0; depth <= 16; depth++) { + deep = path.join(deep, `level-${depth}`) + } + mkdirSync(deep, { recursive: true }) + writeFileSync(path.join(deep, 'note.txt'), 'deep\n') + await assert.rejects(registerFrontierStageWithBeta(owner, fixture.frontierPath, staged), /maximum depth/) + assert.equal(existsSync(fixture.frontierPath), false) + assert.deepEqual(stageContainers(fixture), []) + }) + + it('reports cleanup-pending when authenticated stage cleanup refuses, even with successful bookkeeping', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + setFallbackFrontierPublicationSeamForTests((event) => { + if (event.phase === 'post-population') { + // An unexpected direct child makes the authenticated container proof + // fail, so cleanup refuses and preserves the stage; the bookkeeping + // CAS after cleanup still succeeds. + writeFileSync(path.join(fixture.anchorPath, stageContainers(fixture)[0], 'smuggled.txt'), 'SMUGGLED') + } + }) + try { + const completed = await applyFallbackEntry(registered, fixture.frontierPath) + // Publication is complete and the bookkeeping CAS succeeded. + const disk = JSON.parse(readFileSync(completed.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean; stage?: string }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.applied, true) + // Cleanup refused: the stage is preserved with its smuggled child and + // the record is frozen cleanup-pending. + assert.equal(stageContainers(fixture).length, 1) + assert.equal(readFileSync(path.join(fixture.anchorPath, stageContainers(fixture)[0], 'smuggled.txt'), 'utf8'), 'SMUGGLED') + const publications = publishedFallbackFrontiers(completed) + assert.equal(publications.length, 1) + assert.equal(publications[0].cleanupPending, true) + } finally { + setFallbackFrontierPublicationSeamForTests() + } + }) + + it('fails oversized post-registration tamper within bounds before any live mutation', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + const container = stageContainers(fixture)[0] + assert.ok(container) + // Replace a small registered file with content beyond the per-file bound. + const bigPath = path.join(fixture.anchorPath, container, 'payload', 'alpha', 'SKILL.md') + writeFileSync(bigPath, 'x'.repeat(8 * 1024 * 1024 + 1)) + let reachedReserve = false + setFallbackFrontierPublicationSeamForTests((event) => { + if (event.phase === 'before-reserve') reachedReserve = true + }) + try { + await assert.rejects(applyFallbackEntry(registered, fixture.frontierPath), /exceeds the maximum file size/) + // The failure happened inside the single bounded walk, before the + // reservation seam: no unbounded read and zero live mutation. + assert.equal(reachedReserve, false) + assert.equal(existsSync(fixture.frontierPath), false) + assert.equal(stageContainers(fixture).length, 1) + } finally { + setFallbackFrontierPublicationSeamForTests() + } + }) + + it('returns frozen copies whose mutation cannot alter later publishedFallbackFrontiers reads', async () => { + // A genuine publication through the integration-style frontier APIs: the + // process-local record is created only by a real applyFallbackEntry call. + const { fixture, applied } = await setupPublishedFrontier() + const authentic = { + frontierPath: path.resolve(fixture.frontierPath), + dev: lstatSync(fixture.frontierPath).dev, + ino: lstatSync(fixture.frontierPath).ino, + stageDigest: await pathDigest(fixture.frontierPath), + cleanupPending: false, + } + const first = publishedFallbackFrontiers(applied) + assert.equal(first.length, 1) + // Attack every mutable field of the returned copy. Object.freeze makes + // strict-mode writes throw (ESM tests are strict); both a throw and a + // silently ignored write are acceptable, the source of truth must not + // move either way. + const record = first[0] as unknown as Record + const attacks: Array<[string, unknown]> = [ + ['frontierPath', path.resolve(home, 'elsewhere')], + ['dev', 999999], + ['ino', 999999], + ['stageDigest', 'f'.repeat(64)], + ['cleanupPending', true], + ] + for (const [field, value] of attacks) { + try { + record[field] = value + } catch { /* strict-mode frozen write throws; both outcomes are safe */ } + } + // Adding properties must not work either. + try { + ;(record as Record).planted = true + } catch { /* same */ } + const second = publishedFallbackFrontiers(applied) + assert.equal(second.length, 1) + assert.equal(path.resolve(second[0].frontierPath), authentic.frontierPath) + assert.equal(second[0].dev, authentic.dev) + assert.equal(second[0].ino, authentic.ino) + assert.equal(second[0].stageDigest, authentic.stageDigest) + assert.equal(second[0].cleanupPending, false) + assert.equal(Object.isFrozen(second[0]), true) + }) +}) +describe('fallback journal frontier rollback authority', () => { + const quarantineContainers = (anchorPath: string): string[] => + readdirSync(anchorPath).filter((name) => name.includes('.nsolid-quarantine-')) + + afterEach(() => { + setFallbackFrontierRollbackSeamForTests() + setFallbackFrontierPublicationSeamForTests() + }) + + it('rolls a genuine same-process publication back to missing and preserves the exact tree in quarantine', async () => { + const { fixture, applied } = await setupPublishedFrontier() + assert.equal(publishedFallbackFrontiers(applied).length, 1) + const inoBefore = lstatSync(fixture.frontierPath).ino + const restore = await restoreFallbackJournal(applied) + // The physical restore IS complete: the missing state is back. + assert.equal(restore.succeeded, true) + assert.equal(restore.frontierJournalPending, undefined) + assert.equal(existsSync(fixture.frontierPath), false) + assert.deepEqual(publishedFallbackFrontiers(applied), []) + // The quarantine preserves the exact proven tree as a reporting artifact. + const containers = quarantineContainers(fixture.anchorPath) + assert.equal(containers.length, 1) + const movedRoot = path.join(fixture.anchorPath, containers[0], path.basename(fixture.frontierPath)) + assert.equal(readFileSync(path.join(movedRoot, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(readFileSync(path.join(movedRoot, 'beta', 'SKILL.md'), 'utf8'), '# beta staged\n') + assert.equal(lstatSync(movedRoot).ino, inoBefore) + // A mutator handle deliberately leaves the journal for its owner; the + // rollback itself completed (missing state + preserved quarantine). + assert.equal(existsSync(applied.journalPath), true) + }) + + it('lets a locally reclaimed owner roll back its own publication', async () => { + const fixture = await setupFrontierFixture() + const first = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(first, fixture.frontierPath, staged) + const applied = await applyFallbackEntry(registered, fixture.frontierPath) + // Reclaim in the SAME process: the completion capability survives the + // handle spread and binds to the transaction, so the new mutator handle + // still holds the rollback authority. + const reclaimed = await reclaimFallbackJournalMutation(applied) + const restore = await restoreFallbackJournal(reclaimed) + assert.equal(restore.succeeded, true) + assert.equal(existsSync(fixture.frontierPath), false) + assert.equal(quarantineContainers(fixture.anchorPath).length, 1) + }) + + it('fail-closes when the process-local capability is cleared (simulated restart)', async () => { + const { fixture, applied } = await setupPublishedFrontier() + clearFallbackFrontierPublicationStateForTests() + const before = lstatSync(fixture.frontierPath) + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + // The live frontier is preserved untouched, never renamed or deleted. + assert.equal(lstatSync(fixture.frontierPath).ino, before.ino) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.deepEqual(restore.preservedPaths, [path.resolve(fixture.frontierPath)]) + assert.equal(existsSync(applied.journalPath), true) + }) + + it('never lets forged disk fields grant rollback authority', async () => { + const { fixture, applied } = await setupPublishedFrontier() + clearFallbackFrontierPublicationStateForTests() + // Forge the full disk authorization surface: applied, stage digest, and a + // fabricated quarantine identity. None of it may move the live root. + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { revision: number; entries: Array<{ path: string; applied?: boolean; stageDigest?: string; quarantine?: string }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.ok(entry) + entry.applied = true + entry.stageDigest = await pathDigest(fixture.frontierPath) + disk.revision = JSON.parse(readFileSync(applied.journalPath, 'utf8')).revision + writeFileSync(applied.journalPath, JSON.stringify(disk, null, 2) + '\n') + const before = lstatSync(fixture.frontierPath) + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.equal(lstatSync(fixture.frontierPath).ino, before.ino) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + }) + + it('preserves a drifted frontier and reports the rollback unproven', async () => { + type Drift = { label: string; tamper: (frontierPath: string, anchorPath: string) => void } + const drifts: Drift[] = [ + { label: 'content drift', tamper: (frontierPath) => { writeFileSync(path.join(frontierPath, 'alpha', 'SKILL.md'), '# tampered\n') } }, + { label: 'extra child', tamper: (frontierPath) => { writeFileSync(path.join(frontierPath, 'stray.txt'), 'FOREIGN\n') } }, + { label: 'symlink child', tamper: (frontierPath, anchorPath) => { symlinkSync(path.join(anchorPath, 'elsewhere'), path.join(frontierPath, 'gamma')) } }, + ] + for (const { label, tamper } of drifts) { + const fixture = await setupFrontierFixture() + const { journal, handle } = await beginFallbackJournal(fixture.manifest) + await markFallbackJournalMutating(handle) + const owner = await claimFallbackJournalMutation(fixture.manifest, manifestDigestOf(fixture.manifest)) + if (!owner) throw new Error('mutation claim failed') + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + const applied = await applyFallbackEntry(registered, fixture.frontierPath) + tamper(fixture.frontierPath, fixture.anchorPath) + const before = lstatSync(fixture.frontierPath) + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, false, label) + assert.equal(restore.unproven, true, label) + assert.equal(lstatSync(fixture.frontierPath).ino, before.ino, label) + assert.equal(quarantineContainers(fixture.anchorPath).length, 0, label) + assert.equal(existsSync(applied.journalPath), true, label) + // Reset for the next iteration: remove the anchor (with the drift) and + // dispose this transaction's journal so the next begin can reserve it. + rmSync(fixture.anchorPath, { recursive: true, force: true }) + rmSync(applied.journalPath, { force: true }) + rmSync(journal.snapshotDirectory!, { recursive: true, force: true }) + } + }) + + it('preserves a frontier whose root identity was replaced after publication', async () => { + const { fixture, applied } = await setupPublishedFrontier() + // Swap the published root for a byte-identical but different-inode copy. + const stashed = path.join(home, 'stashed-root') + renameSync(fixture.frontierPath, stashed) + const copy = path.join(home, 'copied-root') + cpSync(stashed, copy, { recursive: true, verbatimSymlinks: true }) + renameSync(copy, fixture.frontierPath) + rmSync(stashed, { recursive: true, force: true }) + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.equal(existsSync(fixture.frontierPath), true) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(quarantineContainers(fixture.anchorPath).length, 0) + }) + + it('preserves a partially published frontier (never proven complete)', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + // Native-separator leaf match as above; the injected failure must fire + // for the unproven-restore assertions below to be exercised. + let injectorReached = false + setFallbackFrontierPublicationSeamForTests((event) => { + if (event.phase === 'before-leaf' && event.leaf === path.join('beta', 'SKILL.md')) { + injectorReached = true + throw new Error('injected failure') + } + }) + try { + await assert.rejects(applyFallbackEntry(registered, fixture.frontierPath), /FALLBACK_FRONTIER_PUBLICATION_INCOMPLETE/) + assert.equal(injectorReached, true) + // No completion record exists, so the restore can never quarantine it. + const restore = await restoreFallbackJournal(registered) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.equal(existsSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md')), true) + assert.equal(existsSync(path.join(fixture.frontierPath, 'beta', 'SKILL.md')), false) + assert.equal(quarantineContainers(fixture.anchorPath).length, 0) + } finally { + setFallbackFrontierPublicationSeamForTests() + } + }) + + it('keeps an external parent without publication capability preserve-only', async () => { + // Simulated by clearing the registry after a genuine publication in this + // same process, since the suite cannot spawn a real second process: the + // semantic under test is the absence of capability, not the process id. + const { fixture, applied } = await setupPublishedFrontier() + clearFallbackFrontierPublicationStateForTests() + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.equal(existsSync(fixture.frontierPath), true) + }) + + it('keeps next-run recovery strictly preserve-only for a published frontier', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(owner, fixture.frontierPath, staged) + await applyFallbackEntry(registered, fixture.frontierPath) + clearFallbackFrontierPublicationStateForTests() + const recovery = await recoverFallbackJournal(fixture.trackingPath) + assert.equal(recovery.pending, true) + assert.equal(recovery.recovered, false) + assert.ok(recovery.preservedPaths.includes(path.resolve(fixture.frontierPath))) + assert.equal(existsSync(fixture.frontierPath), true) + }) + + it('consumes capabilities and rejects a genuine handle retargeted in place to another live transaction', async () => { + const { fixture, applied } = await setupPublishedFrontier() + const first = await restoreFallbackJournal(applied) + assert.equal(first.succeeded, true) + assert.equal(existsSync(fixture.frontierPath), false) + // Replay against the same handle: the rollback consumed the one-shot + // capability AND the CAS bumped the revision, so the stale handle fails + // closed without touching anything. + const replay = await restoreFallbackJournal(applied) + assert.equal(replay.succeeded, false) + assert.equal(replay.unproven, true) + assert.equal(existsSync(fixture.frontierPath), false) + // A second frontier published by another transaction must not be + // reachable from the first transaction's handles. Dispose the first + // transaction's pending journal (mutator leaves it) and snapshots so the + // second begin can reserve the journal path. + rmSync(applied.journalPath, { force: true }) + const trackingDir = path.dirname(getTrackingFilePath()) + for (const name of readdirSync(trackingDir)) { + if (name.startsWith('.nsolid-plugin-update-')) rmSync(path.join(trackingDir, name), { recursive: true, force: true }) + } + rmSync(path.join(home, 'frontier-anchor'), { recursive: true, force: true }) + const second = await setupFrontierFixture() + const secondOwner = await beginMutatingFrontier(second) + const secondStaged = await buildStagedFrontier() + const secondRegistered = await registerFrontierStageWithBeta(secondOwner, second.frontierPath, secondStaged) + const secondApplied = await applyFallbackEntry(secondRegistered, second.frontierPath) + // Retarget the still-genuine transaction-A object in place with every + // valid current field from transaction B. A WeakSet-only gate accepts the + // object and reaches B; immutable WeakMap issuance metadata must reject it. + Object.assign(applied as unknown as Record, secondApplied) + const secondBefore = lstatSync(second.frontierPath) + const cross = await restoreFallbackJournal(applied) + assert.equal(cross.succeeded, false) + assert.equal(cross.unproven, true) + assert.equal(lstatSync(second.frontierPath).ino, secondBefore.ino) + assert.equal(quarantineContainers(second.anchorPath).length, 0) + // B's own genuine current handle still succeeds, proving the rejection + // came from issued-object binding rather than invalid transaction fields. + const secondRestore = await restoreFallbackJournal(secondApplied) + assert.equal(secondRestore.succeeded, true) + assert.equal(existsSync(second.frontierPath), false) + // The first handle still cannot reach the second (now missing) frontier + // and the first transaction's quarantine remains intact. + assert.equal(quarantineContainers(second.anchorPath).length, 1) + }) + + it('rolls back two completed frontiers healthily with both quarantines and overall success', async () => { + const fixture = await setupFrontierFixture() + const destAnchor = path.join(home, 'two-dest-anchor') + const linkAnchor = path.join(home, 'two-link-anchor') + mkdirSync(destAnchor) + mkdirSync(linkAnchor) + const destRoot = path.join(destAnchor, 'skills') + const linkRoot = path.join(linkAnchor, 'skills') + const destGammaPath = path.join(destRoot, 'gamma') + const linkGammaPath = path.join(linkRoot, 'gamma') + const skillFrontier = { + frontierPath: destRoot, + activation: 'required' as const, + anchor: await frontierAnchorIdentity(destAnchor), + leaves: [{ id: 'skill:gamma', role: 'skill' as const, activation: 'required' as const, path: destGammaPath }], + } + const linkFrontier = { + frontierPath: linkRoot, + activation: 'required' as const, + anchor: await frontierAnchorIdentity(linkAnchor), + leaves: [{ id: 'link:gamma', role: 'link' as const, activation: 'required' as const, path: linkGammaPath }], + } + const manifest = { + ...fixture.manifest, + plannedMissingFrontiers: [skillFrontier, linkFrontier], + bundleDestinations: [await pathEvidence(destGammaPath), await pathEvidence(linkGammaPath)], + approvedDestinationRoots: [...fixture.manifest.approvedDestinationRoots, destAnchor, linkAnchor], + } + rmSync(fallbackJournalPath(getTrackingFilePath()), { force: true }) + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + const owner = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + if (!owner) throw new Error('mutation claim failed') + const stagedDest = path.join(home, `two-staged-dest-${randomUUID().slice(0, 8)}`) + mkdirSync(path.join(stagedDest, 'gamma'), { recursive: true }) + writeFileSync(path.join(stagedDest, 'gamma', 'SKILL.md'), '# gamma two\n') + const stagedLink = path.join(home, `two-staged-link-${randomUUID().slice(0, 8)}`) + mkdirSync(stagedLink, { recursive: true }) + symlinkSync(destGammaPath, path.join(stagedLink, 'gamma')) + const withDest = await registerFallbackFrontierStage(owner, destRoot, stagedDest) + const withLink = await registerFallbackFrontierStage(withDest, linkRoot, stagedLink) + const appliedLink = await applyFallbackEntry(withLink, linkRoot) + const appliedBoth = await applyFallbackEntry(appliedLink, destRoot) + assert.equal(publishedFallbackFrontiers(appliedBoth).length, 2) + const restore = await restoreFallbackJournal(appliedBoth) + // Healthy multi-frontier rollback: BOTH quarantines reported, overall success. + assert.equal(restore.succeeded, true) + assert.equal(restore.frontierJournalPending, undefined) + assert.equal(restore.preservedArtifacts.length, 2) + assert.equal(quarantineContainers(destAnchor).length, 1) + assert.equal(quarantineContainers(linkAnchor).length, 1) + assert.equal(existsSync(destRoot), false) + assert.equal(existsSync(linkRoot), false) + assert.deepEqual(publishedFallbackFrontiers(appliedBoth), []) + }) + + it('reports a structured incomplete when an unplanned child appears at the moved seam', async () => { + const { fixture, applied } = await setupPublishedFrontier() + let quarantineContainer: string | undefined + setFallbackFrontierRollbackSeamForTests((event) => { + if (event.phase === 'moved') { + quarantineContainer = path.dirname(event.quarantinePath!) + writeFileSync(path.join(event.quarantinePath!, 'stray-unplanned.txt'), 'FOREIGN\n') + } + }) + try { + const restore = await restoreFallbackJournal(applied) + // One structured incomplete outcome: exact container, durable pending + // retained, no partial success. + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.ok(quarantineContainer) + assert.deepEqual(restore.preservedArtifacts, [path.resolve(quarantineContainer!)]) + assert.equal(readFileSync(path.join(quarantineContainer!, path.basename(fixture.frontierPath), 'stray-unplanned.txt'), 'utf8'), 'FOREIGN\n') + assert.equal(existsSync(applied.journalPath), true) + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { entries: Array<{ path: string; frontierRollbackPending?: boolean; quarantine?: string }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.frontierRollbackPending, true) + assert.ok(entry?.quarantine?.includes('.nsolid-quarantine-')) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('reports the exact quarantine after an ordinary post-allocation seam error', async () => { + const { fixture, applied } = await setupPublishedFrontier() + let quarantineContainer: string | undefined + setFallbackFrontierRollbackSeamForTests(async (event) => { + if (event.phase === 'quarantine-ready') { + quarantineContainer = path.dirname(event.quarantinePath!) + throw new Error('ordinary runtime failure') + } + }) + try { + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.ok(quarantineContainer) + assert.deepEqual(restore.preservedArtifacts, [path.resolve(quarantineContainer!)]) + assert.equal(existsSync(fixture.frontierPath), true) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(existsSync(applied.journalPath), true) + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { entries: Array<{ path: string; frontierRollbackPending?: boolean; quarantine?: string }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.frontierRollbackPending, true) + assert.equal(path.dirname(path.resolve(entry!.quarantine!)), path.resolve(quarantineContainer!)) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('preserves everything when the authenticated quarantine container is replaced by a symlink at the quarantine-ready seam', async () => { + const { fixture, applied } = await setupPublishedFrontier() + // Foreign test-owned target the substituted symlink points at. It lives + // inside the temp home so afterEach cleanup never touches anything + // outside the test's own artifacts. + const foreignTarget = path.join(home, 'foreign-quarantine-target') + mkdirSync(foreignTarget) + writeFileSync(path.join(foreignTarget, 'foreign-sentinel.txt'), 'FOREIGN\n') + let substitutedContainer: string | undefined + setFallbackFrontierRollbackSeamForTests(async (event) => { + if (event.phase === 'quarantine-ready') { + substitutedContainer = path.dirname(event.quarantinePath!) + // Displace the real container (same filesystem, test-owned temp home) + // and replace its path with a symlink to the foreign directory: the + // rollback must re-prove container identity BEFORE the move and + // refuse to move the frontier through the link. + renameSync(substitutedContainer, path.join(home, 'displaced-original-quarantine-container')) + symlinkSync(foreignTarget, substitutedContainer) + } + }) + try { + const restore = await restoreFallbackJournal(applied) + // Structured preservation: the symlinked container is reported, the + // durable pending state stays, and nothing followed the link. + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.ok(substitutedContainer) + assert.deepEqual(restore.preservedArtifacts, [path.resolve(substitutedContainer!)]) + // The live frontier was never moved through the symlink. + assert.equal(existsSync(fixture.frontierPath), true) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + // The foreign target was never written into. + assert.equal(readFileSync(path.join(foreignTarget, 'foreign-sentinel.txt'), 'utf8'), 'FOREIGN\n') + assert.deepEqual(readdirSync(foreignTarget), ['foreign-sentinel.txt']) + // The substituted container path still exists (as the symlink) and is + // preserved as evidence; the displaced original stays in the temp home. + assert.equal(quarantineContainers(fixture.anchorPath).length, 1) + assert.ok(lstatSync(substitutedContainer!).isSymbolicLink()) + assert.equal(existsSync(applied.journalPath), true) + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { entries: Array<{ path: string; frontierRollbackPending?: boolean; quarantine?: string }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.frontierRollbackPending, true) + assert.equal(path.dirname(path.resolve(entry!.quarantine!)), path.resolve(substitutedContainer!)) + // The destructive publication authority was retired (observable as an + // empty publication list), so a replay can never move anything. + assert.deepEqual(publishedFallbackFrontiers(applied), []) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('rejects the move and preserves the collision when an empty directory appears at the quarantine payload destination', async () => { + const { fixture, applied } = await setupPublishedFrontier() + let collisionContainer: string | undefined + let collisionPath: string | undefined + setFallbackFrontierRollbackSeamForTests(async (event) => { + if (event.phase === 'quarantine-ready') { + collisionContainer = path.dirname(event.quarantinePath!) + collisionPath = event.quarantinePath! + // An empty directory at the exact payload destination: the pre-move + // proof must see it and refuse to rename over it. + mkdirSync(event.quarantinePath!) + } + }) + try { + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.ok(collisionContainer) + assert.deepEqual(restore.preservedArtifacts, [path.resolve(collisionContainer!)]) + // The live frontier bytes are intact and were never moved. + assert.equal(existsSync(fixture.frontierPath), true) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + // The collision was preserved, never overwritten or removed. + assert.equal(existsSync(collisionPath!), true) + assert.deepEqual(readdirSync(collisionPath!), []) + // The quarantine container and its durable pending evidence survive. + assert.equal(quarantineContainers(fixture.anchorPath).length, 1) + assert.equal(existsSync(applied.journalPath), true) + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { entries: Array<{ path: string; frontierRollbackPending?: boolean; quarantine?: string }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.frontierRollbackPending, true) + assert.equal(path.resolve(entry!.quarantine!), path.resolve(collisionPath!)) + // The publication authority was retired; replay cannot move anything. + assert.deepEqual(publishedFallbackFrontiers(applied), []) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('keeps a reclaimed-owner retry after an ambiguous move incomplete with exact artifact and pending state', async () => { + const { fixture, applied } = await setupPublishedFrontier() + let quarantineContainer: string | undefined + setFallbackFrontierRollbackSeamForTests((event) => { + if (event.phase === 'moved') { + quarantineContainer = path.dirname(event.quarantinePath!) + writeFileSync(path.join(event.quarantinePath!, 'alpha', 'SKILL.md'), 'TAMPERED\n') + } + }) + try { + const first = await restoreFallbackJournal(applied) + assert.equal(first.succeeded, false) + assert.equal(first.unproven, true) + // A legitimately reclaimed owner retries: the durable pending state + // keeps the retry incomplete — the ambiguous state must be resolved + // deliberately, never re-rolled or cleaned. + const reclaimed = await reclaimFallbackJournalMutation(applied) + const retry = await restoreFallbackJournal(reclaimed) + assert.equal(retry.succeeded, false) + assert.equal(retry.unproven, true) + assert.ok(quarantineContainer) + assert.deepEqual(retry.preservedArtifacts, [path.resolve(quarantineContainer!)]) + assert.equal(existsSync(fixture.frontierPath), false) + assert.equal(quarantineContainers(fixture.anchorPath).length, 1) + assert.equal(existsSync(applied.journalPath), true) + // Next-run recovery also preserves while pending. + const recovery = await recoverFallbackJournal(fixture.trackingPath) + assert.equal(recovery.recovered, false) + assert.ok(recovery.preservedPaths.includes(path.resolve(fixture.frontierPath))) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('refuses commit while a durable rollback-pending state exists', async () => { + const { fixture, applied } = await setupPublishedFrontier() + setFallbackFrontierRollbackSeamForTests((event) => { + if (event.phase === 'moved') { + writeFileSync(path.join(event.quarantinePath!, 'alpha', 'SKILL.md'), 'TAMPERED\n') + } + }) + try { + const first = await restoreFallbackJournal(applied) + assert.equal(first.succeeded, false) + const reclaimed = await reclaimFallbackJournalMutation(applied) + await assert.rejects(commitFallbackJournal(reclaimed), /FALLBACK_FRONTIER_ROLLBACK_PENDING/) + assert.equal(existsSync(applied.journalPath), true) + assert.equal(quarantineContainers(fixture.anchorPath).length, 1) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('moves nothing when the durable rollback-pending CAS fails before the rename', async () => { + const { fixture, applied } = await setupPublishedFrontier() + setFallbackFrontierRollbackSeamForTests((event) => { + if (event.phase === 'proof-complete') { + // Corrupt the revision so the durable pending-state CAS fails during + // preparation (the container is allocated first, but NOTHING moves). + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { revision: number } + disk.revision += 1000 + writeFileSync(applied.journalPath, JSON.stringify(disk, null, 2) + '\n') + } + }) + try { + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + // Nothing moved, nothing pending, no container: the live frontier is + // fully intact and the journal carries no pending flag. + assert.equal(existsSync(fixture.frontierPath), true) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + // The container was allocated during preparation and is preserved as + // an artifact; nothing was moved and no pending state was recorded. + assert.equal(quarantineContainers(fixture.anchorPath).length, 1) + assert.ok(restore.preservedArtifacts.some((artifact) => artifact.includes('.nsolid-quarantine-'))) + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { entries: Array<{ path: string; frontierRollbackPending?: boolean }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.frontierRollbackPending, undefined) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('rejects same-revision transaction identity tampering before the pending-state CAS', async () => { + const { fixture, applied } = await setupPublishedFrontier() + setFallbackFrontierRollbackSeamForTests((event) => { + if (event.phase === 'proof-complete') { + // Schema-valid tamper at the SAME revision: swap the transaction + // identity the durable pending-state CAS is about to compare + // against. Full-state equality must reject it. + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { revision: number; transactionId: string } + disk.transactionId = randomUUID() + writeFileSync(applied.journalPath, JSON.stringify(disk, null, 2) + '\n') + } + }) + try { + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + // The live frontier never moved. + assert.equal(existsSync(fixture.frontierPath), true) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(readFileSync(path.join(fixture.frontierPath, 'beta', 'SKILL.md'), 'utf8'), '# beta staged\n') + // The allocated container is preserved as evidence; no cleanup ran. + assert.equal(quarantineContainers(fixture.anchorPath).length, 1) + assert.ok(restore.preservedArtifacts.some((artifact) => artifact.includes('.nsolid-quarantine-'))) + // The durable tamper itself is preserved untouched (evidence), and no + // pending flag was ever written by the rejected transaction. + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { transactionId: string; entries: Array<{ path: string; frontierRollbackPending?: boolean }> } + assert.notEqual(disk.transactionId, applied.transactionId) + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.frontierRollbackPending, undefined) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('rejects same-revision insertion of durable pending ambiguity before the move', async () => { + const { fixture, applied } = await setupPublishedFrontier() + setFallbackFrontierRollbackSeamForTests((event) => { + if (event.phase === 'proof-complete') { + // Schema-valid same-revision injection of the durable pending + // ambiguity state the rollback itself would write: the full-state + // CAS must refuse to build on top of it, and the injected state + // must survive untouched as preserved evidence. + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { revision: number; entries: Array<{ path: string; frontierRollbackPending?: boolean }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.ok(entry) + entry.frontierRollbackPending = true + writeFileSync(applied.journalPath, JSON.stringify(disk, null, 2) + '\n') + } + }) + try { + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + // Nothing moved: the live frontier keeps the exact published bytes. + assert.equal(existsSync(fixture.frontierPath), true) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + // The container allocated during preparation stays preserved. + assert.equal(quarantineContainers(fixture.anchorPath).length, 1) + assert.ok(restore.preservedArtifacts.some((artifact) => artifact.includes('.nsolid-quarantine-'))) + // The injected ambiguity state was never consumed or overwritten by + // the rejected transaction: it remains durable for manual resolution. + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { entries: Array<{ path: string; frontierRollbackPending?: boolean }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.frontierRollbackPending, true) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('preserves everything when a collision occupies the quarantine container child before the move', async () => { + const { fixture, applied } = await setupPublishedFrontier() + const foreign = path.join(home, 'foreign-child') + mkdirSync(foreign) + writeFileSync(path.join(foreign, 'foreign.txt'), 'FOREIGN\n') + let quarantineContainer: string | undefined + setFallbackFrontierRollbackSeamForTests((event) => { + if (event.phase === 'quarantine-ready') { + quarantineContainer = event.quarantinePath === undefined ? undefined : path.dirname(event.quarantinePath) + renameSync(foreign, event.quarantinePath!) + } + }) + try { + const before = lstatSync(fixture.frontierPath) + const restore = await restoreFallbackJournal(applied) + // The collision is preserved: the live root never moved. + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.equal(lstatSync(fixture.frontierPath).ino, before.ino) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + // The occupied container keeps the foreign child untouched: it was + // renamed into the payload path inside the container. The exact + // allocated container path is the reported artifact (blocker 4). + assert.ok(quarantineContainer) + assert.deepEqual(restore.preservedArtifacts, [path.resolve(quarantineContainer!)]) + assert.equal(readFileSync(path.join(quarantineContainer!, path.basename(fixture.frontierPath), 'foreign.txt'), 'utf8'), 'FOREIGN\n') + assert.equal(existsSync(applied.journalPath), true) + // The completion capability survives a collision (nothing moved), but + // a replay with a re-legitimated handle still cannot pass because the + // original live root is intact and the container is occupied; the + // replay via the SAME genuine handle is stale-revision refused first. + const replay = await restoreFallbackJournal(applied) + assert.equal(replay.succeeded, false) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('preserves the quarantine when a replacement appears at the frontier path after the move', async () => { + const { fixture, applied } = await setupPublishedFrontier() + setFallbackFrontierRollbackSeamForTests((event) => { + if (event.phase === 'moved') { + // A racing writer creates a replacement at the vacated path inside + // the rename→prove window. + mkdirSync(fixture.frontierPath, { recursive: true }) + writeFileSync(path.join(fixture.frontierPath, 'raced.txt'), 'RACED\n') + } + }) + try { + const restore = await restoreFallbackJournal(applied) + // The ambiguity is preserved: both the raced replacement and the + // quarantine stay, and the rollback is reported incomplete. + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'raced.txt'), 'utf8'), 'RACED\n') + assert.equal(quarantineContainers(fixture.anchorPath).length, 1) + assert.equal(existsSync(applied.journalPath), true) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('stops the restore and reports succeeded:false when the bookkeeping CAS fails after the move', async () => { + const { fixture, applied } = await setupPublishedFrontier() + // A modified authenticated preexisting entry the restore has not reached + // yet: it must be reported in preservedPaths because the restore stops + // at the untrustworthy handle revision. + writeFileSync(path.join(fixture.keptSkillPath, 'SKILL.md'), 'drifted-before-restore\n') + setFallbackFrontierRollbackSeamForTests((event) => { + if (event.phase === 'proven') { + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { revision: number } + disk.revision += 1000 + writeFileSync(applied.journalPath, JSON.stringify(disk, null, 2) + '\n') + } + }) + try { + const restore = await restoreFallbackJournal(applied) + // The physical frontier move IS done, but the journal could not be + // updated: the whole operation is incomplete (never succeeded:true). + assert.equal(restore.succeeded, false) + assert.equal(restore.frontierJournalPending, true) + assert.equal(existsSync(fixture.frontierPath), false) + // The exact allocated quarantine container is reported as the artifact. + const containers = quarantineContainers(fixture.anchorPath) + assert.equal(containers.length, 1) + assert.deepEqual(restore.preservedArtifacts, [path.resolve(path.join(fixture.anchorPath, containers[0]))]) + // The unrestored modified preexisting target is reported. + assert.ok(restore.preservedPaths.includes(path.resolve(fixture.keptSkillPath))) + // Journal stays in place for deliberate resolution. + assert.equal(existsSync(applied.journalPath), true) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('refuses a role-substituted handle copy with zero mutation', async () => { + const { fixture, applied } = await setupPublishedFrontier() + // A forged owner handle: same fields, different object identity, never + // registered by begin/claim/reclaim, and the journal still records a + // live same-process mutator. + const forgedOwner = { ...applied, role: 'owner' as const } + const before = lstatSync(fixture.frontierPath) + const restore = await restoreFallbackJournal(forgedOwner) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.equal(lstatSync(fixture.frontierPath).ino, before.ino) + assert.equal(existsSync(fixture.frontierPath), true) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(quarantineContainers(fixture.anchorPath).length, 0) + }) + + it('refuses a stale unreclaimed owner after a legitimate claim, keeping legitimate reclaim working', async () => { + const fixture = await setupFrontierFixture() + const { handle } = await beginFallbackJournal(fixture.manifest) + const preparedOwner = await markFallbackJournalMutating(handle) + const claimed = await claimFallbackJournalMutation(fixture.manifest, manifestDigestOf(fixture.manifest)) + if (!claimed) throw new Error('mutation claim failed') + const staged = await buildStagedFrontier() + const registered = await registerFrontierStageWithBeta(claimed, fixture.frontierPath, staged) + const applied = await applyFallbackEntry(registered, fixture.frontierPath) + // `preparedOwner` is a genuine object but stale: the journal mutator is + // this process and no reclaim has cleared it. + const before = lstatSync(fixture.frontierPath) + const staleRestore = await restoreFallbackJournal(preparedOwner) + assert.equal(staleRestore.succeeded, false) + assert.equal(staleRestore.unproven, true) + assert.equal(lstatSync(fixture.frontierPath).ino, before.ino) + assert.equal(existsSync(fixture.frontierPath), true) + assert.equal(quarantineContainers(fixture.anchorPath).length, 0) + // The genuine reclaimed owner still succeeds: legitimate local reclaim + // keeps working. + const reclaimed = await reclaimFallbackJournalMutation(applied) + const reclaimedRestore = await restoreFallbackJournal(reclaimed) + assert.equal(reclaimedRestore.succeeded, true) + assert.equal(existsSync(fixture.frontierPath), false) + }) + + it('treats a tampered quarantined tree at the moved seam as a whole-operation incomplete', async () => { + const { fixture, applied } = await setupPublishedFrontier() + let quarantineContainer: string | undefined + setFallbackFrontierRollbackSeamForTests((event) => { + if (event.phase === 'moved') { + quarantineContainer = path.dirname(event.quarantinePath!) + // Tamper the quarantined content inside the rename→prove window. + writeFileSync(path.join(event.quarantinePath!, 'alpha', 'SKILL.md'), 'TAMPERED-IN-QUARANTINE\n') + } + }) + try { + const restore = await restoreFallbackJournal(applied) + // The original path IS missing, but the moved content cannot be proven: + // the whole operation is incomplete, never a success. + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.equal(existsSync(fixture.frontierPath), false) + // The exact allocated container is reported and the tampered content + // is preserved inside it (never deleted). + assert.ok(quarantineContainer) + assert.deepEqual(restore.preservedArtifacts, [path.resolve(quarantineContainer!)]) + assert.equal(readFileSync(path.join(quarantineContainer!, path.basename(fixture.frontierPath), 'alpha', 'SKILL.md'), 'utf8'), 'TAMPERED-IN-QUARANTINE\n') + // The durable pending state blocks any replay: the ambiguous physical + // state (missing path + unproven quarantined content) must be resolved + // deliberately; every retry stays incomplete with the exact artifact. + const replay = await restoreFallbackJournal(applied) + assert.equal(replay.succeeded, false) + assert.equal(replay.unproven, true) + assert.equal(existsSync(fixture.frontierPath), false) + assert.equal(quarantineContainers(fixture.anchorPath).length, 1) + assert.deepEqual(publishedFallbackFrontiers(applied), []) + // Journal and evidence retained. + assert.equal(existsSync(applied.journalPath), true) + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.frontierPath)) + assert.equal(entry?.applied, true) + } finally { + setFallbackFrontierRollbackSeamForTests() + } + }) + + it('publishes only required leaves with an empty selector and rolls back to missing', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + // Staged tree contains only the required skill:alpha leaf: the staged + // payload must exactly match the active selection. + const staged = path.join(home, `staged-frontier-${randomUUID().slice(0, 8)}`) + mkdirSync(path.join(staged, 'alpha'), { recursive: true }) + writeFileSync(path.join(staged, 'alpha', 'SKILL.md'), '# alpha staged\n') + // Default/empty selector: only required skill:alpha is authorized. + const registered = await registerFallbackFrontierStage(owner, fixture.frontierPath, staged) + const applied = await applyFallbackEntry(registered, fixture.frontierPath) + const publications = publishedFallbackFrontiers(applied) + assert.equal(publications.length, 1) + // Reporting shows the registered selection: beta (conditional, unselected) is absent. + assert.deepEqual(publications[0].activeConditionalLeafIds, []) + // Live tree has alpha only; beta never materialized. + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(existsSync(path.join(fixture.frontierPath, 'beta')), false) + // Rollback proves the alpha-only tree: success and beta stays absent everywhere. + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, true) + assert.equal(existsSync(fixture.frontierPath), false) + const containers = quarantineContainers(fixture.anchorPath) + assert.equal(containers.length, 1) + const movedRoot = path.join(fixture.anchorPath, containers[0], path.basename(fixture.frontierPath)) + assert.equal(readFileSync(path.join(movedRoot, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(existsSync(path.join(movedRoot, 'beta')), false) + assert.deepEqual(publishedFallbackFrontiers(applied), []) + }) + + it('publishes and proves both leaves with an explicit conditional selection', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const registered = await registerFallbackFrontierStage(owner, fixture.frontierPath, staged, { activeConditionalLeafIds: ['skill:beta'] }) + const applied = await applyFallbackEntry(registered, fixture.frontierPath) + const publications = publishedFallbackFrontiers(applied) + assert.equal(publications.length, 1) + assert.deepEqual(publications[0].activeConditionalLeafIds, ['skill:beta']) + assert.equal(readFileSync(path.join(fixture.frontierPath, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(readFileSync(path.join(fixture.frontierPath, 'beta', 'SKILL.md'), 'utf8'), '# beta staged\n') + // Rollback re-proves BOTH leaves through the immutable registered selection. + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, true) + assert.equal(existsSync(fixture.frontierPath), false) + const containers = quarantineContainers(fixture.anchorPath) + assert.equal(containers.length, 1) + const movedRoot = path.join(fixture.anchorPath, containers[0], path.basename(fixture.frontierPath)) + assert.equal(readFileSync(path.join(movedRoot, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(readFileSync(path.join(movedRoot, 'beta', 'SKILL.md'), 'utf8'), '# beta staged\n') + assert.deepEqual(publishedFallbackFrontiers(applied), []) + }) + + it('ignores caller mutations of the selector and reporting arrays after registration', async () => { + const fixture = await setupFrontierFixture() + const owner = await beginMutatingFrontier(fixture) + const staged = await buildStagedFrontier() + const selector = ['skill:beta'] + const registered = await registerFallbackFrontierStage(owner, fixture.frontierPath, staged, { activeConditionalLeafIds: selector }) + // Caller mutates its own selector copy after registration. + selector.push('mcp-config:0') + // Reporting arrays are frozen: in-place mutation must throw. + const publications = publishedFallbackFrontiers(registered) + assert.equal(publications.length, 0) + const applied = await applyFallbackEntry(registered, fixture.frontierPath) + const appliedPublications = publishedFallbackFrontiers(applied) + assert.equal(appliedPublications.length, 1) + assert.throws(() => { + (appliedPublications[0].activeConditionalLeafIds as unknown as string[]).push('mcp-config:0') + }) + // The registered immutable selection still drives apply/rollback: only + // skill:beta was materialized, never mcp-config:0. + assert.equal(existsSync(path.join(fixture.frontierPath, 'beta', 'SKILL.md')), true) + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, true) + assert.equal(existsSync(fixture.frontierPath), false) + const containers = quarantineContainers(fixture.anchorPath) + assert.equal(containers.length, 1) + const movedRoot = path.join(fixture.anchorPath, containers[0], path.basename(fixture.frontierPath)) + assert.equal(readFileSync(path.join(movedRoot, 'alpha', 'SKILL.md'), 'utf8'), '# alpha staged\n') + assert.equal(readFileSync(path.join(movedRoot, 'beta', 'SKILL.md'), 'utf8'), '# beta staged\n') + assert.deepEqual(publishedFallbackFrontiers(applied), []) + }) +}) + +describe('fallback journal copied-link bindings', () => { + async function setupCopiedLinkFixture (): Promise<{ + liveSkillPath: string + liveLinkPath: string + manifest: FallbackTransactionIdentity + }> { + // Pi harness: managed links materialize as real directory copies bound to + // the registered staged skill payload. + const skillsRoot = path.join(home, '.agents', 'skills') + const linkRoot = path.join(home, '.pi', 'agent', 'skills') + const liveSkillPath = path.join(skillsRoot, 'tracked') + const liveLinkPath = path.join(linkRoot, 'tracked') + mkdirSync(liveSkillPath, { recursive: true }) + writeFileSync(path.join(liveSkillPath, 'SKILL.md'), '# old tracked\n') + mkdirSync(liveLinkPath, { recursive: true }) + writeFileSync(path.join(liveLinkPath, 'SKILL.md'), '# old tracked\n') + const trackingPath = getTrackingFilePath() + mkdirSync(path.dirname(trackingPath), { recursive: true }) + writeFileSync(trackingPath, JSON.stringify({ + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'pi', + skills: [{ name: 'tracked', path: liveSkillPath, paths: { pi: liveSkillPath }, installedAt: new Date().toISOString(), harnesses: ['pi'] }], + mcpServers: [], + })) + const manifest: FallbackTransactionIdentity = { + installationId: 'pi:fallback', + harness: 'pi', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + digestAlgorithm: 'fallback-path-v2', + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await pathEvidence(liveSkillPath)], + ownedLinks: [await pathEvidence(liveLinkPath)], + ownedMcpFields: [], + ownedMcpConfigPaths: [await pathEvidence(path.join(home, '.pi', 'agent', 'mcp.json'))], + bundleDestinations: [await pathEvidence(liveSkillPath), await pathEvidence(liveLinkPath)], + approvedDestinationRoots: [path.resolve(skillsRoot), path.resolve(linkRoot)], + } + return { liveSkillPath, liveLinkPath, manifest } + } + + async function beginMutating (manifest: FallbackTransactionIdentity): Promise { + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + if (!claimed) throw new Error('mutation claim failed') + return claimed + } + + function buildStagedSkill (content: string): string { + const staged = path.join(home, `staged-skill-${randomUUID().slice(0, 8)}`) + mkdirSync(staged, { recursive: true }) + writeFileSync(path.join(staged, 'SKILL.md'), content) + return staged + } + + async function publishCopiedLink (manifest: FallbackTransactionIdentity, liveSkillPath: string, liveLinkPath: string, content: string): Promise { + let owner = await beginMutating(manifest) + owner = await registerFallbackStage(owner, liveSkillPath, { directory: buildStagedSkill(content) }) + owner = await registerFallbackStage(owner, liveLinkPath, { directory: buildStagedSkill(content) }) + owner = await applyFallbackEntry(owner, liveSkillPath) + owner = await applyFallbackEntry(owner, liveLinkPath) + return owner + } + + it('rejects an independent copied link whose bytes do not match the registered skill payload', async () => { + const fixture = await setupCopiedLinkFixture() + let owner = await beginMutating(fixture.manifest) + owner = await registerFallbackStage(owner, fixture.liveSkillPath, { directory: buildStagedSkill('# new tracked\n') }) + await assert.rejects( + registerFallbackStage(owner, fixture.liveLinkPath, { directory: buildStagedSkill('# attacker bytes\n') }), + /does not match the registered skill payload/ + ) + // Live paths untouched; the failed registration cleaned its own stage container. + assert.equal(readFileSync(path.join(fixture.liveSkillPath, 'SKILL.md'), 'utf8'), '# old tracked\n') + assert.equal(readFileSync(path.join(fixture.liveLinkPath, 'SKILL.md'), 'utf8'), '# old tracked\n') + assert.deepEqual(readdirSync(path.dirname(fixture.liveLinkPath)).filter((name) => name.includes('.nsolid-stage-')), []) + }) + + it('rejects an independent copied link carrying a nested symlink without publication', async () => { + const fixture = await setupCopiedLinkFixture() + let owner = await beginMutating(fixture.manifest) + owner = await registerFallbackStage(owner, fixture.liveSkillPath, { directory: buildStagedSkill('# new tracked\n') }) + const stagedLink = buildStagedSkill('# new tracked\n') + symlinkSync(path.join(fixture.liveSkillPath, 'SKILL.md'), path.join(stagedLink, 'nested-link')) + await assert.rejects( + registerFallbackStage(owner, fixture.liveLinkPath, { directory: stagedLink }), + /contains a symlink/ + ) + assert.equal(readFileSync(path.join(fixture.liveLinkPath, 'SKILL.md'), 'utf8'), '# old tracked\n') + assert.deepEqual(readdirSync(path.dirname(fixture.liveLinkPath)).filter((name) => name.includes('.nsolid-stage-')), []) + }) + + it('aborts the copied-link swap when the staged payload is replaced after registration', async () => { + const fixture = await setupCopiedLinkFixture() + let owner = await beginMutating(fixture.manifest) + owner = await registerFallbackStage(owner, fixture.liveSkillPath, { directory: buildStagedSkill('# new tracked\n') }) + owner = await registerFallbackStage(owner, fixture.liveLinkPath, { directory: buildStagedSkill('# new tracked\n') }) + const disk = JSON.parse(readFileSync(owner.journalPath, 'utf8')) as { entries: Array<{ path: string, stage?: string }> } + const linkEntry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(fixture.liveLinkPath)) + rmSync(linkEntry!.stage!, { recursive: true, force: true }) + mkdirSync(linkEntry!.stage!, { recursive: true }) + writeFileSync(path.join(linkEntry!.stage!, 'SKILL.md'), '# attacker\n') + owner = await applyFallbackEntry(owner, fixture.liveSkillPath) + assert.equal(readFileSync(path.join(fixture.liveSkillPath, 'SKILL.md'), 'utf8'), '# new tracked\n') + await assert.rejects(applyFallbackEntry(owner, fixture.liveLinkPath), /no longer matches its registered digest/) + assert.equal(readFileSync(path.join(fixture.liveLinkPath, 'SKILL.md'), 'utf8'), '# old tracked\n') + }) + + it('preserves a published copied link whose live bytes no longer match what this process published', async () => { + const fixture = await setupCopiedLinkFixture() + const owner = await publishCopiedLink(fixture.manifest, fixture.liveSkillPath, fixture.liveLinkPath, '# new tracked\n') + assert.equal(readFileSync(path.join(fixture.liveLinkPath, 'SKILL.md'), 'utf8'), '# new tracked\n') + // Foreign bytes replace the published link before the restore runs. + writeFileSync(path.join(fixture.liveLinkPath, 'SKILL.md'), '# foreign\n') + const restore = await restoreFallbackJournal(owner) + assert.equal(restore.succeeded, false) + assert.ok(restore.preservedPaths.includes(path.resolve(fixture.liveLinkPath))) + assert.equal(readFileSync(path.join(fixture.liveLinkPath, 'SKILL.md'), 'utf8'), '# foreign\n') + assert.equal(existsSync(owner.journalPath), true) + }) + + it('restores the authenticated original when the published copied link is intact', async () => { + const fixture = await setupCopiedLinkFixture() + const owner = await publishCopiedLink(fixture.manifest, fixture.liveSkillPath, fixture.liveLinkPath, '# new tracked\n') + const restore = await restoreFallbackJournal(owner) + assert.equal(restore.succeeded, true) + assert.deepEqual(restore.preservedPaths, []) + assert.equal(readFileSync(path.join(fixture.liveSkillPath, 'SKILL.md'), 'utf8'), '# old tracked\n') + assert.equal(readFileSync(path.join(fixture.liveLinkPath, 'SKILL.md'), 'utf8'), '# old tracked\n') + }) + + async function setupCopiedLinkFrontierFixture (): Promise<{ + destRoot: string + linkRoot: string + linkGammaPath: string + manifest: FallbackTransactionIdentity + }> { + const destAnchor = path.join(home, 'dest-anchor') + const linkAnchor = path.join(home, 'link-anchor') + mkdirSync(destAnchor) + mkdirSync(linkAnchor) + const destRoot = path.join(destAnchor, 'skills') + const linkRoot = path.join(linkAnchor, 'skills') + const destGammaPath = path.join(destRoot, 'gamma') + const linkGammaPath = path.join(linkRoot, 'gamma') + // The destination skill frontier registers first so the link frontier + // binds its directory copy against the registered skill payload. + const skillFrontier: FallbackFrontierEvidence = { + frontierPath: destRoot, + activation: 'required', + anchor: await frontierAnchorIdentity(destAnchor), + leaves: [{ id: 'skill:gamma', role: 'skill', activation: 'required', path: destGammaPath }], + } + const linkFrontier: FallbackFrontierEvidence = { + frontierPath: linkRoot, + activation: 'required', + anchor: await frontierAnchorIdentity(linkAnchor), + leaves: [{ id: 'link:gamma', role: 'link', activation: 'required', path: linkGammaPath }], + } + const trackingPath = getTrackingFilePath() + mkdirSync(path.dirname(trackingPath), { recursive: true }) + writeFileSync(trackingPath, JSON.stringify({ + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'pi', + skills: [], + mcpServers: [], + })) + const manifest: FallbackTransactionIdentity = { + installationId: 'pi:fallback', + harness: 'pi', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + digestAlgorithm: 'fallback-path-v2', + nonce: randomUUID(), + plannedMissingFrontiers: [skillFrontier, linkFrontier], + ownedSkills: [], + ownedLinks: [], + ownedMcpFields: [], + ownedMcpConfigPaths: [await pathEvidence(path.join(home, '.pi', 'agent', 'mcp.json'))], + bundleDestinations: [await pathEvidence(destGammaPath), await pathEvidence(linkGammaPath)], + approvedDestinationRoots: [path.resolve(destRoot), path.resolve(linkRoot)], + } + return { destRoot, linkRoot, linkGammaPath, manifest } + } + + async function publishCopiedLinkFrontier (manifest: FallbackTransactionIdentity, destRoot: string, linkRoot: string, content: string): Promise { + let owner = await beginMutating(manifest) + const stagedSkillRoot = path.join(home, `staged-dest-${randomUUID().slice(0, 8)}`) + mkdirSync(path.join(stagedSkillRoot, 'gamma'), { recursive: true }) + writeFileSync(path.join(stagedSkillRoot, 'gamma', 'SKILL.md'), content) + const stagedLinkRoot = path.join(home, `staged-link-${randomUUID().slice(0, 8)}`) + mkdirSync(path.join(stagedLinkRoot, 'gamma'), { recursive: true }) + writeFileSync(path.join(stagedLinkRoot, 'gamma', 'SKILL.md'), content) + owner = await registerFallbackFrontierStage(owner, destRoot, stagedSkillRoot) + owner = await registerFallbackFrontierStage(owner, linkRoot, stagedLinkRoot) + owner = await applyFallbackEntry(owner, destRoot) + owner = await applyFallbackEntry(owner, linkRoot) + return owner + } + + it('publishes a copied-link frontier bound to the registered skill bytes without leaking bindings into reports', async () => { + const fixture = await setupCopiedLinkFrontierFixture() + const owner = await publishCopiedLinkFrontier(fixture.manifest, fixture.destRoot, fixture.linkRoot, '# new gamma\n') + assert.equal(lstatSync(fixture.linkGammaPath).isSymbolicLink(), false) + assert.equal(readFileSync(path.join(fixture.linkGammaPath, 'SKILL.md'), 'utf8'), '# new gamma\n') + const publications = publishedFallbackFrontiers(owner) + assert.equal(publications.length, 2) + for (const publication of publications) { + assert.equal((publication as unknown as Record).copiedLinkBindings, undefined) + } + }) + + it('rolls back a published copied-link frontier when its live bytes are intact', async () => { + const fixture = await setupCopiedLinkFrontierFixture() + const owner = await publishCopiedLinkFrontier(fixture.manifest, fixture.destRoot, fixture.linkRoot, '# new gamma\n') + const restore = await restoreFallbackJournal(owner) + assert.equal(restore.succeeded, true) + assert.equal(existsSync(fixture.destRoot), false) + assert.equal(existsSync(fixture.linkRoot), false) + }) + + it('preserves a copied-link frontier whose live leaf no longer matches its frozen binding', async () => { + const fixture = await setupCopiedLinkFrontierFixture() + const owner = await publishCopiedLinkFrontier(fixture.manifest, fixture.destRoot, fixture.linkRoot, '# new gamma\n') + writeFileSync(path.join(fixture.linkGammaPath, 'SKILL.md'), '# tampered\n') + const restore = await restoreFallbackJournal(owner) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + // The tampered frontier is preserved exactly; the destination frontier + // rolled back independently and the journal stays for deliberate resolution. + assert.equal(readFileSync(path.join(fixture.linkGammaPath, 'SKILL.md'), 'utf8'), '# tampered\n') + assert.equal(existsSync(fixture.destRoot), false) + assert.equal(existsSync(owner.journalPath), true) + }) +}) + +describe('fallback journal planned-missing publication ownership', () => { + const mcpConfigPath = (): string => path.join(home, '.claude.json') + + async function beginClaimed (manifest: FallbackTransactionIdentity): Promise { + const { handle } = await beginFallbackJournal(manifest) + await markFallbackJournalMutating(handle) + const claimed = await claimFallbackJournalMutation(manifest, manifestDigestOf(manifest)) + if (!claimed) throw new Error('mutation claim failed') + return claimed + } + + async function publishMcpConfig (manifest: FallbackTransactionIdentity, bytes: string): Promise { + const claimed = await beginClaimed(manifest) + const staged = await registerFallbackStage(claimed, mcpConfigPath(), { bytes: Buffer.from(bytes) }) + return await applyFallbackEntry(staged, mcpConfigPath()) + } + + function quarantineContainers (dir: string, basename: string): string[] { + return readdirSync(dir).filter((name) => name.startsWith(`.${basename}.nsolid-quarantine-`)) + } + + it('preserves a foreign MCP config created after journaling, unproven, through child restore and parent recovery', async () => { + const { manifest } = await setupValidFixture() + const mcpPath = mcpConfigPath() + const claimed = await beginClaimed(manifest) + // The foreign writer (Claude Code itself, or the user) creates the live + // config AFTER the journal recorded the path as missing. This transaction + // never published anything. + const foreignBytes = JSON.stringify({ mcpServers: { 'user-tool': { type: 'stdio', command: 'uvx', args: ['user-tool'] } }, userState: 'precious' }, null, 2) + writeFileSync(mcpPath, foreignBytes) + + // Child-owned rollback (mutator role): must NOT move the foreign file. + const childRestore = await restoreFallbackJournal(claimed) + assert.equal(childRestore.succeeded, false) + assert.equal(childRestore.unproven, true) + assert.ok(childRestore.preservedPaths.includes(path.resolve(mcpPath))) + assert.equal(readFileSync(mcpPath, 'utf8'), foreignBytes) + assert.deepEqual(quarantineContainers(home, 'claude.json'), []) + assert.equal(existsSync(claimed.journalPath), true) + + // Parent recovery (strategies/fallback.ts reclaim + owner-role restore): + // still no publication evidence in this process, still preserved. + const owner = await reclaimFallbackJournalMutation(claimed) + const parentRestore = await restoreFallbackJournal(owner) + assert.equal(parentRestore.succeeded, false) + assert.equal(parentRestore.unproven, true) + assert.ok(parentRestore.preservedPaths.includes(path.resolve(mcpPath))) + assert.equal(readFileSync(mcpPath, 'utf8'), foreignBytes) + assert.deepEqual(quarantineContainers(home, 'claude.json'), []) + assert.equal(existsSync(owner.journalPath), true) + }) + + it('rolls a genuine same-process published MCP config back into a preserved quarantine', async () => { + const { manifest } = await setupValidFixture() + const mcpPath = mcpConfigPath() + const publishedBytes = JSON.stringify({ mcpServers: { 'nsolid-console': { type: 'http', url: 'https://example.com/mcp' } } }, null, 2) + const applied = await publishMcpConfig(manifest, publishedBytes) + assert.equal(readFileSync(mcpPath, 'utf8'), publishedBytes) + + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, true) + assert.equal(existsSync(mcpPath), false) + const quarantines = quarantineContainers(home, 'claude.json') + assert.equal(quarantines.length, 1) + assert.equal(readFileSync(path.join(home, quarantines[0], '.claude.json'), 'utf8'), publishedBytes) + assert.ok(restore.preservedArtifacts.some((artifact) => artifact.includes('.nsolid-quarantine-'))) + assert.equal(existsSync(applied.journalPath), true, 'a mutator leaves journal disposition to its owner') + }) + + it('preserves a same-process published MCP config whose live bytes were replaced afterwards', async () => { + const { manifest } = await setupValidFixture() + const mcpPath = mcpConfigPath() + const publishedBytes = JSON.stringify({ mcpServers: { 'nsolid-console': { type: 'http', url: 'https://example.com/mcp' } } }, null, 2) + const applied = await publishMcpConfig(manifest, publishedBytes) + const replacementBytes = '{"foreign":true}' + writeFileSync(mcpPath, replacementBytes) + + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.ok(restore.preservedPaths.includes(path.resolve(mcpPath))) + assert.equal(readFileSync(mcpPath, 'utf8'), replacementBytes) + assert.deepEqual(quarantineContainers(home, 'claude.json'), []) + assert.equal(existsSync(applied.journalPath), true) + }) + + it('preserves a live planned-missing destination when a stage was registered but never published', async () => { + const { manifest } = await setupValidFixture() + const mcpPath = mcpConfigPath() + const claimed = await beginClaimed(manifest) + const staged = await registerFallbackStage(claimed, mcpPath, { bytes: Buffer.from('{"nsolid":"staged"}') }) + // The journal now carries stage/stageDigest for the entry, but the swap + // never ran: a concurrent foreign writer lands on the live path. + const foreignBytes = '{"foreign":true}' + writeFileSync(mcpPath, foreignBytes) + + const restore = await restoreFallbackJournal(staged) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.ok(restore.preservedPaths.includes(path.resolve(mcpPath))) + assert.equal(readFileSync(mcpPath, 'utf8'), foreignBytes) + assert.equal(existsSync(staged.journalPath), true) + }) + + it('never infers ownership from serialized applied/stageDigest fields after the publication records are gone', async () => { + const { manifest } = await setupValidFixture() + const mcpPath = mcpConfigPath() + const publishedBytes = JSON.stringify({ mcpServers: {} }, null, 2) + const applied = await publishMcpConfig(manifest, publishedBytes) + // Simulated process restart: every process-local publication record is + // cleared. The disk journal still says applied:true with the exact + // stage digest, and the live bytes are exactly what the transaction + // published — none of that serialized state may authorize the move. + clearFallbackFrontierPublicationStateForTests() + const disk = JSON.parse(readFileSync(applied.journalPath, 'utf8')) as { entries: Array<{ path: string; applied?: boolean; stageDigest?: string }> } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(mcpPath)) + assert.equal(entry?.applied, true) + assert.ok(entry?.stageDigest) + + const restore = await restoreFallbackJournal(applied) + assert.equal(restore.succeeded, false) + assert.equal(restore.unproven, true) + assert.ok(restore.preservedPaths.includes(path.resolve(mcpPath))) + assert.equal(readFileSync(mcpPath, 'utf8'), publishedBytes) + assert.deepEqual(quarantineContainers(home, 'claude.json'), []) + assert.equal(existsSync(applied.journalPath), true) + }) +}) + +describe('fallback journal partial recovery reporting', () => { + async function setupTwoSkillRecoveryFixture (): Promise<{ + skillAPath: string + skillBPath: string + manifest: FallbackTransactionIdentity + }> { + const skillsRoot = path.join(home, '.agents', 'skills') + const skillAPath = path.join(skillsRoot, 'alpha') + const skillBPath = path.join(skillsRoot, 'beta') + mkdirSync(skillAPath, { recursive: true }) + writeFileSync(path.join(skillAPath, 'SKILL.md'), '# alpha tracked\n') + mkdirSync(skillBPath, { recursive: true }) + writeFileSync(path.join(skillBPath, 'SKILL.md'), '# beta tracked\n') + const trackingPath = getTrackingFilePath() + mkdirSync(path.dirname(trackingPath), { recursive: true }) + writeFileSync(trackingPath, JSON.stringify({ + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + skills: [ + { name: 'alpha', path: skillAPath, paths: { opencode: skillAPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }, + { name: 'beta', path: skillBPath, paths: { opencode: skillBPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }, + ], + mcpServers: [], + })) + const manifest: FallbackTransactionIdentity = { + installationId: 'opencode:fallback', + harness: 'opencode', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + digestAlgorithm: 'fallback-path-v2', + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await pathEvidence(skillAPath), await pathEvidence(skillBPath)], + ownedLinks: [], + ownedMcpFields: [], + ownedMcpConfigPaths: [await pathEvidence(path.join(home, '.config', 'opencode', 'opencode.jsonc'))], + bundleDestinations: [await pathEvidence(skillAPath), await pathEvidence(skillBPath)], + approvedDestinationRoots: [path.resolve(skillsRoot)], + } + return { skillAPath, skillBPath, manifest } + } + + /** Break one snapshot backup's authentication by smuggling a stray child into it. */ + function tamperBackupOf (journalPath: string, entryPath: string): void { + const disk = JSON.parse(readFileSync(journalPath, 'utf8')) as { + entries: Array<{ path: string, backup?: string }> + revision?: number + } + const entry = disk.entries.find((candidate) => path.resolve(candidate.path) === path.resolve(entryPath)) + if (entry?.backup === undefined) throw new Error(`no backup for ${entryPath}`) + writeFileSync(path.join(entry.backup, 'tampered.txt'), 'tampered backup bytes') + } + + async function assertReadOnlyInspection (trackingPath: string, preservedPaths: string[]): Promise { + const before = await pathDigest(home) + assert.ok(before, 'the fixture tree must be digestible') + const inspection = await inspectFallbackJournal(trackingPath) + assert.equal(inspection.pending, true) + assert.equal(inspection.recovered, preservedPaths.length === 0) + assert.deepEqual(inspection.restoredPaths, []) + assert.deepEqual(inspection.preservedPaths.sort(), preservedPaths.map((value) => path.resolve(value)).sort()) + assert.equal(await pathDigest(home), before, 'inspection must preserve live, journal and backup bytes') + } + + it('reports both owned paths as restored when full recovery rewrites every entry', async () => { + const fixture = await setupTwoSkillRecoveryFixture() + const { handle } = await beginFallbackJournal(fixture.manifest) + await markFallbackJournalMutating(handle) + writeFileSync(path.join(fixture.skillAPath, 'SKILL.md'), '# alpha drifted\n') + writeFileSync(path.join(fixture.skillBPath, 'SKILL.md'), '# beta drifted\n') + + await assertReadOnlyInspection(fixture.manifest.trackingPath, [fixture.skillAPath, fixture.skillBPath]) + const recovery = await recoverFallbackJournal(fixture.manifest.trackingPath) + assert.equal(recovery.pending, true) + assert.equal(recovery.recovered, true) + assert.deepEqual(recovery.restoredPaths.sort(), [path.resolve(fixture.skillAPath), path.resolve(fixture.skillBPath)].sort()) + assert.deepEqual(recovery.preservedPaths, []) + assert.equal(readFileSync(path.join(fixture.skillAPath, 'SKILL.md'), 'utf8'), '# alpha tracked\n') + assert.equal(readFileSync(path.join(fixture.skillBPath, 'SKILL.md'), 'utf8'), '# beta tracked\n') + }) + + it('reports a partial recovery: one entry restored, its tampered sibling preserved', async () => { + const fixture = await setupTwoSkillRecoveryFixture() + const { handle } = await beginFallbackJournal(fixture.manifest) + await markFallbackJournalMutating(handle) + writeFileSync(path.join(fixture.skillAPath, 'SKILL.md'), '# alpha drifted\n') + writeFileSync(path.join(fixture.skillBPath, 'SKILL.md'), '# beta drifted\n') + tamperBackupOf(handle.journalPath, fixture.skillBPath) + + await assertReadOnlyInspection(fixture.manifest.trackingPath, [fixture.skillAPath, fixture.skillBPath]) + const recovery = await recoverFallbackJournal(fixture.manifest.trackingPath) + assert.equal(recovery.pending, true) + assert.equal(recovery.recovered, false, 'a tampered backup leaves the journal unproven') + assert.deepEqual(recovery.restoredPaths, [path.resolve(fixture.skillAPath)]) + assert.deepEqual(recovery.preservedPaths, [path.resolve(fixture.skillBPath)]) + // The restored entry holds its tracked bytes; the failed entry keeps its + // drifted live bytes untouched. + assert.equal(readFileSync(path.join(fixture.skillAPath, 'SKILL.md'), 'utf8'), '# alpha tracked\n') + assert.equal(readFileSync(path.join(fixture.skillBPath, 'SKILL.md'), 'utf8'), '# beta drifted\n') + }) + + it('reports nothing to rewrite when every owned path already matches its tracked state', async () => { + const fixture = await setupTwoSkillRecoveryFixture() + const { handle } = await beginFallbackJournal(fixture.manifest) + await markFallbackJournalMutating(handle) + + await assertReadOnlyInspection(fixture.manifest.trackingPath, []) + const recovery = await recoverFallbackJournal(fixture.manifest.trackingPath) + assert.equal(recovery.pending, true) + assert.equal(recovery.recovered, true) + assert.deepEqual(recovery.restoredPaths, []) + assert.deepEqual(recovery.preservedPaths, []) + assert.equal(readFileSync(path.join(fixture.skillAPath, 'SKILL.md'), 'utf8'), '# alpha tracked\n') + assert.equal(readFileSync(path.join(fixture.skillBPath, 'SKILL.md'), 'utf8'), '# beta tracked\n') + }) + + it('reports nothing restored when the only entry cannot be recovered', async () => { + const fixture = await setupTwoSkillRecoveryFixture() + const { handle } = await beginFallbackJournal(fixture.manifest) + await markFallbackJournalMutating(handle) + writeFileSync(path.join(fixture.skillAPath, 'SKILL.md'), '# alpha drifted\n') + tamperBackupOf(handle.journalPath, fixture.skillAPath) + + await assertReadOnlyInspection(fixture.manifest.trackingPath, [fixture.skillAPath]) + const recovery = await recoverFallbackJournal(fixture.manifest.trackingPath) + assert.equal(recovery.pending, true) + assert.equal(recovery.recovered, false) + assert.deepEqual(recovery.restoredPaths, []) + assert.deepEqual(recovery.preservedPaths, [path.resolve(fixture.skillAPath)]) + assert.equal(readFileSync(path.join(fixture.skillAPath, 'SKILL.md'), 'utf8'), '# alpha drifted\n') + assert.equal(readFileSync(path.join(fixture.skillBPath, 'SKILL.md'), 'utf8'), '# beta tracked\n') + }) +}) 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-public-seam-driver.ts b/packages/core/test/unit/update/fallback-public-seam-driver.ts new file mode 100644 index 0000000..77b1ba7 --- /dev/null +++ b/packages/core/test/unit/update/fallback-public-seam-driver.ts @@ -0,0 +1,87 @@ +/** + * Real-process driver for the fallback public-seam test. It runs the real + * fallbackStrategy.execute() against the real `nsolid-plugin-refresh-owned` + * child entrypoint (the repository TypeScript sources through tsx) and then + * renders the resulting UpdateResult exactly the way the CLI does: one single + * JSON document on stdout (packages/core/src/cli.ts prints + * console.log(JSON.stringify(summary))) and nothing else. + * + * Usage: node --import tsx/esm fallback-public-seam-driver.ts '' + */ + +import path from 'node:path' +import { readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { fileURLToPath, pathToFileURL } from 'node:url' +import type { FallbackTransactionIdentity, UpdatePlanItem } from '../../../src/update/types.js' + +const fixture = JSON.parse(process.argv[2] ?? '{}') as { + home: string + manifestDir: string + manifestPath: string + resultPath: string + identity: FallbackTransactionIdentity +} + +// The isolated HOME must be in place before any module that resolves user +// paths (credentials, tracking) is evaluated. +process.env.HOME = fixture.home +process.env.USERPROFILE = fixture.home +delete process.env.NSOLID_ACCOUNTS_URL + +const { fallbackStrategy } = await import('../../../src/update/strategies/fallback.js') +const { createCommandRunner } = await import('../../../src/update/command-runner.js') +const { recordContainmentDirectoryIdentity } = await import('../../../src/update/fallback-result-protocol.js') +const { manifestDigestOf } = await import('../../../src/update/fallback-journal.js') + +const require = createRequire(import.meta.url) +// Absolute loader URL so the child does not depend on its cwd to resolve tsx. +const tsxLoader = pathToFileURL(require.resolve('tsx/esm')).href +const childEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../src/update/refresh-owned-cli.ts') + +const item: UpdatePlanItem = { + installationId: fixture.identity.installationId, + target: fixture.identity.harness, + ownership: 'fallback', + installed: true, + source: { kind: 'fallback', bundleVersion: '1.0.0' }, + version: { current: '1.0.0', latest: '1.0.3', status: 'update-available' }, + steps: [{ + kind: 'command', + description: 'refresh', + command: { + executable: process.execPath, + args: ['--import', tsxLoader, childEntry, '--transaction', fixture.manifestPath, '--manifest-digest', manifestDigestOf(fixture.identity), '--result', fixture.resultPath], + timeoutMs: 60_000, + }, + }], + rollbackSteps: [], + requiresConfirmation: true, + fallbackTransaction: fixture.identity, + temporaryDirectories: [fixture.manifestDir], + resultContainment: [await recordContainmentDirectoryIdentity(fixture.manifestDir)], +} + +const runner = createCommandRunner() +const result = await fallbackStrategy.execute(item, { + options: {}, + commandRunner: { + async run (command) { + const completion = await runner.run(command) + const envelope = JSON.parse(readFileSync(command.args[command.args.indexOf('--result') + 1]!, 'utf8')) + // Observe the real runner without changing its verdict or forwarding raw + // child output. This evidence belongs to the test's isolated HOME. + writeFileSync(path.join(fixture.home, 'command-completion.json'), JSON.stringify({ + exitCode: completion.exitCode, + timedOut: completion.timedOut, + treeTerminated: completion.treeTerminated, + spawnErrorCode: completion.spawnErrorCode, + childCode: envelope.code, + childNonce: envelope.nonce, + })) + return completion + }, + }, +}) +// Exactly one JSON document on stdout, mirroring the CLI's --json rendering. +console.log(JSON.stringify(result)) diff --git a/packages/core/test/unit/update/fallback-result-protocol.test.ts b/packages/core/test/unit/update/fallback-result-protocol.test.ts new file mode 100644 index 0000000..c0f0713 --- /dev/null +++ b/packages/core/test/unit/update/fallback-result-protocol.test.ts @@ -0,0 +1,373 @@ +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' +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 { + FALLBACK_CHILD_RESULT_MAX_BYTES, + FALLBACK_CHILD_RESULT_MAX_PRESERVED, + FALLBACK_CHILD_RESULT_SCHEMA, + childResultArgs, + fallbackChildResultMessage, + isValidChildResultCode, + readValidatedFallbackChildResult, + writeFallbackChildResult, + type ContainmentDirectoryIdentity, +} from '../../../src/update/fallback-result-protocol.js' + +describe('fallback child result protocol', () => { + let directory: string + let previousDirectory: string | undefined + + beforeEach(() => { + directory = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-result-protocol-')) + chmodSync(directory, 0o700) + previousDirectory = process.env.NSOLID_RESULT_PROTOCOL_TEST_DIR + process.env.NSOLID_RESULT_PROTOCOL_TEST_DIR = directory + }) + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }) + if (previousDirectory === undefined) delete process.env.NSOLID_RESULT_PROTOCOL_TEST_DIR + else process.env.NSOLID_RESULT_PROTOCOL_TEST_DIR = previousDirectory + }) + + function resultPath (): string { + return path.join(directory, 'result.json') + } + + /** Identity of the beforeEach-created directory, mirroring production recording. */ + function containment (): ContainmentDirectoryIdentity { + const stat = lstatSync(path.resolve(directory)) + return { directory: path.resolve(directory), dev: stat.dev, ino: stat.ino } + } + + it('builds explicit child args for the planned result path', () => { + assert.deepEqual(childResultArgs(resultPath()), ['--result', resultPath()]) + }) + + it('accepts only safe structured error code shapes', () => { + assert.equal(isValidChildResultCode('MCP_RECONCILIATION_REQUIRED'), true) + assert.equal(isValidChildResultCode('FALLBACK_MCP_DRIFT'), true) + assert.equal(isValidChildResultCode(''), false) + assert.equal(isValidChildResultCode('bad-code'), false) + assert.equal(isValidChildResultCode('lower_case'), false) + assert.equal(isValidChildResultCode('WITH SPACE'), false) + assert.equal(isValidChildResultCode(undefined), false) + assert.equal(isValidChildResultCode(42), false) + }) + + it('round-trips the envelope with atomic publication, mode 0600, and no leftovers', async () => { + const target = resultPath() + await writeFallbackChildResult(target, 'nonce-1', 'MCP_RECONCILIATION_REQUIRED', { attempted: false }) + const stat = statSync(target) + if (process.platform !== 'win32') assert.equal(stat.mode & 0o777, 0o600) + const envelope = JSON.parse(readFileSync(target, 'utf8')) + assert.equal(envelope.schema, FALLBACK_CHILD_RESULT_SCHEMA) + assert.equal(envelope.nonce, 'nonce-1') + assert.equal(envelope.code, 'MCP_RECONCILIATION_REQUIRED') + assert.deepEqual(envelope.rollback, { attempted: false }) + const leftovers = readdirSync(directory).filter((name) => name !== 'result.json') + assert.deepEqual(leftovers, [], 'a temporary sibling must never survive the atomic write') + const validated = await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }) + assert.ok(validated) + assert.equal(validated.code, 'MCP_RECONCILIATION_REQUIRED') + assert.deepEqual(validated.rollback, { attempted: false }) + }) + + it('refuses to publish a code that is not a safe structured identifier', async () => { + const target = resultPath() + await writeFallbackChildResult(target, 'nonce-1', 'arbitrary text with secrets', { attempted: false }) + assert.throws(() => statSync(target), 'no envelope may be written for an unsafe code') + }) + + it('publishes an envelope exactly at the byte limit and refuses one byte more', async () => { + const code = 'MCP_RECONCILIATION_REQUIRED' + const probe = JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce: '', code }) + const exactNonce = 'x'.repeat(FALLBACK_CHILD_RESULT_MAX_BYTES - Buffer.byteLength(probe)) + const exactPayload = JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce: exactNonce, code }) + assert.equal(Buffer.byteLength(exactPayload), FALLBACK_CHILD_RESULT_MAX_BYTES) + + const exactTarget = resultPath() + await writeFallbackChildResult(exactTarget, exactNonce, code, undefined) + assert.equal(statSync(exactTarget).size, FALLBACK_CHILD_RESULT_MAX_BYTES) + const accepted = await readValidatedFallbackChildResult(exactTarget, exactNonce, { containmentDirectories: [containment()] }) + assert.ok(accepted) + assert.equal(accepted.code, code) + + const oversizedTarget = path.join(directory, 'oversized-result.json') + const oversizedNonce = `${exactNonce}x` + const oversizedPayload = JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce: oversizedNonce, code }) + assert.equal(Buffer.byteLength(oversizedPayload), FALLBACK_CHILD_RESULT_MAX_BYTES + 1) + await writeFallbackChildResult(oversizedTarget, oversizedNonce, code, undefined) + assert.equal(existsSync(oversizedTarget), false, 'an over-limit envelope must not be published') + assert.equal( + await readValidatedFallbackChildResult(oversizedTarget, oversizedNonce, { containmentDirectories: [containment()] }), + undefined, + 'an absent over-limit result must preserve the parent generic-fallback path' + ) + }) + + it('rejects a result path outside the parent-owned containment directories', async () => { + const outside = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-result-outside-')) + const target = path.join(outside, 'result.json') + await writeFallbackChildResult(target, 'nonce-1', 'MCP_RECONCILIATION_REQUIRED', { attempted: false }) + const envelope = await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }) + assert.equal(envelope, undefined) + const noContainment = await readValidatedFallbackChildResult(target, 'nonce-1', {}) + assert.equal(noContainment, undefined, 'validation must fail closed without containment evidence') + rmSync(outside, { recursive: true, force: true }) + }) + + it('rejects missing, malformed, oversized, wrong-nonce, schema-skewed, and unsafe-code envelopes', async () => { + const target = resultPath() + assert.equal(await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }), undefined) + + writeFileSync(target, 'not json at all', { mode: 0o600 }) + assert.equal(await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }), undefined) + + const oversized = { schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce: 'nonce-1', code: 'MCP_RECONCILIATION_REQUIRED', pad: 'x'.repeat(FALLBACK_CHILD_RESULT_MAX_BYTES) } + writeFileSync(target, JSON.stringify(oversized), { mode: 0o600 }) + assert.equal(await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }), undefined) + + await writeFallbackChildResult(target, 'nonce-1', 'MCP_RECONCILIATION_REQUIRED', { attempted: false }) + assert.equal(await readValidatedFallbackChildResult(target, 'other-nonce', { containmentDirectories: [containment()] }), undefined) + + writeFileSync(target, JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA + 1, nonce: 'nonce-1', code: 'MCP_RECONCILIATION_REQUIRED' }), { mode: 0o600 }) + assert.equal(await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }), undefined) + + writeFileSync(target, JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce: 'nonce-1', code: 'bad code shape' }), { mode: 0o600 }) + assert.equal(await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }), undefined) + + writeFileSync(target, JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce: 'nonce-1', code: 'MCP_RECONCILIATION_REQUIRED', rollback: { attempted: 'yes' } }), { mode: 0o600 }) + assert.equal(await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }), undefined) + }) + + it('refuses to follow a symlinked result path', async () => { + const realDir = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-result-real-')) + const realTarget = path.join(realDir, 'real-result.json') + await writeFallbackChildResult(realTarget, 'nonce-1', 'MCP_RECONCILIATION_REQUIRED', { attempted: false }) + const target = resultPath() + symlinkSync(realTarget, target) + const envelope = await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }) + assert.equal(envelope, undefined) + rmSync(realDir, { recursive: true, force: true }) + }) + + it('rejects a result whose containment directory was swapped for a symlink', async () => { + const recorded = containment() + const other = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-result-swapped-')) + try { + const target = resultPath() + await writeFallbackChildResult(target, 'nonce-1', 'MCP_RECONCILIATION_REQUIRED', { attempted: false }) + // A nonce-valid envelope now also exists behind the symlink target, so a + // lexical-only check would accept it. The recorded directory identity + // must reject the swap. + await writeFallbackChildResult(path.join(other, 'result.json'), 'nonce-1', 'MCP_RECONCILIATION_REQUIRED', { attempted: false }) + rmSync(directory, { recursive: true, force: true }) + symlinkSync(other, directory) + const envelope = await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [recorded] }) + assert.equal(envelope, undefined, 'a swapped containment directory must fail closed') + } finally { + rmSync(directory, { recursive: true, force: true }) + mkdirSync(directory) + rmSync(other, { recursive: true, force: true }) + } + }) + + it('rejects a well-formed envelope whose code is not in the parent allowlist', async () => { + const target = resultPath() + await writeFallbackChildResult(target, 'nonce-1', 'UNKNOWN_SAFE_SHAPE', { attempted: true, succeeded: true }) + assert.equal(existsSync(target), true, 'the child may still publish a shape-valid code') + const envelope = await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }) + assert.equal(envelope, undefined, 'an unrecognized code must never surface an envelope') + }) + + it('maps accepted child codes to parent-owned safe messages and rejects unknown codes', () => { + const reconciliation = fallbackChildResultMessage('MCP_RECONCILIATION_REQUIRED', 'opencode') + assert.ok(reconciliation) + assert.ok(reconciliation.includes('nsolid-plugin setup --harness opencode')) + assert.ok(fallbackChildResultMessage('FALLBACK_MCP_DRIFT', 'opencode')) + assert.ok(fallbackChildResultMessage('FALLBACK_OWNERSHIP_DRIFT', 'opencode')) + assert.ok(fallbackChildResultMessage('UNTRACKED_INSTALLATION', 'opencode')) + assert.equal(fallbackChildResultMessage('TOTALLY_UNKNOWN_CODE', 'opencode'), undefined) + assert.equal(fallbackChildResultMessage('', 'opencode'), undefined) + }) + + it('names the planned harness in the reconciliation guidance for every supported target', () => { + for (const target of ['claude', 'codex', 'pi', 'antigravity', 'opencode'] as const) { + const message = fallbackChildResultMessage('MCP_RECONCILIATION_REQUIRED', target) + assert.ok(message, `a message is expected for ${target}`) + assert.ok(message.includes(`nsolid-plugin setup --harness ${target}`), `${message} must name ${target}`) + if (target !== 'opencode') assert.ok(!message.includes('opencode'), `${message} must not hardcode opencode for ${target}`) + } + }) + + it('never interpolates a target that fails the validated shape', () => { + const hostile = '../evil && rm -rf ~' + const message = fallbackChildResultMessage('MCP_RECONCILIATION_REQUIRED', hostile) + assert.ok(message) + assert.ok(!message.includes(hostile), 'an unvalidated target must never reach the message') + assert.ok(message.includes('setup --harness harness'), 'an invalid target falls back to the generic word') + }) + + describe('schema-2 preservation reporting boundaries', () => { + // FALLBACK_STATE_UNPROVEN / FALLBACK_RECOVERY_PENDING are the allowlisted + // codes under which preservation reporting is produced; preservation data + // is child-supplied reporting output and must never widen acceptance or + // drive a mutation decision at this boundary. + const code = 'FALLBACK_STATE_UNPROVEN' + const rollback = { attempted: true, succeeded: false } + + const preservedEntry = (name: string): string => path.join(directory, name) + const artifacts = (count: number): string[] => + Array.from({ length: count }, (_, index) => preservedEntry(`preserved-artifact-${index}`)) + const paths = (count: number): string[] => + Array.from({ length: count }, (_, index) => preservedEntry(`preserved-path-${index}`)) + + function writeRawEnvelope (fields: Record): void { + writeFileSync( + resultPath(), + JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce: 'nonce-1', code, ...fields }), + { mode: 0o600 } + ) + } + + const readValidated = (): Promise => + readValidatedFallbackChildResult(resultPath(), 'nonce-1', { containmentDirectories: [containment()] }) + + it('round-trips valid preservation arrays verbatim and deterministically', async () => { + const target = resultPath() + const reportedArtifacts = artifacts(2) + const reportedPaths = paths(1) + await writeFallbackChildResult(target, 'nonce-1', code, rollback, { + preservedArtifacts: reportedArtifacts, + preservedPaths: reportedPaths, + }) + const first = await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }) + assert.ok(first) + assert.equal(first.code, code) + assert.deepEqual(first.rollback, rollback) + assert.deepEqual(first.preservedArtifacts, reportedArtifacts, 'artifact reporting must survive the round trip unchanged') + assert.deepEqual(first.preservedPaths, reportedPaths, 'path reporting must survive the round trip unchanged') + const second = await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }) + assert.deepEqual(second, first, 'the same envelope must validate identically on every read') + }) + + it('accepts exactly the preservation count limit and rejects one entry more', async () => { + writeRawEnvelope({ rollback, preservedArtifacts: artifacts(FALLBACK_CHILD_RESULT_MAX_PRESERVED) }) + const accepted = await readValidated() + assert.ok(accepted) + assert.equal((accepted as { preservedArtifacts?: string[] }).preservedArtifacts?.length, FALLBACK_CHILD_RESULT_MAX_PRESERVED) + + writeRawEnvelope({ rollback, preservedArtifacts: artifacts(FALLBACK_CHILD_RESULT_MAX_PRESERVED + 1) }) + assert.equal(await readValidated(), undefined, 'an over-count array must reject the whole envelope') + + writeRawEnvelope({ rollback, preservedPaths: paths(FALLBACK_CHILD_RESULT_MAX_PRESERVED + 1) }) + assert.equal(await readValidated(), undefined, 'the same count limit must bound preservedPaths') + }) + + it('never publishes an over-count array but keeps the rest of the envelope', async () => { + const target = resultPath() + await writeFallbackChildResult(target, 'nonce-1', code, rollback, { + preservedArtifacts: artifacts(FALLBACK_CHILD_RESULT_MAX_PRESERVED + 1), + }) + assert.equal(existsSync(target), true, 'the envelope itself remains publishable reporting data') + const raw = JSON.parse(readFileSync(target, 'utf8')) as { preservedArtifacts?: string[] } + assert.equal(raw.preservedArtifacts, undefined, 'an invalid array must never be published') + const envelope = await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }) + assert.ok(envelope) + assert.equal(envelope.code, code) + assert.equal(envelope.preservedArtifacts, undefined) + }) + + it('requires absolute paths and rejects traversal spellings in preservation entries', async () => { + writeRawEnvelope({ rollback, preservedArtifacts: ['relative/preserved-skill'] }) + assert.equal(await readValidated(), undefined, 'a relative path is not a reportable location') + + writeRawEnvelope({ rollback, preservedArtifacts: [`${directory}/../escaped-preservative`] }) + assert.equal(await readValidated(), undefined, 'a `..` spelling must be rejected even when absolute') + + writeRawEnvelope({ rollback, preservedPaths: [preservedEntry('ok-path'), 'also/relative'] }) + assert.equal(await readValidated(), undefined, 'one invalid entry rejects the whole preservedPaths array') + }) + + it('rejects non-array fields and non-string entries', async () => { + for (const field of ['preservedArtifacts', 'preservedPaths']) { + for (const value of ['just-a-string', 42, null, true, { 0: preservedEntry('object-key') }]) { + writeRawEnvelope({ rollback, [field]: value }) + assert.equal(await readValidated(), undefined, `${field} must be an array`) + } + } + for (const entry of [42, null, {}, [], '']) { + writeRawEnvelope({ rollback, preservedArtifacts: [entry] }) + assert.equal(await readValidated(), undefined, `a non-string or empty entry (${JSON.stringify(entry)}) must reject the envelope`) + } + }) + + it('bounds every entry length at exactly 512 characters', async () => { + const entry512 = `/${'a'.repeat(511)}` + const entry513 = `/${'a'.repeat(512)}` + assert.equal(Buffer.byteLength(entry512), 512) + assert.equal(Buffer.byteLength(entry513), 513) + + writeRawEnvelope({ rollback, preservedArtifacts: [entry512] }) + const accepted = await readValidated() + assert.ok(accepted) + assert.deepEqual((accepted as { preservedArtifacts?: string[] }).preservedArtifacts, [entry512]) + + writeRawEnvelope({ rollback, preservedArtifacts: [entry513] }) + assert.equal(await readValidated(), undefined, 'an entry beyond the per-item limit must reject the envelope') + }) + + it('bounds the combined envelope at 4096 bytes, not each array alone', async () => { + const target = resultPath() + // Each array alone stays under the limit; the combination must not publish. + const longArtifacts = Array.from({ length: FALLBACK_CHILD_RESULT_MAX_PRESERVED }, (_, index) => `/${'a'.repeat(100)}-${index}`) + const manyPaths = paths(FALLBACK_CHILD_RESULT_MAX_PRESERVED) + const artifactsAlone = JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce: 'nonce-1', code, rollback, preservedArtifacts: longArtifacts }) + const combined = JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce: 'nonce-1', code, rollback, preservedArtifacts: longArtifacts, preservedPaths: manyPaths }) + assert.equal(Buffer.byteLength(artifactsAlone) <= FALLBACK_CHILD_RESULT_MAX_BYTES, true, 'test setup: one array alone must fit') + assert.equal(Buffer.byteLength(combined) > FALLBACK_CHILD_RESULT_MAX_BYTES, true, 'test setup: the combination must exceed the bound') + + await writeFallbackChildResult(target, 'nonce-1', code, rollback, { preservedArtifacts: longArtifacts, preservedPaths: manyPaths }) + assert.equal(existsSync(target), false, 'an over-limit combined envelope must never be published') + assert.equal(await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }), undefined) + + writeRawEnvelope({ rollback, preservedArtifacts: longArtifacts, preservedPaths: manyPaths }) + assert.equal(statSync(resultPath()).size > FALLBACK_CHILD_RESULT_MAX_BYTES, true) + assert.equal(await readValidated(), undefined, 'the reader must refuse an oversized result before parsing') + }) + + it('reports duplicates verbatim: the protocol never dedups or reorders reporting data', async () => { + const target = resultPath() + const duplicated = [preservedEntry('same-artifact'), preservedEntry('same-artifact'), preservedEntry('other-artifact')] + await writeFallbackChildResult(target, 'nonce-1', code, rollback, { preservedArtifacts: duplicated }) + const envelope = await readValidatedFallbackChildResult(target, 'nonce-1', { containmentDirectories: [containment()] }) + assert.ok(envelope) + assert.deepEqual(envelope.preservedArtifacts, duplicated, 'reporting arrays round-trip verbatim; dedup is an upstream responsibility') + }) + + it('proves preservation is reporting-only: it never rescues an invalid envelope or reaches parent messages', async () => { + const reported = artifacts(2) + // Wrong nonce with perfectly valid preservation arrays: still rejected. + writeRawEnvelope({ nonce: 'forged-nonce', rollback, preservedArtifacts: reported }) + assert.equal( + await readValidatedFallbackChildResult(resultPath(), 'nonce-1', { containmentDirectories: [containment()] }), + undefined, + 'preservation data must not bypass nonce binding' + ) + // A code outside the parent allowlist with valid preservation: still rejected. + writeFileSync( + resultPath(), + JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce: 'nonce-1', code: 'UNKNOWN_SAFE_SHAPE', rollback, preservedArtifacts: reported }), + { mode: 0o600 } + ) + assert.equal(await readValidated(), undefined, 'preservation data must not bypass the code allowlist') + // Parent-owned messages are constant: child-supplied paths never appear in them. + const message = fallbackChildResultMessage(code, 'opencode') + assert.ok(message) + for (const artifact of reported) assert.ok(!message.includes(artifact), 'no reported path may leak into the safe message') + }) + }) +}) 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..b4b803d --- /dev/null +++ b/packages/core/test/unit/update/fallback-strategy.test.ts @@ -0,0 +1,1908 @@ +import { tarEntry } from '../../helpers/tar.js' +import { createCanonicalTempRoot } from '../../helpers/canonical-temp-root.js' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { gzipSync } from 'node:zlib' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { fallbackStrategy } from '../../../src/update/strategies/fallback.js' +import { resolveExecutableIdentity } from '../../../src/update/command-runner.js' +import { applyFallbackEntry, claimFallbackJournalMutation, fallbackJournalPath, manifestDigestOf, pathDigest, pathKind, registerFallbackStage, setFallbackJournalTransitionLockTimeoutForTests, 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 { FALLBACK_CHILD_RESULT_SCHEMA, recordContainmentDirectoryIdentity } from '../../../src/update/fallback-result-protocol.js' +import { FALLBACK_PROTOCOL_VERSION, type CommandResult, type FallbackTransactionIdentity, type UpdateInstallation, type UpdatePlanItem, type UpdateResult } from '../../../src/update/types.js' + +function isolateHome (home: string): () => void { + const previousHome = process.env.HOME + const previousUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home + return () => { + 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 (): 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, + } +} + +async function pathEvidence (target: string) { + const kind = await pathKind(target) + const digest = kind === 'missing' ? undefined : await pathDigest(target) + return { path: path.resolve(target), kind, digest } +} + +describe('fallback update strategy', () => { + it('rejects a mutable fallback plan without a transaction before creating a workspace or running a command', async () => { + let commandRan = false + const result = await fallbackStrategy.execute(item(), { + options: {}, + commandRunner: { + run: async () => { + commandRan = true + return { exitCode: 1, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }, + }) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'INVALID_PLAN') + assert.equal(commandRan, false) + assert.equal(result.rollback, undefined) + }) + + 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 = createCanonicalTempRoot('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, treeTerminated: true }) } }) + 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 restoreHome: () => void + + beforeEach(() => { + home = createCanonicalTempRoot('nsolid-plugin-fallback-strategy-') + restoreHome = isolateHome(home) + }) + + afterEach(() => { + restoreHome() + 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)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await pathEvidence(skillPath)], + ownedLinks: [await pathEvidence(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, captured as whole-path + // evidence: this is what authenticates MCP backups during rollback. + ownedMcpConfigPaths: await Promise.all( + [...new Set([configPath, resolveHome('~/.claude.json')].map((value) => path.resolve(value)))].map((value) => pathEvidence(value)) + ), + bundleDestinations: [], + 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 recomputes the canonical manifest digest from the transaction manifest it verified, claims the mutating journal, and only then stages and applies through the journal API. */ + async function childStagesAndApplies (fixture: GateFixture, target: string, bytes: Buffer): Promise { + const claimed = await claimFallbackJournalMutation(fixture.identity, manifestDigestOf(fixture.identity)) + assert.ok(claimed, 'the simulated child must be able to claim the mutating journal') + const staged = await registerFallbackStage(claimed, 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, treeTerminated: true }) }, + }) + + 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, treeTerminated: true } + }, + }, + }) + + 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, treeTerminated: true } + }, + }, + }) + + 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') + }) +}) + +describe('fallback strategy structured child result', () => { + let home: string + let restoreHome: () => void + + beforeEach(() => { + home = createCanonicalTempRoot('nsolid-plugin-fallback-result-') + restoreHome = isolateHome(home) + }) + + afterEach(() => { + restoreHome() + rmSync(home, { recursive: true, force: true }) + }) + + interface ResultFixture { + identity: FallbackTransactionIdentity + manifestDir: string + manifestPath: string + resultPath: string + skillPath: string + item: UpdatePlanItem + } + + /** A journal-backed fallback item whose child command carries the transaction manifest and a result path. */ + async function setupResultFixture (): Promise { + const skillPath = path.join(getSkillsDir(), 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old tracked') + 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: [], + }) + const identity: FallbackTransactionIdentity = { + installationId: 'claude:fallback', + harness: 'claude', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await pathEvidence(skillPath)], + ownedLinks: [await pathEvidence(path.join(getHarnessSkillsPath('claude'), 'tracked'))], + ownedMcpFields: [], + // The adapter canonical path is usually absent on a fresh machine; + // missing evidence (no digest) is a legitimate planned state. + ownedMcpConfigPaths: [await pathEvidence(resolveHome('~/.claude.json'))], + bundleDestinations: [], + approvedDestinationRoots: [getSkillsDir(), getHarnessSkillsPath('claude')].map((value) => path.resolve(value)), + } + const manifestDir = mkdtempSync(path.join(home, 'nsolid-plugin-manifest-')) + if (process.platform !== 'win32') chmodSync(manifestDir, 0o700) + const manifestPath = path.join(manifestDir, 'transaction.json') + writeFileSync(manifestPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 }) + const resultPath = path.join(manifestDir, 'result.json') + const item: 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', '--yes', '--package=x.tgz', '--', 'nsolid-plugin-refresh-owned', '--transaction', manifestPath, '--result', resultPath], + cwd: tmpdir(), + timeoutMs: 1000, + }, + }], + rollbackSteps: [], + requiresConfirmation: true, + fallbackTransaction: identity, + temporaryDirectories: [manifestDir], + resultContainment: [await recordContainmentDirectoryIdentity(manifestDir)], + } + return { identity, manifestDir, manifestPath, resultPath, skillPath, item } + } + + interface ChildContext { + resultPath?: string + manifestPath?: string + workspace?: string + } + + /** Simulate the real child: it reads the nonce from its own manifest and publishes the structured envelope. */ + function childWritesEnvelope (build: (nonce: string) => Record): (child: ChildContext) => CommandResult { + return (child) => { + if (child.resultPath && child.manifestPath) { + const nonce = (JSON.parse(readFileSync(child.manifestPath, 'utf8')) as { nonce: string }).nonce + writeFileSync(child.resultPath, JSON.stringify(build(nonce)), { mode: 0o600 }) + } + return { exitCode: 1, stdout: '', stderr: 'Fallback refresh failed\nrollback: not-attempted\n', timedOut: false, treeTerminated: true } + } + } + + async function execute (fixture: ResultFixture, runner: (child: ChildContext) => CommandResult) { + return fallbackStrategy.execute(fixture.item, { + options: {}, + commandRunner: { + run: async (command) => { + const args = command.args ?? [] + const at = (flag: string): string | undefined => { + const index = args.indexOf(flag) + return index >= 0 ? args[index + 1] : undefined + } + return runner({ resultPath: at('--result'), manifestPath: at('--transaction'), workspace: command.cwd }) + } + }, + }) + } + + interface RecoveryScenario { + name: string + expectedCode: string + expectedRollback: UpdateResult['rollback'] + run: (fixture: ResultFixture) => Promise + } + + const recoveryScenarios: RecoveryScenario[] = [ + { + name: 'unconfirmed termination', + expectedCode: 'FALLBACK_TREE_TERMINATION_UNCONFIRMED', + expectedRollback: { attempted: false }, + run: (fixture) => execute(fixture, (child) => { + const envelope = childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED' }))(child) + return { ...envelope, timedOut: true, treeTerminated: false } + }), + }, + { + name: 'missing executable', + expectedCode: 'MISSING_EXECUTABLE', + expectedRollback: { attempted: true, succeeded: true }, + run: (fixture) => execute(fixture, (child) => { + const envelope = childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED' }))(child) + return { ...envelope, spawnErrorCode: 'ENOENT', treeTerminated: true } + }), + }, + { + name: 'confirmed timeout', + expectedCode: 'FALLBACK_COMMAND_TIMEOUT', + expectedRollback: { attempted: true, succeeded: true }, + run: (fixture) => execute(fixture, (child) => { + const envelope = childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED' }))(child) + return { ...envelope, timedOut: true, treeTerminated: true } + }), + }, + { + name: 'unproven state', + expectedCode: 'FALLBACK_STATE_UNPROVEN', + expectedRollback: { attempted: true, succeeded: false }, + run: (fixture) => execute(fixture, (child) => { + const journal = JSON.parse(readFileSync(fallbackJournalPath(fixture.identity.trackingPath), 'utf8')) as { entries: Array<{ backup?: string }> } + const backup = journal.entries.find((entry) => entry.backup)?.backup + assert.ok(backup, 'the scenario journal must hold at least one backup') + if (statSync(backup).isDirectory()) writeFileSync(path.join(backup, '__tampered__'), 'tampered') + else writeFileSync(backup, 'tampered') + return childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED' }))(child) + }), + }, + { + name: 'incomplete rollback', + expectedCode: 'FALLBACK_ROLLBACK_FAILED', + expectedRollback: { attempted: true, succeeded: false }, + run: (fixture) => execute(fixture, (child) => { + // Replacing only the skill parent leaves the tracking file available; + // the authenticated restore cannot traverse this foreign file and + // therefore returns an incomplete (not unproven) outcome. + const skillParent = path.dirname(fixture.skillPath) + rmSync(skillParent, { recursive: true, force: true }) + writeFileSync(skillParent, 'foreign parent') + assert.equal(statSync(skillParent).isFile(), true) + return childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED' }))(child) + }), + }, + { + name: 'validated child error', + expectedCode: 'MCP_RECONCILIATION_REQUIRED', + expectedRollback: { attempted: true, succeeded: true }, + run: (fixture) => execute(fixture, childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED' }))), + }, + ] + + for (const scenario of recoveryScenarios) { + it(`projects the ${scenario.name} recovery outcome with the required precedence`, async () => { + const fixture = await setupResultFixture() + try { + const result = await scenario.run(fixture) + assert.equal(result.error?.code, scenario.expectedCode) + assert.deepEqual(result.rollback, scenario.expectedRollback) + } finally { + rmSync(fixture.manifestDir, { recursive: true, force: true }) + } + }) + } + + it('uses a private temporary cwd and parent rollback with a real transaction', async () => { + const fixture = await setupResultFixture() + let observedCwd = '' + const result = await fallbackStrategy.execute(fixture.item, { + options: {}, + commandRunner: { + run: async (command) => { + observedCwd = command.cwd ?? '' + assert.notEqual(observedCwd, tmpdir()) + 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, treeTerminated: true } + }, + }, + }) + + assert.equal(result.status, 'failed') + assert.deepEqual(result.rollback, { attempted: true, succeeded: true }) + assert.equal(existsSync(path.resolve(observedCwd)), false) + }) + + it('never removes a path derived from command args during strategy execution', async () => { + const fixture = await setupResultFixture() + const foreign = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-foreign-')) + const commandStep = fixture.item.steps.find((entry) => entry.kind === 'command') + assert.ok(commandStep !== undefined && commandStep.kind === 'command') + fixture.item.steps = [{ + ...commandStep, + command: { ...commandStep.command, args: ['--transaction', path.join(foreign, 'transaction.json')] }, + }] + + const result = await execute(fixture, () => ({ exitCode: 1, stdout: '', stderr: 'refresh failed\n', timedOut: false, treeTerminated: true })) + + assert.equal(result.status, 'failed') + assert.equal(existsSync(fixture.manifestDir), true, 'the strategy must not release planning temporaries owned by the coordinator') + assert.equal(existsSync(foreign), true, 'a directory not created by this process must never be deleted') + rmSync(foreign, { recursive: true, force: true }) + }) + + it('surfaces the child MCP_RECONCILIATION_REQUIRED code instead of the generic fallback failure', async () => { + const fixture = await setupResultFixture() + const writeEnvelope = childWritesEnvelope((nonce) => ({ + schema: FALLBACK_CHILD_RESULT_SCHEMA, + nonce, + code: 'MCP_RECONCILIATION_REQUIRED', + rollback: { attempted: false }, + })) + const result = await execute(fixture, (child) => ({ ...writeEnvelope(child), treeTerminated: true })) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'MCP_RECONCILIATION_REQUIRED') + assert.ok(result.error?.message.includes('nsolid-plugin setup --harness claude'), 'the approved recovery guidance must name the planned harness, not a hardcoded one') + // The parent journal exists, so its verified recovery outcome — not the + // child envelope claim — is the public rollback state. + assert.deepEqual(result.rollback, { attempted: true, succeeded: true }) + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked', 'the reconciliation failure must not mutate owned state') + assert.equal(existsSync(fixture.resultPath), false, 'the structured result must be removed during normal workspace cleanup') + assert.equal(existsSync(fixture.manifestDir), true, 'planning temporaries remain for coordinator-owned cleanup') + const serialized = JSON.stringify(result) + const parsed = JSON.parse(serialized) as UpdateResult + assert.deepEqual(JSON.parse(JSON.stringify(parsed)), parsed, 'the parent result must remain exactly one stable JSON document') + }) + + for (const exitCode of [0, 1]) { + it(`prioritizes unconfirmed termination over a valid child envelope without timeout (exit: ${exitCode})`, async () => { + const fixture = await setupResultFixture() + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + let journalBefore = '' + const result = await execute(fixture, (child) => { + journalBefore = readFileSync(journalPath, 'utf8') + writeFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'child mutation must remain') + const envelope = childWritesEnvelope((nonce) => ({ + schema: FALLBACK_CHILD_RESULT_SCHEMA, + nonce, + code: 'MCP_RECONCILIATION_REQUIRED', + rollback: { attempted: true, succeeded: true }, + }))(child) + return { ...envelope, exitCode, treeTerminated: false, stdout: 'RAW-CHILD-SECRET', stderr: 'RAW-CHILD-SECRET\nrollback: succeeded\n' } + }) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'FALLBACK_TREE_TERMINATION_UNCONFIRMED') + assert.deepEqual(result.rollback, { attempted: false }) + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'child mutation must remain') + assert.equal(readFileSync(journalPath, 'utf8'), journalBefore, 'deferral must not reclaim, restore or dispose the journal') + const journal = JSON.parse(journalBefore) as { snapshotDirectory: string } + assert.equal(existsSync(journal.snapshotDirectory), true) + assert.equal(existsSync(fixture.manifestDir), true) + assert.ok(!JSON.stringify(result).includes('RAW-CHILD-SECRET')) + }) + } + + it('never publishes child-controlled text carried inside the envelope', async () => { + const fixture = await setupResultFixture() + const result = await execute(fixture, childWritesEnvelope((nonce) => ({ + schema: FALLBACK_CHILD_RESULT_SCHEMA, + nonce, + code: 'MCP_RECONCILIATION_REQUIRED', + rollback: { attempted: false }, + message: 'LEAKED CHILD TEXT npm notice registry https://registry.npmjs.org/SECRET', + }))) + + assert.equal(result.error?.code, 'MCP_RECONCILIATION_REQUIRED') + assert.ok(!result.error?.message.includes('LEAKED'), 'arbitrary child text must never reach the public message') + assert.ok(!result.error?.message.includes('SECRET'), 'arbitrary child text must never reach the public message') + assert.ok(result.error?.message.includes('nsolid-plugin setup --harness claude')) + }) + + it('keeps raw child stdout/stderr out of the public error and trusts the envelope over output parsing', async () => { + const fixture = await setupResultFixture() + const result = await execute(fixture, (child) => { + const quiet = childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'FALLBACK_MCP_DRIFT', rollback: { attempted: false } }))(child) + return { + ...quiet, + stdout: 'npm notice New version available\nnpm ERR code E404\nhttps://registry.npmjs.org/nsolid-plugin/-/nsolid-plugin-1.0.1.tgz', + stderr: 'npm error TOKEN=SECRET-VALUE\nrollback: succeeded\n', + } + }) + + assert.equal(result.error?.code, 'FALLBACK_MCP_DRIFT') + assert.ok(!result.error?.message.includes('registry.npmjs.org')) + assert.ok(!result.error?.message.includes('SECRET-VALUE')) + assert.ok(!result.error?.message.includes('npm error')) + }) + + it('fails safe when the child publishes no result file at all', async () => { + const fixture = await setupResultFixture() + const result = await execute(fixture, () => ({ exitCode: 1, stdout: '', stderr: 'refresh failed\n', timedOut: false, treeTerminated: true })) + assert.equal(result.error?.code, 'FALLBACK_COMMAND_FAILED') + assert.equal(result.error?.message, 'Fallback refresh command failed') + }) + + it('fails safe when the result file is malformed JSON', async () => { + const fixture = await setupResultFixture() + const result = await execute(fixture, (child) => { + if (child.resultPath) writeFileSync(child.resultPath, 'not json', { mode: 0o600 }) + return { exitCode: 1, stdout: '', stderr: 'refresh failed\n', timedOut: false, treeTerminated: true } + }) + assert.equal(result.error?.code, 'FALLBACK_COMMAND_FAILED') + }) + + for (const [name, invalidFields] of [ + ['oversized result', { pad: 'x'.repeat(8192) }], + ['different transaction nonce', { nonce: 'stale-transaction-nonce' }], + ['unknown child code', { code: 'TOTALLY_UNKNOWN_CODE' }], + ['unsafe child code shape', { code: 'bad code' }], + ['schema-version skew', { schema: 99 }], + ['malformed rollback shape', { rollback: { attempted: 1 } }], + ] as const) { + it(`fails safe on ${name}`, async () => { + const fixture = await setupResultFixture() + const result = await execute(fixture, childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED', ...invalidFields }))) + assert.equal(result.error?.code, 'FALLBACK_COMMAND_FAILED') + }) + } + + it('fails safe when the result path is a symlink', async () => { + const fixture = await setupResultFixture() + const realDir = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-result-real-')) + const realTarget = path.join(realDir, 'real.json') + const nonce = fixture.identity.nonce + writeFileSync(realTarget, JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED' }), { mode: 0o600 }) + rmSync(fixture.resultPath, { force: true }) + symlinkSync(realTarget, fixture.resultPath) + const result = await execute(fixture, () => ({ exitCode: 1, stdout: '', stderr: 'refresh failed\n', timedOut: false, treeTerminated: true })) + assert.equal(result.error?.code, 'FALLBACK_COMMAND_FAILED') + rmSync(realDir, { recursive: true, force: true }) + }) + + it('refuses to execute when a recorded containment directory was swapped', async () => { + const fixture = await setupResultFixture() + const replacement = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-result-swap-')) + try { + rmSync(fixture.manifestDir, { recursive: true, force: true }) + symlinkSync(replacement, fixture.manifestDir) + let ran = false + const result = await execute(fixture, () => { + ran = true + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }) + assert.equal(result.error?.code, 'FALLBACK_COMMAND_FAILED', 'a swapped transaction workspace must fail closed') + assert.equal(ran, false, 'the child command must never run against a swapped workspace') + } finally { + rmSync(fixture.manifestDir, { force: true }) + rmSync(replacement, { recursive: true, force: true }) + } + }) + + it('never replays a prior envelope when the same plan item executes twice', async () => { + const fixture = await setupResultFixture() + const observedResultPaths: string[] = [] + let blockedCommandRan = false + // First execution: an unconfirmed-termination timeout preserves the + // transaction workspace AND the journal for manual recovery. + const first = await execute(fixture, (child) => { + if (child.resultPath !== undefined) observedResultPaths.push(child.resultPath) + const quiet = childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED' }))(child) + return { ...quiet, timedOut: true, treeTerminated: false } + }) + assert.equal(first.error?.code, 'FALLBACK_TREE_TERMINATION_UNCONFIRMED') + // Second execution of the SAME item: the preserved journal is a pending + // next-run recovery state. Recovery never restores or commits on its own, + // so it must block the run before any command executes. + const second = await execute(fixture, (child) => { + blockedCommandRan = true + if (child.resultPath !== undefined) observedResultPaths.push(child.resultPath) + return { exitCode: 1, stdout: '', stderr: 'refresh failed\n', timedOut: false, treeTerminated: true } + }) + assert.equal(second.error?.code, 'FALLBACK_RECOVERY_PENDING', 'a preserved journal must block the next run as pending') + assert.equal(blockedCommandRan, false, 'the pending-blocked run must never execute the child command') + // Direct strategy execution leaves planning-owned state for the + // coordinator; a coordinator run releases it after this result. + assert.equal(existsSync(fixture.manifestDir), true, 'direct strategy execution must not own planning cleanup') + // The user applies the prescribed manual remedy (remove the pending + // journal and its snapshot). A retry then RE-PLANS: a fresh fixture means + // a fresh manifest, fresh nonce, and fresh per-execution result location, + // so the first envelope (already gone with its workspace) could never + // replay even if some copy survived. + const pendingJournalPath = fallbackJournalPath(fixture.identity.trackingPath) + const snapshotDirectory = (JSON.parse(readFileSync(pendingJournalPath, 'utf8')) as { snapshotDirectory: string }).snapshotDirectory + rmSync(pendingJournalPath, { force: true }) + rmSync(snapshotDirectory, { recursive: true, force: true }) + const rePlannedFixture = await setupResultFixture() + const third = await execute(rePlannedFixture, (child) => { + if (child.resultPath !== undefined) observedResultPaths.push(child.resultPath) + return { exitCode: 1, stdout: '', stderr: 'refresh failed\n', timedOut: false, treeTerminated: true } + }) + assert.equal(third.error?.code, 'FALLBACK_COMMAND_FAILED', 'the post-remedy execution must not replay the first envelope') + assert.equal(observedResultPaths.length, 2, 'both executed runs must receive an explicit result path') + assert.notEqual(observedResultPaths[0], observedResultPaths[1], 'each execution must use a fresh result location') + }) + + it('fails safe when the planned result path escapes the parent-owned workspace', async () => { + const fixture = await setupResultFixture() + const foreignDir = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-result-foreign-')) + const foreignResult = path.join(foreignDir, 'result.json') + const nonce = fixture.identity.nonce + writeFileSync(foreignResult, JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED' }), { mode: 0o600 }) + fixture.item.steps = [{ + kind: 'command', + description: 'refresh', + command: { + executable: 'npm', + args: ['exec', '--yes', '--package=x.tgz', '--', 'nsolid-plugin-refresh-owned', '--transaction', fixture.manifestPath, '--result', foreignResult], + cwd: tmpdir(), + timeoutMs: 1000, + }, + }] + const result = await execute(fixture, () => ({ exitCode: 1, stdout: '', stderr: 'refresh failed\n', timedOut: false, treeTerminated: true })) + assert.equal(result.error?.code, 'FALLBACK_COMMAND_FAILED', 'a result outside the parent-owned workspace must never be trusted') + // The parent must also never DELETE a file it did not create: the stale- + // result cleanup is bound to the recorded containment identity. + assert.equal(existsSync(foreignResult), true, 'the escape attempt must leave the foreign file untouched') + rmSync(foreignDir, { recursive: true, force: true }) + }) + + it('rejects a mutable fallback plan without a transaction before invoking the child', async () => { + const fixture = await setupResultFixture() + try { + delete (fixture.item as { fallbackTransaction?: unknown }).fallbackTransaction + let commandRan = false + const result = await fallbackStrategy.execute(fixture.item, { + options: {}, + commandRunner: { + run: async () => { + commandRan = true + return { exitCode: 1, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }, + }) + assert.equal(result.error?.code, 'INVALID_PLAN') + assert.equal(commandRan, false) + assert.equal(existsSync(fixture.manifestDir), true, 'an invalid plan must not clean up planning-owned state') + } finally { + rmSync(fixture.manifestDir, { recursive: true, force: true }) + } + }) + + it('lets parent journal recovery stay authoritative over the child rollback claim', async () => { + // The child claims its rollback FAILED, but the parent journal recovers + // successfully: the public state must follow the verified parent outcome, + // so the structured child code (not FALLBACK_ROLLBACK_FAILED) is reported. + { + const fixture = await setupResultFixture() + const result = await execute(fixture, childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED', rollback: { attempted: true, succeeded: false } }))) + assert.equal(result.error?.code, 'MCP_RECONCILIATION_REQUIRED') + assert.deepEqual(result.rollback, { attempted: true, succeeded: true }) + } + // The child claims its rollback SUCCEEDED, but parent recovery verifiably + // fails (a tampered backup aborts the restore preflight): the public state + // must report the rollback failure the parent actually observed. + { + const fixture = await setupResultFixture() + const result = await execute(fixture, (child) => { + const outcome = childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED', rollback: { attempted: true, succeeded: true } }))(child) + // Between the child failure and parent recovery, corrupt one journaled + // backup so the parent restore preflight provably fails. + const journal = JSON.parse(readFileSync(fallbackJournalPath(fixture.identity.trackingPath), 'utf8')) as { entries: Array<{ backup?: string }> } + const backup = journal.entries.find((entry) => entry.backup)?.backup + assert.ok(backup, 'the fixture journal must hold at least one backup') + if (statSync(backup).isDirectory()) writeFileSync(path.join(backup, '__tampered__'), 'tampered') + else writeFileSync(backup, 'tampered') + return outcome + }) + assert.equal(result.error?.code, 'FALLBACK_STATE_UNPROVEN') + assert.equal(result.error?.message, 'The fallback transaction could not prove the state of its owned files, so the automatic rollback was refused. Its journal, snapshot, and preserved artifacts were left untouched for manual recovery.') + assert.deepEqual(result.rollback, { attempted: true, succeeded: false }) + } + }) + + it('keeps FALLBACK_COMMAND_TIMEOUT precedence when the confirmed timeout carries a structured result', async () => { + const fixture = await setupResultFixture() + const result = await execute(fixture, (child) => { + const quiet = childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED' }))(child) + return { ...quiet, timedOut: true, treeTerminated: true } + }) + assert.equal(result.error?.code, 'FALLBACK_COMMAND_TIMEOUT') + // The confirmed timeout must own both the code AND its message; the + // structured message must not pair with the timeout code. + assert.equal(result.error?.message, 'Fallback refresh command timed out') + }) + + it('never replays a stale envelope left at the planned path by a prior attempt', async () => { + const fixture = await setupResultFixture() + // A prior attempt left a valid envelope at the planned result path. The + // parent now points each execution at a FRESH result location, so the + // stale file is never read regardless of its nonce. + writeFileSync(fixture.resultPath, JSON.stringify({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce: fixture.identity.nonce, code: 'MCP_RECONCILIATION_REQUIRED', rollback: { attempted: false } }), { mode: 0o600 }) + const result = await execute(fixture, () => ({ exitCode: 1, stdout: '', stderr: 'refresh failed\n', timedOut: false, treeTerminated: true })) + assert.equal(result.error?.code, 'FALLBACK_COMMAND_FAILED', 'the stale envelope must not surface its structured code') + // (The planned-path file may still be removed as part of the parent-owned + // manifest workspace cleanup; the fresh-path design means it was never + // READ, which is the property under test.) + }) + + it('keeps unconfirmed tree termination precedence and preserves recovery artifacts', async () => { + const fixture = await setupResultFixture() + const result = await execute(fixture, (child) => { + const quiet = childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED' }))(child) + return { ...quiet, timedOut: true, treeTerminated: false } + }) + assert.equal(result.error?.code, 'FALLBACK_TREE_TERMINATION_UNCONFIRMED') + assert.equal(existsSync(fixture.manifestDir), true, 'recovery artifacts must be preserved for the unconfirmed case') + rmSync(fixture.manifestDir, { recursive: true, force: true }) + }) + + it('keeps MISSING_EXECUTABLE precedence when the spawn fails with a structured result present', async () => { + const fixture = await setupResultFixture() + const result = await execute(fixture, (child) => { + const quiet = childWritesEnvelope((nonce) => ({ schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'MCP_RECONCILIATION_REQUIRED' }))(child) + return { ...quiet, spawnErrorCode: 'ENOENT' } + }) + assert.equal(result.error?.code, 'MISSING_EXECUTABLE') + }) + + it('maps a validated FALLBACK_PROTOCOL_UNSUPPORTED pre-mutation rejection to a no-rollback failure and preserves its verified journal state', async () => { + const fixture = await setupResultFixture() + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + const result = await execute(fixture, childWritesEnvelope((nonce) => ({ + schema: FALLBACK_CHILD_RESULT_SCHEMA, + nonce, + code: 'FALLBACK_PROTOCOL_UNSUPPORTED', + rollback: { attempted: false }, + }))) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'FALLBACK_PROTOCOL_UNSUPPORTED') + assert.equal(result.error?.message, 'This fallback update was planned by an incompatible nsolid-plugin version. Update nsolid-plugin manually (for example with your package manager) and retry the update.') + assert.deepEqual(result.rollback, { attempted: false }, 'a pre-mutation child rejection must never report a parent rollback') + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked', 'a pre-mutation rejection must not mutate owned state') + // The parent proved, from its own in-memory manifest, that nothing was + // mutated. A disposal-only journal operation does not exist yet, so the + // journal and snapshot remain preserved for deliberate manual resolution. + assert.equal(existsSync(journalPath), true, 'the verified pre-mutation journal must be preserved') + const snapshotDirectory = (JSON.parse(readFileSync(journalPath, 'utf8')) as { snapshotDirectory: string }).snapshotDirectory + assert.equal(existsSync(snapshotDirectory), true, 'the verified pre-mutation snapshot must be preserved') + assert.deepEqual(result.preservedArtifacts, [journalPath, snapshotDirectory].sort()) + assert.equal(result.preservedPaths, undefined) + }) + + it('preserves an owned skill changed concurrently after original-state verification', async () => { + const fixture = await setupResultFixture() + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + const originalTrackingDigest = fixture.identity.trackingDigest + let trackingDigestRead = false + let concurrentChangeApplied = false + let workspace = '' + let resultDirectory = '' + let snapshotDirectory = '' + + try { + const result = await execute(fixture, (child) => { + workspace = child.workspace ?? '' + resultDirectory = child.resultPath === undefined ? '' : path.dirname(child.resultPath) + // Install the seam only after beginFallbackJournal has completed. The + // final tracking-digest read in liveStateMatchesPlannedEvidence queues + // a separate writer after that async verifier resolves true but before + // its caller can start any disposal operation. This is deterministic + // event-loop concurrency, not a pre-verification drift. + Object.defineProperty(fixture.identity, 'trackingDigest', { + configurable: true, + enumerable: true, + get: () => { + if (!trackingDigestRead) { + trackingDigestRead = true + queueMicrotask(() => { + concurrentChangeApplied = true + writeFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'concurrent owned edit') + }) + } + return originalTrackingDigest + }, + }) + return childWritesEnvelope((nonce) => ({ + schema: FALLBACK_CHILD_RESULT_SCHEMA, + nonce, + code: 'FALLBACK_PROTOCOL_UNSUPPORTED', + rollback: { attempted: false }, + }))(child) + }) + + assert.equal(concurrentChangeApplied, true) + assert.equal(result.error?.code, 'FALLBACK_PROTOCOL_UNSUPPORTED') + assert.deepEqual(result.rollback, { attempted: false }) + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'concurrent owned edit', 'a concurrent edit after verification must never be restored over') + assert.equal(existsSync(journalPath), true, 'the journal must remain for manual recovery') + snapshotDirectory = (JSON.parse(readFileSync(journalPath, 'utf8')) as { snapshotDirectory: string }).snapshotDirectory + assert.equal(existsSync(snapshotDirectory), true, 'the snapshot must remain for manual recovery') + assert.ok((result.preservedArtifacts ?? []).includes(journalPath)) + assert.ok((result.preservedArtifacts ?? []).includes(snapshotDirectory)) + assert.equal(existsSync(workspace), true, 'execution resources must remain paired with preserved recovery state') + assert.equal(existsSync(resultDirectory), true, 'result resources must remain paired with preserved recovery state') + } finally { + Object.defineProperty(fixture.identity, 'trackingDigest', { + configurable: true, + enumerable: true, + writable: true, + value: originalTrackingDigest, + }) + if (workspace !== '') rmSync(workspace, { recursive: true, force: true }) + if (resultDirectory !== '') rmSync(resultDirectory, { recursive: true, force: true }) + if (snapshotDirectory !== '') rmSync(snapshotDirectory, { recursive: true, force: true }) + rmSync(journalPath, { force: true }) + rmSync(fixture.manifestDir, { recursive: true, force: true }) + } + }) + + it('preserves its pre-mutation journal when a protocol rejection cannot be proven pre-mutation', async () => { + const fixture = await setupResultFixture() + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + const result = await execute(fixture, childWritesEnvelope((nonce) => { + // Hostile drift during the transaction window: the live owned state no + // longer matches the planned evidence, so the parent can NOT prove the + // rejection was pre-mutation and must fail closed. + writeFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'mutated during the window') + return { schema: FALLBACK_CHILD_RESULT_SCHEMA, nonce, code: 'FALLBACK_PROTOCOL_UNSUPPORTED', rollback: { attempted: false } } + })) + + assert.equal(result.error?.code, 'FALLBACK_PROTOCOL_UNSUPPORTED') + assert.deepEqual(result.rollback, { attempted: false }) + // The mutated live bytes are never rolled back on this path; the journal + // and snapshot stay preserved and are reported for manual recovery. + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'mutated during the window', 'an unprovable rejection must never authorize a restore') + assert.equal(existsSync(journalPath), true, 'the unprovable journal must be preserved, not disposed') + assert.ok((result.preservedArtifacts ?? []).includes(journalPath), 'the preserved journal must be reported') + }) + + it('treats a child-disposed parent journal as unproven while keeping validated child preservation reporting', async () => { + const fixture = await setupResultFixture() + const childArtifact = path.join(home, 'preserved', 'stage-container') + const childPath = path.join(home, 'preserved', 'live-path') + mkdirSync(path.dirname(childArtifact), { recursive: true }) + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + const result = await execute(fixture, childWritesEnvelope((nonce) => { + // The child removed the parent journal before exiting: losing journal + // authority is never proof of a successful rollback, so the envelope + // claim must not become a verified success. Only its validated + // reporting survives, alongside the retained recovery locations. + rmSync(journalPath, { force: true }) + return { + schema: FALLBACK_CHILD_RESULT_SCHEMA, + nonce, + code: 'FALLBACK_MCP_DRIFT', + rollback: { attempted: true, succeeded: true }, + preservedArtifacts: [childArtifact], + preservedPaths: [childPath], + } + })) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'FALLBACK_STATE_UNPROVEN') + assert.deepEqual(result.rollback, { attempted: true, succeeded: false }, 'a lost journal must never be promoted to a child-claimed success') + assert.ok((result.preservedArtifacts ?? []).includes(childArtifact), 'child preservation reporting must survive a child-disposed journal') + assert.ok((result.preservedArtifacts ?? []).includes(journalPath), 'the lost journal location must be retained for manual guidance') + assert.deepEqual(result.preservedPaths, [childPath]) + }) + + it('treats a reclaim failure with only a stdout rollback claim as unproven and preserves live bytes', async () => { + const fixture = await setupResultFixture() + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + let snapshotDirectory = '' + const result = await execute(fixture, () => { + // Failing child with no envelope: it changed live owned bytes, deleted + // the journal, and left a bare stdout success claim behind. + snapshotDirectory = (JSON.parse(readFileSync(journalPath, 'utf8')) as { snapshotDirectory: string }).snapshotDirectory + writeFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'mutated by child') + rmSync(journalPath, { force: true }) + return { exitCode: 1, stdout: '', stderr: 'refresh failed\nrollback: succeeded\n', timedOut: false, treeTerminated: true } + }) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'FALLBACK_STATE_UNPROVEN') + assert.deepEqual(result.rollback, { attempted: true, succeeded: false }, 'the stdout claim must not become a verified success') + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'mutated by child', 'unproven live bytes must be preserved, never silently restored or removed') + assert.ok((result.preservedArtifacts ?? []).includes(journalPath), 'the lost journal location must be retained for manual guidance') + assert.ok((result.preservedArtifacts ?? []).includes(snapshotDirectory), 'the surviving snapshot must be retained for manual guidance') + assert.equal(existsSync(snapshotDirectory), true, 'the surviving snapshot must not be removed') + }) + + it('treats a reclaim failure with a nonce-valid success envelope as unproven and preserves live bytes', async () => { + const fixture = await setupResultFixture() + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + const result = await execute(fixture, childWritesEnvelope((nonce) => { + // Failing child with a nonce-valid envelope: it changed live owned + // bytes, corrupted the journal transaction identity, and still claims + // a successful rollback. + writeFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'mutated by child') + const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as Record + writeFileSync(journalPath, JSON.stringify({ ...journal, transactionId: randomUUID() }, null, 2) + '\n') + return { + schema: FALLBACK_CHILD_RESULT_SCHEMA, + nonce, + code: 'FALLBACK_MCP_DRIFT', + rollback: { attempted: true, succeeded: true }, + } + })) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'FALLBACK_STATE_UNPROVEN') + assert.deepEqual(result.rollback, { attempted: true, succeeded: false }, 'even a nonce-valid envelope must not become a verified success without reclaim') + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'mutated by child', 'unproven live bytes must be preserved, never silently restored or removed') + assert.ok((result.preservedArtifacts ?? []).includes(journalPath), 'the corrupted journal location must be retained for manual guidance') + assert.equal(existsSync(journalPath), true, 'the corrupted journal must survive for manual recovery') + }) + + it('names the abandoned transition lock when a successful child cannot be reclaimed, preserving journal and lock', async () => { + const fixture = await setupResultFixture() + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + const lockPath = `${journalPath}.mutation-lock` + let journalInChild = '' + let lockContent = '' + setFallbackJournalTransitionLockTimeoutForTests(50) + try { + const result = await execute(fixture, () => { + // The child succeeded, but an abandoned mutation transition lock now + // blocks the parent's reclaim: the parent must fail closed and name + // the exact lock path instead of swallowing the refusal. + journalInChild = readFileSync(journalPath, 'utf8') + lockContent = 'held by an abandoned process' + writeFileSync(lockPath, lockContent, { mode: 0o600 }) + return { exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'FALLBACK_STATE_UNPROVEN') + assert.ok(result.error?.message.includes(lockPath), `actual lock path expected in: ${result.error?.message}`) + assert.match(result.error?.message ?? '', /manual recovery/) + assert.deepEqual(result.rollback, { attempted: false }) + // Fail-closed preservation: journal bytes and the foreign lock are untouched. + assert.equal(readFileSync(journalPath, 'utf8'), journalInChild) + assert.ok(existsSync(lockPath)) + assert.equal(readFileSync(lockPath, 'utf8'), lockContent) + } finally { + setFallbackJournalTransitionLockTimeoutForTests(undefined) + } + }) + + it('names the abandoned transition lock when parent recovery cannot reclaim the journal, preserving journal and backup', async () => { + const fixture = await setupResultFixture() + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + const lockPath = `${journalPath}.mutation-lock` + let journalInChild = '' + let backupInChild = '' + setFallbackJournalTransitionLockTimeoutForTests(50) + try { + const result = await execute(fixture, () => { + // Failing child; the recovery reclaim then hits the abandoned lock and + // the projected user-facing failure must carry the actual lock path. + journalInChild = readFileSync(journalPath, 'utf8') + const journal = JSON.parse(journalInChild) as { entries: Array<{ backup?: string }> } + const backup = journal.entries.map((entry) => entry.backup).find((candidate) => candidate !== undefined && statSync(candidate).isFile()) + assert.ok(backup !== undefined, 'the fixture journal must hold at least one file backup') + backupInChild = readFileSync(backup, 'utf8') + writeFileSync(lockPath, 'held by an abandoned process', { mode: 0o600 }) + return { exitCode: 1, stdout: '', stderr: 'refresh failed\nrollback: not-attempted\n', timedOut: false, treeTerminated: true } + }) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'FALLBACK_STATE_UNPROVEN') + assert.ok(result.error?.message.includes(lockPath), `actual lock path expected in: ${result.error?.message}`) + assert.match(result.error?.message ?? '', /manual recovery/) + assert.deepEqual(result.rollback, { attempted: true, succeeded: false }) + assert.ok((result.preservedArtifacts ?? []).includes(journalPath), 'the journaled location must be retained for manual guidance') + // Fail-closed preservation: journal bytes, backup bytes, and the lock are untouched. + assert.equal(readFileSync(journalPath, 'utf8'), journalInChild) + const journal = JSON.parse(journalInChild) as { entries: Array<{ backup?: string }> } + const backup = journal.entries.map((entry) => entry.backup).find((candidate) => candidate !== undefined && statSync(candidate).isFile())! + assert.equal(readFileSync(backup, 'utf8'), backupInChild) + assert.ok(existsSync(lockPath)) + } finally { + setFallbackJournalTransitionLockTimeoutForTests(undefined) + } + }) + + it('still reports a verified parent rollback when the journal survives a mutating failing child', async () => { + // Positive control: the child mutates the live tracking file through the + // genuine journal API and then fails. The parent reclaims, restores from + // authenticated backups, and proves the rollback. + const fixture = await setupResultFixture() + const result = await fallbackStrategy.execute(fixture.item, { + options: {}, + commandRunner: { + run: async () => { + const claimed = await claimFallbackJournalMutation(fixture.identity, manifestDigestOf(fixture.identity)) + assert.ok(claimed, 'the simulated child must be able to claim the mutating journal') + const mutated = { ...(await readTrackingFile())!, bundleVersions: { claude: '9.9.9' } } + const staged = await registerFallbackStage(claimed, fixture.identity.trackingPath, { bytes: Buffer.from(JSON.stringify(mutated, null, 2) + '\n') }) + await applyFallbackEntry(staged, fixture.identity.trackingPath) + return { exitCode: 1, stdout: '', stderr: 'refresh failed\nrollback: succeeded\n', timedOut: false, treeTerminated: true } + }, + }, + }) + + assert.equal(result.status, 'failed') + assert.equal(result.error?.code, 'FALLBACK_COMMAND_FAILED') + assert.deepEqual(result.rollback, { attempted: true, succeeded: true }) + assert.equal((await readTrackingFile())?.bundleVersions?.claude, '1.0.0', 'the verified parent restore must bring back the planned tracking bytes') + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + }) + + it('deterministically merges child preservation reporting with an unproven parent restore', async () => { + const fixture = await setupResultFixture() + const childArtifactLate = path.join(home, 'preserved', 'z-artifact') + const childArtifactEarly = path.join(home, 'preserved', 'a-artifact') + const childPath = path.join(home, 'preserved', 'm-path') + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + const result = await execute(fixture, childWritesEnvelope((nonce) => { + // Tamper one journaled backup so the parent's own restore fails closed + // (unproven) while the validated child reporting still propagates. + const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries: Array<{ backup?: string }> } + const backup = journal.entries.find((entry) => entry.backup)?.backup + assert.ok(backup, 'the fixture journal must hold at least one backup') + if (statSync(backup).isDirectory()) writeFileSync(path.join(backup, '__tampered__'), 'tampered') + else writeFileSync(backup, 'tampered') + return { + schema: FALLBACK_CHILD_RESULT_SCHEMA, + nonce, + code: 'FALLBACK_STATE_UNPROVEN', + rollback: { attempted: true, succeeded: false }, + preservedArtifacts: [childArtifactLate, childArtifactEarly], + preservedPaths: [childPath], + } + })) + + assert.equal(result.error?.code, 'FALLBACK_STATE_UNPROVEN') + assert.deepEqual(result.rollback, { attempted: true, succeeded: false }) + assert.deepEqual(result.preservedArtifacts, [childArtifactEarly, childArtifactLate], 'merged reporting must be deduplicated and deterministically ordered') + assert.deepEqual(result.preservedPaths, [childPath]) + assert.equal(existsSync(journalPath), true, 'an unproven restore must preserve the journal') + }) + + it('fails safely when an older child publishes no structured result', async () => { + const fixture = await setupResultFixture() + const result = await execute(fixture, () => ({ exitCode: 1, stdout: '', stderr: 'Fallback MCP state changed but valid credentials are unavailable\nrollback: succeeded\n', timedOut: false, treeTerminated: true })) + assert.equal(result.error?.code, 'FALLBACK_COMMAND_FAILED') + assert.equal(result.error?.message, 'Fallback refresh command failed') + // The verified parent journal, not child stdout, supplies the rollback + // outcome when the command has completed and parent recovery succeeds. + assert.deepEqual(result.rollback, { attempted: true, succeeded: true }) + }) + + it('keeps the public seam to exactly one JSON document and never leaks raw child output', { timeout: 120_000 }, async () => { + // Real-process test: a driver process runs the real fallback strategy with + // the real command runner against the real refresh-owned child (repository + // sources through tsx), then renders the UpdateResult the way the CLI does. + const fixture = await setupResultFixture() + const require = createRequire(import.meta.url) + const tsxLoader = pathToFileURL(require.resolve('tsx/esm')).href + const driverPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fallback-public-seam-driver.ts') + const payload = JSON.stringify({ + home, + manifestDir: fixture.manifestDir, + manifestPath: fixture.manifestPath, + resultPath: fixture.resultPath, + identity: fixture.identity, + }) + + const child = spawn(process.execPath, ['--import', tsxLoader, driverPath, payload], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { stdout += chunk }) + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + const exitCode = await new Promise((resolve) => child.on('close', resolve)) + + assert.equal(exitCode, 0, `driver process must exit cleanly; stderr: ${stderr}`) + // Exactly one JSON document: JSON.parse over the whole trimmed stdout must + // succeed and consume it entirely (a second document would throw). + const trimmed = stdout.trim() + const parsed = JSON.parse(trimmed) as UpdateResult + const completion = JSON.parse(readFileSync(path.join(home, 'command-completion.json'), 'utf8')) as Pick & { childCode: string; childNonce: string } + assert.equal(parsed.status, 'failed') + assert.equal(completion.exitCode, 1) + assert.equal(completion.timedOut, false) + assert.equal(completion.childCode, 'MCP_RECONCILIATION_REQUIRED', 'the real child must reach the intended fixture failure') + assert.equal(completion.childNonce, fixture.identity.nonce) + assert.equal(typeof completion.treeTerminated, 'boolean', 'the real runner must provide explicit completion evidence') + // The public document must reflect the REAL completion verdict. Parallel + // process activity (or a platform without proof) can require deferral; + // the child envelope must never override that safety decision. + if (completion.treeTerminated) { + assert.equal(completion.spawnErrorCode, undefined) + assert.equal(parsed.error?.code, 'MCP_RECONCILIATION_REQUIRED') + assert.ok(parsed.error?.message.includes('nsolid-plugin setup --harness claude'), 'the approved recovery guidance must name the planned harness') + } else { + assert.equal(completion.spawnErrorCode, 'TREE_TERMINATION_UNCONFIRMED') + assert.equal(parsed.error?.code, 'FALLBACK_TREE_TERMINATION_UNCONFIRMED') + assert.deepEqual(parsed.rollback, { attempted: false }) + const journalPath = fallbackJournalPath(fixture.identity.trackingPath) + assert.equal(existsSync(journalPath), true) + const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { snapshotDirectory: string } + assert.equal(existsSync(journal.snapshotDirectory), true) + assert.equal(existsSync(fixture.manifestDir), true) + } + // The child's own raw stderr text is child-controlled data and must not + // appear anywhere in the public result or on the public stdout. (The + // phrase 'valid credentials are unavailable' also appears in the approved + // parent-owned template, so the unique child fragment is used here.) + const childRawFragment = 'Fallback MCP state changed' + assert.ok(!trimmed.includes(childRawFragment), 'raw child stderr must never reach public stdout') + assert.ok(!stderr.includes(childRawFragment), 'raw child stderr must never be forwarded to public stderr') + assert.ok(!parsed.error?.message.includes(childRawFragment), 'raw child text must never be promoted into the public message') + assert.equal(JSON.parse(JSON.stringify(parsed)) && true, true, 'the public document must round-trip as one stable JSON value') + }) +}) + +describe('fallback strategy frontier planning', () => { + let home: string + let restoreHome: () => void + let restoreNpmPath: (() => void) | undefined + let previousOpenCodeSkillsDir: string | undefined + const createdManifestDirectories: string[] = [] + + /** + * Deterministic npm launcher fixture: a resolver-verified npm identity so + * planning resolves `npm` from the fixture instead of the ambient runner + * PATH (whose launcher distribution differs across CI hosts and can be + * rejected as UNSAFE_FALLBACK_EXECUTOR for reasons unrelated to these + * destination-classification scenarios). Same pattern as + * package-manager.test.ts: a POSIX executable script on POSIX, and an + * npm-style `.CMD` shim plus its adjacent node_modules entrypoint with a + * matching package manifest on Windows, which `resolveExecutableIdentity` + * verifies through the production shim path before accepting. + */ + function writeNpmIdentityFixture (binPath: string): void { + mkdirSync(binPath, { recursive: true }) + if (process.platform === 'win32') { + const entrypoint = path.join(binPath, 'node_modules', 'npm', 'bin', 'npm-cli.js') + mkdirSync(path.dirname(entrypoint), { recursive: true }) + writeFileSync(entrypoint, '#!/usr/bin/env node\n') + writeFileSync(path.join(binPath, 'node_modules', 'npm', 'package.json'), JSON.stringify({ + name: 'npm', + bin: { npm: 'bin/npm-cli.js' }, + })) + writeFileSync(path.join(binPath, 'npm.CMD'), + '@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\\npm\\bin\\npm-cli.js" %*\r\n' + + 'exit /b %errorlevel%\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n') + } else { + const launcher = path.join(binPath, 'npm') + writeFileSync(launcher, '#!/bin/sh\n') + chmodSync(launcher, 0o755) + } + } + + /** + * Isolate executable lookup to the fixture bin directory. Saves every + * case-variant of PATH (and PATHEXT on Windows) so the restore puts the + * environment back exactly, regardless of the host's key casing. + */ + function isolateExecutableLookup (fixtureBin: string): () => void { + const saved = Object.entries(process.env).filter(([name]) => { + const lowered = name.toLowerCase() + return lowered === 'path' || (process.platform === 'win32' && lowered === 'pathext') + }) + for (const [name] of saved) delete process.env[name] + process.env.PATH = fixtureBin + if (process.platform === 'win32') process.env.PATHEXT = '.CMD' + return () => { + // Delete every case-variant of the installed lookup keys — including + // ones that were initially absent — so the restore cannot leak the + // fixture state into the ambient environment. + for (const name of Object.keys(process.env)) { + const lowered = name.toLowerCase() + if (lowered === 'path' || (process.platform === 'win32' && lowered === 'pathext')) delete process.env[name] + } + for (const [name, value] of saved) { + if (value !== undefined) process.env[name] = value + } + } + } + + beforeEach(() => { + home = createCanonicalTempRoot('nsolid-plugin-frontier-') + previousOpenCodeSkillsDir = process.env.NSOLID_OPENCODE_SKILLS_DIR + restoreHome = isolateHome(home) + const npmFixtureBin = path.join(home, 'npm-identity-fixture') + writeNpmIdentityFixture(npmFixtureBin) + restoreNpmPath = isolateExecutableLookup(npmFixtureBin) + }) + + afterEach(() => { + restoreNpmPath?.() + restoreHome() + if (previousOpenCodeSkillsDir === undefined) delete process.env.NSOLID_OPENCODE_SKILLS_DIR + else process.env.NSOLID_OPENCODE_SKILLS_DIR = previousOpenCodeSkillsDir + while (createdManifestDirectories.length > 0) { + rmSync(createdManifestDirectories.pop()!, { recursive: true, force: true }) + } + rmSync(home, { recursive: true, force: true }) + }) + + it('resolves npm through the isolated resolver-verified fixture, never the ambient PATH', () => { + const fixtureBin = path.join(home, 'npm-identity-fixture') + const identity = resolveExecutableIdentity('npm') + assert.ok( + identity.kind === 'native' || identity.kind === 'node', + `the resolver must accept the fixture npm identity, got: ${JSON.stringify(identity)}` + ) + if (identity.kind === 'node') { + // Windows: the verified shim launches the immutable JS entrypoint + // through the parent's own node executable, never cmd.exe. + assert.equal(identity.executable, process.execPath) + assert.equal(identity.entrypoint, path.join(fixtureBin, 'node_modules', 'npm', 'bin', 'npm-cli.js')) + } else { + assert.equal(identity.executable, path.join(fixtureBin, 'npm')) + } + }) + + function writeBundleTarball (bundle: object): string { + const tarball = path.join(home, 'artifact.tgz') + writeFileSync(tarball, gzipSync(Buffer.concat([ + tarEntry('package/', undefined, '5'), + tarEntry('package/bundle.json', Buffer.from(JSON.stringify(bundle)), '0'), + Buffer.alloc(1024), + ]))) + return tarball + } + + async function writeFixtureTracking (options: { + harness: 'opencode' | 'claude' + skills: readonly { name: string, path: string }[] + mcpServers?: readonly Record[] + }): Promise { + 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: options.harness, + bundleVersions: { [options.harness]: '1.0.0' }, + skills: options.skills.map((skill) => ({ + ...skill, + installedAt: new Date().toISOString(), + harnesses: [options.harness], + paths: { [options.harness]: skill.path }, + })), + mcpServers: [...(options.mcpServers ?? [])], + } as never) + } + + function buildInstallation (options: { + harness: 'opencode' | 'claude' + tarball: string + trackedSkills: readonly { name: string, path: string }[] + trackedMcpConfigPath?: string + trackedMcpNames?: readonly string[] + trackedMcpFields?: readonly { configPath: string, server: string, field: string, expectedDigest: string }[] + }): UpdateInstallation { + return { + installationId: `${options.harness}:fallback`, + target: options.harness, + 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' }, + artifact: { + kind: 'npm', + packageName: 'nsolid-plugin', + version: '1.0.1', + registry: 'https://registry.example.com', + tarball: 'artifact.tgz', + integrity: 'sha512-unused-at-plan-time', + tarballPath: options.tarball, + }, + metadata: { + trackedSkills: [...options.trackedSkills], + ...(options.trackedMcpConfigPath !== undefined ? { trackedMcpConfigPath: options.trackedMcpConfigPath } : {}), + ...(options.trackedMcpNames !== undefined ? { trackedMcpNames: [...options.trackedMcpNames] } : {}), + ...(options.trackedMcpFields !== undefined ? { trackedMcpFields: [...options.trackedMcpFields] } : {}), + }, + } + } + + async function planFixture (installation: UpdateInstallation): Promise { + const planned = await fallbackStrategy.plan(installation, { options: {}, commandRunner: { run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false, treeTerminated: true }) } }) + for (const directory of planned.temporaryDirectories ?? []) createdManifestDirectories.push(directory) + return planned + } + + /** + * Active planned-missing frontiers only proceed to the manifest workspace + * on Linux (platform preflight). Non-linux hosts assert the fail-closed + * rejection instead of the manifest assertions that follow. + */ + async function planOrRejectOnNonLinux (installation: UpdateInstallation): Promise { + const planned = await planFixture(installation) + if (process.platform !== 'linux') { + assert.equal(planned.planningError?.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + assert.equal(planned.steps.length, 0) + assert.equal(planned.temporaryDirectories, undefined) + } else { + // Callers early-return on ANY planning error: on Linux the error must + // be asserted undefined here, otherwise a planner failure would + // silently skip every success/frontier assertion below. + assert.equal(planned.planningError, undefined) + } + return planned + } + + function anchorIdentityOfFixture (anchorPath: string): Record { + const stats = statSync(anchorPath, { bigint: true }) + return { path: anchorPath, realpath: anchorPath, type: 'directory', device: stats.dev.toString(), inode: stats.ino.toString() } + } + + function readManifest (planned: UpdatePlanItem): Record { + const manifestPath = path.join(planned.temporaryDirectories![0], 'transaction.json') + return JSON.parse(readFileSync(manifestPath, 'utf8')) + } + + it('embeds the exact deterministic frontier graph in the manifest and binds the command digest to it', async () => { + const skillsRoot = path.join(home, '.config', 'opencode', 'skills') + mkdirSync(path.join(home, '.config', 'opencode'), { recursive: true }) + await writeFixtureTracking({ + harness: 'opencode', + skills: [{ name: 'tracked', path: path.join(skillsRoot, 'tracked') }], + }) + const tarball = writeBundleTarball({ + 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: {} }], + }) + const planned = await planOrRejectOnNonLinux(buildInstallation({ + harness: 'opencode', + tarball, + trackedSkills: [{ name: 'tracked', path: path.join(skillsRoot, 'tracked') }], + })) + // Non-linux hosts already asserted the fail-closed platform rejection. + if (planned.planningError !== undefined) return + + assert.equal(planned.planningError, undefined) + assert.equal(planned.steps.length, 4) + const expectedFrontier = { + frontierPath: skillsRoot, + activation: 'required', + anchor: anchorIdentityOfFixture(path.join(home, '.config', 'opencode')), + leaves: [ + { id: 'skill:added', role: 'skill', activation: 'required', path: path.join(skillsRoot, 'added') }, + { id: 'skill:tracked', role: 'skill', activation: 'required', path: path.join(skillsRoot, 'tracked') }, + ], + } + const manifest = readManifest(planned) + assert.deepEqual(manifest.plannedMissingFrontiers, [expectedFrontier]) + // The published child command digest covers exactly the embedded graph. + const commandStep = planned.steps.find((entry) => entry.kind === 'command') + assert.ok(commandStep !== undefined && commandStep.kind === 'command') + const command = commandStep.command + const digestArgumentIndex = command.args.indexOf('--manifest-digest') + assert.ok(digestArgumentIndex >= 0) + assert.equal(command.args[digestArgumentIndex + 1], manifestDigestOf(manifest as never)) + // Derivation is deterministic: an identical planned state yields a + // byte-identical frontier graph. + const replanned = await planOrRejectOnNonLinux(buildInstallation({ + harness: 'opencode', + tarball, + trackedSkills: [{ name: 'tracked', path: path.join(skillsRoot, 'tracked') }], + })) + assert.deepEqual(readManifest(replanned).plannedMissingFrontiers, [expectedFrontier]) + }) + + it('derives complete leaf roles inside a frontier including planned removals while existing parents keep their leaves out', async () => { + const skillsRoot = path.join(home, '.agents', 'skills') + const linkRoot = path.resolve(getHarnessSkillsPath('claude')) + mkdirSync(path.join(home, '.agents'), { recursive: true }) + mkdirSync(linkRoot, { recursive: true }) + await writeFixtureTracking({ + harness: 'claude', + skills: [ + { name: 'kept', path: path.join(skillsRoot, 'kept') }, + { name: 'dropped', path: path.join(skillsRoot, 'dropped') }, + ], + }) + const tarball = writeBundleTarball({ + name: 'nsolid-plugin', + version: '1.0.1', + skills: [ + { name: 'kept', path: 'skills/kept', description: 'kept' }, + { name: 'brand-new', path: 'skills/brand-new', description: 'brand-new' }, + ], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + const planned = await planOrRejectOnNonLinux(buildInstallation({ + harness: 'claude', + tarball, + trackedSkills: [ + { name: 'kept', path: path.join(skillsRoot, 'kept') }, + { name: 'dropped', path: path.join(skillsRoot, 'dropped') }, + ], + })) + // Non-linux hosts already asserted the fail-closed platform rejection. + if (planned.planningError !== undefined) return + + assert.equal(planned.planningError, undefined) + const manifest = readManifest(planned) + assert.deepEqual(manifest.plannedMissingFrontiers, [{ + frontierPath: skillsRoot, + activation: 'required', + anchor: anchorIdentityOfFixture(path.join(home, '.agents')), + leaves: [ + { id: 'skill:brand-new', role: 'skill', activation: 'required', path: path.join(skillsRoot, 'brand-new') }, + { id: 'owned-skill:dropped', role: 'skill', activation: 'conditional', path: path.join(skillsRoot, 'dropped') }, + { id: 'skill:kept', role: 'skill', activation: 'required', path: path.join(skillsRoot, 'kept') }, + ], + }]) + }) + + it('treats a missing file leaf under an existing parent as a normal destination, never a frontier', async () => { + const skillsRoot = path.join(home, '.config', 'opencode', 'skills') + mkdirSync(path.join(skillsRoot, 'tracked'), { recursive: true }) + writeFileSync(path.join(skillsRoot, 'tracked', 'SKILL.md'), 'tracked') + await writeFixtureTracking({ + harness: 'opencode', + skills: [{ name: 'tracked', path: path.join(skillsRoot, 'tracked') }], + }) + const tarball = writeBundleTarball({ + 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: {} }], + }) + const planned = await planFixture(buildInstallation({ + harness: 'opencode', + tarball, + trackedSkills: [{ name: 'tracked', path: path.join(skillsRoot, 'tracked') }], + })) + + // The canonical MCP config (~/.config/opencode/opencode.jsonc) is missing + // but its parent exists: an independent leaf destination, so the update + // stays fully supported with an explicitly empty frontier list. + assert.equal(planned.planningError, undefined) + assert.equal(planned.steps.length, 4) + assert.deepEqual(readManifest(planned).plannedMissingFrontiers, []) + }) + + it('keeps frontiers sharing one parent together and separates distinct missing parents', async () => { + const skillsRoot = path.join(home, '.agents', 'skills') + const linkRoot = path.resolve(getHarnessSkillsPath('claude')) + mkdirSync(path.join(home, '.agents'), { recursive: true }) + mkdirSync(path.join(home, '.claude'), { recursive: true }) + await writeFixtureTracking({ + harness: 'claude', + skills: [{ name: 'kept', path: path.join(skillsRoot, 'kept') }], + }) + const tarball = writeBundleTarball({ + name: 'nsolid-plugin', + version: '1.0.1', + skills: [ + { name: 'kept', path: 'skills/kept', description: 'kept' }, + { name: 'brand-new', path: 'skills/brand-new', description: 'brand-new' }, + ], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + const planned = await planOrRejectOnNonLinux(buildInstallation({ + harness: 'claude', + tarball, + trackedSkills: [{ name: 'kept', path: path.join(skillsRoot, 'kept') }], + })) + // Non-linux hosts already asserted the fail-closed platform rejection. + if (planned.planningError !== undefined) return + + assert.equal(planned.planningError, undefined) + const manifest = readManifest(planned) + const frontiers = manifest.plannedMissingFrontiers as Record[] + assert.deepEqual(frontiers, [ + { + frontierPath: skillsRoot, + activation: 'required', + anchor: anchorIdentityOfFixture(path.join(home, '.agents')), + leaves: [ + { id: 'skill:brand-new', role: 'skill', activation: 'required', path: path.join(skillsRoot, 'brand-new') }, + { id: 'skill:kept', role: 'skill', activation: 'required', path: path.join(skillsRoot, 'kept') }, + ], + }, + { + frontierPath: linkRoot, + activation: 'required', + anchor: anchorIdentityOfFixture(path.join(home, '.claude')), + leaves: [ + { id: 'link:brand-new', role: 'link', activation: 'required', path: path.join(linkRoot, 'brand-new') }, + { id: 'link:kept', role: 'link', activation: 'required', path: path.join(linkRoot, 'kept') }, + ], + }, + ]) + }) + + it('fails a leaf ancestor collision closed before any manifest workspace exists', async () => { + // Destination root = ~/.config so the bundle skill `opencode` plans the + // leaf ~/.config/opencode, a proper ancestor of the canonical MCP config + // leaf ~/.config/opencode/opencode.jsonc. + process.env.NSOLID_OPENCODE_SKILLS_DIR = path.join(home, '.config') + mkdirSync(path.join(home, '.config', 'opencode'), { recursive: true }) + await writeFixtureTracking({ + harness: 'opencode', + skills: [{ name: 'opencode', path: path.join(home, '.config', 'opencode') }], + }) + const tarball = writeBundleTarball({ + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'opencode', path: 'skills/opencode', description: 'opencode' }], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + const planned = await planFixture(buildInstallation({ + harness: 'opencode', + tarball, + trackedSkills: [{ name: 'opencode', path: path.join(home, '.config', 'opencode') }], + })) + + assert.equal(planned.planningError?.code, 'FALLBACK_FRONTIER_COLLISION') + assert.equal(planned.steps.length, 0) + assert.equal(planned.temporaryDirectories, undefined) + }) + + it('rejects an active frontier on unsupported platforms before the manifest exists and keeps existing-parent updates supported', async () => { + const skillsRoot = path.join(home, '.config', 'opencode', 'skills') + mkdirSync(path.join(home, '.config', 'opencode'), { recursive: true }) + await writeFixtureTracking({ + harness: 'opencode', + skills: [{ name: 'tracked', path: path.join(skillsRoot, 'tracked') }], + }) + const tarball = writeBundleTarball({ + 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: {} }], + }) + const installation = buildInstallation({ + harness: 'opencode', + tarball, + trackedSkills: [{ name: 'tracked', path: path.join(skillsRoot, 'tracked') }], + }) + // Capture the REAL host platform BEFORE the spoof: inside the try block + // below process.platform is faked to 'win32' on every host, so it can no + // longer distinguish a native Windows runner from a POSIX host faking it. + const realPlatform = process.platform + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + const rejected = await planFixture(installation) + assert.equal(rejected.planningError?.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + assert.ok(rejected.planningError?.message.includes(skillsRoot)) + assert.equal(rejected.steps.length, 0) + assert.equal(rejected.temporaryDirectories, undefined) + assert.deepEqual(rejected.manualCommands, ['nsolid-plugin install --harness opencode']) + + // Once every parent and leaf exists, the frontier gate passes on the + // same faked platform: the rejection is strictly frontier-conditional. + mkdirSync(path.join(skillsRoot, 'tracked'), { recursive: true }) + const pastGate = await planFixture(installation) + assert.notEqual(pastGate.planningError?.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + if (realPlatform === 'win32') { + // Native Windows: the beforeEach npm-identity fixture ships a verified + // npm.CMD shim, so planning legitimately proceeds into a real manifest + // workspace (CI evidence: run 34258873417 actual temporaryDirectories + // contained an nsolid-plugin-manifest-* directory created by this very + // call). Assert the full supported outcome; planFixture registers the + // workspace in createdManifestDirectories and afterEach disposes it. + assert.equal(pastGate.planningError, undefined) + assert.equal(pastGate.steps.length, 4) + assert.ok(Array.isArray(pastGate.temporaryDirectories) && pastGate.temporaryDirectories.length > 0) + assert.deepEqual(readManifest(pastGate).plannedMissingFrontiers, []) + } else { + // POSIX host faking win32: the isolated single-segment fixture PATH + // contains no Windows-extension candidates, so findExecutable('npm', + // env, 'win32') deterministically returns undefined (verified against + // the production resolver) and planning must reject with the exact + // executor reason BEFORE creating any manifest workspace. + assert.equal(pastGate.planningError?.code, 'UNSAFE_FALLBACK_EXECUTOR') + assert.equal(pastGate.temporaryDirectories, undefined) + } + } finally { + Object.defineProperty(process, 'platform', platformDescriptor) + } + // Existing-parent support on the REAL platform: once every parent and + // leaf exists, with no frontier the plan carries a full command step and + // an explicitly empty frontier list. + const supported = await planFixture(installation) + assert.equal(supported.planningError, undefined) + assert.equal(supported.steps.length, 4) + assert.deepEqual(readManifest(supported).plannedMissingFrontiers, []) + }) + + it('keeps a conditional MCP-only frontier active on unsupported platforms instead of inferring a no-op', async () => { + const skillsRoot = path.join(home, '.config', 'opencode', 'skills') + mkdirSync(path.join(skillsRoot, 'kept'), { recursive: true }) + mkdirSync(path.join(home, 'custom'), { recursive: true }) + const deepConfigPath = path.join(home, 'custom', 'deep', 'nested', 'config.json') + const fields = { + url: valueDigest('https://old.example.com/mcp'), + headers: valueDigest({ AUTH: 'x' }), + } + await writeFixtureTracking({ + harness: 'opencode', + skills: [{ name: 'kept', path: path.join(skillsRoot, 'kept') }], + mcpServers: [{ name: 'alpha-console', configPath: deepConfigPath, harness: 'opencode', configuredAt: new Date().toISOString(), fields }], + }) + const trackedMcpFields = [ + { configPath: deepConfigPath, server: 'alpha-console', field: 'url', expectedDigest: fields.url }, + { configPath: deepConfigPath, server: 'alpha-console', field: 'headers', expectedDigest: fields.headers }, + ] + const tarball = writeBundleTarball({ + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'kept', path: 'skills/kept', description: 'kept' }], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + const installation = buildInstallation({ + harness: 'opencode', + tarball, + trackedSkills: [{ name: 'kept', path: path.join(skillsRoot, 'kept') }], + trackedMcpConfigPath: deepConfigPath, + trackedMcpNames: ['alpha-console'], + trackedMcpFields, + }) + + // Linux plans the conditional frontier: the parent cannot prove the + // config render is a no-op, so the frontier is carried as conditional + // evidence and never marked inactive. Non-linux hosts already assert the + // fail-closed platform rejection in the forced-win32 phase below, so the + // manifest assertions here are Linux-only. + const linuxPlanned = await planOrRejectOnNonLinux(installation) + if (linuxPlanned.planningError !== undefined) return + assert.equal(linuxPlanned.planningError, undefined) + assert.deepEqual(readManifest(linuxPlanned).plannedMissingFrontiers, [{ + frontierPath: path.join(home, 'custom', 'deep'), + activation: 'conditional', + anchor: anchorIdentityOfFixture(path.join(home, 'custom')), + leaves: [ + // Sorted union index 1: the canonical ~/.config/opencode/opencode.jsonc + // leaf sorts before custom/... and is a missing leaf (parent exists). + { id: 'mcp-config:1', role: 'mcp-config', activation: 'conditional', path: deepConfigPath }, + ], + }]) + + // An unsupported platform must not infer frontier inactivity from that + // mutable plan state: the same conditional frontier fails the plan closed + // before any manifest workspace exists. + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + const rejected = await planFixture(installation) + assert.equal(rejected.planningError?.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + assert.ok(rejected.planningError?.message.includes(path.join(home, 'custom', 'deep'))) + assert.equal(rejected.steps.length, 0) + assert.equal(rejected.temporaryDirectories, undefined) + } finally { + Object.defineProperty(process, 'platform', platformDescriptor) + } + }) +}) + +describe('fallback change summary', () => { + function sampleBundle (): object { + return { + name: 'nsolid-plugin', + version: '90.0.0', + skills: [ + { name: 'kept', path: 'skills/kept', description: 'kept' }, + { name: 'brand-new', path: 'skills/brand-new', description: 'brand-new' }, + ], + mcpServers: [ + { name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }, + { name: 'brand-new-mcp', url: 'https://example.com/mcp2', headers: {} }, + ], + } + } + + function writeTarball (directory: string, name: string, bytes: Buffer): string { + const tarball = path.join(directory, name) + writeFileSync(tarball, bytes) + return tarball + } + + it('summarizes skill and MCP diffs from the verified tarball bundle', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-changes-')) + try { + const tarball = writeTarball(dir, 'artifact.tgz', gzipSync(Buffer.concat([ + tarEntry('package/', undefined, '5'), + tarEntry('package/bundle.json', Buffer.from(JSON.stringify(sampleBundle())), '0'), + Buffer.alloc(1024), + ]))) + + const { summarizeFallbackChanges } = await import('../../../src/update/strategies/fallback.js') + const summary = await summarizeFallbackChanges( + { + metadata: { + trackedSkills: [{ name: 'kept', path: '/tmp/kept' }, { name: 'dropped', path: '/tmp/dropped' }], + trackedMcpNames: ['nsolid-console', 'dropped-mcp'], + }, + } as never, + tarball + ) + assert.deepEqual(summary, { + skillsAdded: ['brand-new'], + skillsRemoved: ['dropped'], + skillsUpdated: 1, + mcpAdded: ['brand-new-mcp'], + mcpRemoved: ['dropped-mcp'], + mcpUpdated: 1, + }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + for (const hostile of [true, false]) { + it(hostile ? 'never executes a PATH-resolved tar' : 'summarizes without any tar on PATH', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-changes-')) + const previousPath = process.env.PATH + try { + const hostileBin = path.join(dir, 'hostile-bin') + if (hostile) { + mkdirSync(hostileBin) + const fakeTar = path.join(hostileBin, 'tar') + writeFileSync(fakeTar, '#!/bin/sh\nprintf "TAMPERED"\n') + chmodSync(fakeTar, 0o755) + } + const tarball = writeTarball(dir, 'artifact.tgz', gzipSync(Buffer.concat([ + tarEntry('package/bundle.json', Buffer.from(JSON.stringify(sampleBundle())), '0'), + Buffer.alloc(1024), + ]))) + process.env.PATH = hostile ? hostileBin : '' + const { summarizeFallbackChanges } = await import('../../../src/update/strategies/fallback.js') + const summary = await summarizeFallbackChanges({ metadata: { trackedSkills: [], trackedMcpNames: [] } } as never, tarball) + assert.deepEqual(summary, { + skillsAdded: ['kept', 'brand-new'], + skillsRemoved: [], + skillsUpdated: 0, + mcpAdded: ['nsolid-console', 'brand-new-mcp'], + mcpRemoved: [], + mcpUpdated: 0, + }, 'the summary must come from the archive bytes, never from a PATH lookup') + } finally { + if (previousPath === undefined) delete process.env.PATH + else process.env.PATH = previousPath + rmSync(dir, { recursive: true, force: true }) + } + }) + } + + it('never blocks planning when the tarball cannot be read', async () => { + const { summarizeFallbackChanges } = await import('../../../src/update/strategies/fallback.js') + const summary = await summarizeFallbackChanges({ metadata: { trackedSkills: [], trackedMcpNames: [] } } as never, '/nonexistent/artifact.tgz') + assert.equal(summary, undefined) + }) + + it('never blocks planning when the tarball is not a tar archive', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-changes-')) + try { + const tarball = writeTarball(dir, 'garbage.tgz', Buffer.from('definitely not a tar archive')) + const { summarizeFallbackChanges } = await import('../../../src/update/strategies/fallback.js') + assert.equal(await summarizeFallbackChanges({ metadata: { trackedSkills: [], trackedMcpNames: [] } } as never, tarball), undefined) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/test/unit/update/fallback-transaction.test.ts b/packages/core/test/unit/update/fallback-transaction.test.ts new file mode 100644 index 0000000..3a420e2 --- /dev/null +++ b/packages/core/test/unit/update/fallback-transaction.test.ts @@ -0,0 +1,2411 @@ +import { afterEach, beforeEach, describe, it, mock } from 'node:test' +import assert from 'node:assert/strict' +import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, symlinkSync, rmSync, writeFileSync } from 'node:fs' +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +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 { applyFallbackEntry, beginFallbackJournal, claimFallbackJournalMutation, commitFallbackJournal, fallbackJournalPath, manifestDigestOf, markFallbackJournalMutating, pathDigest, pathKind, reclaimFallbackJournalMutation, registerFallbackStage, reloadFallbackJournal, restoreFallbackJournal, setFallbackFrontierPublicationSeamForTests, trackingDigest } from '../../../src/update/fallback-journal.js' +import { valueDigest, readMcpFieldDigests, harnessMcpKey } from '../../../src/update/mcp-lookup.js' +import { randomUUID } from 'node:crypto' +import { FALLBACK_PROTOCOL_VERSION } from '../../../src/update/types.js' +import { FALLBACK_CHILD_RESULT_SCHEMA } from '../../../src/update/fallback-result-protocol.js' +import type { FallbackTransactionIdentity } from '../../../src/update/types.js' +import { getHarnessSkillsPath } from '../../../src/skills/skill-linker.js' +import { getAuthFilePath, getSkillsDir, getTrackingFilePath } from '../../../src/utils/path.js' +import { readTrackingFile } from '../../../src/skills/skill-tracker.js' +import { parseJsonc } from '../../../src/utils/config.js' +import { resolvePackageRoot } from '../../../src/update/version.js' +import { deriveFallbackFrontierLeafTargets, deriveFallbackFrontierPlan, type FallbackFrontierPlan } from '../../../src/update/fallback-frontier.js' +import { getAdapter } from '../../../src/harnesses/index.js' +import type { BundleDescriptor } from '../../../src/types.js' + +import { createParentIdentity, refreshWithParent } from '../../helpers/fallback-refresh.js' +import { createCanonicalTempRoot } from '../../helpers/canonical-temp-root.js' + +let home: string +let previousHome: string | undefined +let previousUserProfile: string | undefined + +async function pathEvidence (target: string) { + const kind = await pathKind(target) + const digest = kind === 'missing' ? undefined : await pathDigest(target) + return { path: path.resolve(target), kind, digest } +} + +beforeEach(() => { + home = createCanonicalTempRoot('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)) +} + +function setupSkillSource (skills: Record): { sourceRoot: string; bundlePath: string } { + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + for (const [name, content] of Object.entries(skills)) { + const directory = path.join(sourceRoot, 'skills', name) + mkdirSync(directory, { recursive: true }) + writeFileSync(path.join(directory, 'SKILL.md'), content) + } + return { sourceRoot, bundlePath: path.join(sourceRoot, 'bundle.json') } +} + +/** Keep scenario-specific skills and MCP values visible at each call site. */ +function writeBundle (bundlePath: string, skillNames: string[], mcpServers: BundleDescriptor['mcpServers']): void { + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: skillNames.map((name) => ({ name, path: `skills/${name}`, description: name })), + mcpServers, + }) +} + +function writeCredentials (filePath: string, mcpUrl: string, lifetimeMs: number): void { + writeJson(filePath, { + serviceToken: 'token', + organizationId: 'org', + saasToken: 'saas', + consoleUrl: 'https://console.example.com', + mcpUrl, + expiresAt: new Date(Date.now() + lifetimeMs).toISOString(), + }) +} + +/** + * Read-only state snapshot for the fail-closed no-mutation checks: the exact + * bytes of the relevant live files plus the journal/snapshot/stage artifact + * inventory under the tracking directory and the given artifact directories. + */ +function captureRunState (liveFiles: readonly string[], artifactDirs: readonly string[] = [path.join(home, '.agents')]): { + bytes: Record + journal: string | undefined + artifactEntries: Record +} { + const journalPath = fallbackJournalPath(getTrackingFilePath()) + const artifactEntries: Record = {} + for (const dir of artifactDirs) { + artifactEntries[dir] = existsSync(dir) ? readdirSync(dir).filter((name) => name.startsWith('.nsolid-plugin-update-') || name.includes('.nsolid-stage-')) : [] + } + return { + bytes: Object.fromEntries(liveFiles.map((filePath) => [filePath, readFileSync(filePath, 'utf8')])), + journal: existsSync(journalPath) ? readFileSync(journalPath, 'utf8') : undefined, + artifactEntries, + } +} + +/** Every captured live file, the journal, and the artifact inventory must be untouched. */ +function assertRunStateUnchanged (state: ReturnType, label: string): void { + for (const [filePath, bytes] of Object.entries(state.bytes)) { + assert.equal(readFileSync(filePath, 'utf8'), bytes, `${label}: ${filePath} must keep its pre-run bytes`) + } + const journalPath = fallbackJournalPath(getTrackingFilePath()) + const journal = existsSync(journalPath) ? readFileSync(journalPath, 'utf8') : undefined + assert.equal(journal, state.journal, `${label}: the rejected run must not create or rewrite the journal`) + for (const [dir, entries] of Object.entries(state.artifactEntries)) { + const current = existsSync(dir) ? readdirSync(dir).filter((name) => name.startsWith('.nsolid-plugin-update-') || name.includes('.nsolid-stage-')) : [] + assert.deepEqual(current, entries, `${label}: the rejected run must not create journal snapshot or stage artifacts in ${dir}`) + } +} + +describe('fallback refresh transaction', () => { + it('rejects a missing parent manifest before reading or mutating owned state', async () => { + const before = readdirSync(home) + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath: '/missing/bundle.json', skillsSource: '/missing/package' } as Parameters[0]) + assert.equal(result.success, false) + assert.equal(result.error?.code, 'INVALID_TRANSACTION_MANIFEST') + assert.equal(result.rollbackAttempted, undefined) + assert.deepEqual(readdirSync(home), before) + }) + + it('replaces owned directories, reconciles shared ownership, and recreates harness links', async () => { + // The bundle adds a brand-new skill directory: a planned-missing frontier + // that only Linux may publish (platform preflight), so non-linux fails + // closed before any mutation. The rich ownership assertions below need + // the frontier publication and are therefore Linux-only. + if (process.platform !== 'linux') { + const sourceRootProbe = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + try { + const retainedSource = path.join(sourceRootProbe, 'skills', 'retained') + const addedSource = path.join(sourceRootProbe, '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') + writeBundle(path.join(sourceRootProbe, 'bundle.json'), ['retained', 'added'], [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }]) + writeCredentials(path.join(home, '.agents', '.nodesource-auth.json'), 'https://example.com/mcp', 60_000) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + skills: [], + mcpServers: [], + }) + // Capture the pre-run state BEFORE the operation: comparing a + // post-operation capture with itself would be vacuous. + const preRunState = captureRunState([ + path.join(home, '.agents', '.nodesource-installed.json'), + path.join(home, '.agents', '.nodesource-auth.json'), + ]) + const rejected = await refreshWithParent({ harness: 'claude', bundlePath: path.join(sourceRootProbe, 'bundle.json'), skillsSource: sourceRootProbe }) + assert.equal(rejected.success, false) + assert.equal(rejected.error?.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + assert.equal(rejected.rollbackAttempted, false) + // Fail-closed proof: the rejected parent-creation run must keep the + // tracking/credentials bytes, leave no journal or snapshot artifacts, + // and create none of the planned-missing destinations. + assertRunStateUnchanged(preRunState, 'rejected parent-creation run') + assert.equal(existsSync(path.join(home, '.agents', 'skills')), false, 'the planned-missing frontier destination must stay absent') + assert.equal(existsSync(getHarnessSkillsPath('claude')), false, 'the harness link root must stay absent') + } finally { + rmSync(sourceRootProbe, { recursive: true, force: true }) + } + return + } + 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') + writeBundle(bundlePath, ['retained', 'added'], [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }]) + writeCredentials(path.join(home, '.agents', '.nodesource-auth.json'), 'https://example.com/mcp', 60_000) + 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 refreshWithParent({ 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 an untracked directory at a new harness link during parent planning', 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') + writeBundle(bundlePath, ['tracked', 'added'], [{ 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 refreshWithParent({ harness: 'claude', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, false) + // POSIX: the strict symlink-only claude link policy rejects the existing + // user directory with FALLBACK_FRONTIER_LEAF_KIND_MISMATCH during + // derivation. Windows: the win32 link-materialization policy deliberately + // permits copied directories, so the existing directory is kind-valid and + // the missing terminal skill leaf makes a frontier active; the platform + // preflight then rejects fail-closed with FALLBACK_PARENT_CREATION_UNSUPPORTED, + // equally BEFORE journal reservation. Both outcomes are preserve-only. + assert.equal(result.error?.code, process.platform === 'win32' ? 'FALLBACK_PARENT_CREATION_UNSUPPORTED' : 'FALLBACK_FRONTIER_LEAF_KIND_MISMATCH') + assert.equal(existsSync(path.join(harnessDir, 'added', 'user-owned.txt')), true) + // Even if a caller bypasses the parent's leaf planner, the child keeps + // its independent collision guard and rejects before claiming a journal. + const transaction = await createParentIdentity({ harness: 'claude' }) + const child = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource: sourceRoot, transaction }) + assert.equal(child.error?.code, 'UNTRACKED_DESTINATION') + assert.equal(existsSync(path.join(harnessDir, 'added', 'user-owned.txt')), true) + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), false) + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('does not roll back or delete owned state when planning rejects a kind-conflicted destination', async () => { + // A tracked skill destination that is a plain file contradicts the role + // its leaf requires. Per the frontier binding decision this is rejected + // during local planning — BEFORE journal reservation or any snapshot — + // with the precise FALLBACK_FRONTIER_LEAF_KIND_MISMATCH code, and the + // owned state must remain exactly as it was. + 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') + writeBundle(bundlePath, ['tracked'], [{ 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: [], + }) + // Valid credentials let the MCP reconciliation gate pass so the run + // reaches local planning, which must reject the kind conflict before any + // journal or snapshot state exists. + writeCredentials(path.join(home, '.agents', '.nodesource-auth.json'), 'https://example.com/mcp', 60_000) + + const result = await refreshWithParent({ harness: 'opencode', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'FALLBACK_FRONTIER_LEAF_KIND_MISMATCH') + assert.notEqual(result.rollbackAttempted, true) + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), 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 }) + // A real installed claude harness owns an existing link root; without it + // the missing ancestor would become a planned frontier and the platform + // preflight would preempt the MCP reconciliation gate under test. + mkdirSync(path.join(home, '.claude', 'skills'), { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const { sourceRoot, bundlePath } = setupSkillSource({ tracked: 'new' }) + writeBundle(bundlePath, ['tracked'], [{ 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(), fields: { type: valueDigest('http'), url: valueDigest('https://old.example/mcp') } }], + }) + + const result = await refreshWithParent({ 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, bundlePath } = setupSkillSource({ tracked: 'new' }) + writeBundle(bundlePath, ['tracked'], [{ name: 'new-server', url: 'https://example.com/mcp', headers: {} }]) + writeCredentials(path.join(home, '.agents', '.nodesource-auth.json'), 'https://example.com/mcp', 60_000) + 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 refreshWithParent({ 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 }) + // A real installed codex harness owns an existing skills link root; the + // missing ancestor would otherwise become a planned frontier and the + // platform preflight would preempt the TOML edit under test. + mkdirSync(path.join(home, '.codex', 'skills'), { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const { sourceRoot, bundlePath } = setupSkillSource({ tracked: 'new' }) + writeBundle(bundlePath, ['tracked'], [{ name: 'alpha-console', url: 'https://new.example.com/mcp', headers: {} }]) + writeCredentials(path.join(home, '.agents', '.nodesource-auth.json'), 'https://new.example.com/mcp', 60_000) + 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 refreshWithParent({ 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 }) + // A real installed codex harness owns an existing skills link root; the + // missing ancestor would otherwise become a planned frontier and the + // platform preflight would preempt the tracked-field assertions. + mkdirSync(path.join(home, '.codex', 'skills'), { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const { sourceRoot, bundlePath } = setupSkillSource({ tracked: 'new' }) + writeBundle(bundlePath, ['tracked'], [{ name: 'alpha-console', url: 'https://new.example.com/mcp', headers: {} }]) + writeCredentials(path.join(home, '.agents', '.nodesource-auth.json'), 'https://new.example.com/mcp', 120_000) + 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 refreshWithParent({ 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 refreshWithParent({ 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 }) + // A real installed codex harness owns an existing skills link root; the + // missing ancestor would otherwise become a planned frontier and the + // platform preflight would preempt the digest assertions. + mkdirSync(path.join(home, '.codex', 'skills'), { 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')) + writeCredentials(path.join(home, '.agents', '.nodesource-auth.json'), 'https://one.example.com/mcp', 120_000) + 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 refreshWithParent({ 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')) + writeCredentials(path.join(home, '.agents', '.nodesource-auth.json'), 'https://two.example.com/mcp', 120_000) + const second = await refreshWithParent({ 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 }) + // A real installed codex harness owns an existing skills link root; the + // missing ancestor would otherwise become a planned frontier and the + // platform preflight would preempt the malformed-TOML gate under test. + mkdirSync(path.join(home, '.codex', 'skills'), { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const { sourceRoot, bundlePath } = setupSkillSource({ tracked: 'new' }) + writeBundle(bundlePath, ['tracked'], [{ name: 'fresh-server', url: 'https://example.com/mcp', headers: {} }]) + writeCredentials(path.join(home, '.agents', '.nodesource-auth.json'), 'https://example.com/mcp', 60_000) + 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 refreshWithParent({ 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, bundlePath } = setupSkillSource({ tracked: 'new' }) + writeBundle(bundlePath, ['tracked'], [{ name: 'alpha-console', url: 'https://new.example.com/mcp', headers: {} }]) + writeCredentials(path.join(home, '.agents', '.nodesource-auth.json'), 'https://new.example.com/mcp', 60_000) + 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 refreshWithParent({ 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') + } + // A real installed claude harness owns an existing link root; without it + // the missing ancestor would become a planned frontier and the platform + // preflight would preempt the legacy-path repoint under test. + mkdirSync(path.join(home, '.claude', 'skills'), { recursive: true }) + const { sourceRoot, bundlePath } = setupSkillSource({ retained: 'new' }) + writeBundle(bundlePath, ['retained'], [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }]) + writeCredentials(path.join(home, '.agents', '.nodesource-auth.json'), 'https://example.com/mcp', 60_000) + 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 refreshWithParent({ 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, '.config', 'opencode', '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') + writeBundle(bundlePath, ['tracked'], [{ 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 refreshWithParent({ 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') + writeBundle(bundlePath, ['tracked'], [{ name: 'nsolid-console', url: 'https://new.example.com/mcp', headers: {} }]) + + const trackingPath = getTrackingFilePath() + const identity: FallbackTransactionIdentity = { + installationId: `${harness}:fallback`, + harness, + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await pathEvidence(skillPath)], + ownedLinks: [await pathEvidence(linkPath)], + ownedMcpFields, + ownedMcpConfigPaths: await Promise.all(ownedMcpConfigPaths.map((value) => pathEvidence(value))), + bundleDestinations: [ + await pathEvidence(path.join(getSkillsDir(), 'tracked')), + await pathEvidence(path.join(getHarnessSkillsPath(harness), 'tracked')), + ], + approvedDestinationRoots: [getSkillsDir(), getHarnessSkillsPath(harness)].map((value) => path.resolve(value)), + } + return { identity, home, skillPath, linkPath, canonicalPath, trackedConfigPath: options.trackedMcp ? trackedConfigPath : undefined, sourceRoot, bundlePath } + } + + it('rejects a transaction manifest whose frontier evidence is absent or malformed before any journal state exists', async () => { + const fixture = await setupJournalFixture({ harness: 'claude' }) + try { + const absent: Record = { ...fixture.identity } + delete absent.plannedMissingFrontiers + const malformed = { + ...fixture.identity, + plannedMissingFrontiers: [{ + frontierPath: path.join(fixture.home, 'missing-parent'), + activation: 'required', + anchor: { path: fixture.home }, + leaves: [], + }], + } + for (const broken of [absent, malformed]) { + const result = await refreshOwnedInstallation({ + harness: fixture.identity.harness, + bundlePath: fixture.bundlePath, + skillsSource: fixture.sourceRoot, + transaction: broken as unknown as FallbackTransactionIdentity, + }) + assert.equal(result.success, false) + assert.equal(result.error?.code, 'FALLBACK_FRONTIER_EVIDENCE_INVALID') + // The preflight gate fires before the journal reservation: no journal + // state may exist for frontier evidence that fails strict parsing. + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), false) + } + } finally { + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } + }) + + it('journals the missing canonical MCP path and installs the first server into it', async () => { + const fixture = await setupJournalFixture({ harness: 'claude' }) + try { + // The parent began the journal and advanced it to mutating before the + // child claimed it; the destructured handle is the live journal state. + let { handle: journal } = await beginFallbackJournal(fixture.identity) + journal = await markFallbackJournalMutating(journal) + // The canonical path does not exist yet but is journaled as missing state. + const disk = await reloadFallbackJournal(journal) + const canonicalEntry = disk.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') + + // External child success: the applied journal remains mutating for the + // waiting parent — a mutator must never reclaim or commit a journal + // owned by another process. + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), true, 'an external child must leave the applied journal for its parent') + // Parent completion: reclaim owner authority, prove the applied state + // from the strictly reloaded journal plus the live filesystem, and + // commit with the journal as the single snapshot: the journal file is + // removed last, so its absence is the observable commit proof. + const ownerHandle = await reclaimFallbackJournalMutation(journal) + const proven = await reloadFallbackJournal(ownerHandle) + for (const entry of proven.entries) { + if (entry.stageDigest !== undefined) { + assert.equal(entry.applied, true, `entry ${entry.path} must be applied`) + assert.equal(await pathDigest(entry.path), entry.stageDigest, `entry ${entry.path} must hold its staged digest`) + } + } + await commitFallbackJournal(ownerHandle) + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), false, 'the parent commit must remove the 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 { handle: 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 { handle: 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') + // Child-owned rollback: with the mutator capabilities still in this + // process, a successful restore authenticated-cleans the journal-owned + // stage containers too; nothing survives a proven rollback. + assert.equal(readdirSync(path.dirname(fixture.skillPath)).some((name) => name.startsWith('.tracked.nsolid-stage-')), false, 'a proven rollback must clean authenticated stage containers') + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + } 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 { + // A foreign server already occupies the new name inside the tracked + // config at planning time: the manifest's whole-file evidence captures + // those bytes, and 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 identity: FallbackTransactionIdentity = { + ...fixture.identity, + ownedMcpConfigPaths: await Promise.all(fixture.identity.ownedMcpConfigPaths.map(async (entry) => + path.resolve(entry.path) === path.resolve(fixture.trackedConfigPath!) ? await pathEvidence(fixture.trackedConfigPath!) : entry)), + } + const { handle: journal } = await beginFallbackJournal(identity) + await markFallbackJournalMutating(journal) + const journalPath = fallbackJournalPath(identity.trackingPath) + const journalBefore = readFileSync(journalPath) + + const result = await refreshOwnedInstallation({ harness, bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: 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) + // The unclaimed prepared journal belongs to this test's own begin; + // remove it so the next loop iteration's begin is not busy. + rmSync(fallbackJournalPath(identity.trackingPath), { force: true }) + } 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 { handle: journal } = await beginFallbackJournal(fixture.identity) + await markFallbackJournalMutating(journal) + + const originalPlatform = process.platform + let result + try { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + result = await refreshWithSimulatedWindows({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot, transaction: fixture.identity }) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + 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) + + // External child success leaves the applied journal for the parent; + // the parent reclaims, proves, and commits before the run is complete + // and the applied live update survives the commit. + const ownerHandle = await reclaimFallbackJournalMutation(journal) + const proven = await reloadFallbackJournal(ownerHandle) + for (const entry of proven.entries) { + if (entry.stageDigest !== undefined) assert.equal(entry.applied, true, `entry ${entry.path} must be applied`) + } + await commitFallbackJournal(ownerHandle) + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), false, 'the parent commit must remove the journal') + assert.equal(readFileSync(path.join(fixture.linkPath, 'SKILL.md'), 'utf8'), 'new tracked') + + // SECOND refresh under the same simulated Windows policy: the committed + // state refreshes again through a fresh parent journal flow, the junction + // still fails, and the copied directory again binds to the registered + // newly staged bytes instead of the live directory. + const secondOriginalPlatform = process.platform + let second + try { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + second = await refreshWithParent({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot }, refreshWithSimulatedWindows) + } finally { + Object.defineProperty(process, 'platform', { value: secondOriginalPlatform, configurable: true }) + } + assert.equal(second.success, true, JSON.stringify(second)) + assert.equal(materializations.length, 2, 'the second refresh must materialize the staged link through the same Windows-safe policy') + assert.equal(lstatSync(fixture.linkPath).isSymbolicLink(), false) + assert.equal(readFileSync(path.join(fixture.linkPath, 'SKILL.md'), 'utf8'), 'new tracked') + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), false, 'the second parent run must commit and remove its journal') + assert.equal(readdirSync(harnessDirParent).some((name) => name.startsWith(`.${path.basename(harnessDir)}.nsolid-stage-`)), false) + } finally { + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } + }) + + it('refreshes a Pi owned copied link twice, binding each copy to the newly staged skill bytes', async () => { + const fixture = await setupJournalFixture({ harness: 'pi' }) + try { + // The existing Pi link is a real copied directory holding the OLD live + // bytes; the staged bundle carries different NEW bytes. + writeFileSync(path.join(fixture.linkPath, 'SKILL.md'), 'old tracked') + const linkParent = path.dirname(fixture.linkPath) + for (const round of [1, 2]) { + const result = await refreshWithParent({ harness: 'pi', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot }) + assert.equal(result.success, true, `refresh #${round} failed: ${JSON.stringify(result)}`) + // The shared skill and the Pi copied link both hold the NEW staged + // bytes; the copy is a real directory, not a symlink. + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'new tracked') + assert.equal(lstatSync(fixture.linkPath).isSymbolicLink(), false, 'the Pi link must remain a real copied directory') + assert.equal(readFileSync(path.join(fixture.linkPath, 'SKILL.md'), 'utf8'), 'new tracked') + // No stage containers and no journal survive a completed local run. + assert.equal(readdirSync(linkParent).some((name) => name.startsWith(`.${path.basename(fixture.linkPath)}.nsolid-stage-`)), false) + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), false) + } + } finally { + rmSync(fallbackJournalPath(getTrackingFilePath()), { force: true }) + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + } + }) + + it('publishes a missing Pi link root as copied directories bound to the registered skill bytes', async () => { + const fixture = await setupJournalFixture({ harness: 'pi' }) + try { + // The entire Pi link root is missing: local planning derives a + // link-root frontier whose link leaves materialize as directory copies + // bound to the registered staged skill payloads. + rmSync(path.dirname(fixture.linkPath), { recursive: true, force: true }) + assert.equal(existsSync(fixture.linkPath), false) + const piPreState = captureRunState([ + path.join(fixture.skillPath, 'SKILL.md'), + getTrackingFilePath(), + getAuthFilePath(), + // Production stages the link frontier beside the frontier root + // (fallback-transaction.ts: the stage container is a direct sibling of + // the frontier root), so the actual Pi stage parent ~/.pi/agent must + // be part of the pre-run artifact inventory for non-Linux too. + ], [path.join(home, '.agents'), path.join(home, '.agents', 'skills'), path.dirname(path.dirname(fixture.linkPath))]) + const result = await refreshWithParent({ harness: 'pi', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot }) + if (process.platform !== 'linux') { + // The missing Pi link root is an active required frontier: the + // platform preflight fails closed before any journal or mutation. + assert.equal(result.success, false, JSON.stringify(result)) + assert.equal(result.error?.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + assert.equal(result.rollbackAttempted, false) + assert.match(result.error?.message ?? '', /no files were changed/) + // Fail-closed proof: the shared skill bytes are untouched, the + // missing Pi link root stays absent, and no journal or snapshot + // survives the rejected run (the journal check below only covers + // the Linux success path). + assertRunStateUnchanged(piPreState, 'rejected Pi frontier run') + assert.equal(existsSync(fixture.linkPath), false, 'the missing Pi link root must stay absent') + assert.equal(existsSync(path.dirname(fixture.linkPath)), false, 'the removed Pi link-root parent must stay absent') + return + } + assert.equal(result.success, true, JSON.stringify(result)) + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'new tracked') + assert.equal(existsSync(fixture.linkPath), true) + assert.equal(lstatSync(fixture.linkPath).isSymbolicLink(), false, 'the published Pi link must be a real copied directory') + assert.equal(readFileSync(path.join(fixture.linkPath, 'SKILL.md'), 'utf8'), 'new tracked') + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), false) + // The frontier stage container lives next to the link root under + // ~/.pi/agent and must be cleaned after the run. + assert.equal(readdirSync(path.dirname(path.dirname(fixture.linkPath))).some((name) => name.includes('.nsolid-stage-')), false) + } finally { + rmSync(fallbackJournalPath(getTrackingFilePath()), { force: true }) + 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' + writeBundle(bundlePath, ['tracked'], [{ name: 'alpha-console', url: newUrl, headers: {} }]) + const trackingPath = getTrackingFilePath() + const identity: FallbackTransactionIdentity = { + installationId: 'opencode:fallback', + harness: 'opencode', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await pathEvidence(skillPath)], + ownedLinks: [], + ownedMcpFields: [ + { configPath: canonicalPath, server: 'alpha-console', field: 'url', expectedDigest: valueDigest(preferredUrl) }, + { configPath: canonicalPath, server: 'alpha-console', field: 'headers', expectedDigest: valueDigest(preferredRecord.headers) }, + ], + ownedMcpConfigPaths: [await pathEvidence(canonicalPath)], + bundleDestinations: [await pathEvidence(path.join(destination, 'tracked'))], + approvedDestinationRoots: [path.resolve(destination)], + } + let { handle: journal } = await beginFallbackJournal(identity) + journal = await markFallbackJournalMutating(journal) + // Pre-mutation seam: the preferred MCP container is journaled with its + // planning-time evidence before any live byte moves. + const journaled = await reloadFallbackJournal(journal) + const canonicalEntry = journaled.entries.find((entry) => path.resolve(entry.path) === path.resolve(canonicalPath)) + assert.ok(canonicalEntry, 'the preferred MCP container must have a journal entry') + assert.equal(canonicalEntry!.existed, true) + assert.equal(canonicalEntry!.digest, identity.ownedMcpConfigPaths[0]!.digest) + const result = await refreshOwnedInstallation({ harness: 'opencode', bundlePath, skillsSource: sourceRoot, transaction: identity }) + assert.equal(result.success, true) + assert.equal(result.error, undefined) + // External child success: the applied journal remains mutating for the + // waiting parent — a mutator must never reclaim or commit a journal + // owned by another process. + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), true, 'an external child must leave the applied journal for its parent') + // Parent completion: reclaim, prove, commit. The journal file is + // removed last, so its absence is the observable commit proof. + const ownerHandle = await reclaimFallbackJournalMutation(journal) + const proven = await reloadFallbackJournal(ownerHandle) + for (const entry of proven.entries) { + if (entry.stageDigest !== undefined) assert.equal(entry.applied, true, `entry ${entry.path} must be applied`) + } + await commitFallbackJournal(ownerHandle) + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), false, 'the parent commit must remove the 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 }) + } + }) + + for (const harness of ['claude', 'codex'] as const) { + it(`aborts before claiming the journal when the ${harness} configuration cannot be parsed`, async () => { + // Capture invalid bytes before planning; render must reject without claiming. + const configPath = harness === 'codex' ? path.join(home, '.codex', 'config.toml') : path.join(home, '.claude.json') + const malformedConfig = harness === 'codex' ? '# user comment\n[mcp_servers.alpha\nurl = "broken"\n' : '{ mcpServers: broken' + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(configPath, malformedConfig) + const fixture = await setupJournalFixture({ harness }) + try { + const { handle: journal } = await beginFallbackJournal(fixture.identity) + await markFallbackJournalMutating(journal) + 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) + if (harness === 'codex') assert.equal(result.error?.code, 'MCP_PARSE_FAILED') + assert.notEqual(result.rollbackAttempted, true) + assert.deepEqual(readFileSync(journalPath), journalBefore, 'the journal was never claimed or rewritten') + assert.equal(readFileSync(configPath, 'utf8'), malformedConfig) + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old tracked') + for (const dir of new Set([path.join(home, '.agents'), path.dirname(configPath)])) { + const leftovers = readdirSync(dir).filter((name) => name.includes('.nsolid-stage-')) + assert.equal(leftovers.length, 0, `${dir}: ${leftovers.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) + + // The new destinations are parent-planned bundleDestinations evidence in + // the manifest, so the journal records their missing state at begin; the + // child never appends unplanned paths. + const extendedIdentity: FallbackTransactionIdentity = { + ...fixture.identity, + bundleDestinations: [ + ...fixture.identity.bundleDestinations, + await pathEvidence(addedSkill), + await pathEvidence(addedLink), + ], + } + let { handle: journal } = await beginFallbackJournal(extendedIdentity) + journal = await markFallbackJournalMutating(journal) + // The child claims the mutating journal before staging, exactly as the + // transaction performs it (same-process claim mirrors the external CLI). + const claimed = await claimFallbackJournalMutation(extendedIdentity, manifestDigestOf(extendedIdentity)) + assert.ok(claimed, 'the child must be able to claim the mutating journal') + journal = claimed + // Child staging + apply. + 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: reclaim owner authority from the crashed child's + // journal, then restore. The orphan destinations move into an + // authenticated restore quarantine that is cleaned on success. + const ownerHandle = await reclaimFallbackJournalMutation(journal) + const restored = await restoreFallbackJournal(ownerHandle) + assert.equal(restored.succeeded, 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. On + // non-linux the restored-away destinations are missing directories + // again: an active planned frontier, so the platform preflight fails + // closed before any journal reservation or mutation. + const postRestoreState = captureRunState([ + path.join(fixture.skillPath, 'SKILL.md'), + getTrackingFilePath(), + getAuthFilePath(), + ], [path.join(home, '.agents'), path.join(home, '.agents', 'skills'), getHarnessSkillsPath('claude')]) + const result = await refreshWithParent({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: sourceRoot }) + if (process.platform !== 'linux') { + assert.equal(result.success, false) + assert.equal(result.error?.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + assert.equal(result.rollbackAttempted, false) + // Fail-closed proof: the restored-away destinations stay absent, the + // owned skill bytes are untouched, and the pre-existing journal and + // snapshot state from the recovery phase is neither rewritten nor + // extended by the rejected run. + assertRunStateUnchanged(postRestoreState, 'rejected post-restore run') + assert.equal(existsSync(addedSkill), false, 'the restored-away skill destination must stay absent') + assert.equal(existsSync(addedLink), false, 'the restored-away link destination must stay absent') + return + } + 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' } } + // A real installed claude harness owns an existing link root; without it + // the missing ancestor would become a planned frontier and the platform + // preflight would preempt the drift gate under test. + mkdirSync(path.join(home, '.claude', 'skills'), { recursive: true }) + 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') + writeBundle(bundlePath, ['tracked'], [{ 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 refreshWithParent({ 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 refreshWithParent({ 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: {} } + // A real installed claude harness owns an existing link root; without it + // the missing ancestor would become a planned frontier and the platform + // preflight would preempt the multi-config reconciliation under test. + mkdirSync(path.join(home, '.claude', 'skills'), { recursive: true }) + // 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') + writeBundle(bundlePath, ['tracked'], [{ 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 refreshWithParent({ 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 }) + } + }) +}) + +describe('credentialless fallback reconciliation', () => { + interface CredentiallessFixture { + skillPath: string + sourceRoot: string + bundlePath: string + configPath: string + bundle: { version: string } + } + + /** Fixture matching a real credentialless install: skills tracked, zero tracked MCP servers, a new bundle that wants MCP servers. */ + async function setupCredentiallessFixture (): Promise { + const sharedDir = path.join(home, '.agents', 'skills') + const skillPath = path.join(sharedDir, 'tracked') + mkdirSync(skillPath, { recursive: true }) + // A real installed claude harness owns an existing link root; without it + // the missing ancestor would become a planned frontier and the platform + // preflight would preempt the reconciliation scenarios under test. + mkdirSync(path.join(home, '.claude', 'skills'), { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-credentialless-')) + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeBundle(bundlePath, ['tracked'], [{ name: 'new-server', url: 'https://mcp.example.com/mcp', headers: { AUTH: 'auth-token-value' } }]) + const configPath = path.join(home, '.claude.json') + writeJson(configPath, {}) + 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: [], + }) + return { skillPath, sourceRoot, bundlePath, configPath, bundle: { version: '1.0.1' } } + } + + function validCredentialsJson (): Record { + return { + serviceToken: 'service-token', + organizationId: 'org-1', + saasToken: 'saas-token', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://mcp.example.com/mcp', + expiresAt: new Date(Date.now() + 120_000).toISOString(), + } + } + + it('fails closed without mutation when zero MCP servers are tracked and credentials are missing, then reconciles after credentials become available', async () => { + const fixture = await setupCredentiallessFixture() + const configBefore = readFileSync(fixture.configPath, 'utf8') + + const blocked = await refreshWithParent({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot }) + assert.equal(blocked.success, false) + assert.equal(blocked.error?.code, 'MCP_RECONCILIATION_REQUIRED') + assert.equal(blocked.rollbackAttempted, undefined, 'the reconciliation gate must abort before any mutation') + + // Nothing moved: tracking, owned skills, MCP config, and bundle evidence are unchanged. + const blockedTracking = await readTrackingFile() + assert.equal(blockedTracking?.bundleVersions?.claude, '1.0.0') + assert.equal(blockedTracking?.mcpServers.length, 0) + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old') + assert.equal(readFileSync(fixture.configPath, 'utf8'), configBefore) + + // The same fixture reconciles successfully once valid credentials exist. + writeJson(getAuthFilePath(), validCredentialsJson()) + const retried = await refreshWithParent({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot }) + assert.equal(retried.success, true, String(retried.error?.code ?? '')) + const tracking = await readTrackingFile() + assert.equal(tracking?.bundleVersions?.claude, '1.0.1') + const trackedServer = tracking?.mcpServers.find((entry) => entry.name === 'new-server') + assert.ok(trackedServer, 'the desired MCP server must be tracked after reconciliation') + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'new') + const config = parseJsonc(readFileSync(fixture.configPath, 'utf8')) as { mcpServers: Record } + assert.equal(config.mcpServers['new-server'].url, 'https://mcp.example.com/mcp') + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + }) + + it('publishes a schema-bounded structured result envelope from the child CLI with --result', async () => { + const fixture = await setupCredentiallessFixture() + const trackingPath = getTrackingFilePath() + const identity: FallbackTransactionIdentity = { + installationId: 'claude:fallback', + harness: 'claude', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await pathEvidence(fixture.skillPath)], + ownedLinks: [await pathEvidence(path.join(getHarnessSkillsPath('claude'), 'tracked'))], + ownedMcpFields: [], + ownedMcpConfigPaths: [await pathEvidence(fixture.configPath)], + bundleDestinations: [ + await pathEvidence(path.join(getSkillsDir(), 'tracked')), + await pathEvidence(path.join(getHarnessSkillsPath('claude'), 'tracked')), + ], + approvedDestinationRoots: [getSkillsDir(), getHarnessSkillsPath('claude')].map((value) => path.resolve(value)), + } + const workspace = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-child-result-')) + if (process.platform !== 'win32') chmodSync(workspace, 0o700) + const manifestPath = path.join(workspace, 'transaction.json') + writeJson(manifestPath, identity) + const resultPath = path.join(workspace, 'result.json') + + const { pathToFileURL } = await import('node:url') + const require = createRequire(import.meta.url) + const cliPath = fileURLToPath(new URL('../../../src/update/refresh-owned-cli.ts', import.meta.url)) + const child = spawn(process.execPath, ['--import', pathToFileURL(require.resolve('tsx/esm')).href, cliPath, '--transaction', manifestPath, '--manifest-digest', manifestDigestOf(identity), '--result', resultPath], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + cwd: path.resolve(fileURLToPath(import.meta.url), '../../../../..'), + stdio: ['ignore', 'pipe', 'pipe'], + }) + const [exitCode, stdout, stderr] = await new Promise<[number | null, string, string]>((resolve, reject) => { + let out = '' + let err = '' + child.stdout?.on('data', (chunk) => { out += String(chunk) }) + child.stderr?.on('data', (chunk) => { err += String(chunk) }) + child.on('error', reject) + child.on('close', (code) => resolve([code, out, err])) + }) + + assert.equal(exitCode, 1, `child should fail closed without credentials (stderr: ${stderr} stdout: ${stdout})`) + const stat = statSync(resultPath) + if (process.platform !== 'win32') assert.equal(stat.mode & 0o777, 0o600) + assert.equal(stat.size <= 4096, true, 'the envelope must stay bounded') + const envelope = JSON.parse(readFileSync(resultPath, 'utf8')) as Record + assert.deepEqual(Object.keys(envelope).sort(), ['code', 'nonce', 'rollback', 'schema'], 'the envelope must not transport arbitrary child text') + assert.equal(envelope.schema, FALLBACK_CHILD_RESULT_SCHEMA, 'the child must publish the schema the parent validates against') + assert.equal(envelope.nonce, identity.nonce) + assert.equal(envelope.code, 'MCP_RECONCILIATION_REQUIRED') + assert.deepEqual(envelope.rollback, { attempted: false }) + assert.equal(readFileSync(path.join(fixture.skillPath, 'SKILL.md'), 'utf8'), 'old', 'the child must not mutate owned state') + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + rmSync(workspace, { recursive: true, force: true }) + }) + + it('external child exits 0 leaving the applied journal for the parent to reclaim, prove, and commit', { timeout: 120_000 }, async () => { + // Run the real CLI from a private package root with its own skill sources. + // Shared checkout assets must stay untouched while other suites inspect + // source hygiene. Keep the real bundle and dependencies, and use the repo + // as cwd to verify that the CLI resolves assets from its own package. + const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) + const sourcePackageRoot = resolvePackageRoot(path.dirname(fileURLToPath(new URL('../../../src/update/refresh-owned-cli.ts', import.meta.url)))) + const sharedSkillsPath = path.join(sourcePackageRoot, 'skills') + const sharedSkillsBefore = await pathDigest(sharedSkillsPath) + const childPackageRoot = path.join(home, 'child-package') + mkdirSync(childPackageRoot) + cpSync(path.join(sourcePackageRoot, 'src'), path.join(childPackageRoot, 'src'), { recursive: true }) + for (const file of ['package.json', 'bundle.json']) cpSync(path.join(sourcePackageRoot, file), path.join(childPackageRoot, file)) + symlinkSync(path.join(sourcePackageRoot, 'node_modules'), path.join(childPackageRoot, 'node_modules'), 'junction') + const cliPath = path.join(childPackageRoot, 'src', 'update', 'refresh-owned-cli.ts') + const realBundle = JSON.parse(readFileSync(path.join(childPackageRoot, 'bundle.json'), 'utf8')) as { version: string; skills: Array<{ name: string }> } + assert.ok(realBundle.skills.length > 1, 'the real bundle must contain skills for this fixture') + const refreshedName = realBundle.skills[0]!.name + const fixtureSkillsRoot = path.join(childPackageRoot, 'skills') + for (const skill of realBundle.skills) { + const sourceDir = path.join(fixtureSkillsRoot, skill.name) + mkdirSync(sourceDir, { recursive: true }) + writeFileSync(path.join(sourceDir, 'SKILL.md'), `fixture source bytes for ${skill.name}\n`) + } + writeJson(getAuthFilePath(), validCredentialsJson()) + const trackingPath = getTrackingFilePath() + const skillPath = path.join(getSkillsDir(), refreshedName) + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old installed bytes') + // The canonical claude config does not exist yet: the child journals its + // missing state and the reconciliation creates it with the first server. + const configPath = path.join(home, '.claude.json') + writeJson(trackingPath, { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + bundleVersion: realBundle.version, + bundleVersions: { claude: realBundle.version }, + skills: [{ name: refreshedName, path: skillPath, paths: { claude: skillPath }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [], + }) + const identity: FallbackTransactionIdentity = { + installationId: 'claude:fallback', + harness: 'claude', + trackingPath, + trackingDigest: trackingDigest(trackingPath)!, + protocolVersion: FALLBACK_PROTOCOL_VERSION, + nonce: randomUUID(), + plannedMissingFrontiers: [], + ownedSkills: [await pathEvidence(skillPath)], + ownedLinks: [await pathEvidence(path.join(getHarnessSkillsPath('claude'), refreshedName))], + ownedMcpFields: [], + ownedMcpConfigPaths: [await pathEvidence(configPath)], + bundleDestinations: await Promise.all(realBundle.skills.flatMap((skill) => [ + pathEvidence(path.join(getSkillsDir(), skill.name)), + pathEvidence(path.join(getHarnessSkillsPath('claude'), skill.name)), + ])), + approvedDestinationRoots: [getSkillsDir(), getHarnessSkillsPath('claude')].map((value) => path.resolve(value)), + } + // The parent began the journal and advanced it to mutating before the + // child was spawned; the owner handle stays in this process. + let ownerHandle = (await beginFallbackJournal(identity)).handle + ownerHandle = await markFallbackJournalMutating(ownerHandle) + + const workspace = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-child-success-')) + if (process.platform !== 'win32') chmodSync(workspace, 0o700) + const manifestPath = path.join(workspace, 'transaction.json') + writeJson(manifestPath, identity) + const resultPath = path.join(workspace, 'result.json') + + const { pathToFileURL } = await import('node:url') + const require = createRequire(import.meta.url) + try { + assert.equal(await pathDigest(sharedSkillsPath), sharedSkillsBefore, 'the child fixture must not mutate shared package skills') + const child = spawn(process.execPath, ['--import', pathToFileURL(require.resolve('tsx/esm')).href, cliPath, '--transaction', manifestPath, '--manifest-digest', manifestDigestOf(identity), '--result', resultPath], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + cwd: repoRoot, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const [exitCode, stdout, stderr] = await new Promise<[number | null, string, string]>((resolve, reject) => { + let out = '' + let err = '' + child.stdout?.on('data', (chunk) => { out += String(chunk) }) + child.stderr?.on('data', (chunk) => { err += String(chunk) }) + child.on('error', reject) + child.on('close', (code) => resolve([code, out, err])) + }) + + assert.equal(exitCode, 0, `child should succeed (stderr: ${stderr} stdout: ${stdout})`) + assert.equal(existsSync(resultPath), false, 'a successful child publishes no failure envelope') + // The applied journal remains mutating for the parent: the child is a + // different process and must never reclaim or commit a journal owned by + // another PID, even on success. + const journalPath = fallbackJournalPath(trackingPath) + assert.equal(existsSync(journalPath), true, 'the applied journal must remain for the parent') + // Parent completion after the confirmed child exit: reclaim owner + // authority (requires the recorded mutator to be gone), prove the applied + // state from the strictly reloaded journal plus the live filesystem, then + // commit with the journal as the single snapshot. + const reclaimed = await reclaimFallbackJournalMutation(ownerHandle) + const proven = await reloadFallbackJournal(reclaimed) + for (const entry of proven.entries) { + if (entry.stageDigest !== undefined) { + assert.equal(entry.applied, true, `entry ${entry.path} must be applied`) + assert.equal(await pathDigest(entry.path), entry.stageDigest, `entry ${entry.path} must hold its staged digest`) + } + } + await commitFallbackJournal(reclaimed) + assert.equal(existsSync(journalPath), false, 'the parent commit must remove the journal') + // The live update survived the child exit and the parent commit. + assert.notEqual(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'old installed bytes') + assert.equal(existsSync(path.join(getSkillsDir(), realBundle.skills[1]!.name)), true, 'a new bundle skill must be installed') + assert.equal(existsSync(path.join(getHarnessSkillsPath('claude'), refreshedName)), true, 'the refreshed harness link must exist') + const config = parseJsonc(readFileSync(configPath, 'utf8')) as { mcpServers: Record } + assert.ok(config.mcpServers['nsolid-console'], 'the canonical config must gain the bundle MCP server') + const tracking = await readTrackingFile() + assert.equal(tracking?.bundleVersions?.claude, realBundle.version) + assert.equal(await pathDigest(sharedSkillsPath), sharedSkillsBefore, 'the child must leave shared package skills unchanged') + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + it('refreshes an owned MCP config whose tracked fields already match the desired render without touching the config bytes', async () => { + const fixture = await setupCredentiallessFixture() + const serverRecord = { type: 'http', url: 'https://mcp.example.com/mcp', headers: { AUTH: 'auth-token-value' } } + writeJson(fixture.configPath, { mcpServers: { 'new-server': serverRecord } }) + const configBefore = readFileSync(fixture.configPath, 'utf8') + const fields = readMcpFieldDigests(fixture.configPath, 'new-server', { preferredKey: harnessMcpKey('claude') }) + assert.ok(fields) + const trackingPath = getTrackingFilePath() + const current = JSON.parse(readFileSync(trackingPath, 'utf8')) as Record + writeJson(trackingPath, { + ...current, + mcpServers: [{ name: 'new-server', configPath: path.resolve(fixture.configPath), harness: 'claude', configuredAt: new Date().toISOString(), fields }], + }) + writeJson(getAuthFilePath(), validCredentialsJson()) + + const result = await refreshWithParent({ harness: 'claude', bundlePath: fixture.bundlePath, skillsSource: fixture.sourceRoot }) + assert.equal(result.success, true, JSON.stringify(result)) + // Regression: a planned no-op field update must never move the live + // configuration into quarantine with no staged replacement. + assert.equal(readFileSync(fixture.configPath, 'utf8'), configBefore) + const tracking = await readTrackingFile() + assert.equal(tracking?.bundleVersions?.claude, '1.0.1') + const tracked = tracking?.mcpServers.find((entry) => entry.name === 'new-server') + assert.deepEqual(tracked?.fields, fields) + rmSync(fixture.sourceRoot, { recursive: true, force: true }) + }) +}) + +describe('fallback parent frontier planning', () => { + interface LocalManifestObservation { + manifest: FallbackTransactionIdentity | undefined + plan: FallbackFrontierPlan | undefined + leaves: ReturnType | undefined + observeManifest: (manifest: FallbackTransactionIdentity) => Promise + } + + /** + * Derive the shared-API expectation at the same live-state instant: the + * observer fires after local planning and before any mutation, so both + * derivations see identical filesystem state. + */ + function observeLocalManifest (deriveExpectation: () => Promise<{ plan: FallbackFrontierPlan, leaves: ReturnType }>): LocalManifestObservation { + const observation: LocalManifestObservation = { + manifest: undefined, + plan: undefined, + leaves: undefined, + observeManifest: async (manifest) => { + observation.manifest = manifest + const expectation = await deriveExpectation() + observation.plan = expectation.plan + observation.leaves = expectation.leaves + }, + } + return observation + } + + it('derives the parent manifest frontier graph through the shared planner API', 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(removedDir, 'SKILL.md'), 'shared with Codex') + const claudeSkills = path.join(home, '.claude', 'skills') + mkdirSync(claudeSkills, { recursive: true }) + symlinkSync(retainedDir, path.join(claudeSkills, 'retained'), 'dir') + symlinkSync(removedDir, path.join(claudeSkills, 'removed'), 'dir') + const { sourceRoot, bundlePath } = setupSkillSource({ retained: 'new retained', added: 'new skill' }) + writeBundle(bundlePath, ['retained', 'added'], [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }]) + writeCredentials(getAuthFilePath(), 'https://example.com/mcp', 60_000) + writeJson(getTrackingFilePath(), { + 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: [], + }) + + const canonicalConfigPath = getAdapter('claude').getMcpConfigPath() + assert.ok(canonicalConfigPath) + const observation = observeLocalManifest(async () => { + const leaves = deriveFallbackFrontierLeafTargets({ + ownedSkills: [await pathEvidence(retainedDir), await pathEvidence(removedDir)], + ownedLinks: [await pathEvidence(path.join(claudeSkills, 'retained')), await pathEvidence(path.join(claudeSkills, 'removed'))], + ownedMcpConfigPaths: [await pathEvidence(canonicalConfigPath)], + trackingPath: getTrackingFilePath(), + destination: path.resolve(getSkillsDir()), + linkDir: path.resolve(getHarnessSkillsPath('claude')), + bundleSkillNames: ['retained', 'added'], + }) + return { plan: await deriveFallbackFrontierPlan(leaves), leaves } + }) + try { + if (process.platform !== 'linux') { + // The new 'added' skill directory is an active required frontier: + // the platform preflight fails closed before any manifest workspace + // exists, so the manifest-parity assertions below are Linux-only. + const sharedPlannerState = captureRunState([ + path.join(retainedDir, 'SKILL.md'), + path.join(removedDir, 'SKILL.md'), + getTrackingFilePath(), + getAuthFilePath(), + ], [path.join(home, '.agents'), sharedDir, claudeSkills]) + const rejected = await refreshWithParent({ harness: 'claude', bundlePath, skillsSource: sourceRoot, observeManifest: observation.observeManifest }) + assert.equal(rejected.success, false) + assert.equal(rejected.error?.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + assert.equal(rejected.rollbackAttempted, false) + // Fail-closed proof: the owned skill bytes and tracking/auth stay + // untouched and no journal or snapshot workspace is created before + // the manifest-parity assertions run on Linux. + assertRunStateUnchanged(sharedPlannerState, 'rejected shared-planner run') + assert.equal(existsSync(path.join(getSkillsDir(), 'added')), false, 'the missing frontier destination must stay absent') + return + } + const result = await refreshWithParent({ harness: 'claude', bundlePath, skillsSource: sourceRoot, observeManifest: observation.observeManifest }) + assert.equal(result.success, true, JSON.stringify(result)) + assert.ok(observation.manifest && observation.plan && observation.leaves) + // Exact parity with the shared planner for the same planned state: the + // parent manifest carries byte-identical frontier evidence. + assert.deepEqual(observation.manifest.plannedMissingFrontiers, observation.plan.frontiers) + // Graph coverage: bundle destinations, tracked removals, canonical MCP, + // and tracking leaves are all authorized by one derivation. + const ids = observation.leaves.map((leaf) => leaf.id) + for (const expectedId of ['skill:retained', 'skill:added', 'link:retained', 'link:added', 'owned-skill:removed', 'owned-link:removed', 'tracking']) { + assert.ok(ids.includes(expectedId), `leaf graph is missing ${expectedId}`) + } + assert.ok(ids.some((id) => id.startsWith('mcp-config:')), 'leaf graph is missing the canonical MCP config leaf') + // The new 'added' skill directory is missing under an existing root: + // exactly one required frontier anchored at the destination root. + const frontier = observation.manifest.plannedMissingFrontiers?.[0] + assert.ok(frontier) + assert.equal(observation.manifest.plannedMissingFrontiers?.length, 1) + assert.equal(frontier.frontierPath, path.join(path.resolve(getSkillsDir()), 'added')) + assert.equal(frontier.activation, 'required') + assert.deepEqual(frontier.leaves.map((leaf) => leaf.id), ['skill:added']) + // The canonical MCP config is a missing file under an existing parent: + // an independent leaf destination, never a directory frontier. + const mcpLeaf = observation.leaves.find((leaf) => leaf.role === 'mcp-config') + assert.ok(mcpLeaf) + assert.ok(observation.plan.missingLeaves.some((leaf) => leaf.id === mcpLeaf.id)) + for (const entry of observation.plan.frontiers) { + assert.ok(entry.leaves.every((leaf) => leaf.id !== mcpLeaf.id)) + } + } finally { + rmSync(sourceRoot, { recursive: true, force: true }) + } + }) + + it('rejects an active missing frontier on non-linux before any journal, snapshot, or live mutation, then proceeds on linux', async () => { + const destination = path.resolve(getSkillsDir()) + const claudeSkills = path.join(home, '.claude', 'skills') + const keptPath = path.join(destination, 'kept') + const { sourceRoot, bundlePath } = setupSkillSource({ kept: 'new kept' }) + writeBundle(bundlePath, ['kept'], [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }]) + writeCredentials(getAuthFilePath(), 'https://example.com/mcp', 60_000) + writeJson(getTrackingFilePath(), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + skills: [{ name: 'kept', path: keptPath, paths: { claude: keptPath }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [], + }) + const trackingDir = path.dirname(getTrackingFilePath()) + const trackingDirBefore = readdirSync(trackingDir).sort() + + let observedFrontierCount: number | undefined + const observeManifest = (manifest: FallbackTransactionIdentity): void => { + observedFrontierCount = manifest.plannedMissingFrontiers?.length + } + try { + const originalPlatform = process.platform + try { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + const rejected = await refreshWithParent({ harness: 'claude', bundlePath, skillsSource: sourceRoot, observeManifest }) + assert.equal(rejected.success, false) + assert.equal(rejected.error?.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + assert.equal(rejected.rollbackAttempted, false) + assert.match(rejected.error?.message ?? '', /no files were changed/) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + // The gate fired on real derived frontier evidence (destination root and + // link root missing), strictly before journal reservation. + assert.equal(observedFrontierCount, 2) + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), false) + assert.deepEqual(readdirSync(trackingDir).sort(), trackingDirBefore) + assert.equal(existsSync(destination), false) + assert.equal(existsSync(claudeSkills), false) + // On linux the same run passes the preflight and completes end-to-end. + // The forced-win32 phase above already covers non-linux hosts; the + // end-to-end phase requires the host platform to support frontier + // publication, which only Linux does. + if (process.platform === 'linux') { + const result = await refreshWithParent({ harness: 'claude', bundlePath, skillsSource: sourceRoot, observeManifest }) + assert.equal(result.success, true, JSON.stringify(result)) + assert.equal(readFileSync(path.join(destination, 'kept', 'SKILL.md'), 'utf8'), 'new kept') + } + } finally { + rmSync(sourceRoot, { recursive: true, force: true }) + } + }) + + it('keeps existing-parent parent-managed updates supported on non-linux platforms', async () => { + // Replace-only bundle over fully existing parents: zero frontiers. + 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(removedDir, 'SKILL.md'), 'shared with Codex') + const claudeSkills = path.join(home, '.claude', 'skills') + mkdirSync(claudeSkills, { recursive: true }) + symlinkSync(retainedDir, path.join(claudeSkills, 'retained'), 'dir') + symlinkSync(removedDir, path.join(claudeSkills, 'removed'), 'dir') + const { sourceRoot, bundlePath } = setupSkillSource({ retained: 'new retained' }) + writeBundle(bundlePath, ['retained'], [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }]) + writeCredentials(getAuthFilePath(), 'https://example.com/mcp', 60_000) + writeJson(getTrackingFilePath(), { + 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: [], + }) + + let observedFrontiers: readonly unknown[] | undefined + const observeManifest = (manifest: FallbackTransactionIdentity): void => { + observedFrontiers = manifest.plannedMissingFrontiers + } + try { + const originalPlatform = process.platform + let result + try { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + result = await refreshWithParent({ harness: 'claude', bundlePath, skillsSource: sourceRoot, observeManifest }) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + assert.equal(result.success, true, JSON.stringify(result)) + // No missing parents means no platform restriction anywhere. + assert.deepEqual(observedFrontiers, []) + assert.equal(readFileSync(path.join(retainedDir, 'SKILL.md'), 'utf8'), 'new retained') + } finally { + rmSync(sourceRoot, { recursive: true, force: true }) + } + }) + + it('publishes an active all-MCP missing-parent frontier without creating the config parent early', async () => { + // Fresh opencode ownership whose entire config root (~/.config/opencode) + // is missing: the derived graph must contain exactly one planned-missing + // frontier equal to the config parent, covering only the mcp-config leaf. + // The shared skills destination and harness link directory point at an + // existing directory via NSOLID_OPENCODE_SKILLS_DIR, so no skill/link + // frontier is derived and the MCP parent frontier is the sole one. + const sharedSkillsDir = path.join(home, 'shared-skills') + mkdirSync(path.join(sharedSkillsDir, 'tracked'), { recursive: true }) + writeFileSync(path.join(sharedSkillsDir, 'tracked', 'SKILL.md'), 'old') + const previousSkillsDir = process.env.NSOLID_OPENCODE_SKILLS_DIR + process.env.NSOLID_OPENCODE_SKILLS_DIR = sharedSkillsDir + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + try { + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeBundle(bundlePath, ['tracked'], [{ name: 'new-server', url: 'https://example.com/mcp', headers: {} }]) + writeCredentials(getAuthFilePath(), 'https://example.com/mcp', 60_000) + writeJson(getTrackingFilePath(), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersions: { opencode: '1.0.0' }, + skills: [{ name: 'tracked', path: path.join(sharedSkillsDir, 'tracked'), paths: { opencode: path.join(sharedSkillsDir, 'tracked') }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }], + mcpServers: [], + }) + + const configPath = path.join(home, '.config', 'opencode', 'opencode.jsonc') + // The whole ~/.config chain is missing, so the derived planned-missing + // frontier root is the FIRST missing ancestor (~/.config), covering the + // single mcp-config leaf. + const frontierRoot = path.join(home, '.config') + // The harness adapter confirms this fixture targets the real config file. + assert.equal(getAdapter('opencode').getMcpConfigPath(), configPath) + assert.equal(await pathKind(configPath), 'missing') + assert.equal(await pathKind(frontierRoot), 'missing') + + const seamEvents: Array<{ phase: string; frontierPath: string }> = [] + let beforeReserveObserved = false + setFallbackFrontierPublicationSeamForTests(async (event) => { + seamEvents.push({ phase: event.phase, frontierPath: event.frontierPath }) + if (event.phase === 'before-reserve' && event.frontierPath === path.resolve(frontierRoot)) { + // The recursive mkdir gate must have skipped this parent: it is + // still missing at the exclusive-reservation window. + assert.equal(await pathKind(frontierRoot), 'missing') + beforeReserveObserved = true + } + }) + + let observedFrontierCount: number | undefined + const observeManifest = (manifest: FallbackTransactionIdentity): void => { + observedFrontierCount = manifest.plannedMissingFrontiers.length + assert.deepEqual(manifest.plannedMissingFrontiers.map((frontier) => path.resolve(frontier.frontierPath)), [path.resolve(frontierRoot)]) + assert.deepEqual(manifest.plannedMissingFrontiers[0].leaves.map((leaf) => leaf.role), ['mcp-config']) + } + + try { + if (process.platform !== 'linux') { + // Platform preflight rejects the active frontier before any mutation. + const rejected = await refreshWithParent({ harness: 'opencode', bundlePath, skillsSource: sourceRoot, observeManifest }) + assert.equal(rejected.success, false) + assert.equal(rejected.error?.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + return + } + const result = await refreshWithParent({ harness: 'opencode', bundlePath, skillsSource: sourceRoot, observeManifest }) + assert.equal(result.success, true, JSON.stringify(result)) + assert.equal(observedFrontierCount, 1) + // The seam observed the reservation window exactly once and never an + // early live-parent creation for the config frontier. + assert.equal(beforeReserveObserved, true) + assert.equal(seamEvents.filter((event) => event.phase === 'before-reserve' && event.frontierPath === path.resolve(frontierRoot)).length, 1) + // The config parent exists only through the frontier publication and + // carries exactly the planned MCP bytes. + assert.equal(await pathKind(configPath), 'file') + const written = parseJsonc(readFileSync(configPath, 'utf8')) as { mcp: Record } + assert.equal(written.mcp['new-server'].url, 'https://example.com/mcp') + const tracking = await readTrackingFile() + assert.equal(tracking?.bundleVersions?.opencode, '1.0.1') + assert.ok(tracking?.mcpServers.some((entry) => entry.name === 'new-server')) + // A successful parent-managed refresh leaves no pending journal behind. + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), false) + } finally { + setFallbackFrontierPublicationSeamForTests(undefined) + rmSync(sourceRoot, { recursive: true, force: true }) + } + } finally { + if (previousSkillsDir === undefined) delete process.env.NSOLID_OPENCODE_SKILLS_DIR + else process.env.NSOLID_OPENCODE_SKILLS_DIR = previousSkillsDir + } + }) + + it('publishes an active mixed skill+MCP ancestor frontier without creating the ancestor early', async () => { + // Fresh opencode ownership whose ENTIRE config root (~/.config) is + // missing: the derived graph must contain exactly one planned-missing + // frontier equal to ~/.config covering BOTH the required shared-skill + // destination leaf (~/.config/opencode/skills/) and the canonical + // mcp-config leaf. The bundle declares one MCP server while previous + // tracking owns none, and valid credentials enable reconciliation, so the + // mcp-config leaf is an ACTIVE conditional: both payloads must be staged + // into the single private frontier tree and published together, without + // any early recursive mkdir of the live ancestor. + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + try { + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeBundle(bundlePath, ['tracked'], [{ name: 'new-server', url: 'https://example.com/mcp', headers: {} }]) + writeCredentials(getAuthFilePath(), 'https://example.com/mcp', 60_000) + const destination = path.join(home, '.config', 'opencode', 'skills') + const configPath = path.join(home, '.config', 'opencode', 'opencode.jsonc') + writeJson(getTrackingFilePath(), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersions: { opencode: '1.0.0' }, + skills: [{ name: 'tracked', path: path.join(destination, 'tracked'), paths: { opencode: path.join(destination, 'tracked') }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }], + mcpServers: [], + }) + + const frontierRoot = path.join(home, '.config') + const skillLeafPath = path.join(destination, 'tracked') + // The harness adapter confirms this fixture targets the real config file. + assert.equal(getAdapter('opencode').getMcpConfigPath(), configPath) + assert.equal(await pathKind(frontierRoot), 'missing') + assert.equal(await pathKind(skillLeafPath), 'missing') + assert.equal(await pathKind(configPath), 'missing') + + let observedFrontierCount: number | undefined + const observeManifest = (manifest: FallbackTransactionIdentity): void => { + observedFrontierCount = manifest.plannedMissingFrontiers.length + assert.deepEqual(manifest.plannedMissingFrontiers.map((frontier) => path.resolve(frontier.frontierPath)), [path.resolve(frontierRoot)]) + const frontier = manifest.plannedMissingFrontiers[0] + assert.deepEqual(frontier.leaves.map((leaf) => leaf.role).sort(), ['mcp-config', 'skill']) + assert.deepEqual(frontier.leaves.map((leaf) => leaf.activation).sort(), ['conditional', 'required']) + } + + const seamEvents: Array<{ phase: string; frontierPath: string }> = [] + let beforeReserveObserved = false + setFallbackFrontierPublicationSeamForTests(async (event) => { + seamEvents.push({ phase: event.phase, frontierPath: event.frontierPath }) + if (event.phase === 'before-reserve' && event.frontierPath === path.resolve(frontierRoot)) { + // The recursive mkdir gate must have skipped the shared ancestor: it + // is still missing inside the exclusive-reservation window. + assert.equal(await pathKind(frontierRoot), 'missing') + beforeReserveObserved = true + } + }) + + try { + if (process.platform !== 'linux') { + // Platform preflight rejects the active frontier before any mutation. + const rejected = await refreshWithParent({ harness: 'opencode', bundlePath, skillsSource: sourceRoot, observeManifest }) + assert.equal(rejected.success, false) + assert.equal(rejected.error?.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + return + } + const result = await refreshWithParent({ harness: 'opencode', bundlePath, skillsSource: sourceRoot, observeManifest }) + assert.equal(result.success, true, JSON.stringify(result)) + assert.equal(observedFrontierCount, 1) + // The seam observed the reservation window exactly once and never an + // early live-parent creation for the shared ancestor frontier. + assert.equal(beforeReserveObserved, true) + assert.equal(seamEvents.filter((event) => event.phase === 'before-reserve' && event.frontierPath === path.resolve(frontierRoot)).length, 1) + // The required skill leaf exists only through the frontier publication. + assert.equal(readFileSync(path.join(skillLeafPath, 'SKILL.md'), 'utf8'), 'new') + // The active conditional mcp-config leaf carries the desired server. + assert.equal(await pathKind(configPath), 'file') + const written = parseJsonc(readFileSync(configPath, 'utf8')) as { mcp: Record } + assert.equal(written.mcp['new-server'].url, 'https://example.com/mcp') + const tracking = await readTrackingFile() + assert.equal(tracking?.bundleVersions?.opencode, '1.0.1') + assert.ok(tracking?.mcpServers.some((entry) => entry.name === 'new-server')) + // A successful parent-managed refresh leaves no pending journal behind. + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), false) + } finally { + setFallbackFrontierPublicationSeamForTests(undefined) + } + } finally { + rmSync(sourceRoot, { recursive: true, force: true }) + } + }) + + it('rejects an unsupported active link+MCP frontier without creating the shared root or a journal', async () => { + // Antigravity parent-managed refresh whose ENTIRE ~/.gemini root is missing: the + // derived planned-missing frontier there carries BOTH a required link + // leaf (~/.gemini/config/skills/) and an ACTIVE conditional + // mcp-config leaf (~/.gemini/config/mcp_config.json, rendered because + // valid credentials let the bundle server reconcile into the empty + // previous set). A link+MCP frontier is not a supported publication + // shape, so the refresh must fail closed BEFORE any live recursive mkdir + // of ~/.gemini (the historical bug created the whole shared root here), + // leave the root and config missing, run no frontier publication, and + // dispose the journal reserved before the classification gate. + const sharedSkillPath = path.join(home, '.agents', 'skills', 'tracked') + mkdirSync(sharedSkillPath, { recursive: true }) + writeFileSync(path.join(sharedSkillPath, 'SKILL.md'), 'old') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + try { + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeBundle(bundlePath, ['tracked'], [{ name: 'new-server', url: 'https://example.com/mcp', headers: {} }]) + writeCredentials(getAuthFilePath(), 'https://example.com/mcp', 60_000) + writeJson(getTrackingFilePath(), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'antigravity', + bundleVersions: { antigravity: '1.0.0' }, + skills: [{ name: 'tracked', path: sharedSkillPath, paths: { antigravity: sharedSkillPath }, installedAt: new Date().toISOString(), harnesses: ['antigravity'] }], + mcpServers: [], + }) + + const linkDir = path.resolve(getHarnessSkillsPath('antigravity')) + const configPath = getAdapter('antigravity').getMcpConfigPath() + const frontierRoot = path.join(home, '.gemini') + // The adapter fixtures confirm the real Antigravity layout: the harness + // link directory and the canonical MCP config both live below ~/.gemini. + assert.equal(linkDir, path.join(frontierRoot, 'config', 'skills')) + assert.equal(configPath, path.join(frontierRoot, 'config', 'mcp_config.json')) + assert.equal(await pathKind(frontierRoot), 'missing') + assert.equal(await pathKind(linkDir), 'missing') + assert.equal(await pathKind(configPath), 'missing') + + let observedFrontierCount: number | undefined + const observeManifest = (manifest: FallbackTransactionIdentity): void => { + observedFrontierCount = manifest.plannedMissingFrontiers.length + assert.deepEqual(manifest.plannedMissingFrontiers.map((frontier) => path.resolve(frontier.frontierPath)), [path.resolve(frontierRoot)]) + const frontier = manifest.plannedMissingFrontiers[0] + // Exactly the unsupported active mix: required link + active + // conditional mcp-config under one missing root. + assert.deepEqual(frontier.leaves.map((leaf) => leaf.role).sort(), ['link', 'mcp-config']) + assert.deepEqual(frontier.leaves.map((leaf) => leaf.activation).sort(), ['conditional', 'required']) + } + + const seamEvents: Array<{ phase: string; frontierPath: string }> = [] + setFallbackFrontierPublicationSeamForTests(async (event) => { + seamEvents.push({ phase: event.phase, frontierPath: event.frontierPath }) + }) + + try { + if (process.platform !== 'linux') { + // Platform preflight rejects the active frontier before any mutation. + const rejected = await refreshWithParent({ harness: 'antigravity', bundlePath, skillsSource: sourceRoot, observeManifest }) + assert.equal(rejected.success, false) + assert.equal(rejected.error?.code, 'FALLBACK_PARENT_CREATION_UNSUPPORTED') + return + } + const result = await refreshWithParent({ harness: 'antigravity', bundlePath, skillsSource: sourceRoot, observeManifest }) + assert.equal(result.success, false, JSON.stringify(result)) + assert.equal(result.error?.code, 'INVALID_TRANSACTION_MANIFEST') + assert.equal(observedFrontierCount, 1) + // No frontier publication ever ran: the rejection happened at the + // pure classification gate, before staging or reservation windows. + assert.equal(seamEvents.length, 0) + // The unsupported frontier must not have created the shared root: + // every path below it stays missing after the failed refresh. + assert.equal(await pathKind(frontierRoot), 'missing') + assert.equal(await pathKind(linkDir), 'missing') + assert.equal(await pathKind(configPath), 'missing') + // The owned shared skill was never touched. + assert.equal(readFileSync(path.join(sharedSkillPath, 'SKILL.md'), 'utf8'), 'old') + // The child cannot dispose the parent's journal. If the owner cannot + // re-authenticate the already-restored snapshot, it preserves residue. + assert.equal(existsSync(fallbackJournalPath(getTrackingFilePath())), true) + } finally { + setFallbackFrontierPublicationSeamForTests(undefined) + rmSync(sourceRoot, { recursive: true, force: true }) + } + } finally { + rmSync(sharedSkillPath, { 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..b9d421d --- /dev/null +++ b/packages/core/test/unit/update/inventory.test.ts @@ -0,0 +1,902 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { detectCliInstallation, 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, treeTerminated: true }) } +} + +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, treeTerminated: true } + }, + }, + }) + + 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, '.config', 'opencode', '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('never classifies an unrelated absolute tracking path as fallback-owned', async () => { + const victim = path.join(home, 'user-owned', 'tracked') + mkdirSync(victim, { recursive: true }) + writeFileSync(path.join(victim, 'keep.txt'), 'keep\n') + 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: victim, paths: { claude: victim }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [], + }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const fallback = detected.find((installation) => installation.installationId === 'claude:fallback') + assert.equal(fallback?.source.kind, 'unsupported') + assert.equal(readFileSync(path.join(victim, 'keep.txt'), 'utf8'), 'keep\n') + }) + + 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, 'plugin.json'), { name: 'nsolid-plugin' }) + writeJson(path.join(pluginRoot, 'bundle.json'), { version: '1.0.0' }) + writeJson(path.join(home, '.gemini', 'config', 'import_manifest.json'), { imports: [{ name: 'nsolid-plugin', source: 'antigravity' }] }) + 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') + }) + + it('rejects Antigravity substring imports and foreign conventional roots', async () => { + const pluginRoot = path.join(home, '.gemini', 'config', 'plugins', 'nsolid-plugin') + mkdirSync(pluginRoot, { recursive: true }) + writeJson(path.join(pluginRoot, 'plugin.json'), { name: 'my-nsolid-plugin-helper' }) + writeJson(path.join(pluginRoot, 'bundle.json'), { version: '9.9.9' }) + writeJson(path.join(home, '.gemini', 'config', 'import_manifest.json'), { + imports: { 'my-nsolid-plugin-helper': { name: 'nsolid-plugin', source: 'https://github.com/foreign/plugin.git' } }, + }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const antigravity = detected.find((installation) => installation.target === 'antigravity') + assert.notEqual(antigravity?.source.kind, 'antigravity-git') + }) + + it('rejects Antigravity imports without canonical source provenance', async () => { + const pluginRoot = path.join(home, '.gemini', 'config', 'plugins', 'nsolid-plugin') + const manifestPath = path.join(home, '.gemini', 'config', 'import_manifest.json') + mkdirSync(pluginRoot, { recursive: true }) + writeJson(path.join(pluginRoot, 'plugin.json'), { name: 'nsolid-plugin' }) + writeJson(path.join(pluginRoot, 'bundle.json'), { version: '9.9.9' }) + + const unprovenImports = [ + { imports: { 'nsolid-plugin': true } }, + { imports: [{ name: 'nsolid-plugin' }] }, + ] + for (const manifest of unprovenImports) { + writeJson(manifestPath, manifest) + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const antigravity = detected.find((installation) => installation.target === 'antigravity') + assert.notEqual(antigravity?.source.kind, 'antigravity-git', JSON.stringify(manifest)) + } + }) +}) + +describe('CLI installation provenance', () => { + function cliPackageRoot (root: string, version: string): string { + writeJson(path.join(root, 'package.json'), { name: 'nsolid-plugin', version }) + writeJson(path.join(root, 'bundle.json'), { name: 'nsolid-plugin', version }) + return root + } + + function cliLauncher (root: string, relative = path.join('dist', 'src', 'cli.js')): string { + const launcher = path.join(root, relative) + mkdirSync(path.dirname(launcher), { recursive: true }) + writeFileSync(launcher, '#!/usr/bin/env node\n') + return launcher + } + + function noProbeRunner () { + return { + run: async () => { + throw new Error('read-only inventory must not probe package managers') + }, + } + } + + it('reports an unproven workspace launcher as unsupported without a current version', async () => { + const root = cliPackageRoot(path.join(home, 'repo', 'packages', 'core'), '1.0.3') + const launcher = cliLauncher(root) + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: launcher, + }) + + assert.equal(installation.ownership, 'none') + if (installation.source.kind !== 'unsupported') assert.fail(JSON.stringify(installation.source)) + assert.equal(installation.source.source, launcher) + assert.equal(installation.version.current, undefined) + }) + + it('proves an npm global installation only through its matching prefix bin link', async () => { + const prefix = path.join(home, 'node-v24') + const root = cliPackageRoot(path.join(prefix, 'lib', 'node_modules', 'nsolid-plugin'), '90.0.1') + const entrypoint = cliLauncher(root) + const launcher = path.join(prefix, 'bin', 'nsolid-plugin') + mkdirSync(path.dirname(launcher), { recursive: true }) + symlinkSync(entrypoint, launcher) + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: launcher, + }) + + assert.equal(installation.ownership, 'global-package') + if (installation.source.kind !== 'global-package') assert.fail(JSON.stringify(installation.source)) + assert.equal(installation.source.packageManager, 'npm') + assert.equal(installation.version.current, '90.0.1') + }) + + it('does not treat a workspace path ending in lib/node_modules as npm global ownership', async () => { + const root = cliPackageRoot(path.join(home, 'workspace', 'lib', 'node_modules', 'nsolid-plugin'), '1.0.3') + const launcher = cliLauncher(root) + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: launcher, + }) + + assert.equal(installation.ownership, 'none') + assert.equal(installation.source.kind, 'unsupported') + assert.equal(installation.version.current, undefined) + }) + + it('keeps Volta package images and their shim launchers unsupported', async () => { + const root = cliPackageRoot(path.join(home, '.volta', 'tools', 'image', 'packages', 'nsolid-plugin', 'lib', 'node_modules', 'nsolid-plugin'), '90.0.1') + const realLauncher = cliLauncher(root) + + const imageInstallation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: realLauncher, + }) + assert.equal(imageInstallation.ownership, 'none', 'a Volta tool image never proves npm-global ownership') + assert.equal(imageInstallation.source.kind, 'unsupported') + assert.equal(imageInstallation.version.current, undefined) + + const shim = path.join(home, '.volta', 'bin', 'nsolid-plugin') + mkdirSync(path.dirname(shim), { recursive: true }) + symlinkSync(realLauncher, shim) + const shimInstallation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: shim, + }) + assert.equal(shimInstallation.ownership, 'none') + assert.equal(shimInstallation.source.kind, 'unsupported') + assert.equal(shimInstallation.version.current, undefined) + }) + + it('keeps a fabricated npm prefix without a bin link unsupported', async () => { + // A tree that only ends in lib/node_modules/nsolid-plugin (no + // /bin/nsolid-plugin shim resolving into the payload) cannot + // claim npm-global ownership by suffix alone. + const root = cliPackageRoot(path.join(home, 'fake-prefix', 'lib', 'node_modules', 'nsolid-plugin'), '90.0.1') + const launcher = cliLauncher(root) + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: launcher, + }) + assert.equal(installation.ownership, 'none') + assert.equal(installation.source.kind, 'unsupported') + assert.equal(installation.version.current, undefined) + }) + + it('skips the positive ownership probe for Volta image launchers in mutating inventory', async () => { + const root = cliPackageRoot(path.join(home, '.volta', 'tools', 'image', 'packages', 'nsolid-plugin', 'lib', 'node_modules', 'nsolid-plugin'), '90.0.1') + const launcher = cliLauncher(root) + let calls = 0 + const installation = await detectCliInstallation({ + commandRunner: { + run: async () => { + calls += 1 + return { exitCode: 0, stdout: path.join(home, '.volta', 'tools', 'image', 'packages', 'nsolid-plugin', 'lib'), stderr: '', timedOut: false, treeTerminated: true } + }, + }, + packageRoot: root, + readOnly: false, + executablePath: launcher, + }) + assert.equal(calls, 0, 'a definitely-unsupported launcher must fail closed before any manager probe') + assert.equal(installation.ownership, 'none') + assert.equal(installation.source.kind, 'unsupported') + assert.equal(installation.version.current, undefined) + }) + + it('proves a real pnpm global symlink into its versioned store without probing', async () => { + const pnpmHome = path.join(home, '.local', 'share', 'pnpm') + const globalVersion = path.join(pnpmHome, 'global', '5') + const storeRoot = cliPackageRoot(path.join(globalVersion, '.pnpm', 'nsolid-plugin@90.0.1', 'node_modules', 'nsolid-plugin'), '90.0.1') + const entrypoint = cliLauncher(storeRoot) + const root = path.join(globalVersion, 'node_modules', 'nsolid-plugin') + mkdirSync(path.dirname(root), { recursive: true }) + symlinkSync(storeRoot, root, 'dir') + const launcher = path.join(pnpmHome, 'nsolid-plugin') + symlinkSync(entrypoint, launcher) + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: launcher, + }) + + assert.equal(installation.ownership, 'global-package') + if (installation.source.kind !== 'global-package') assert.fail(JSON.stringify(installation.source)) + assert.equal(installation.source.packageManager, 'pnpm') + assert.equal(installation.version.current, '90.0.1') + }) + + it('keeps npx cache, Volta image, and mismatched-root launches unsupported without a current version', async () => { + const cases = [ + { + name: 'npx cache', + root: cliPackageRoot(path.join(home, '.npm', '_npx', 'abc123', 'node_modules', 'nsolid-plugin'), '90.0.1'), + }, + { + name: 'volta package image', + root: cliPackageRoot(path.join(home, '.volta', 'tools', 'image', 'packages', 'nsolid-plugin'), '90.0.0'), + }, + ].map((entry) => ({ ...entry, launcher: cliLauncher(entry.root) })) + const mismatchedRoot = cliPackageRoot(path.join(home, 'repo', 'packages', 'core'), '1.0.3') + cases.push({ name: 'launcher outside running root', root: mismatchedRoot, launcher: cliLauncher(path.join(home, 'elsewhere')) }) + + for (const { name, root, launcher } of cases) { + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: launcher, + }) + + assert.equal(installation.source.kind, 'unsupported', name) + assert.equal(installation.ownership, 'none', name) + assert.equal(installation.version.current, undefined, name) + } + }) + + it('still requires the real ownership probe when a mutation is planned', async () => { + const root = cliPackageRoot(path.join(home, 'node-v24', 'lib', 'node_modules', 'nsolid-plugin'), '90.0.1') + const launcher = cliLauncher(root) + let calls = 0 + + const installation = await detectCliInstallation({ + commandRunner: { + run: async () => { + calls++ + return { exitCode: 1, stdout: '', stderr: '', timedOut: false, treeTerminated: true } + }, + }, + packageRoot: root, + readOnly: false, + executablePath: launcher, + }) + + // Structural evidence is check-only: a mutating plan demands the real + // probe, and the failing probe leaves the installation unsupported. + assert.ok(calls > 0) + assert.equal(installation.source.kind, 'unsupported') + assert.equal(installation.version.current, undefined) + }) + + it('recognizes a Windows npm .cmd shim next to the npm prefix as owned npm', async () => { + // npm on Windows installs the .cmd/.ps1 shim in the prefix root itself, + // next to node_modules (no separate bin directory). + const prefix = path.join(home, 'nodejs') + const root = cliPackageRoot(path.join(prefix, 'node_modules', 'nsolid-plugin'), '90.0.1') + cliLauncher(root) + const shim = path.join(prefix, 'nsolid-plugin.cmd') + writeFileSync(shim, '@ECHO off\r\n"%~dp0node_modules\\nsolid-plugin\\dist\\src\\cli.js" %*\r\n') + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: shim, + }) + + assert.equal(installation.ownership, 'global-package') + if (installation.source.kind !== 'global-package') assert.fail(JSON.stringify(installation.source)) + assert.equal(installation.source.packageManager, 'npm') + assert.equal(installation.version.current, '90.0.1') + }) + + it('recognizes a Windows pnpm .cmd shim in the pnpm home as owned pnpm', async () => { + const pnpmHome = path.join(home, '.local', 'share', 'pnpm') + const globalVersion = path.join(pnpmHome, 'global', '5') + const storeRoot = cliPackageRoot(path.join(globalVersion, '.pnpm', 'nsolid-plugin@90.0.1', 'node_modules', 'nsolid-plugin'), '90.0.1') + cliLauncher(storeRoot) + const root = path.join(globalVersion, 'node_modules', 'nsolid-plugin') + mkdirSync(path.dirname(root), { recursive: true }) + symlinkSync(storeRoot, root, 'dir') + const shim = path.join(pnpmHome, 'nsolid-plugin.cmd') + writeFileSync(shim, '@ECHO off\r\n"%~dp0global\\5\\node_modules\\nsolid-plugin\\dist\\src\\cli.js" %*\r\n') + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: shim, + }) + + assert.equal(installation.ownership, 'global-package') + if (installation.source.kind !== 'global-package') assert.fail(JSON.stringify(installation.source)) + assert.equal(installation.source.packageManager, 'pnpm') + assert.equal(installation.version.current, '90.0.1') + }) + + it('keeps a workspace .cmd shim outside any documented global layout unsupported', async () => { + const root = cliPackageRoot(path.join(home, 'workspace', 'lib', 'node_modules', 'nsolid-plugin'), '1.0.3') + cliLauncher(root) + // The shim sits at the workspace root: neither the npm prefix root (next + // to node_modules), the prefix bin directory, nor the pnpm home. + const shim = path.join(home, 'workspace', 'nsolid-plugin.cmd') + writeFileSync(shim, '@ECHO off\r\n"%~dp0lib\\node_modules\\nsolid-plugin\\dist\\src\\cli.js" %*\r\n') + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: shim, + }) + + assert.equal(installation.ownership, 'none') + assert.equal(installation.source.kind, 'unsupported') + assert.equal(installation.version.current, undefined) + }) + + it('recognizes a real Windows npm-global launch through the payload entrypoint', async () => { + // npm on Windows generates the prefix shim, and the shim invokes node with + // the payload entrypoint, so process.argv[1] is + // /node_modules/nsolid-plugin/dist/src/cli.js — not the shim. + const prefix = path.join(home, 'npm-global') + const root = cliPackageRoot(path.join(prefix, 'node_modules', 'nsolid-plugin'), '90.0.1') + const launcher = cliLauncher(root) + writeFileSync(path.join(prefix, 'nsolid-plugin.cmd'), '@ECHO off\r\n"%~dp0node_modules\\nsolid-plugin\\dist\\src\\cli.js" %*\r\n') + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: launcher, + }) + + assert.equal(installation.ownership, 'global-package') + if (installation.source.kind !== 'global-package') assert.fail(JSON.stringify(installation.source)) + assert.equal(installation.source.packageManager, 'npm') + assert.equal(installation.version.current, '90.0.1') + }) + + it('keeps a payload entrypoint without a generated prefix shim unsupported', async () => { + // A node_modules tree containing the payload but no npm-generated shim is + // a project-local install (or a fabricated prefix), never a proven global. + const prefix = path.join(home, 'project', 'node_modules') + const root = cliPackageRoot(path.join(prefix, 'nsolid-plugin'), '1.0.3') + const launcher = cliLauncher(root) + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: launcher, + }) + + assert.equal(installation.ownership, 'none') + assert.equal(installation.source.kind, 'unsupported') + assert.equal(installation.version.current, undefined) + }) + + it('keeps a fabricated prefix whose shim body does not reference the payload unsupported', async () => { + const prefix = path.join(home, 'fabricated') + const root = cliPackageRoot(path.join(prefix, 'node_modules', 'nsolid-plugin'), '1.0.3') + const launcher = cliLauncher(root) + writeFileSync(path.join(prefix, 'nsolid-plugin.cmd'), '@ECHO off\r\necho unrelated\r\n') + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: launcher, + }) + + assert.equal(installation.ownership, 'none') + assert.equal(installation.source.kind, 'unsupported') + }) + + it('keeps a payload-internal launcher that is not the documented bin entrypoint unsupported', async () => { + const prefix = path.join(home, 'npm-global-wrong-entry') + const root = cliPackageRoot(path.join(prefix, 'node_modules', 'nsolid-plugin'), '1.0.3') + const launcher = cliLauncher(root, path.join('dist', 'src', 'other.js')) + writeFileSync(path.join(prefix, 'nsolid-plugin.cmd'), '@ECHO off\r\n"%~dp0node_modules\\nsolid-plugin\\dist\\src\\cli.js" %*\r\n') + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: root, + readOnly: true, + executablePath: launcher, + }) + + assert.equal(installation.ownership, 'none') + assert.equal(installation.source.kind, 'unsupported') + }) + + it('recognizes the resolved pnpm store layout through a pnpm-home sh script', async () => { + // Node resolves the payload module through the global link, so production + // observes the RESOLVED versioned-store path as the package root, and the + // pnpm launcher is a regular sh script at the pnpm home whose body embeds + // that store path. PNPM_HOME is user-configurable and does not need to be + // named "pnpm". + const pnpmHome = path.join(home, 'tooling', 'pnpm-home') + const storeRoot = cliPackageRoot(path.join(pnpmHome, 'global', '5', '.pnpm', 'nsolid-plugin@90.0.1', 'node_modules', 'nsolid-plugin'), '90.0.1') + cliLauncher(storeRoot) + const root = path.join(pnpmHome, 'global', '5', 'node_modules', 'nsolid-plugin') + mkdirSync(path.dirname(root), { recursive: true }) + symlinkSync(storeRoot, root, 'dir') + const shim = path.join(pnpmHome, 'nsolid-plugin') + writeFileSync(shim, `#!/bin/sh\nexec node "${storeRoot}/dist/src/cli.js" "$@"\n`, { mode: 0o755 }) + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: storeRoot, + readOnly: true, + executablePath: shim, + }) + + assert.equal(installation.ownership, 'global-package') + if (installation.source.kind !== 'global-package') assert.fail(JSON.stringify(installation.source)) + assert.equal(installation.source.packageManager, 'pnpm') + assert.equal(installation.version.current, '90.0.1') + }) + + it('rejects a pnpm-home shim whose body references a different payload', async () => { + const pnpmHome = path.join(home, '.local', 'share', 'pnpm') + const storeRoot = cliPackageRoot(path.join(pnpmHome, 'global', '5', '.pnpm', 'nsolid-plugin@90.0.1', 'node_modules', 'nsolid-plugin'), '90.0.1') + cliLauncher(storeRoot) + const root = path.join(pnpmHome, 'global', '5', 'node_modules', 'nsolid-plugin') + mkdirSync(path.dirname(root), { recursive: true }) + symlinkSync(storeRoot, root, 'dir') + const shim = path.join(pnpmHome, 'nsolid-plugin') + writeFileSync(shim, `#!/bin/sh\nexec node "${pnpmHome}/global/3/.pnpm/other-plugin@1.0.0/node_modules/other-plugin/cli.js" "$@"\n`, { mode: 0o755 }) + + const installation = await detectCliInstallation({ + commandRunner: noProbeRunner(), + packageRoot: storeRoot, + readOnly: true, + executablePath: shim, + }) + + assert.equal(installation.ownership, 'none', 'a shim bound to another payload must not prove ownership') + assert.equal(installation.source.kind, 'unsupported') + assert.equal(installation.version.current, undefined) + }) +}) 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..faaf1e4 --- /dev/null +++ b/packages/core/test/unit/update/mcp-edit.test.ts @@ -0,0 +1,199 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { detectJsonMcpKey, editMcpJsonBytes, McpEditError } 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('fails closed instead of replacing scalar, null, or array MCP containers', () => { + for (const scalar of ['null', '[]', '"applied"', '3']) { + const raw = `{\n "keep": true,\n "mcp": ${scalar}\n}\n` + assert.throws( + () => editMcpJsonBytes(raw, { upsertServers: { fresh: { url: 'https://fresh' } } }, { mcpKey: detectJsonMcpKey(raw, 'mcp') }), + (error: unknown) => error instanceof McpEditError && error.code === 'MCP_BLOCK_INVALID' + ) + } + // A scalar preferred key is never overwritten even when a legacy object exists. + const withLegacy = '{\n "mcpServers": {"old": {"url": "https://old"}},\n "mcp": "text"\n}\n' + assert.throws(() => detectJsonMcpKey(withLegacy, 'mcp'), (error: unknown) => error instanceof McpEditError && error.code === 'MCP_BLOCK_INVALID') + }) + + 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('preserves exact document bytes when owned primitive fields already match', () => { + const raw = '{\n "mcpServers": {"s": {"url": "https://x", "n": 3, "b": true, "z": null}}\n}\n' + const fields = { url: 'https://x', n: 3, b: true, z: null } + const result = editMcpJsonBytes(raw, { setFields: Object.entries(fields).map(([field, value]) => ({ server: 's', field, value })) }) + assert.equal(result, raw) + }) +}) 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..b9ac031 --- /dev/null +++ b/packages/core/test/unit/update/mcp-reconciliation.test.ts @@ -0,0 +1,170 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { planMcpReconciliation } from '../../../src/update/mcp-reconciliation.js' +import { valueDigest } from '../../../src/update/mcp-lookup.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, []) + }) + + it('plans no field updates when tracked digests already match the desired value', () => { + const desiredValue = { url: 'https://example.com/mcp', headers: {} } + const plan = planMcpReconciliation({ + previousServers: [{ + name: 'nsolid-console', + configPath: '/configs/a.json', + fields: { url: valueDigest(desiredValue.url), headers: valueDigest(desiredValue.headers) }, + }], + desiredServers: desired, + desiredValues: { 'nsolid-console': desiredValue }, + canonicalConfigPath: '/configs/canonical.json', + }) + assert.equal(plan.kind, 'planned') + if (plan.kind !== 'planned') return + const entry = plan.entries[0] + // Matching digests must not become no-op updates: rendering such an + // update yields bytes identical to the live file, which leaves the + // apply stage without staged bytes. + assert.deepEqual(entry.updateFields, []) + assert.deepEqual(entry.removeFields, []) + assert.deepEqual(entry.ownedFieldDigests.map((field) => field.field), ['url', 'headers']) + }) + + it('plans field updates only for tracked fields whose digests differ from the desired value', () => { + const plan = planMcpReconciliation({ + previousServers: [{ + name: 'nsolid-console', + configPath: '/configs/a.json', + fields: { url: valueDigest('https://old'), headers: valueDigest({ token: 'old' }) }, + }], + desiredServers: desired, + desiredValues: { 'nsolid-console': { url: 'https://example.com/mcp', headers: { token: 'old' } } }, + canonicalConfigPath: '/configs/canonical.json', + }) + assert.equal(plan.kind, 'planned') + if (plan.kind !== 'planned') return + const entry = plan.entries[0] + assert.deepEqual(entry.updateFields, [{ server: 'nsolid-console', field: 'url', value: 'https://example.com/mcp' }]) + }) +}) 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..889fe4b --- /dev/null +++ b/packages/core/test/unit/update/native-payload.test.ts @@ -0,0 +1,565 @@ +import { makeTar, writeOctal } from '../../helpers/tar.js' +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { gzipSync } from 'node:zlib' +import { gitArchivePayloadDigest, isWindowsDeviceNamespaceTarget, nativePayloadTreeDigest, plannedPayloadIdentityFromArchive, plannedPayloadIdentityFromTree } from '../../../src/update/native-payload.js' + +const CODEX_PROFILE = 'codex-installed-v1' as const + +function materializePayload (root: string, files: Map): void { + for (const [relative, content] of files) { + const target = path.join(root, relative) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, content) + } +} + +function cleanPayloadFiles (): Map { + return new Map([ + ['bundle.json', Buffer.from('{"name":"nsolid-plugin","version":"1.0.2","skills":[]}')], + ['skills/example/SKILL.md', Buffer.from('# example\n')], + ]) +} + +describe('native payload identity', () => { + // Golden digests for the FIXED fixture below, computed INDEPENDENTLY from + // the code under test by reimplementing the documented canonical byte + // layout (entries sorted by relative name; per entry: kind NUL name NUL + // [file: byteLength NUL content | symlink: target] NUL) with a one-off + // script using node:crypto only. Any shared canonicalization or mapping + // bug in the digest APIs cannot keep these assertions green. + // bundle.json = '{"name":"nsolid-plugin","version":"1.0.2","skills":[]}' + // skills/example/SKILL.md = '# example\n' + // .codex-marketplace-install.json = '{"source":"marketplace"}\n' (variant) + const GOLDEN_STRICT_CLEAN = '08b5781a7f8756f79abb4aed0aad420bdef38e76f15ccd6bb0fab0b37fef4c46' + const GOLDEN_STRICT_WITH_METADATA = '7e70208218edf8277c2f2f039f22f0082aac8af8f7925e52f63f556779b4aaaa' + + it('matches independently computed golden digests for the strict and codex-installed-v1 identities', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-golden-')) + try { + const files = cleanPayloadFiles() + materializePayload(root, files) + assert.equal(nativePayloadTreeDigest(root), GOLDEN_STRICT_CLEAN, 'strict tree digest must match the golden bytes') + assert.equal(nativePayloadTreeDigest(root, { profile: CODEX_PROFILE }), GOLDEN_STRICT_CLEAN, 'a clean payload has no normalizable entries') + assert.equal(gitArchivePayloadDigest(gzipSync(makeTar(files))), GOLDEN_STRICT_CLEAN, 'strict archive digest must match the same golden bytes') + assert.equal(gitArchivePayloadDigest(gzipSync(makeTar(files)), {}, { profile: CODEX_PROFILE }), GOLDEN_STRICT_CLEAN) + + // Same payload plus the harness metadata file: strict differs, the + // comparison identity still equals the clean golden (only the proven + // harness entry is normalized). + const withMetadata = new Map(files) + withMetadata.set('.codex-marketplace-install.json', Buffer.from('{"source":"marketplace"}\n')) + materializePayload(root, withMetadata) + assert.equal(nativePayloadTreeDigest(root), GOLDEN_STRICT_WITH_METADATA, 'strict evidence must remain sensitive to every extra entry') + assert.equal(nativePayloadTreeDigest(root, { profile: CODEX_PROFILE }), GOLDEN_STRICT_CLEAN, 'only the proven harness entry is normalized') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + 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('compares a clean Git-archive payload equal to the installed payload plus Codex harness metadata under the codex profile', () => { + const clean = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-clean-')) + const installed = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-installed-')) + try { + materializePayload(clean, cleanPayloadFiles()) + materializePayload(installed, cleanPayloadFiles()) + // Exact proven harness metadata written by a real Codex marketplace install. + writeFileSync(path.join(installed, '.codex-marketplace-install.json'), '{"source":"marketplace"}\n') + const archive = gzipSync(makeTar(cleanPayloadFiles())) + + assert.equal( + gitArchivePayloadDigest(archive, {}, { profile: CODEX_PROFILE }), + nativePayloadTreeDigest(installed, { profile: CODEX_PROFILE }) + ) + } finally { + rmSync(clean, { recursive: true, force: true }) + rmSync(installed, { recursive: true, force: true }) + } + }) + + it('keeps strict source evidence distinct from the normalized comparison digest', () => { + const clean = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-strict-clean-')) + const installed = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-strict-installed-')) + try { + materializePayload(clean, cleanPayloadFiles()) + materializePayload(installed, cleanPayloadFiles()) + writeFileSync(path.join(installed, '.codex-marketplace-install.json'), '{"source":"marketplace"}\n') + + // Strict evidence: harness metadata is significant. + const strictClean = nativePayloadTreeDigest(clean) + const strictInstalled = nativePayloadTreeDigest(installed) + assert.ok(strictClean && strictInstalled) + assert.notEqual(strictInstalled, strictClean) + + // Normalized comparison: harness metadata is ignored. + const profileClean = nativePayloadTreeDigest(clean, { profile: CODEX_PROFILE }) + const profileInstalled = nativePayloadTreeDigest(installed, { profile: CODEX_PROFILE }) + assert.ok(profileClean && profileInstalled) + assert.equal(profileInstalled, profileClean) + + // The two identities remain distinct values on the same bytes. + assert.notEqual(profileInstalled, strictInstalled) + } finally { + rmSync(clean, { recursive: true, force: true }) + rmSync(installed, { recursive: true, force: true }) + } + }) + + it('keeps nested .git and unapproved dotfiles significant under the codex profile', () => { + const clean = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-nested-')) + try { + materializePayload(clean, cleanPayloadFiles()) + const baseline = nativePayloadTreeDigest(clean, { profile: CODEX_PROFILE }) + assert.ok(baseline) + + const significant: [string, string][] = [ + ['.DS_Store', 'junk'], + ['.gitignore', 'node_modules\n'], + ['.gitattributes', '* text=auto\n'], + ['.gitmodules', '[submodule "x"]\n'], + ['nested/.git/HEAD', 'ref: refs/heads/main\n'], + ['skills/.git/config', '[core]\n'], + ['skills/example/extra.js', 'module.exports = 1\n'], + ] + for (const [relative, content] of significant) { + const mutated = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-mutated-')) + try { + materializePayload(mutated, cleanPayloadFiles()) + materializePayload(mutated, new Map([[relative, Buffer.from(content)]])) + assert.notEqual( + nativePayloadTreeDigest(mutated, { profile: CODEX_PROFILE }), baseline, + `expected ${relative} to stay significant under the codex profile` + ) + } finally { + rmSync(mutated, { recursive: true, force: true }) + } + } + } finally { + rmSync(clean, { recursive: true, force: true }) + } + }) + + it('rejects tampered payload bytes under the codex comparison profile', () => { + const clean = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-tamper-')) + try { + materializePayload(clean, cleanPayloadFiles()) + const baseline = nativePayloadTreeDigest(clean, { profile: CODEX_PROFILE }) + assert.ok(baseline) + + // A modified installable file changes the normalized digest. + writeFileSync(path.join(clean, 'bundle.json'), '{"name":"nsolid-plugin","version":"9.9.9","skills":[]}') + const tamperedManifest = nativePayloadTreeDigest(clean, { profile: CODEX_PROFILE }) + assert.ok(tamperedManifest) + assert.notEqual(tamperedManifest, baseline) + + // Restoring the manifest but tampering the skill bytes also changes it. + writeFileSync(path.join(clean, 'bundle.json'), '{"name":"nsolid-plugin","version":"1.0.2","skills":[]}') + writeFileSync(path.join(clean, 'skills/example/SKILL.md'), '# substituted\n') + const tamperedSkill = nativePayloadTreeDigest(clean, { profile: CODEX_PROFILE }) + assert.ok(tamperedSkill) + assert.notEqual(tamperedSkill, baseline) + } finally { + rmSync(clean, { recursive: true, force: true }) + } + }) + + it('keeps root .git excluded but rejects nested symlinks in an installed tree', (t) => { + const clean = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-symlink-')) + try { + materializePayload(clean, cleanPayloadFiles()) + const baseline = nativePayloadTreeDigest(clean, { profile: CODEX_PROFILE }) + assert.ok(baseline) + + // Root .git stays excluded on the installed side, as today. + mkdirSync(path.join(clean, '.git')) + writeFileSync(path.join(clean, '.git', 'HEAD'), 'ref: refs/heads/main\n') + assert.equal(nativePayloadTreeDigest(clean, { profile: CODEX_PROFILE }), baseline) + + // A nested redirect makes the entire installed identity unprovable. + rmSync(path.join(clean, '.git'), { recursive: true, force: true }) + try { + symlinkSync('../shared/asset.bin', path.join(clean, 'skills/example/asset.bin')) + } catch (error) { + if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') { + t.skip('file symlink creation requires Windows developer mode or elevation') + return + } + throw error + } + assert.equal(nativePayloadTreeDigest(clean), undefined) + assert.equal(nativePayloadTreeDigest(clean, { profile: CODEX_PROFILE }), undefined) + assert.deepEqual(plannedPayloadIdentityFromTree(clean, CODEX_PROFILE), {}) + } finally { + rmSync(clean, { recursive: true, force: true }) + } + }) + + it('rejects a symlink used as the installed payload root without reading its referent', () => { + const fixture = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-root-symlink-')) + try { + const real = path.join(fixture, 'real') + mkdirSync(real) + materializePayload(real, cleanPayloadFiles()) + const link = path.join(fixture, 'payload-link') + symlinkSync(real, link, process.platform === 'win32' ? 'junction' : 'dir') + + assert.equal(nativePayloadTreeDigest(link), undefined) + assert.equal(nativePayloadTreeDigest(link, { profile: CODEX_PROFILE }), undefined) + assert.deepEqual(plannedPayloadIdentityFromTree(link, CODEX_PROFILE), {}) + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + }) + + it('normalizes Windows symlink target separators without collapsing device-prefixed forms', { skip: process.platform !== 'win32' }, () => { + const digestWithTarget = (target: string): string | undefined => + gitArchivePayloadDigest(gzipSync(makeTarWithSymlink(cleanPayloadFiles(), 'skills/example/asset.bin', target))) + // Forward- and backslash-separated forms resolve identically on Windows. + assert.equal(digestWithTarget('../shared/asset.bin'), digestWithTarget('..\\shared\\asset.bin')) + // Same for absolute UNC paths outside the device namespace. + assert.equal(digestWithTarget('\\\\server\\share\\asset.bin'), digestWithTarget('//server/share/asset.bin')) + // Genuinely different destinations stay distinct. (Absolute device-namespace + // spellings are intentionally not compared here: Windows readlink + // canonicalizes absolute targets to NT form on read, so distinct spellings + // of the same destination digest identically — which is correct. The + // device-namespace classification itself is pinned by the cross-platform + // unit test below.) + assert.notEqual(digestWithTarget('C:\\payload\\asset.bin'), digestWithTarget('C:\\payload\\other.bin')) + }) + + it('classifies Windows device-namespace symlink targets for verbatim digesting (cross-platform)', () => { + for (const device of ['\\\\?\\C:\\x', '//?/C:/x', '\\\\.\\COM1', '\\??\\C:\\x', '\\\\?\\UNC\\s\\s']) { + assert.equal(isWindowsDeviceNamespaceTarget(device), true, device) + } + for (const ordinary of ['..\\a\\b', '../a/b', 'C:\\a\\b', '\\\\server\\share\\x', 'asset.bin']) { + assert.equal(isWindowsDeviceNamespaceTarget(ordinary), false, ordinary) + } + }) + + it('rejects a reserved-name symlink on the installed side', (t) => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-reserved-link-')) + try { + materializePayload(root, cleanPayloadFiles()) + const reserved = path.join(root, '.codex-marketplace-install.json') + try { + symlinkSync('../../shared/meta.json', reserved, 'file') + } catch (error) { + if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') { + t.skip('file symlink creation requires Windows developer mode or elevation') + return + } + throw error + } + assert.equal(nativePayloadTreeDigest(root, { profile: CODEX_PROFILE }), undefined) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps reserved-name directories significant on the installed side', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-reserved-directory-')) + try { + materializePayload(root, cleanPayloadFiles()) + const baseline = nativePayloadTreeDigest(root, { profile: CODEX_PROFILE }) + assert.ok(baseline) + + // A root regular file at the reserved name is the only normalized entry. + const reserved = path.join(root, '.codex-marketplace-install.json') + writeFileSync(reserved, '{"source":"marketplace"}\n') + assert.equal(nativePayloadTreeDigest(root, { profile: CODEX_PROFILE }), baseline) + + // A directory at the reserved name stays significant. + rmSync(reserved) + mkdirSync(reserved) + writeFileSync(path.join(reserved, 'nested.txt'), 'payload-ish\n') + const withDirectory = nativePayloadTreeDigest(root, { profile: CODEX_PROFILE }) + assert.ok(withDirectory) + assert.notEqual(withDirectory, baseline) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps an empty reserved-name directory significant on the installed side', () => { + const clean = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-empty-dir-clean-')) + const installed = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-empty-dir-installed-')) + try { + materializePayload(clean, cleanPayloadFiles()) + materializePayload(installed, cleanPayloadFiles()) + mkdirSync(path.join(installed, '.codex-marketplace-install.json')) + + const cleanDigest = nativePayloadTreeDigest(clean, { profile: CODEX_PROFILE }) + const installedDigest = nativePayloadTreeDigest(installed, { profile: CODEX_PROFILE }) + assert.ok(cleanDigest && installedDigest) + assert.notEqual(installedDigest, cleanDigest) + } finally { + rmSync(clean, { recursive: true, force: true }) + rmSync(installed, { recursive: true, force: true }) + } + }) + + it('fails closed when a planned tree contains only an empty reserved-name directory', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-empty-dir-plan-')) + try { + mkdirSync(path.join(root, '.codex-marketplace-install.json')) + const identity = plannedPayloadIdentityFromTree(root, CODEX_PROFILE) + assert.ok(identity.contentDigest) + assert.equal(identity.comparisonDigest, undefined) + assert.equal(identity.comparisonProfile, undefined) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('captures an empty reserved-name directory in Git archive identities', () => { + const files = cleanPayloadFiles() + const cleanArchive = gzipSync(makeTar(files)) + const reservedArchive = gzipSync(makeTarWithDirectory(files, '.codex-marketplace-install.json')) + + // Strict evidence records the reserved directory marker. + assert.notEqual(gitArchivePayloadDigest(reservedArchive), gitArchivePayloadDigest(cleanArchive)) + // Plan-side normalization rejects the collision rather than hiding it. + assert.equal(gitArchivePayloadDigest(reservedArchive, {}, { profile: CODEX_PROFILE }), undefined) + const identity = plannedPayloadIdentityFromArchive(reservedArchive, {}, CODEX_PROFILE) + assert.ok(identity.contentDigest) + assert.equal(identity.comparisonDigest, undefined) + assert.equal(identity.comparisonProfile, undefined) + }) + + it('does not record empty non-reserved root directories in strict identities', () => { + const clean = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-non-reserved-clean-')) + const withDirectory = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-non-reserved-dir-')) + try { + materializePayload(clean, cleanPayloadFiles()) + materializePayload(withDirectory, cleanPayloadFiles()) + mkdirSync(path.join(withDirectory, 'empty-directory')) + assert.equal(nativePayloadTreeDigest(withDirectory), nativePayloadTreeDigest(clean)) + + const cleanArchive = gzipSync(makeTar(cleanPayloadFiles())) + const directoryArchive = gzipSync(makeTarWithDirectory(cleanPayloadFiles(), 'empty-directory')) + assert.equal(gitArchivePayloadDigest(directoryArchive), gitArchivePayloadDigest(cleanArchive)) + } finally { + rmSync(clean, { recursive: true, force: true }) + rmSync(withDirectory, { recursive: true, force: true }) + } + }) + + it('fails closed on the planning side when the planned source already contains reserved harness paths', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-reserved-plan-')) + try { + // Archive with a root regular file at the reserved name: no comparison digest. + const withReserved = new Map([...cleanPayloadFiles(), ['.codex-marketplace-install.json', Buffer.from('{"source":"marketplace"}\n')]]) + const reservedArchive = gzipSync(makeTar(withReserved)) + assert.equal(gitArchivePayloadDigest(reservedArchive, {}, { profile: CODEX_PROFILE }), undefined) + // The strict digest of the same planned archive stays defined and significant. + assert.ok(gitArchivePayloadDigest(reservedArchive)) + + // Content nested under a reserved directory name: also fail closed. + const nestedReserved = new Map([...cleanPayloadFiles(), ['.codex-marketplace-install.json/extra.txt', Buffer.from('payload\n')]]) + assert.equal(gitArchivePayloadDigest(gzipSync(makeTar(nestedReserved)), {}, { profile: CODEX_PROFILE }), undefined) + + // A symlink at the reserved name in the planned archive: fail closed too. + const symlinkArchive = gzipSync(makeTarWithSymlink(cleanPayloadFiles(), '.codex-marketplace-install.json', '../shared/meta')) + assert.equal(gitArchivePayloadDigest(symlinkArchive, {}, { profile: CODEX_PROFILE }), undefined) + + // Same policy for planned local snapshots via the identity helpers. + materializePayload(root, cleanPayloadFiles()) + writeFileSync(path.join(root, '.codex-marketplace-install.json'), '{"source":"marketplace"}\n') + const identity = plannedPayloadIdentityFromTree(root, CODEX_PROFILE) + assert.ok(identity.contentDigest) + assert.equal(identity.comparisonDigest, undefined) + assert.equal(identity.comparisonProfile, undefined) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects unknown normalization profiles instead of silently digesting strict', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-unknown-profile-')) + try { + materializePayload(root, cleanPayloadFiles()) + writeFileSync(path.join(root, '.codex-marketplace-install.json'), '{"source":"marketplace"}\n') + const unknownProfile = 'codex-installed-v2-future' as never + assert.equal(nativePayloadTreeDigest(root, { profile: unknownProfile }), undefined) + assert.equal(gitArchivePayloadDigest(gzipSync(makeTar(cleanPayloadFiles())), {}, { profile: unknownProfile }), undefined) + assert.deepEqual(plannedPayloadIdentityFromTree(root, unknownProfile), {}) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('derives planned strict and comparison identities from one payload capture', () => { + const clean = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-identity-clean-')) + const installed = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-identity-installed-')) + try { + materializePayload(clean, cleanPayloadFiles()) + materializePayload(installed, cleanPayloadFiles()) + writeFileSync(path.join(installed, '.codex-marketplace-install.json'), '{"source":"marketplace"}\n') + const archive = gzipSync(makeTar(cleanPayloadFiles())) + + const treeIdentity = plannedPayloadIdentityFromTree(clean, CODEX_PROFILE) + assert.equal(treeIdentity.comparisonProfile, CODEX_PROFILE) + assert.equal(treeIdentity.contentDigest, nativePayloadTreeDigest(clean)) + // The comparison identity of the clean plan equals the installed-side + // digest of a faithful install carrying only the harness metadata file. + assert.equal(treeIdentity.comparisonDigest, nativePayloadTreeDigest(installed, { profile: CODEX_PROFILE })) + + const archiveIdentity = plannedPayloadIdentityFromArchive(archive, {}, CODEX_PROFILE) + assert.equal(archiveIdentity.comparisonProfile, CODEX_PROFILE) + assert.equal(archiveIdentity.contentDigest, gitArchivePayloadDigest(archive)) + // Both planning media produce the same identities for the same bytes. + assert.equal(archiveIdentity.comparisonDigest, treeIdentity.comparisonDigest) + assert.equal(archiveIdentity.contentDigest, treeIdentity.contentDigest) + } finally { + rmSync(clean, { recursive: true, force: true }) + rmSync(installed, { recursive: true, force: true }) + } + }) + + it('keeps default tree digest behavior strict when no profile is requested', () => { + const clean = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-default-')) + const installed = mkdtempSync(path.join(os.tmpdir(), 'nsolid-native-f4-default-installed-')) + try { + materializePayload(clean, cleanPayloadFiles()) + materializePayload(installed, cleanPayloadFiles()) + writeFileSync(path.join(installed, '.codex-marketplace-install.json'), '{"source":"marketplace"}\n') + + // Without a profile the harness metadata stays significant. + assert.notEqual(nativePayloadTreeDigest(installed), nativePayloadTreeDigest(clean)) + // And the archive-side digest remains strict by default too. + const archiveWithMetadata = gzipSync(makeTar(new Map([ + ...cleanPayloadFiles(), + ['.codex-marketplace-install.json', Buffer.from('{"source":"marketplace"}\n')], + ]))) + assert.notEqual(gitArchivePayloadDigest(archiveWithMetadata), nativePayloadTreeDigest(clean)) + } finally { + rmSync(clean, { recursive: true, force: true }) + rmSync(installed, { 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 }) + } + }) +}) + +/** Tar combining regular files with one empty directory entry (GNU type '5'). */ +function makeTarWithDirectory (files: Map, directoryPath: string): Buffer { + const regularTar = makeTar(files) + const directoryHeader = Buffer.alloc(512) + directoryHeader.write(`repository-commit/${directoryPath.replace(/\/$/, '')}/`, 0, 100, 'utf8') + writeOctal(directoryHeader, 100, 8, 0o755) + writeOctal(directoryHeader, 108, 8, 0) + writeOctal(directoryHeader, 116, 8, 0) + writeOctal(directoryHeader, 124, 12, 0) + writeOctal(directoryHeader, 136, 12, 0) + directoryHeader.fill(0x20, 148, 156) + directoryHeader[156] = '5'.charCodeAt(0) + directoryHeader.write('ustar\0', 257, 6, 'ascii') + directoryHeader.write('00', 263, 2, 'ascii') + const checksum = directoryHeader.reduce((sum, byte) => sum + byte, 0) + directoryHeader.write(checksum.toString(8).padStart(6, '0'), 148, 6, 'ascii') + directoryHeader[154] = 0 + directoryHeader[155] = 0x20 + // Replace the regular tar's two terminating zero blocks with the directory + // header followed by a fresh terminator. + return Buffer.concat([regularTar.subarray(0, regularTar.length - 1024), directoryHeader, Buffer.alloc(1024)]) +} + +/** Tar combining regular files with one symlink entry (GNU type '2'). */ +function makeTarWithSymlink (files: Map, symlinkPath: string, symlinkTarget: string): Buffer { + const regularTar = makeTar(files) + const linkHeader = Buffer.alloc(512) + linkHeader.write(`repository-commit/${symlinkPath}`, 0, 100, 'utf8') + writeOctal(linkHeader, 100, 8, 0o777) + writeOctal(linkHeader, 108, 8, 0) + writeOctal(linkHeader, 116, 8, 0) + writeOctal(linkHeader, 124, 12, symlinkTarget.length) + writeOctal(linkHeader, 136, 12, 0) + linkHeader.fill(0x20, 148, 156) + linkHeader[156] = '2'.charCodeAt(0) + linkHeader.write('ustar\0', 257, 6, 'ascii') + linkHeader.write('00', 263, 2, 'ascii') + linkHeader.write(symlinkTarget, 157, 100, 'utf8') + const checksum = linkHeader.reduce((sum, byte) => sum + byte, 0) + linkHeader.write(checksum.toString(8).padStart(6, '0'), 148, 6, 'ascii') + linkHeader[154] = 0 + linkHeader[155] = 0x20 + return Buffer.concat([regularTar.subarray(0, regularTar.length - 1024), linkHeader, Buffer.alloc(1024)]) +} diff --git a/packages/core/test/unit/update/owned-fs.test.ts b/packages/core/test/unit/update/owned-fs.test.ts new file mode 100644 index 0000000..e6e0a13 --- /dev/null +++ b/packages/core/test/unit/update/owned-fs.test.ts @@ -0,0 +1,561 @@ +import { describe, it, beforeEach, afterEach, type TestContext } from 'node:test' +import assert from 'node:assert/strict' +import { open as fsOpen } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { + assertNoSymlinksInTree, + copyOwnedPath, + ownedFileDigest, + ownedPathKind, + ownedTreeDigest, + OwnedFsError, + removeOwnedPath, + writeOwnedFile, +} from '../../../src/update/owned-fs.js' + +describe('owned-fs primitives', () => { + let root: string + + beforeEach(() => { + root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-owned-fs-')) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + const filePath = () => path.join(root, 'file.txt') + const dirPath = () => path.join(root, 'tree') + + const makeFile = (p: string = filePath(), body = 'content'): string => { + writeFileSync(p, body) + return p + } + const makeDir = (p: string = dirPath()): string => { + mkdirSync(p, { recursive: true }) + return p + } + const makeFileLink = (t: TestContext, target: string, link: string): boolean => { + try { + symlinkSync(target, link, 'file') + return true + } catch (error) { + if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') { + t.skip('file symlink privilege is unavailable') + return false + } + throw error + } + } + const makeDirectoryLink = (target: string, link: string): void => { + symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir') + } + + describe('ownedPathKind', () => { + it('classifies file, directory, symlinks, other, and missing without dereferencing', async (t) => { + makeFile() + makeDir() + const linkToFile = path.join(root, 'link-to-file') + if (!makeFileLink(t, filePath(), linkToFile)) return + const linkToDir = path.join(root, 'link-to-dir') + makeDirectoryLink(dirPath(), linkToDir) + const dangling = path.join(root, 'dangling') + if (!makeFileLink(t, path.join(root, 'nope'), dangling)) return + + assert.equal(await ownedPathKind(filePath()), 'file') + assert.equal(await ownedPathKind(dirPath()), 'directory') + assert.equal(await ownedPathKind(linkToFile), 'junction-or-symlink') + assert.equal(await ownedPathKind(linkToDir), 'junction-or-symlink') + assert.equal(await ownedPathKind(dangling), 'junction-or-symlink') + assert.equal(await ownedPathKind(path.join(root, 'absent')), 'missing') + }) + + it('never dereferences a symlink when classifying', async (t) => { + makeFile(filePath(), 'content') + const link = path.join(root, 'link') + if (!makeFileLink(t, filePath(), link)) return + // Removing the referent leaves the link; classification must not fail. + rmSync(filePath()) + assert.equal(await ownedPathKind(link), 'junction-or-symlink') + }) + }) + + describe('ownedFileDigest', () => { + it('digests a regular file and returns null for every other kind', async (t) => { + makeFile(filePath(), 'abc') + const digest = await ownedFileDigest(filePath()) + assert.equal(digest, createHash('sha256').update('abc').digest('hex')) + + makeDir() + assert.equal(await ownedFileDigest(dirPath()), null) + assert.equal(await ownedFileDigest(path.join(root, 'absent')), null) + const link = path.join(root, 'link') + if (!makeFileLink(t, filePath(), link)) return + // A symlink is not a regular file: null, never the referent digest. + assert.equal(await ownedFileDigest(link), null) + }) + + it('changes when one byte changes', async () => { + makeFile(filePath(), 'abc') + const before = await ownedFileDigest(filePath()) + makeFile(filePath(), 'abd') + assert.notEqual(await ownedFileDigest(filePath()), before) + }) + }) + + describe('ownedTreeDigest', () => { + it('is null for missing paths and non-directories', async () => { + assert.equal(await ownedTreeDigest(path.join(root, 'absent')), null) + makeFile() + assert.equal(await ownedTreeDigest(filePath()), null) + }) + + it('is stable across identical trees and independent of creation order', async () => { + const first = makeDir(path.join(root, 'first')) + writeFileSync(path.join(first, 'a.txt'), 'alpha') + writeFileSync(path.join(first, 'b.txt'), 'beta') + mkdirSync(path.join(first, 'nested')) + writeFileSync(path.join(first, 'nested', 'c.txt'), 'gamma') + + const second = makeDir(path.join(root, 'second')) + // Create in a different order. + mkdirSync(path.join(second, 'nested')) + writeFileSync(path.join(second, 'nested', 'c.txt'), 'gamma') + writeFileSync(path.join(second, 'b.txt'), 'beta') + writeFileSync(path.join(second, 'a.txt'), 'alpha') + + assert.equal(await ownedTreeDigest(first), await ownedTreeDigest(second)) + assert.ok(await ownedTreeDigest(first)) + }) + + it('gives an empty directory a deterministic non-null digest', async () => { + const empty = makeDir() + const digest = await ownedTreeDigest(empty) + assert.ok(digest) + const otherEmpty = makeDir(path.join(root, 'other-empty')) + assert.equal(await ownedTreeDigest(otherEmpty), digest) + }) + + it('differs when one byte changes anywhere in the tree', async () => { + const tree = makeDir() + writeFileSync(path.join(tree, 'a.txt'), 'alpha') + mkdirSync(path.join(tree, 'nested')) + writeFileSync(path.join(tree, 'nested', 'b.txt'), 'beta') + const before = await ownedTreeDigest(tree) + writeFileSync(path.join(tree, 'nested', 'b.txt'), 'beta!') + assert.notEqual(await ownedTreeDigest(tree), before) + }) + + it('rejects a nested symlink with SYMLINK_IN_TREE and the relative path', async (t) => { + const tree = makeDir() + makeFile(path.join(tree, 'real.txt')) + const nested = path.join(tree, 'nested') + mkdirSync(nested) + if (!makeFileLink(t, path.join(tree, 'real.txt'), path.join(nested, 'link'))) return + await assert.rejects( + ownedTreeDigest(tree), + (error: unknown) => error instanceof OwnedFsError && error.code === 'SYMLINK_IN_TREE' && error.relativePath === 'nested/link' + ) + }) + + it('rejects a symlink at the root of a nested walk and stays silent-free (no swallow)', async () => { + const tree = makeDir() + const link = path.join(tree, 'dir-link') + makeDirectoryLink(makeDir(path.join(root, 'target-dir')), link) + await assert.rejects( + ownedTreeDigest(tree), + (error: unknown) => error instanceof OwnedFsError && error.code === 'SYMLINK_IN_TREE' && error.relativePath === 'dir-link' + ) + }) + + it('rejects a symlinked root instead of returning null', async () => { + const real = makeDir() + makeFile(path.join(real, 'a.txt')) + const link = path.join(root, 'root-link') + makeDirectoryLink(real, link) + await assert.rejects( + ownedTreeDigest(link), + (error: unknown) => error instanceof OwnedFsError && error.code === 'SYMLINK_IN_TREE' && error.relativePath === '.' + ) + }) + + it('distinguishes an empty subdirectory from its absence', async () => { + const withEmpty = makeDir(path.join(root, 'with-empty')) + makeFile(path.join(withEmpty, 'a.txt'), 'alpha') + mkdirSync(path.join(withEmpty, 'empty')) + + const without = makeDir(path.join(root, 'without')) + makeFile(path.join(without, 'a.txt'), 'alpha') + + assert.notEqual(await ownedTreeDigest(withEmpty), await ownedTreeDigest(without)) + // A whole empty root keeps its deterministic non-null digest. + const firstEmpty = await ownedTreeDigest(makeDir(path.join(root, 'bare'))) + assert.ok(firstEmpty) + assert.equal(await ownedTreeDigest(makeDir(path.join(root, 'bare-2'))), firstEmpty) + }) + }) + + describe('assertNoSymlinksInTree', () => { + it('accepts a symlink-free tree', async () => { + const tree = makeDir() + makeFile(path.join(tree, 'a.txt')) + mkdirSync(path.join(tree, 'nested')) + makeFile(path.join(tree, 'nested', 'b.txt'), 'beta') + await assert.doesNotReject(assertNoSymlinksInTree(tree)) + }) + + it('throws SYMLINK_IN_TREE with a POSIX relative path for the first sorted nested symlink', async () => { + const tree = makeDir() + mkdirSync(path.join(tree, 'a-dir')) + mkdirSync(path.join(tree, 'b-dir')) + makeDirectoryLink(dirPath(), path.join(tree, 'b-dir', 'link')) + makeDirectoryLink(dirPath(), path.join(tree, 'a-dir', 'link')) + try { + await assertNoSymlinksInTree(tree) + assert.fail('expected rejection') + } catch (error) { + assert.ok(error instanceof OwnedFsError) + assert.equal(error.code, 'SYMLINK_IN_TREE') + assert.equal(error.relativePath, 'a-dir/link') + } + }) + + it('reports the root itself with relativePath "." when the root is a symlink', async () => { + const real = makeDir() + const link = path.join(root, 'root-link') + makeDirectoryLink(real, link) + try { + await assertNoSymlinksInTree(link) + assert.fail('expected rejection') + } catch (error) { + assert.ok(error instanceof OwnedFsError) + assert.equal(error.code, 'SYMLINK_IN_TREE') + assert.equal(error.relativePath, '.') + } + }) + + it('rejects a missing root and a non-directory root with OwnedFsError', async () => { + await assert.rejects( + assertNoSymlinksInTree(path.join(root, 'absent')), + (error: unknown) => error instanceof OwnedFsError && error.code === 'KIND_CHANGED' + ) + makeFile() + await assert.rejects( + assertNoSymlinksInTree(filePath()), + (error: unknown) => error instanceof OwnedFsError && error.code === 'KIND_CHANGED' + ) + }) + }) + + describe('copyOwnedPath', () => { + it('copies a regular file and verifies the kind', async () => { + makeFile(filePath(), 'abc') + const destination = path.join(root, 'copy.txt') + assert.equal(await copyOwnedPath(filePath(), destination), 'file') + assert.equal(readFileSync(destination, 'utf8'), 'abc') + }) + + it('copies a symlink-free directory recursively', async () => { + const tree = makeDir() + makeFile(path.join(tree, 'a.txt'), 'alpha') + mkdirSync(path.join(tree, 'nested')) + makeFile(path.join(tree, 'nested', 'b.txt'), 'beta') + const destination = path.join(root, 'copy-tree') + assert.equal(await copyOwnedPath(tree, destination), 'directory') + assert.equal(readFileSync(path.join(destination, 'nested', 'b.txt'), 'utf8'), 'beta') + }) + + it('rejects a symlink source itself', async (t) => { + makeFile() + const link = path.join(root, 'link') + if (!makeFileLink(t, filePath(), link)) return + await assert.rejects( + copyOwnedPath(link, path.join(root, 'copy')), + (error: unknown) => error instanceof OwnedFsError && error.code === 'SYMLINK_IN_TREE' + ) + }) + + it('rejects a directory tree containing any nested symlink', async (t) => { + const tree = makeDir() + makeFile(path.join(tree, 'a.txt')) + const destination = path.join(root, 'copy-tree') + if (!makeFileLink(t, filePath(), path.join(tree, 'link'))) return + await assert.rejects( + copyOwnedPath(tree, destination), + (error: unknown) => error instanceof OwnedFsError && error.code === 'SYMLINK_IN_TREE' + ) + assert.equal(existsSync(destination), false, 'nothing may be created when the guard fires') + }) + + it('rejects a missing source', async () => { + await assert.rejects(copyOwnedPath(path.join(root, 'absent'), path.join(root, 'copy'))) + }) + }) + + describe('removeOwnedPath', () => { + it('removes files and directories and is idempotent on missing paths', async () => { + makeFile() + makeDir() + const tree = dirPath() + makeFile(path.join(tree, 'child.txt')) + await removeOwnedPath(filePath()) + await removeOwnedPath(tree) + assert.equal(existsSync(filePath()), false) + assert.equal(existsSync(tree), false) + await assert.doesNotReject(removeOwnedPath(filePath())) + }) + + it('honours expectedKind and reports a kind change', async () => { + makeFile() + await assert.rejects(removeOwnedPath(filePath(), 'directory')) + await assert.doesNotReject(removeOwnedPath(filePath(), 'file')) + }) + + it('removes a file symlink without modifying its external referent', async (t) => { + const referent = makeFile(path.join(root, 'external.txt'), 'external') + const link = path.join(root, 'owned-file-link') + if (!makeFileLink(t, referent, link)) return + + await removeOwnedPath(link, 'junction-or-symlink') + + assert.equal(existsSync(link), false) + assert.equal(readFileSync(referent, 'utf8'), 'external') + }) + + it('removes a directory symlink or junction without modifying its external referent', async () => { + const referent = makeDir(path.join(root, 'external-dir')) + makeFile(path.join(referent, 'keep.txt'), 'external') + const link = path.join(root, 'owned-dir-link') + makeDirectoryLink(referent, link) + + await removeOwnedPath(link, 'junction-or-symlink') + + assert.equal(existsSync(link), false) + assert.equal(readFileSync(path.join(referent, 'keep.txt'), 'utf8'), 'external') + }) + + it('removes an owned directory containing a nested link without following it', async () => { + const referent = makeDir(path.join(root, 'external-tree')) + makeFile(path.join(referent, 'keep.txt'), 'external') + const owned = makeDir(path.join(root, 'owned-tree')) + makeFile(path.join(owned, 'owned.txt'), 'owned') + makeDirectoryLink(referent, path.join(owned, 'nested-link')) + + await removeOwnedPath(owned, 'directory') + + assert.equal(existsSync(owned), false) + assert.equal(readFileSync(path.join(referent, 'keep.txt'), 'utf8'), 'external') + }) + }) + + describe('writeOwnedFile', () => { + it('creates a regular file at a missing path with the requested mode', async () => { + const target = path.join(root, 'created.txt') + await writeOwnedFile(target, Buffer.from('hello'), { mode: 0o600, expectedKind: 'missing' }) + assert.equal(readFileSync(target, 'utf8'), 'hello') + const stats = lstatSync(target) + assert.equal(stats.isFile(), true) + if (process.platform !== 'win32') assert.equal(stats.mode & 0o777, 0o600) + }) + + it('atomically replaces an existing regular file and keeps the mode', async () => { + const target = filePath() + makeFile(target, 'before') + await writeOwnedFile(target, Buffer.from('after'), { mode: 0o600, expectedKind: 'file' }) + assert.equal(readFileSync(target, 'utf8'), 'after') + const leftovers = readdirSync(root).filter((name) => name.includes('.nsolid-tmp-')) + assert.deepEqual(leftovers, [], 'no temp files may survive') + }) + + it('retries an EEXIST collision without deleting the foreign temp path', async (t) => { + const target = path.join(root, 'collision.txt') + const randomValue = 0.5 + const randomToken = randomValue.toString(36).slice(2) + t.mock.method(Math, 'random', () => randomValue) + const foreign = path.join(root, `.collision.txt.nsolid-tmp-${process.pid}-${randomToken}-0`) + writeFileSync(foreign, 'foreign') + + await writeOwnedFile(target, Buffer.from('ours'), { expectedKind: 'missing' }) + + assert.equal(readFileSync(target, 'utf8'), 'ours') + assert.equal(readFileSync(foreign, 'utf8'), 'foreign') + assert.deepEqual( + readdirSync(root).filter((name) => name.includes('.nsolid-tmp-')), + [path.basename(foreign)] + ) + }) + + it('propagates after bounded EEXIST retries and preserves every foreign candidate', async (t) => { + const target = path.join(root, 'exhausted.txt') + const randomValue = 0.25 + const randomToken = randomValue.toString(36).slice(2) + t.mock.method(Math, 'random', () => randomValue) + const foreign = Array.from({ length: 5 }, (_, attempt) => + path.join(root, `.exhausted.txt.nsolid-tmp-${process.pid}-${randomToken}-${attempt}`) + ) + for (const [index, candidate] of foreign.entries()) writeFileSync(candidate, `foreign-${index}`) + + await assert.rejects( + writeOwnedFile(target, Buffer.from('ours'), { expectedKind: 'missing' }), + (error: unknown) => (error as NodeJS.ErrnoException).code === 'EEXIST' + ) + + assert.equal(existsSync(target), false) + for (const [index, candidate] of foreign.entries()) assert.equal(readFileSync(candidate, 'utf8'), `foreign-${index}`) + }) + + it('rejects a kind mismatch without writing', async () => { + makeFile(filePath(), 'keep') + await assert.rejects( + writeOwnedFile(filePath(), Buffer.from('x'), { expectedKind: 'missing' }), + (error: unknown) => error instanceof OwnedFsError && error.code === 'KIND_CHANGED' + ) + assert.equal(readFileSync(filePath(), 'utf8'), 'keep') + + const missing = path.join(root, 'absent.txt') + await assert.rejects( + writeOwnedFile(missing, Buffer.from('x'), { expectedKind: 'file' }), + (error: unknown) => error instanceof OwnedFsError && error.code === 'KIND_CHANGED' + ) + assert.equal(existsSync(missing), false) + }) + + it('rejects a symlink target and leaves the referent untouched', async (t) => { + const referent = makeFile(filePath(), 'referent') + const link = path.join(root, 'link.txt') + if (!makeFileLink(t, referent, link)) return + await assert.rejects( + writeOwnedFile(link, Buffer.from('overwritten'), { expectedKind: 'file' }), + (error: unknown) => error instanceof OwnedFsError && error.code === 'KIND_CHANGED' + ) + await assert.rejects( + writeOwnedFile(link, Buffer.from('overwritten'), { expectedKind: 'missing' }), + (error: unknown) => error instanceof OwnedFsError && error.code === 'KIND_CHANGED' + ) + assert.equal(readFileSync(referent, 'utf8'), 'referent', 'the symlink referent must stay untouched') + assert.equal(existsSync(link), true, 'the symlink itself must stay untouched') + const leftovers = readdirSync(root).filter((name) => name.includes('.nsolid-tmp-')) + assert.deepEqual(leftovers, [], 'no temp files may survive a rejected write') + }) + + it('refuses a missing path when expectedKind is file without creating anything', async () => { + const target = path.join(root, 'raced.txt') + await assert.rejects( + writeOwnedFile(target, Buffer.from('x'), { expectedKind: 'file' }), + (error: unknown) => error instanceof OwnedFsError && error.code === 'KIND_CHANGED' + ) + assert.equal(existsSync(target), false) + const leftovers = readdirSync(root).filter((name) => name.includes('.nsolid-tmp-')) + assert.deepEqual(leftovers, [], 'the temp file must be removed on failure') + }) + + it('releases the handle and removes the temp file when writing fails after open', { skip: process.platform !== 'linux' }, async (t) => { + const target = path.join(root, 'io-fail.txt') + // Simulate a mid-write I/O failure by patching the shared FileHandle + // prototype (the class itself is not exported at runtime, so derive it + // from a real probe handle): `open` succeeds for real, then the handle's + // writeFile throws. Cleanup must close the still-open handle BEFORE + // removing the temp file; a leaked fd would still be listed in + // /proc/self/fd pointing at the temp file after the rejection. + const probe = await fsOpen(path.join(root, 'probe.txt'), 'w') + const handlePrototype = Object.getPrototypeOf(probe) as FileHandle + await probe.close() + t.mock.method(handlePrototype, 'writeFile', async function mockWriteFileFailure (this: FileHandle) { + throw new Error('simulated write failure') + }) + await assert.rejects( + writeOwnedFile(target, Buffer.from('x'), { expectedKind: 'missing' }), + /simulated write failure/ + ) + const leakedFd = readdirSync('/proc/self/fd') + .map((fd) => { + try { + return readlinkSync(path.join('/proc/self/fd', fd)) + } catch { + // The fd vanished between listing and resolving: it closed. + return '' + } + }) + .filter((link) => link.includes('.nsolid-tmp-')) + assert.deepEqual(leakedFd, [], 'the handle opened before the failure must be closed, not leaked') + assert.equal(existsSync(target), false) + const leftovers = readdirSync(root).filter((name) => name.includes('.nsolid-tmp-')) + assert.deepEqual(leftovers, [], 'the temp file must be removed after a post-open failure') + }) + }) + + describe(' OwnedFsError shape', () => { + it('carries code and optional relativePath with a readable message', async () => { + const error = new OwnedFsError('SYMLINK_IN_TREE', 'nested/link') + assert.equal(error.code, 'SYMLINK_IN_TREE') + assert.equal(error.relativePath, 'nested/link') + assert.equal(error.message.includes('nested/link'), true) + assert.equal(error instanceof Error, true) + }) + }) + + describe('cross-check with nativePayloadTreeDigest framing', () => { + it('produces digests of equal length (sha256 hex) and stability semantics', async () => { + const tree = makeDir() + makeFile(path.join(tree, 'a.txt'), 'alpha') + const digest = await ownedTreeDigest(tree) + assert.ok(digest) + assert.match(digest as string, /^[0-9a-f]{64}$/) + }) + }) + + describe('posix path normalization in errors', () => { + it('uses forward slashes for nested relative paths on all platforms', async () => { + const tree = makeDir() + const deep = path.join(tree, 'one', 'two') + mkdirSync(deep, { recursive: true }) + makeDirectoryLink(dirPath(), path.join(deep, 'link')) + try { + await assertNoSymlinksInTree(tree) + assert.fail('expected rejection') + } catch (error) { + assert.ok(error instanceof OwnedFsError) + assert.equal(error.relativePath, 'one/two/link') + } + }) + }) + + describe('deterministic ordering', () => { + it('sorts nested entries deterministically', async () => { + const treeA = makeDir(path.join(root, 'a')) + makeFile(path.join(treeA, 'z.txt'), 'z') + makeFile(path.join(treeA, 'a.txt'), 'a') + mkdirSync(path.join(treeA, 'm')) + makeFile(path.join(treeA, 'm', 'x.txt'), 'x') + + const treeB = makeDir(path.join(root, 'b')) + makeFile(path.join(treeB, 'a.txt'), 'a') + mkdirSync(path.join(treeB, 'm')) + makeFile(path.join(treeB, 'm', 'x.txt'), 'x') + makeFile(path.join(treeB, 'z.txt'), 'z') + + assert.equal(await ownedTreeDigest(treeA), await ownedTreeDigest(treeB)) + }) + + it('orders canonically equivalent but byte-distinct names independently of creation order', async (t) => { + const composed = '\u00e9.txt' + const decomposed = 'e\u0301.txt' + const first = makeDir(path.join(root, 'unicode-a')) + makeFile(path.join(first, composed), 'composed') + makeFile(path.join(first, decomposed), 'decomposed') + const second = makeDir(path.join(root, 'unicode-b')) + makeFile(path.join(second, decomposed), 'decomposed') + makeFile(path.join(second, composed), 'composed') + if (readdirSync(first).length !== 2 || readdirSync(second).length !== 2) { + t.skip('filesystem normalizes canonically equivalent names') + return + } + + assert.equal(await ownedTreeDigest(first), await ownedTreeDigest(second)) + }) + }) +}) diff --git a/packages/core/test/unit/update/package-content.test.ts b/packages/core/test/unit/update/package-content.test.ts new file mode 100644 index 0000000..bf32a43 --- /dev/null +++ b/packages/core/test/unit/update/package-content.test.ts @@ -0,0 +1,59 @@ +import { tarEntry } from '../../helpers/tar.js' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { gzipSync } from 'node:zlib' +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { installedPackageMatchesTarball } from '../../../src/update/package-content.js' + +describe('installedPackageMatchesTarball', () => { + let directory: string + + beforeEach(() => { + directory = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-package-content-')) + }) + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }) + }) + + it('matches an installed package whose files equal the tarball payload', () => { + const manifest = Buffer.from('{"name":"nsolid-plugin","version":"1.0.0"}\n') + const tar = Buffer.concat([tarEntry('package/package.json', manifest, '0'), Buffer.alloc(1024)]) + const tarball = path.join(directory, 'artifact.tgz') + writeFileSync(tarball, gzipSync(tar)) + const root = path.join(directory, 'installed') + mkdirSync(root) + writeFileSync(path.join(root, 'package.json'), manifest) + assert.equal(installedPackageMatchesTarball(root, tarball), true) + }) + + it('returns false for a gzip that expands beyond the archive bound', () => { + const oversized = gzipSync(Buffer.alloc(65 * 1024 * 1024)) + assert.ok(oversized.length < 1024 * 1024, 'fixture must be small on disk') + const tarball = path.join(directory, 'bomb.tgz') + writeFileSync(tarball, oversized) + const root = path.join(directory, 'installed') + mkdirSync(root) + writeFileSync(path.join(root, 'package.json'), '{}') + assert.equal(installedPackageMatchesTarball(root, tarball), false) + }) + + it('returns false for a decompression bomb even when the payload would match', () => { + // Distinguishes the bounded reader from the former unbounded one: a + // small package.json plus enough zero padding to push the decompressed + // archive past MAX_TARBALL_BYTES. The old reader (unbounded gunzip, no + // compressed-size check) returned true here; the bounded reader throws + // in gunzipSync and the outer catch yields false. Verified against the + // pre-fix code (git 09972c4): old result was true, new result is false. + const manifest = Buffer.from('{"name":"nsolid-plugin","version":"1.0.0"}\n') + const tar = Buffer.concat([tarEntry('package/package.json', manifest, '0'), Buffer.alloc(70 * 1024 * 1024)]) + const tarball = path.join(directory, 'matching-bomb.tgz') + writeFileSync(tarball, gzipSync(tar)) + const root = path.join(directory, 'installed-matching') + mkdirSync(root) + writeFileSync(path.join(root, 'package.json'), manifest) + assert.equal(installedPackageMatchesTarball(root, tarball), false) + }) +}) 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..5784fe2 --- /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, treeTerminated: true } + }, + }, + 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, treeTerminated: true }), + }, + 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, treeTerminated: true }), + }, + 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, treeTerminated: true }), + }, + 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, treeTerminated: true }), + }, + 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, treeTerminated: true }), + }, + 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..ea1d1f6 --- /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, treeTerminated: true }) } }) + 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, treeTerminated: true } + }, + }, + }) +} + +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/plan-projection.test.ts b/packages/core/test/unit/update/plan-projection.test.ts new file mode 100644 index 0000000..5496ba1 --- /dev/null +++ b/packages/core/test/unit/update/plan-projection.test.ts @@ -0,0 +1,127 @@ +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { publicPlanSteps } from '../../../src/update/plan-projection.js' +import { resultFromPlan } from '../../../src/update/strategies/common.js' +import type { UpdatePlanItem, UpdatePlanStep } from '../../../src/update/types.js' + +const tempDir = tmpdir() + +function planItem (overrides: Partial = {}): UpdatePlanItem { + return { + installationId: 'opencode:fallback', + target: 'opencode', + ownership: 'fallback', + installed: true, + source: { kind: 'fallback', bundleVersion: '1.0.0' }, + version: { current: '1.0.0', latest: '1.0.1', status: 'update-available' }, + steps: [], + rollbackSteps: [], + requiresConfirmation: true, + ...overrides, + } +} + +describe('public plan step projection', () => { + it('projects a command step without environment, working directory, spawn identity, or timeout', () => { + const steps: UpdatePlanStep[] = [{ + kind: 'command', + description: 'refresh', + command: { + executable: 'npm', + executableIdentity: { kind: 'node', executable: '/usr/bin/node', entrypoint: '/usr/lib/npm.js' }, + args: ['exec', '--yes'], + cwd: path.join(tempDir, 'nsolid-plugin-update-x'), + env: { NPM_CONFIG_USERCONFIG: path.join(tempDir, 'ws', '.npmrc') }, + timeoutMs: 120_000, + }, + }] + + assert.deepEqual(publicPlanSteps(steps), [{ + kind: 'command', + description: 'refresh', + executable: 'npm', + args: ['exec', '--yes'], + }]) + }) + + it('redacts transient temporary paths inside command arguments and filesystem paths', () => { + const manifestPath = path.join(tempDir, 'nsolid-plugin-manifest-x', 'transaction.json') + const tarballPath = path.join(tempDir, 'nsolid-plugin-download-x', 'nsolid-plugin-1.0.1.tgz') + const steps: UpdatePlanStep[] = [ + { + kind: 'command', + description: 'refresh', + command: { executable: 'npm', args: ['exec', '--yes', `--package=${tarballPath}`, '--', 'nsolid-plugin-refresh-owned', '--transaction', manifestPath], timeoutMs: 1000 }, + }, + { + kind: 'filesystem', + description: 'back up', + operation: 'backup', + paths: [path.join(tempDir, 'nsolid-plugin-backup-x', 'skill.md'), '/home/user/.config/opencode/skills/kept'], + }, + ] + + const projected = publicPlanSteps(steps) + const command = projected[0] + assert.ok(command?.kind === 'command') + // Redaction normalizes to forward slashes on every platform + // (plan-projection.ts), so the expectations use literals rather than + // path.join, which would emit native separators on Windows. + assert.deepEqual(command.args, [ + 'exec', '--yes', '--package=/nsolid-plugin-1.0.1.tgz', + '--', 'nsolid-plugin-refresh-owned', '--transaction', '/transaction.json', + ]) + const filesystem = projected[1] + assert.ok(filesystem?.kind === 'filesystem') + assert.deepEqual(filesystem.paths, ['/skill.md', '/home/user/.config/opencode/skills/kept']) + }) + + it('projects validation steps verbatim', () => { + const steps: UpdatePlanStep[] = [{ + kind: 'validation', + description: 'postconditions', + checks: ['tracked skills match new bundle'], + }] + assert.deepEqual(publicPlanSteps(steps), [{ + kind: 'validation', + description: 'postconditions', + checks: ['tracked skills match new bundle'], + }]) + }) +}) + +describe('structured result plan data', () => { + it('carries the projected technical plan: target, steps, changes, and manual commands', () => { + const steps: UpdatePlanStep[] = [ + { kind: 'filesystem', description: 'back up', operation: 'backup', paths: ['/home/user/.config/opencode/skills/kept'] }, + { kind: 'command', description: 'refresh', command: { executable: 'npm', args: ['exec'], timeoutMs: 1000 } }, + ] + const item = planItem({ + steps, + manualCommands: ['nsolid-plugin install --harness opencode'], + changes: { skillsAdded: ['new'], skillsRemoved: ['old'], skillsUpdated: 1, mcpAdded: [], mcpRemoved: [], mcpUpdated: 0 }, + }) + + const result = resultFromPlan(item, 'updated', { resultingVersion: '1.0.1' }) + assert.equal(result.target, 'opencode') + assert.equal(result.steps?.length, 2) + assert.ok(result.steps?.every((step) => step.kind !== 'command' || !('env' in step || 'cwd' in step || 'executableIdentity' in step))) + assert.deepEqual(result.changes, item.changes) + assert.deepEqual(result.manualCommands, ['nsolid-plugin install --harness opencode']) + }) + + it('keeps the serialized result a single stable JSON document with the plan embedded', () => { + const item = planItem({ + steps: [{ kind: 'command', description: 'refresh', command: { executable: 'npm', args: ['exec', '--yes', `--package=${path.join(tempDir, 'x.tgz')}`], timeoutMs: 1000 } }], + }) + const result = resultFromPlan(item, 'skipped', { error: { code: 'CONFIRMATION_REQUIRED', message: 'not approved' } }) + const serialized = JSON.stringify(result) + const parsed = JSON.parse(serialized) as UpdatePlanItem + assert.deepEqual(parsed, JSON.parse(JSON.stringify(result))) + const step = (result.steps ?? [])[0] + assert.ok(step?.kind === 'command') + assert.ok(!JSON.stringify(parsed).includes(tempDir), 'no transient temporary path may leak into the structured output') + }) +}) diff --git a/packages/core/test/unit/update/render.test.ts b/packages/core/test/unit/update/render.test.ts new file mode 100644 index 0000000..c6b199f --- /dev/null +++ b/packages/core/test/unit/update/render.test.ts @@ -0,0 +1,156 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + captureRender, + printUpdateSummary, + printUpdatePlan, + shouldDisplayUpdatePlan, + uniqueCommands, +} from '../../../src/update/render.js' +import type { UpdatePlan, UpdatePlanItem, UpdateResult, UpdateSummary } from '../../../src/update/types.js' + +/** Direct unit coverage of the dedupe helper used by the summary renderer. */ +describe('uniqueCommands', () => { + it('removes only exactly identical commands and preserves order', () => { + assert.deepEqual(uniqueCommands(['a', 'a', 'b', 'a', 'c']), ['a', 'b', 'c']) + assert.deepEqual(uniqueCommands([]), []) + }) +}) + +function planItem (overrides: Partial = {}): UpdatePlanItem { + return { + installationId: 'cli:global', + target: 'cli', + ownership: 'global-package', + installed: true, + source: { kind: 'global-package', packageManager: 'npm', packageName: 'nsolid-plugin' }, + version: { status: 'unknown' }, + steps: [], + rollbackSteps: [], + requiresConfirmation: false, + ...overrides, + } +} + +function result (overrides: Partial = {}): UpdateResult { + return { + installationId: 'cli:global', + target: 'cli', + ownership: 'global-package', + status: 'current', + changed: false, + ...overrides, + } +} + +function summary (results: UpdateResult[]): UpdateSummary { + return { checkOnly: false, results, counts: {} as UpdateSummary['counts'], success: false, exitCode: 2 } +} + +describe('update plan display decision', () => { + const unknownMutablePlan: UpdatePlan = { + checkOnly: false, + items: [planItem({ version: { status: 'unknown', latest: '1.0.4' }, requiresConfirmation: true })], + } + + it('shows the plan before the prompt for a mutable item with an unknown current version', () => { + assert.equal(shouldDisplayUpdatePlan(unknownMutablePlan, { json: false, check: false, willConfirm: true }), true) + }) + + it('decides by requiresConfirmation, not only by update-available status', () => { + assert.equal(unknownMutablePlan.items[0]?.version.status, 'unknown') + assert.equal(unknownMutablePlan.items[0]?.requiresConfirmation, true) + assert.equal(shouldDisplayUpdatePlan(unknownMutablePlan, { json: false, check: false, willConfirm: true }), true) + }) + + it('still shows the plan for a plain update-available item', () => { + const plan: UpdatePlan = { + checkOnly: false, + items: [planItem({ version: { current: '1.0.3', latest: '1.0.4', status: 'update-available' }, requiresConfirmation: true })], + } + assert.equal(shouldDisplayUpdatePlan(plan, { json: false, check: false, willConfirm: true }), true) + }) + + it('stays quiet for an interactive run with nothing to confirm', () => { + const plan: UpdatePlan = { checkOnly: false, items: [planItem({ version: { status: 'current' } })] } + assert.equal(shouldDisplayUpdatePlan(plan, { json: false, check: false, willConfirm: true }), false) + }) + + it('stays quiet for approved --yes runs', () => { + assert.equal(shouldDisplayUpdatePlan(unknownMutablePlan, { json: false, check: false, willConfirm: false }), false) + }) + + it('never contaminates --json output', () => { + assert.equal(shouldDisplayUpdatePlan(unknownMutablePlan, { json: true, check: false, willConfirm: true }), false) + }) + + it('always renders in --check mode', () => { + assert.equal(shouldDisplayUpdatePlan(unknownMutablePlan, { json: false, check: true, willConfirm: false }), true) + assert.equal(shouldDisplayUpdatePlan(unknownMutablePlan, { json: true, check: true, willConfirm: false }), false, 'json beats check') + }) +}) + +describe('update summary rendering', () => { + it('prints every exact-version recovery command for an unsupported CLI launch', () => { + const text = captureRender((output) => printUpdateSummary(summary([result({ + status: 'unsupported', + manualCommands: [ + 'npm install --global nsolid-plugin@90.0.2', + 'pnpm add --global nsolid-plugin@90.0.2', + 'npx -y nsolid-plugin@90.0.2 ', + 'volta install nsolid-plugin@90.0.2', + ], + })]), false, output)) + + for (const command of ['npm install --global nsolid-plugin@90.0.2', 'pnpm add --global nsolid-plugin@90.0.2', 'npx -y nsolid-plugin@90.0.2 ', 'volta install nsolid-plugin@90.0.2']) { + assert.ok(text.includes(`→ ${command}`), `${command} must be printed in full:\n${text}`) + } + assert.ok(!text.includes('(+'), 'no truncation marker may appear') + }) + + it('deduplicates only exactly identical commands and preserves the deterministic order', () => { + const text = captureRender((output) => printUpdateSummary(summary([result({ + status: 'unsupported', + manualCommands: [ + 'npm install --global nsolid-plugin@1.0.4', + 'npm install --global nsolid-plugin@1.0.4', + 'pnpm add --global nsolid-plugin@1.0.4', + ], + })]), false, output)) + + assert.equal(text.split('npm install --global nsolid-plugin@1.0.4').length - 1, 1, 'an identical duplicate is printed once') + assert.ok(text.indexOf('npm install') < text.indexOf('pnpm add'), 'order is preserved') + }) + + it('prints installable guidance under the not-installed message', () => { + const text = captureRender((output) => printUpdateSummary(summary([result({ + installationId: 'pi:none', + target: 'pi', + ownership: 'none', + status: 'not-installed', + manualCommands: ['pi install npm:nsolid-pi-plugin', 'nsolid-plugin setup --harness pi'], + })]), false, output)) + + assert.ok(text.includes('pi:none — not installed'), `the not-installed message must stay:\n${text}`) + assert.ok(text.includes('→ pi install npm:nsolid-pi-plugin')) + assert.ok(text.includes('→ nsolid-plugin setup --harness pi')) + }) +}) + +describe('update plan rendering', () => { + it('renders the mutable unknown-version item with its approval-relevant details', () => { + const text = captureRender((output) => printUpdatePlan({ + checkOnly: false, + items: [planItem({ + installationId: 'cli:global', + version: { status: 'unknown', latest: '1.0.4' }, + requiresConfirmation: true, + manualCommands: ['npm install --global nsolid-plugin@1.0.4'], + })], + }, false, false, output)) + + assert.ok(text.includes('Updates available:')) + assert.ok(text.includes('cli:global — version unknown')) + assert.ok(text.includes('→ npm install --global nsolid-plugin@1.0.4')) + }) +}) 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..fcf5381 --- /dev/null +++ b/packages/core/test/unit/update/strategies.test.ts @@ -0,0 +1,577 @@ +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, treeTerminated: true }) }, + } +} + +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 tarballPath = path.join(root, 'nsolid-pi-plugin-1.0.1.tgz') + writeFileSync(tarballPath, 'verified tarball fixture') + candidate.artifact = { + kind: 'npm', + packageName: 'nsolid-pi-plugin', + version: '1.0.1', + registry: 'https://registry.npmjs.org/', + tarball: 'https://registry.npmjs.org/nsolid-pi-plugin/-/nsolid-pi-plugin-1.0.1.tgz', + tarballPath, + integrity: 'sha512-dGVzdA==', + } + 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, treeTerminated: true } } }, + }) + + 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, treeTerminated: true } } }, + }) + + 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 }) + } + }) + + for (const { target, executable, strategy, source, manualCommands } of [ + { + target: 'claude', + executable: 'claude', + strategy: claudeStrategy, + source: claudeSource, + manualCommands: ['claude plugin marketplace update nodesource', 'claude plugin update nsolid-plugin@nodesource --scope user'], + }, + { + target: 'codex', + executable: 'codex', + strategy: codexStrategy, + source: codexSource, + manualCommands: ['codex plugin marketplace upgrade nodesource', 'codex plugin remove nsolid-plugin@nodesource', 'codex plugin add nsolid-plugin@nodesource'], + }, + { + target: 'pi', + executable: 'pi', + strategy: piStrategy, + source: (): UpdateSource => ({ kind: 'pi-package', spec: 'npm:nsolid-pi-plugin', scopes: ['user'] }), + manualCommands: ['pi update npm:nsolid-pi-plugin --no-approve'], + }, + { + target: 'antigravity', + executable: 'agy', + strategy: antigravityStrategy, + source: antigravitySource, + manualCommands: ['agy plugin uninstall nsolid-plugin', 'agy plugin install https://github.com/NodeSource/nsolid-plugin.git'], + }, + ] as const) { + it(`${target}: degrades an unverifiable launcher to planningError + manualCommands`, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), `nsolid-strategy-${target}-`)) + writeFileSync(path.join(root, executable), 'not executable\n', { mode: 0o644 }) + process.env.PATH = root + try { + const candidate = target === 'pi' ? piInstallation(root, source()) : installation(target, source()) + if (target === 'claude' || target === 'codex') { + 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) + assert.equal(item.planningError?.code, 'UNSAFE_HARNESS_LAUNCHER') + assert.deepEqual(item.manualCommands, manualCommands) + if (target === 'claude') { + const result = await strategy.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, treeTerminated: true } + }, + }, + }) + + 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, treeTerminated: true } + }, + }, + }) + + 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('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: user-only scope invokes the installed source with --no-approve', 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: ['user'] } + 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) + assert.deepEqual(step.command.args, ['update', 'npm:nsolid-pi-plugin', '--no-approve']) + assert.equal(step.command.cwd, undefined) + // The verified tarball stays on the plan as post-update evidence only. + assert.equal(item.artifact?.kind, 'npm') + } 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 }) + } + }) +}) diff --git a/packages/core/test/unit/update/tar-consumers.test.ts b/packages/core/test/unit/update/tar-consumers.test.ts new file mode 100644 index 0000000..c3eb65f --- /dev/null +++ b/packages/core/test/unit/update/tar-consumers.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, it } from 'node:test' +import { gzipSync } from 'node:zlib' +import { installedPackageMatchesTarball } from '../../../src/update/package-content.js' +import { gitArchivePayloadDigest } from '../../../src/update/native-payload.js' +import { tarEntry } from '../../helpers/tar.js' + +const manifest = Buffer.from('{}') +const file = () => tarEntry('package/package.json', manifest, '0') +const metadata = (type: string, body: string) => tarEntry('metadata', Buffer.from(body), type) +const pax = (value: string): Buffer => { + const record = `path=${value}\n` + let length = Buffer.byteLength(record) + 2 + while (length !== Buffer.byteLength(record) + String(length).length + 1) length = Buffer.byteLength(record) + String(length).length + 1 + return metadata('x', `${length} ${record}`) +} +const headerField = (offset: number, length: number, value: string): Buffer => { + const entry = file() + entry.fill(0, offset, offset + length) + entry.write(value, offset, length) + return entry +} + +// Exercise the public consumers together: the same package/ archive is also +// a single-root Git archive, so both must retain their payload decisions. +describe('TAR consumer compatibility', () => { + const cases: Array<[string, Buffer[], boolean]> = [ + ['regular file', [file()], true], + ['NUL regular type', [headerField(156, 1, '\0')], true], + ['ustar prefix', [(() => { const entry = headerField(0, 100, 'package.json'); entry.write('package', 345); return entry })()], true], + ['GNU long path', [metadata('L', 'package/package.json\0'), headerField(0, 100, 'ignored')], true], + ['PAX overrides GNU', [metadata('L', 'wrong/name\0'), pax('package/package.json'), headerField(0, 100, 'ignored')], true], + ['global PAX preserves pending path', [pax('package/package.json'), metadata('g', 'ignored'), headerField(0, 100, 'ignored')], true], + ['extension paths reset after entry', [pax('package/package.json'), headerField(0, 100, 'ignored'), tarEntry('package/empty', undefined, '5')], true], + ['base-256 size', [(() => { const entry = headerField(124, 12, ''); entry[124] = 0x80; entry[135] = 2; return entry })()], true], + ['missing final padding', [file().subarray(0, 514)], true], + ['trailing partial header', [file(), Buffer.alloc(20, 1)], true], + ['zero header terminates traversal', [file(), Buffer.alloc(512), file()], true], + ['duplicate file', [file(), file()], false], + ['path traversal', [file(), tarEntry('package/../escape', manifest, '0')], false], + ['hard link', [file(), tarEntry('package/link', undefined, '1')], false], + ['truncated body', [file().subarray(0, 513)], false], + ['negative size', [headerField(124, 12, '-1')], false], + ['invalid size', [headerField(124, 12, 'invalid')], false], + ['size overflow', [(() => { const entry = file(); entry.fill(0xff, 124, 136); return entry })()], false], + ['invalid PAX record', [metadata('x', 'invalid'), file()], false], + ['PAX length exceeds body', [metadata('x', '999 path=package/package.json\n'), file()], false], + ] + + for (const [name, parts, accepted] of cases) { + it(name, () => { + const directory = mkdtempSync(path.join(tmpdir(), 'nsolid-tar-consumers-')) + try { + const root = path.join(directory, 'installed') + mkdirSync(root) + writeFileSync(path.join(root, 'package.json'), manifest) + const archive = gzipSync(Buffer.concat(parts)) + const tarball = path.join(directory, 'archive.tgz') + writeFileSync(tarball, archive) + assert.equal(installedPackageMatchesTarball(root, tarball), accepted) + assert.equal(gitArchivePayloadDigest(archive), accepted ? gitArchivePayloadDigest(gzipSync(file())) : undefined) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + } +}) diff --git a/packages/core/test/unit/update/tarball.test.ts b/packages/core/test/unit/update/tarball.test.ts new file mode 100644 index 0000000..a7d79dc --- /dev/null +++ b/packages/core/test/unit/update/tarball.test.ts @@ -0,0 +1,157 @@ +import { tarEntry } from '../../helpers/tar.js' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { gzipSync } from 'node:zlib' +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { readTarEntryText, readTarEntryTextFromBytes } from '../../../src/update/tarball.js' + +describe('in-process tar entry reader', () => { + let directory: string + + beforeEach(() => { + directory = mkdtempSync(path.join(tmpdir(), 'nsolid-plugin-tarball-')) + }) + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }) + }) + + function writeTarball (name: string, bytes: Buffer, gzip = true): string { + const tarball = path.join(directory, name) + writeFileSync(tarball, gzip ? gzipSync(bytes) : bytes) + return tarball + } + + it('extracts a file entry from a gzip-compressed npm-style tarball', async () => { + const bundle = Buffer.from('{"name":"nsolid-plugin"}\n') + const tar = Buffer.concat([ + tarEntry('package/', undefined, '5'), + tarEntry('package/bundle.json', bundle, '0'), + tarEntry('package/other.txt', Buffer.from('unrelated'), '0'), + Buffer.alloc(1024), + ]) + assert.equal(await readTarEntryText(writeTarball('artifact.tgz', tar), 'package/bundle.json'), bundle.toString('utf8')) + }) + + it('extracts a file entry from a plain uncompressed tar', async () => { + const tar = Buffer.concat([tarEntry('package/bundle.json', Buffer.from('plain'), '0'), Buffer.alloc(1024)]) + assert.equal(await readTarEntryText(writeTarball('plain.tar', tar, false), 'package/bundle.json'), 'plain') + }) + + it('returns undefined for a missing entry', async () => { + const tar = Buffer.concat([tarEntry('package/other.json', Buffer.from('{}'), '0'), Buffer.alloc(1024)]) + assert.equal(await readTarEntryText(writeTarball('missing.tgz', tar), 'package/bundle.json'), undefined) + }) + + it('returns undefined for a truncated archive', async () => { + const tar = Buffer.concat([tarEntry('package/bundle.json', Buffer.from('{"a":1}'), '0'), Buffer.alloc(1024)]) + const tarball = path.join(directory, 'truncated.tgz') + writeFileSync(tarball, gzipSync(tar).subarray(0, 40)) + assert.equal(await readTarEntryText(tarball, 'package/bundle.json'), undefined) + }) + + it('returns undefined for a file that is not a tar archive', async () => { + const tarball = path.join(directory, 'garbage.tgz') + writeFileSync(tarball, Buffer.from('this is definitely not a tar archive, not even close')) + assert.equal(await readTarEntryText(tarball, 'package/bundle.json'), undefined) + }) + + it('returns undefined for a nonexistent path', async () => { + assert.equal(await readTarEntryText(path.join(directory, 'absent.tgz'), 'package/bundle.json'), undefined) + }) + + it('returns undefined for a gzip that expands beyond the archive bound', async () => { + // 65 MiB of zeros gzips to well under 100 KiB but must never be + // materialized: the decompressed size is capped, not only the file size. + const oversized = gzipSync(Buffer.alloc(65 * 1024 * 1024)) + assert.ok(oversized.length < 1024 * 1024, 'fixture must be small on disk') + const tarball = path.join(directory, 'bomb.tgz') + writeFileSync(tarball, oversized) + assert.equal(await readTarEntryText(tarball, 'package/bundle.json'), undefined) + }) + + it('rejects a decompression bomb even when a matching entry would be readable', async () => { + // Distinguishes the bounded reader from the former unbounded one: the + // archive decompresses past MAX_TARBALL_BYTES, but the entry itself is + // small, so the old reader (no maxOutputLength) successfully returned + // its content while the bounded reader throws during gunzipSync and + // yields the best-effort undefined. Verified against the pre-fix code + // (git 09972c4): old result was '{}', new result is undefined. + const tar = Buffer.concat([ + tarEntry('package/bundle.json', Buffer.from('{}'), '0'), + Buffer.alloc(70 * 1024 * 1024), + ]) + const gzipped = gzipSync(tar) + assert.ok(gzipped.length < 1024 * 1024, 'fixture must be small on disk') + const tarball = path.join(directory, 'readable-bomb.tgz') + writeFileSync(tarball, gzipped) + assert.equal(await readTarEntryText(tarball, 'package/bundle.json'), undefined) + }) + + it('refuses an entry beyond the size bound instead of buffering it', async () => { + const oversized = Buffer.alloc(2 << 20, 0x61) + const tar = Buffer.concat([tarEntry('package/bundle.json', oversized, '0'), Buffer.alloc(1024)]) + assert.equal(await readTarEntryText(writeTarball('oversized.tgz', tar), 'package/bundle.json'), undefined) + }) + + it('reads the archive in-process: correct content even when PATH offers a hostile tar', async () => { + const hostileBin = path.join(directory, 'hostile-bin') + mkdirSync(hostileBin) + const fakeTar = path.join(hostileBin, 'tar') + writeFileSync(fakeTar, '#!/bin/sh\nprintf "TAMPERED"\n') + chmodSync(fakeTar, 0o755) + const bundle = Buffer.from('{"real":"bytes"}') + const tar = Buffer.concat([tarEntry('package/bundle.json', bundle, '0'), Buffer.alloc(1024)]) + const tarball = writeTarball('hostile.tgz', tar) + const previousPath = process.env.PATH + try { + process.env.PATH = hostileBin + assert.equal(await readTarEntryText(tarball, 'package/bundle.json'), bundle.toString('utf8'), 'a hostile PATH must not change the outcome') + } finally { + if (previousPath === undefined) delete process.env.PATH + else process.env.PATH = previousPath + } + }) + + it('works when no tar exists on PATH at all', async () => { + const bundle = Buffer.from('{"offline":true}') + const tar = Buffer.concat([tarEntry('package/bundle.json', bundle, '0'), Buffer.alloc(1024)]) + const tarball = writeTarball('offline.tgz', tar) + const previousPath = process.env.PATH + try { + process.env.PATH = '' + assert.equal(await readTarEntryText(tarball, 'package/bundle.json'), bundle.toString('utf8')) + } finally { + if (previousPath === undefined) delete process.env.PATH + else process.env.PATH = previousPath + } + }) + + it('extracts entries from in-memory bytes for gzip and plain archives', () => { + const bundle = Buffer.from('{"bytes":true}') + const tar = Buffer.concat([tarEntry('package/bundle.json', bundle, '0'), Buffer.alloc(1024)]) + assert.equal(readTarEntryTextFromBytes(gzipSync(tar), 'package/bundle.json'), bundle.toString('utf8')) + assert.equal(readTarEntryTextFromBytes(tar, 'package/bundle.json'), bundle.toString('utf8')) + // Same limits as the path-based reader: identical limits, no filesystem. + const missing = Buffer.concat([tarEntry('package/other.json', Buffer.from('{}'), '0'), Buffer.alloc(1024)]) + assert.equal(readTarEntryTextFromBytes(gzipSync(missing), 'package/bundle.json'), undefined) + assert.equal(readTarEntryTextFromBytes(Buffer.alloc(0), 'package/bundle.json'), undefined) + assert.equal(readTarEntryTextFromBytes(Buffer.from('this is definitely not a tar archive, not even close'), 'package/bundle.json'), undefined) + const oversized = Buffer.alloc(2 << 20, 0x61) + assert.equal( + readTarEntryTextFromBytes(Buffer.concat([tarEntry('package/bundle.json', oversized, '0'), Buffer.alloc(1024)]), 'package/bundle.json'), + undefined + ) + }) + + it('refuses a decompression bomb supplied as raw bytes', () => { + // The in-memory reader keeps the same decompression bound: a hostile + // artifact must not expand past MAX_TARBALL_BYTES even when the caller + // already holds the compressed bytes in memory. + const bomb = gzipSync(Buffer.concat([tarEntry('package/bundle.json', Buffer.from('{}'), '0'), Buffer.alloc(70 * 1024 * 1024)])) + assert.ok(bomb.length < 1024 * 1024, 'fixture must stay small') + assert.equal(readTarEntryTextFromBytes(bomb, 'package/bundle.json'), undefined) + }) +}) 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..666edfb --- /dev/null +++ b/packages/core/test/unit/update/version-source.test.ts @@ -0,0 +1,374 @@ +import { makeTar } from '../../helpers/tar.js' +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' +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') + }) +}) + +describe('planned payload identities', () => { + const COMMIT = 'bc9c87e6ce6ca73756dc20fdd41a3219bcd5b60c' + + function cleanPayloadFiles (): Map { + return new Map([ + ['bundle.json', Buffer.from('{"name":"nsolid-plugin","version":"1.0.2","skills":[]}')], + ['skills/example/SKILL.md', Buffer.from('# example\n')], + ]) + } + + function makeSnapshot (withReservedMetadata: boolean): string { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-version-source-snapshot-')) + for (const [relative, content] of cleanPayloadFiles()) { + const target = path.join(root, relative) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, content) + } + if (withReservedMetadata) writeFileSync(path.join(root, '.codex-marketplace-install.json'), '{"source":"marketplace"}\n') + return root + } + + function gitFetch (archive: Buffer, requested: string[]): typeof fetch { + return (async (url: string | URL) => { + const target = String(url) + requested.push(target) + if (target.includes('codeload.github.com')) return new Response(new Uint8Array(archive), { status: 200 }) + return new Response(JSON.stringify({ version: '1.0.2' }), { status: 200 }) + }) as typeof fetch + } + + it('carries strict and comparison identities from one verified local snapshot capture', async () => { + const root = makeSnapshot(false) + try { + const result = await resolveMarketplaceVersion({ kind: 'local-snapshot', root, manifestPath: 'bundle.json', freshness: 'verified' }) + const strict = nativePayloadTreeDigest(root)! + assert.equal(result.version, '1.0.2') + const artifact = result.artifact + if (!artifact || artifact.kind !== 'local-snapshot') assert.fail('expected a local-snapshot artifact') + assert.equal(artifact.contentDigest, strict) + // A clean payload has no normalizable entries: both identities describe + // the same captured bytes, so the digests coincide. + assert.equal(artifact.comparisonDigest, strict) + assert.equal(artifact.comparisonProfile, 'codex-installed-v1') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('fails closed when a verified local snapshot drifts after discovery', async () => { + const root = makeSnapshot(false) + try { + const result = await resolveMarketplaceVersion({ kind: 'local-snapshot', root, manifestPath: 'bundle.json', freshness: 'verified', contentDigest: 'f'.repeat(64) }) + assert.equal(result.error?.code, 'SOURCE_CONTENT_MISMATCH') + assert.equal(result.artifact, undefined) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('omits the comparison identity when a local snapshot ships reserved harness metadata', async () => { + const root = makeSnapshot(true) + try { + const result = await resolveMarketplaceVersion({ kind: 'local-snapshot', root, manifestPath: 'bundle.json', freshness: 'verified' }) + assert.equal(result.error, undefined) + const artifact = result.artifact + if (!artifact || artifact.kind !== 'local-snapshot') assert.fail('expected a local-snapshot artifact') + assert.equal(artifact.contentDigest, nativePayloadTreeDigest(root)!) + assert.equal(artifact.comparisonDigest, undefined) + assert.equal(artifact.comparisonProfile, undefined) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('carries strict and comparison identities from one immutable Git archive parse', async () => { + const archive = gzipSync(makeTar(cleanPayloadFiles())) + const requested: string[] = [] + const result = await resolveMarketplaceVersion({ + kind: 'git', + repository: 'NodeSource/nsolid-plugin', + commit: COMMIT, + manifestPath: 'bundle.json', + }, { requireImmutable: true, fetchImpl: gitFetch(archive, requested) }) + + assert.equal(result.error, undefined) + const artifact = result.artifact + if (!artifact || artifact.kind !== 'git') assert.fail('expected a git artifact') + assert.equal(artifact.commit, COMMIT) + assert.equal(artifact.contentDigest, gitArchivePayloadDigest(archive)!) + assert.equal(artifact.comparisonDigest, gitArchivePayloadDigest(archive, {}, { profile: 'codex-installed-v1' })!) + assert.equal(artifact.comparisonProfile, 'codex-installed-v1') + assert.ok(requested.some((url) => url === `https://codeload.github.com/NodeSource/nsolid-plugin/tar.gz/${COMMIT}`)) + }) + + it('omits the comparison identity when the immutable Git payload ships reserved harness metadata', async () => { + const files = cleanPayloadFiles() + files.set('.codex-marketplace-install.json', Buffer.from('{"source":"marketplace"}\n')) + const archive = gzipSync(makeTar(files)) + const result = await resolveMarketplaceVersion({ + kind: 'git', + repository: 'NodeSource/nsolid-plugin', + commit: COMMIT, + manifestPath: 'bundle.json', + }, { requireImmutable: true, fetchImpl: gitFetch(archive, []) }) + + assert.equal(result.error, undefined) + const artifact = result.artifact + if (!artifact || artifact.kind !== 'git') assert.fail('expected a git artifact') + assert.equal(artifact.contentDigest, gitArchivePayloadDigest(archive)!) + assert.equal(artifact.comparisonDigest, undefined) + assert.equal(artifact.comparisonProfile, undefined) + }) +}) 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..e2e1487 --- /dev/null +++ b/scripts/check-release-version.mjs @@ -0,0 +1,196 @@ +#!/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') +checkCommand('scripts/sync-plugin-assets.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) } diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index f4ebe4a..61e1564 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -118,7 +118,27 @@ const args = [ '--test-reporter', REPORTER, ...files, ] -const result = spawnSync(process.execPath, args, { stdio: 'inherit', cwd: ROOT }) +// Several integration tests spawn packages/core/dist/src/cli.js. A stale or +// missing dist fails them with an unrelated ERR_MODULE_NOT_FOUND instead of +// "run pnpm build", which bit the husky pre-commit hook in practice. Build +// first unless the caller proves it already did (CI sets the variable). +let builtHere = false +if (process.env.NSOLID_TEST_CORE_ALREADY_BUILT !== '1') { + const build = spawnSync('pnpm', ['-s', 'build'], { stdio: 'inherit', cwd: ROOT, shell: process.platform === 'win32' }) + if (build.status !== 0) { + console.error('pnpm build failed; tests were not run') + process.exit(build.status ?? 1) + } + builtHere = true +} +// When this runner performed the build, tell the test child that dist is +// fresh. Otherwise installer.test.ts rebuilds core concurrently with the +// suites that spawn dist/src/cli.js, and the rm -rf dist window races them +// into an intermittent MODULE_NOT_FOUND (confirmed QA flake). CI exports the +// variable itself, so it never hits the race. +const testEnv = { ...process.env } +if (builtHere) testEnv.NSOLID_TEST_CORE_ALREADY_BUILT = '1' +const result = spawnSync(process.execPath, args, { stdio: 'inherit', cwd: ROOT, env: testEnv }) const status = result.status ?? 1 // If Node fails before the reporter initializes (for example, bad reporter