From c5feeee8ab1c425533146d61f5b8cbfa8702aed1 Mon Sep 17 00:00:00 2001 From: pashifika Date: Fri, 28 Aug 2026 12:07:30 +0900 Subject: [PATCH 1/9] feat: own the codebase-memory-mcp lifecycle and wire it into OMP codebase-memory-mcp indexes a repository into a persistent code knowledge graph and exposes it over MCP. Its own installer configures 44 client surfaces and OMP is not one of them, so an OMP-only machine has no path to the graph at all. This makes `omp plugin install` the whole setup step. Executable resolution is system-first: pin, PATH, ~/.local/bin, then a managed copy. An existing installation is adopted as-is and never replaced, because CBM resolves one canonical per-account cache root and refuses to run when a process is configured with a different root while another CBM session is active -- two executables of different versions there produce mismatched index generations. A private cache root would avoid the conflict by re-indexing every repository a second time. Acquisition reproduces upstream install.sh step for step: tag from the releases/latest redirect rather than the rate-limited API, checksums.txt digest match for the exact archive name, HTTPS on every redirect hop, the closed four-member archive namespace, regular-file-not-symlink extraction, the Linux -portable build, macOS quarantine removal and ad-hoc signing, and a --version smoke run. Nothing is written under the package-owned root until every step passes. One key in the active agent directory's mcp.json is owned and nothing else: idempotent upsert, siblings and indentation preserved, fail closed on a command value this package did not write, removal only when it still matches. It has to be the native file -- a plugin-root .mcp.json gets no ${VAR} expansion and command gets no pre-connect resolution, so a committed file cannot name a home-relative executable. No tool_call handler is registered. OMP treats a throwing tool_call handler as a refusal of the tool call, so a handler there could deny an operator's grep because a subprocess timed out; the packaging test asserts its absence. Background work uses the handler context's managed timers, never the platform globals, because a raw timer callback that throws is fatal to the whole session. Refs: rasen/changes/binary-lifecycle-and-mcp-wiring --- .github/workflows/ci.yml | 311 +++++++++ .github/workflows/release.yml | 81 +++ .gitignore | 17 + .omp-plugin/marketplace.json | 26 + README.md | 178 ++++++ bun.lock | 403 ++++++++++++ dist/index.js | 949 ++++++++++++++++++++++++++++ package.json | 29 + src/acquire.ts | 284 +++++++++ src/exec.ts | 83 +++ src/index.ts | 284 +++++++++ src/lifecycle.ts | 382 +++++++++++ src/mcp-config.ts | 263 ++++++++ src/paths.ts | 121 ++++ src/platform.ts | 137 ++++ src/release.ts | 206 ++++++ src/resolve.ts | 144 +++++ src/scheduler.ts | 41 ++ src/state.ts | 88 +++ test/fixtures/checksums-v0.10.8.txt | 25 + test/packaging/bundle.test.ts | 96 +++ test/support/release.ts | 119 ++++ test/support/scratch.ts | 82 +++ test/unit/acquire.test.ts | 211 +++++++ test/unit/checksums.test.ts | 176 ++++++ test/unit/lifecycle.test.ts | 250 ++++++++ test/unit/mcp-config.test.ts | 363 +++++++++++ test/unit/paths.test.ts | 149 +++++ test/unit/platform.test.ts | 233 +++++++ test/unit/resolve.test.ts | 180 ++++++ tsconfig.json | 43 ++ 31 files changed, 5954 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .omp-plugin/marketplace.json create mode 100644 README.md create mode 100644 bun.lock create mode 100644 dist/index.js create mode 100644 package.json create mode 100644 src/acquire.ts create mode 100644 src/exec.ts create mode 100644 src/index.ts create mode 100644 src/lifecycle.ts create mode 100644 src/mcp-config.ts create mode 100644 src/paths.ts create mode 100644 src/platform.ts create mode 100644 src/release.ts create mode 100644 src/resolve.ts create mode 100644 src/scheduler.ts create mode 100644 src/state.ts create mode 100644 test/fixtures/checksums-v0.10.8.txt create mode 100644 test/packaging/bundle.test.ts create mode 100644 test/support/release.ts create mode 100644 test/support/scratch.ts create mode 100644 test/unit/acquire.test.ts create mode 100644 test/unit/checksums.test.ts create mode 100644 test/unit/lifecycle.test.ts create mode 100644 test/unit/mcp-config.test.ts create mode 100644 test/unit/paths.test.ts create mode 100644 test/unit/platform.test.ts create mode 100644 test/unit/resolve.test.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..519e4a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,311 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + # Reused by `release.yml`, so a tag passes the same gate a pull request does + # rather than a copy of it that can drift. + workflow_call: + +# Least privilege: nothing in this workflow writes to the repository. +permissions: + contents: read + +# One in-flight run per pull request, or per ref outside one. A newer push +# supersedes and cancels the run it replaces. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + # Runtime job. Verifies the workflows themselves, because an unpinned action + # reference is a supply-chain hole that no other job in this repository looks + # at, and reviewing for it by eye is exactly the check that stops happening. + hygiene: + name: hygiene + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out the workflow tree + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + timeout-minutes: 5 + with: + persist-credentials: false + + - name: Verify every action reference is SHA-pinned with a version comment + timeout-minutes: 5 + run: | + set -euo pipefail + + mapfile -t files < <( + find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \) | sort + ) + + if [ "${#files[@]}" -eq 0 ]; then + echo "hygiene: no workflow files under .github/workflows; this check would verify nothing" + exit 1 + fi + + echo "hygiene: scanning ${#files[@]} workflow file(s)" + printf ' %s\n' "${files[@]}" + + awk ' + BEGIN { + checked = 0 + violations = 0 + sq = sprintf("%c", 39) + } + { + line = $0 + sub(/^[[:space:]]+/, "", line) + + # A commented-out example is documentation, not a live reference. + if (line ~ /^#/) next + + sub(/^-[[:space:]]+/, "", line) + if (line !~ /^uses:[[:space:]]*/) next + sub(/^uses:[[:space:]]*/, "", line) + + # Separate the trailing version comment from the reference itself. + comment = "" + hash = index(line, "#") + if (hash > 0) { + comment = substr(line, hash + 1) + line = substr(line, 1, hash - 1) + } + sub(/[[:space:]]+$/, "", line) + sub(/^[[:space:]]+/, "", comment) + sub(/[[:space:]]+$/, "", comment) + + ref = line + if (length(ref) > 1) { + first = substr(ref, 1, 1) + last = substr(ref, length(ref), 1) + if ((first == "\"" && last == "\"") || (first == sq && last == sq)) + ref = substr(ref, 2, length(ref) - 2) + } + if (ref == "") next + + # A local reusable workflow is this repository, already at this SHA. + if (ref ~ /^\.\//) next + + checked++ + + # Action references carry exactly one "@"; scan from the right so a + # reusable-workflow path never confuses the split. + at = 0 + for (i = length(ref); i > 0; i--) + if (substr(ref, i, 1) == "@") { at = i; break } + + if (at == 0) { + printf " FAIL %s:%d %s -- not pinned to a commit SHA\n", FILENAME, FNR, ref + violations++ + next + } + + sha = substr(ref, at + 1) + if (sha !~ /^[0-9a-f]{40}$/) { + printf " FAIL %s:%d %s -- not pinned to a full 40-character commit SHA\n", FILENAME, FNR, ref + violations++ + next + } + + if (comment == "") { + printf " FAIL %s:%d %s -- SHA pin carries no trailing version comment\n", FILENAME, FNR, ref + violations++ + next + } + + printf " ok %s:%d %s (%s)\n", FILENAME, FNR, ref, comment + } + END { + if (checked == 0) { + print "hygiene: no uses: references found; this check would verify nothing" + exit 1 + } + if (violations > 0) { + printf "hygiene: %d of %d reference(s) rejected; every uses: must read owner/repo@<40-hex-sha> # \n", violations, checked + exit 1 + } + printf "hygiene: %d reference(s) checked, all SHA-pinned with a version comment\n", checked + } + ' "${files[@]}" + + # Runtime job. Bun only, deliberately: this package targets OMP's runtime and + # has no npm runtime dependencies, so a Node job would assert a compatibility + # nobody consumes. + bun: + name: bun + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out the source tree + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + timeout-minutes: 5 + with: + persist-credentials: false + + - name: Install the pinned Bun toolchain + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + timeout-minutes: 5 + with: + # Pinned, not `latest`: a runtime that changes underneath a previously + # green build makes a red one unexplainable. Bump deliberately, in + # step with `@types/bun` in `package.json` -- that package supplies + # every runtime type this code uses, so the two move together. + bun-version: 1.3.14 + + - name: Install dependencies + timeout-minutes: 5 + # `--frozen-lockfile` is the point of committing `bun.lock`: a + # dependency resolving differently in CI than locally is a failure, not + # an update to perform silently. + run: bun install --frozen-lockfile + + - name: Report the toolchain versions + timeout-minutes: 5 + # Printed, not assumed: every result below belongs to these versions. + run: | + bun --version + bunx tsc --version + tar --version | head -n 1 + + - name: Type-check + timeout-minutes: 5 + # Through the package scripts, not the underlying commands: a flag that + # lives in only one of the two places is a difference between what a + # contributor runs and what the gate runs. + run: bun run typecheck + + - name: Unit tests + timeout-minutes: 10 + run: bun run test:unit + + - name: Build and load the standalone extension bundle + timeout-minutes: 5 + run: bun run test:packaging + + # `test:packaging` rebuilds `dist/index.js` before loading it, so a green + # packaging step proves that a fresh bundle builds and registers what it + # claims -- never that the committed one matches the source beside it. The + # README tells an operator to load the committed file directly after + # `git clone` with no build step, so without this a pull request carrying + # an innocuous source diff and a substituted bundle would merge green. + - name: Verify the committed bundle matches the one built from source + timeout-minutes: 5 + run: | + set -euo pipefail + + # A pathspec matching nothing makes `git diff` exit 0, so the path is + # confirmed tracked before its diff is trusted. + git ls-files --error-unmatch dist/index.js + git diff --exit-code -- dist/index.js + + # Runtime job. Proves the install command the README documents actually + # resolves on a clean machine, and that the installed tree exposes a loadable + # extension entry -- the same thing OMP's plugin installer validates before + # enabling a plugin. + # + # Not a pull-request job: it installs by ref, and a pull request's merge ref + # does not exist on the remote as an installable ref. It therefore runs on + # manual dispatch and on pushes, which includes the tag push that publishes a + # release, so the documented command is verified against the exact ref an + # operator will type. It is deliberately absent from `ci`'s `needs`: a skipped + # job would otherwise fail the required check on every pull request. + install-check: + name: install check + if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Install the pinned Bun toolchain + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + timeout-minutes: 5 + with: + bun-version: 1.3.14 + + - name: Install the documented git spec into a scratch project + timeout-minutes: 10 + env: + SPEC: github:${{ github.repository }}#${{ github.ref_name }} + run: | + set -euo pipefail + + work="$(mktemp -d)" + cd "$work" + printf '{"name":"install-check","private":true}\n' > package.json + echo "installing $SPEC" + bun add "$SPEC" + + cat > check.ts <<'TS' + import { readFileSync } from "node:fs"; + + interface Manifest { + readonly version?: string; + readonly license?: string; + readonly omp?: { readonly extensions?: readonly string[] }; + } + + const root = "node_modules/omp-codebase-memory"; + const manifest = JSON.parse(readFileSync(`${root}/package.json`, "utf8")) as Manifest; + const entries = manifest.omp?.extensions ?? []; + if (entries.length === 0) { + throw new Error("the installed manifest declares no omp.extensions"); + } + + for (const entry of entries) { + const resolved = new URL( + `${root}/${entry.replace(/^\.\//, "")}`, + `file://${process.cwd()}/`, + ); + const module = (await import(resolved.href)) as { default?: unknown }; + if (typeof module.default !== "function") { + throw new Error(`${entry} does not default-export an extension factory`); + } + console.log(`ok ${entry} -> ${(module.default as { name?: string }).name}`); + } + + console.log( + `installed omp-codebase-memory ${manifest.version} with license ${manifest.license}`, + ); + TS + bun check.ts + + # The single status check branch protection requires. Runtime jobs are never + # named there, so changing the matrix never means editing the ruleset. + ci: + name: ci + needs: [hygiene, bun] + # `always()` is load-bearing. Without it this job is skipped when a + # dependency fails, and a skipped required check blocks a pull request + # rather than failing it -- a stuck merge button instead of a red one. + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Require every runtime job to succeed + timeout-minutes: 5 + env: + RESULTS: ${{ toJSON(needs) }} + run: | + set -euo pipefail + + count=$(jq 'length' <<< "$RESULTS") + if [ "$count" -eq 0 ]; then + echo "ci: no aggregated jobs; the gate would report success while verifying nothing" + exit 1 + fi + + echo "ci: aggregating $count job result(s)" + jq -r 'to_entries[] | " \(.key): \(.value.result)"' <<< "$RESULTS" + + failed=$(jq -r 'to_entries[] | select(.value.result != "success") | .key' <<< "$RESULTS") + if [ -n "$failed" ]; then + echo "ci: gate failed; these jobs did not succeed:" + printf ' %s\n' $failed + exit 1 + fi + + echo "ci: all $count job(s) succeeded" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5795f4d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + # A tag is a claim about four things: the tag itself, the package manifest + # version, the catalog plugin version, and the catalog's source ref. An + # operator installing from the marketplace resolves the last one, so a catalog + # that lags the tag installs the previous release under the new version's + # name. Publish only if all four agree. + version-gate: + name: version gate + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out the tagged tree + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + timeout-minutes: 5 + with: + persist-credentials: false + + - name: Tag, manifest and catalog must name one version + timeout-minutes: 5 + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + version="${TAG#v}" + manifest="$(jq -r '.version' package.json)" + catalog="$(jq -r '.plugins[0].version' .omp-plugin/marketplace.json)" + catalog_ref="$(jq -r '.plugins[0].source.ref' .omp-plugin/marketplace.json)" + + printf 'tag=%s\nmanifest=%s\ncatalog=%s\ncatalog_ref=%s\n' \ + "$TAG" "$manifest" "$catalog" "$catalog_ref" + + fail=0 + if [ "$manifest" != "$version" ]; then + echo "::error::package.json version $manifest does not match tag $TAG" + fail=1 + fi + if [ "$catalog" != "$version" ]; then + echo "::error::catalog plugin version $catalog does not match tag $TAG" + fail=1 + fi + if [ "$catalog_ref" != "$TAG" ]; then + echo "::error::catalog source.ref $catalog_ref does not match tag $TAG" + fail=1 + fi + exit "$fail" + + # The same gate a pull request passes, not a copy of it. `ci.yml` declares + # `workflow_call` for exactly this. + checks: + name: checks + uses: ./.github/workflows/ci.yml + + publish: + name: publish + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: [version-gate, checks] + permissions: + contents: write + steps: + - name: Check out the tagged tree + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + timeout-minutes: 5 + + - name: Create the GitHub release + timeout-minutes: 5 + env: + GH_TOKEN: ${{ github.token }} + # `--verify-tag` refuses to create a release for a tag that does not + # exist on the remote, so a release can only ever name a verified ref. + run: gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12c31ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Dependencies. `bun.lock` is committed on purpose: CI installs with +# `--frozen-lockfile`, so the lockfile is an input to the build, not scratch. +node_modules/ + +# Build scratch. `dist/index.js` is committed on purpose -- the README tells an +# operator to load it directly after `git clone`, and CI verifies it is +# byte-identical to a fresh build -- so `dist/` as a whole is NOT ignored. +*.tsbuildinfo + +# Editor and OS noise. +.DS_Store +.idea/ +.vscode/ + +# Local scratch used while verifying acquisition against a throwaway HOME. +tmp/ +tmp_* diff --git a/.omp-plugin/marketplace.json b/.omp-plugin/marketplace.json new file mode 100644 index 0000000..fb79296 --- /dev/null +++ b/.omp-plugin/marketplace.json @@ -0,0 +1,26 @@ +{ + "name": "omp-codebase-memory", + "owner": { + "name": "pashifika" + }, + "metadata": { + "description": "OMP-native wiring for codebase-memory-mcp" + }, + "plugins": [ + { + "name": "omp-codebase-memory", + "description": "Owns the codebase-memory-mcp executable lifecycle -- verified download, version tracking, pinning -- and wires exactly one MCP server entry into OMP's native user configuration. Adopts an existing system installation rather than replacing it.", + "version": "0.1.0", + "source": { + "source": "github", + "repo": "pashifika/omp-codebase-memory", + "ref": "v0.1.0" + }, + "category": "productivity", + "homepage": "https://github.com/pashifika/omp-codebase-memory", + "repository": "https://github.com/pashifika/omp-codebase-memory", + "license": "MIT", + "tags": ["mcp", "codebase-memory", "knowledge-graph", "installer"] + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b85803 --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# omp-codebase-memory + +An [OMP](https://github.com/can1357/oh-my-pi) extension that owns the +[`codebase-memory-mcp`](https://github.com/DeusData/codebase-memory-mcp) (CBM) +executable's lifecycle and wires exactly one MCP server entry into OMP's native +user configuration. + +CBM indexes a repository into a persistent code knowledge graph and exposes it +over MCP. Its own installer configures 44 client surfaces, and OMP is not one of +them: OMP only sees CBM indirectly, by discovering a Claude, Codex, or Gemini +config that some *other* client's installation happened to leave behind. An +OMP-only machine gets nothing. + +This package is the missing path. `omp plugin install` is the whole setup step. + +## What it does + +- **Resolves the executable, system installation first.** An existing + `codebase-memory-mcp` is adopted as-is and never replaced. A managed copy is + downloaded only when none is found. +- **Verifies a download the way upstream's installer does.** Release tag from + the `releases/latest` redirect, `checksums.txt` digest match for the exact + archive name, HTTPS on every redirect hop, a closed four-member archive + namespace, regular-file-not-symlink extraction, the Linux `-portable` build, + macOS quarantine removal and ad-hoc signing, and a `--version` smoke run + before anything is adopted. +- **Owns one key in `~/.omp/agent/mcp.json`.** `codebase-memory-mcp`, written + with the resolved absolute path, corrected when that path changes, removed on + uninstall. No other user file is touched. +- **Tracks versions without fighting CBM's own updater.** A managed copy is + updated by this package. An adopted system copy is only reported on. + +## Install + +### Git spec (primary) + +```sh +omp plugin install github:pashifika/omp-codebase-memory +``` + +This is the command CI installs into a scratch project on every push, so it is +verified against the exact ref you type. + +### Marketplace + +```sh +omp plugin marketplace add github:pashifika/omp-codebase-memory +omp plugin install omp-codebase-memory@omp-codebase-memory +``` + +The catalog lives at `.omp-plugin/marketplace.json`. Marketplace installs are +discovered through a different provider than git installs, so this is an +additional entry point rather than a replacement. + +### Development + +```sh +git clone https://github.com/pashifika/omp-codebase-memory +cd omp-codebase-memory +bun install +omp plugin link . +``` + +`omp.extensions` names `./dist/index.js`, which is committed, so a fresh clone +loads without a build step. Run `bun run build` after changing anything under +`src/`; CI fails if the committed bundle is not byte-identical to one built from +the current source. + +## Commands + +| Command | What it does | +|---|---| +| `/cbm status` | Resolved source, absolute path, local version, last known upstream version, pin state, resolved agent directory, and whether the MCP entry is present and current | +| `/cbm install [version]` | Downloads, verifies, and adopts a managed copy. Asks for confirmation first when a system executable already resolves | +| `/cbm update` | Updates a managed copy. For an adopted system copy, reports the newer version and points at CBM's own `update` | +| `/cbm pin ` | Holds a version: update checks report but never adopt | +| `/cbm unpin` | Releases the pin | +| `/cbm uninstall` | Removes the managed copy, this package's state, and the owned MCP entry. Leaves an adopted system executable alone | + +No command needs an interactive terminal. In a session with no UI, `/cbm +install` fails with the reason rather than waiting for a confirmation that +cannot arrive. + +## The system-first policy, and why it is not negotiable + +Resolution order is **pin, `PATH`, `~/.local/bin`, managed copy** — system +before managed. + +CBM resolves one canonical per-account cache root, and refuses to run when a +process is configured with a different root while any CBM session or command is +active. Two executables of *different versions* sharing that root produce +mismatched index generations. Giving a managed copy its own private cache root +would avoid the conflict by re-indexing every repository a second time, which +for a large tree is hours of work and gigabytes to hold the same answers twice. + +So the operator's existing installation wins. The cost is that this package +cannot guarantee a version, and that cost is made visible rather than hidden: +`/cbm status` names the source, and an out-of-date system copy produces a +pointer to CBM's own `update` rather than an attempt to perform it. + +`/cbm install` while a system copy resolves is still possible — it is your +machine — but it explains the shared-cache-root consequence and requires +explicit confirmation first. + +## Where things live + +| Path | Owner | Notes | +|---|---|---| +| `~/.omp/codebase-memory/bin//` | this package | Managed executables, one directory per version | +| `~/.omp/codebase-memory/state.json` | this package | Version pointer, digest, pin, last check time | +| `/mcp.json` | the operator | This package owns the single `codebase-memory-mcp` key and nothing else | +| `~/.local/bin/codebase-memory-mcp` | CBM's installer | Read during resolution, **never** written | + +`` is resolved the way OMP resolves it: `PI_CODING_AGENT_DIR` when +set, otherwise `~/.omp/profiles//agent` under `OMP_PROFILE`/`PI_PROFILE`, +otherwise `~/.omp/agent`. A profile-scoped operator therefore gets the entry +only in the active profile — writing every profile would configure profiles you +never asked about — and `/cbm status` names the directory it resolved so the +scope is visible. + +The managed copy lives outside the plugin tree on purpose. OMP caches plugins in +version-qualified directories and replaces them on reinstall, so an executable +stored inside would be discarded by a routine plugin upgrade and re-downloaded +every time. + +## Rollback + +Two steps, in either order: + +```sh +/cbm uninstall # managed copy, state, and the owned MCP entry +omp plugin uninstall omp-codebase-memory +``` + +Neither touches an adopted system executable, CBM's cache, or any other client's +configuration. + +## Requirements + +- **Bun**, which is OMP's runtime. No npm runtime dependencies. +- **`tar`** for archive extraction. +- **`xattr` and `codesign`** on macOS. Both ship with a default install; their + absence is reported as a named prerequisite rather than a mysterious failure. +- macOS and Linux. Windows is deferred, not refused: it needs zip extraction, a + different executable suffix, and its own path handling, and is currently one + explicit unsupported-platform error rather than a half-implemented branch. + +## Known limits + +- **MCP does not pick up a changed `command` without a reload.** A managed + update changes the resolved path mid-session; the entry is corrected + immediately and you are told the session needs `/mcp reload`. +- **A foreign entry of the same name is left alone.** If `mcp.json` already + defines `codebase-memory-mcp` with a `command` this package did not write, the + file is not modified and both paths are reported. CBM's own installer, another + tool, or a hand edit may already own that name. +- **`~/.omp/agent/mcp.json` has two possible writers.** OMP's own `/mcp add` and + this package have no shared lock. The write is a read-modify-write against the + observed content and fails closed on a shape it does not recognise, so a lost + update degrades to "the entry is missing and the next session start rewrites + it" rather than a corrupted file. + +## Development + +```sh +bun install # --frozen-lockfile in CI +bun run typecheck +bun run test:unit +bun run test:packaging # rebuilds dist/index.js, then loads it +bun run build # commit the result +``` + +Tests are written as case tables: one row per case, named by a `scenario` field, +so a failure names the case without anyone reading the table. + +## Licence + +MIT. See [LICENSE](./LICENSE). diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..45daabb --- /dev/null +++ b/bun.lock @@ -0,0 +1,403 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "omp-codebase-memory", + "devDependencies": { + "@oh-my-pi/pi-coding-agent": "18.0.8", + "@types/bun": "1.3.14", + "typescript": "5.9.3", + }, + }, + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], + + "@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="], + + "@huggingface/transformers": ["@huggingface/transformers@4.2.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.6", "@huggingface/tokenizers": "^0.1.3", "onnxruntime-node": "1.24.3", "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", "sharp": "^0.34.5" } }, "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + + "@oh-my-pi/hashline": ["@oh-my-pi/hashline@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8" } }, "sha512-trZWvlk6A5vSWaaMfuCl9798Nk2r16CtqhKAZGQW+Yy8ArQEknqYjh39jZkV6eRrFxGzn/pJfaKkA3FkKUEinw=="], + + "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.0.8", "@oh-my-pi/pi-catalog": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-xtraAY/6WPCQn2XACU48BBCc/0zenRKo+5h1Sc7nuUGP6xk6rSSfG/2GmIK6tGctLWXiCEjBYWlTW1ktODqcTQ=="], + + "@oh-my-pi/omptype": ["@oh-my-pi/omptype@18.0.8", "", {}, "sha512-1g1Q8xAnrA62he2sP/JW84zZine7PvaXjOhgHcmauFvU2HSSIJHX5t8iF5+gcvX/s6P0h4wZSS/aGfVCHeMT4w=="], + + "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.0.8", "@oh-my-pi/pi-catalog": "18.0.8", "@oh-my-pi/pi-natives": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8", "@oh-my-pi/pi-wire": "18.0.8", "@oh-my-pi/snapcompact": "18.0.8", "@opentelemetry/api": "^1.9.1" } }, "sha512-xGmfu7jNhzaBe+ja5zJNCvxkMQR6cJ7M9SZ2Hwmc46i8C5AY6qV+7DibirL262lNPkMPP/blYvdDIAYZ+LYj2g=="], + + "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@18.0.8", "", { "dependencies": { "@oh-my-pi/omptype": "18.0.8", "@oh-my-pi/pi-catalog": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8", "@oh-my-pi/pi-wire": "18.0.8" } }, "sha512-PRqG3mMeMUzdo+OJs1Mb7K4I2rOYmpE2OxPL9FOdoHkyfXbGLemOPW1sYiUF5qZvbKy3b5Nr9cfRXyIhtOPSRw=="], + + "@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@18.0.8", "", { "dependencies": { "@oh-my-pi/omptype": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8" } }, "sha512-wLSCO7jYlO//tNzWNcGEnnzF9WH9s6VVwUYN1iyU9iQOh1IqKzYrd6RUjPD5PWDySEIdz2pVHcqZn6WhzrEaiQ=="], + + "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@18.0.8", "", { "dependencies": { "@babel/parser": "^7.29.7", "@oh-my-pi/hashline": "18.0.8", "@oh-my-pi/omp-stats": "18.0.8", "@oh-my-pi/omptype": "18.0.8", "@oh-my-pi/pi-agent-core": "18.0.8", "@oh-my-pi/pi-ai": "18.0.8", "@oh-my-pi/pi-catalog": "18.0.8", "@oh-my-pi/pi-mnemopi": "18.0.8", "@oh-my-pi/pi-natives": "18.0.8", "@oh-my-pi/pi-tui": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8", "@oh-my-pi/pi-wire": "18.0.8", "@oh-my-pi/snapcompact": "18.0.8", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "puppeteer-core": "25.3.0" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-ZdSj93aSrxbjP3PWqm1Q/jbX9hr3LtFNCpmpTb4EwlYwBClroAJChmuPmC0+gDqECf276Xo/m/QoezlrPg9zsA=="], + + "@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.0.8", "@oh-my-pi/pi-catalog": "18.0.8", "@oh-my-pi/pi-natives": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-FHzFbi5iMRcETpumq/i1T9zL7EzJReiVws4pBYvIZ6zN1EECTldle2A63TXHJCW+znbh/UhFc//IZzj4nm53oA=="], + + "@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@18.0.8", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "18.0.8", "@oh-my-pi/pi-natives-darwin-x64": "18.0.8", "@oh-my-pi/pi-natives-linux-arm64": "18.0.8", "@oh-my-pi/pi-natives-linux-x64": "18.0.8", "@oh-my-pi/pi-natives-win32-x64": "18.0.8" } }, "sha512-H+DkXduyXBotqov9puMV2V14E3LYSPeGyZAx0BPt+HqirXMR9LREWjTmy4I5X3Fgszars+N7J2VQ5t55/kbT4w=="], + + "@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@18.0.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NUaJkzJwjO/ISewUm3AcW/AdiePGISwJmhLwJ21nwq9MGQFNNFTPxDKTfRSjpnKGJzRYXuu1unJxl6SLG84cgg=="], + + "@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@18.0.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-/zvIKXgaHrC9lFHLkLsObGQSKbBktv3OLj09TfTD22uFjghZmmZ7iZk0PMts0hq1Kt0S9YQcbaBfKdpFDPs0vw=="], + + "@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@18.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-U5E1hV5sEckmg0/PXzcMOUnMkawe3eMfhSWeL/LFmQPqiioTPRZrM307hFl26PIqKqeqJ3SGCzEgl+nmLqRAHQ=="], + + "@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@18.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-t5AWTA4E9R876x+ijgcN5hX27p0np4xdQnAuo20/Vj3uOBEUDwc+a+QQYD9YS54Gh/UhQN7FawRgvVjI4ojgag=="], + + "@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@18.0.8", "", { "os": "win32", "cpu": "x64" }, "sha512-16KDe4Jjh8jC1GIDMwlw5cQYYXu1WBf5Z/VGHs5QgGRLtaZ/GwPRaWXbdN22k5XAVaFKCCsyXF9Hdswq2oP4YA=="], + + "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8" } }, "sha512-61KKt1PDdDk8NNj00QVP82hGvCiBTFfY+nYAXKfVlDC4bILWai2gGHnvmHg8woh8GzQpoN42lyIk1IiSTt6Jyg=="], + + "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.0.8" } }, "sha512-JmN6QAyiBpyWIuQ6ZFyntWkRb96C188j+ZdgukDYug7VElnH81lhMlySCoIPs++OIKxXEFz/s5KbmWR/vxnQ0g=="], + + "@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@18.0.8", "", {}, "sha512-5CTEkud+SHxopT6fgD5ZROZhu7w0m13/miHo2Gsf2RvIWBfhIwaiB5Cqn/d3FMTafjILjTfUMlS66/CsYWDeGg=="], + + "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.0.8", "@oh-my-pi/pi-catalog": "18.0.8", "@oh-my-pi/pi-natives": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8", "@oh-my-pi/pi-wire": "18.0.8" } }, "sha512-U9fCPYmNdekmrcCTDjlpIPSlmilORjGEwzFkPRESOpx1eTXJrYxpBYYuhgs7ZeNYFlgJu3lLgelBxGxdJaMJcg=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.10.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.9.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="], + + "@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/sdk-logs": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg=="], + + "@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA=="], + + "@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA=="], + + "@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-transformer": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-logs": "0.220.0", "@opentelemetry/sdk-metrics": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ=="], + + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.10.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.10.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/sdk-trace-base": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], + + "adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="], + + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + + "chromium-bidi": ["chromium-bidi@16.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + + "devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lucide-react": ["lucide-react@1.34.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="], + + "onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="], + + "onnxruntime-web": ["onnxruntime-web@1.26.0-dev.20260416-b7804b056c", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw=="], + + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], + + "puppeteer-core": ["puppeteer-core@25.3.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.6", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1638949", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.0" } }, "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-m3QMOGUHVPUl7bAOPc7oJtmzOVdzQVQPsCuTjNuQ2N6zlYuXDZu9NdUR3Er3rxbhHa6SmWa94flUwmUsO1atow=="], + + "sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-3X5FQ9PpwlBmxLqtb2/uvl7/W2HXXzXHT0MLmB9c0TRMANLujzvIF7YbT6VW3k92ClL/1q9sbIOul0rHgxCTmw=="], + + "sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-xSzhqrGFbBrykpbEFt5dLXB3Sp3a8idNzjhf92uylDgUhupBSRHj3ISeYaL9ZZWTNSzszNXBKlM+xH36dHQjPg=="], + + "sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HiR3yolQDl3WoU1zSXn8L0MSXzF9PXLQpMv+Ptozg1y/DJU8HuvoXBasOarX4cer5d9kGimnOUGm0dl47PCN1A=="], + + "sherpa-onnx-node": ["sherpa-onnx-node@1.13.2", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.13.2", "sherpa-onnx-darwin-x64": "^1.13.2", "sherpa-onnx-linux-arm64": "^1.13.2", "sherpa-onnx-linux-x64": "^1.13.2", "sherpa-onnx-win-ia32": "^1.13.2", "sherpa-onnx-win-x64": "^1.13.2" } }, "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA=="], + + "sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-CN6rNQqn0wzdSp3XRHxdmNaLUMhP+BgsWVbaASU+V9CYBdx74uutoA/43ThUfh3S3CiArpp8OJtn8dJrPeGHnw=="], + + "sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.6", "", { "os": "win32", "cpu": "x64" }, "sha512-bPMdURD1XCu1Zr3eYYlYx2obc5CMSQhEnd1GQuv7FXjdyOeMJ8jNbQEKFEIAyQdC481FgZvPBb2oSJXaLOOeVw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + + "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="], + + "@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + } +} diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..152e1d5 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,949 @@ +// @bun +// src/index.ts +import { existsSync } from "fs"; + +// src/platform.ts +import { cpus } from "os"; + +class UnsupportedPlatformError extends Error { + constructor(message) { + super(message); + this.name = "UnsupportedPlatformError"; + } +} +function describeTarget(os, arch) { + const container = os === "windows" ? "zip" : "tar.gz"; + const executable = os === "windows" ? "codebase-memory-mcp.exe" : "codebase-memory-mcp"; + const installer = os === "windows" ? "install.ps1" : "install.sh"; + const portable = os === "linux" ? "-portable" : ""; + const archive = `codebase-memory-mcp-${os}-${arch}${portable}.${container}`; + return { + os, + arch, + archive, + container, + executable, + installer, + members: [executable, "LICENSE", installer, "THIRD_PARTY_NOTICES.md"] + }; +} +function detectTarget(platform, arch, cpuModel) { + let os; + switch (platform) { + case "darwin": + os = "darwin"; + break; + case "linux": + os = "linux"; + break; + case "win32": + throw new UnsupportedPlatformError("Windows is not supported yet: the release archive is a zip this package cannot extract, " + "and the executable suffix and path handling are unimplemented. " + "Install codebase-memory-mcp with upstream's install.ps1 and this package will adopt it from PATH."); + default: + throw new UnsupportedPlatformError(`unsupported operating system: ${platform} (supported: darwin, linux)`); + } + let target; + switch (arch) { + case "arm64": + target = "arm64"; + break; + case "x64": + target = os === "darwin" && /apple/i.test(cpuModel ?? "") ? "arm64" : "amd64"; + break; + default: + throw new UnsupportedPlatformError(`unsupported architecture: ${arch} (supported: arm64, x64)`); + } + return describeTarget(os, target); +} +function hostTarget() { + return detectTarget(process.platform, process.arch, cpus()[0]?.model); +} + +// src/paths.ts +import { homedir } from "os"; +import path from "path"; +function processHost() { + return { home: homedir(), env: process.env }; +} +var EXECUTABLE_NAME = "codebase-memory-mcp"; +var SERVER_NAME = "codebase-memory-mcp"; +function configDirName(host) { + const override = host.env["PI_CONFIG_DIR"]; + return override !== undefined && override !== "" ? override : ".omp"; +} +function agentDir(host) { + const explicit = host.env["PI_CODING_AGENT_DIR"]; + if (explicit !== undefined && explicit !== "") + return path.resolve(explicit); + const omp = host.env["OMP_PROFILE"]; + const profile = omp !== undefined ? omp : host.env["PI_PROFILE"]; + const root = path.join(host.home, configDirName(host)); + return profile !== undefined && profile !== "" ? path.join(root, "profiles", profile, "agent") : path.join(root, "agent"); +} +function mcpConfigPath(host) { + return path.join(agentDir(host), "mcp.json"); +} +function nativeExtensionPath(host) { + return path.join(agentDir(host), "extensions", "codebase-memory.ts"); +} +function packageRoot(host) { + return path.join(host.home, configDirName(host), "codebase-memory"); +} +function managedBinRoot(host) { + return path.join(packageRoot(host), "bin"); +} +function managedExecutable(host, version) { + return path.join(managedBinRoot(host), version, EXECUTABLE_NAME); +} +function statePath(host) { + return path.join(packageRoot(host), "state.json"); +} +function upstreamInstallDir(host) { + return path.join(host.home, ".local", "bin"); +} + +// src/release.ts +var UPSTREAM_REPO = "DeusData/codebase-memory-mcp"; +var RELEASES = `https://github.com/${UPSTREAM_REPO}/releases`; +var LATEST = `${RELEASES}/latest`; +var MAX_REDIRECTS = 5; +var DEFAULT_TIMEOUT_MS = 20000; +var CHECKSUMS_LIMIT_BYTES = 1048576; +async function fetchHttps(url, options = {}) { + const budget = Math.min(options.maxRedirects ?? MAX_REDIRECTS, MAX_REDIRECTS); + let current = requireHttps(url, "request"); + for (let hop = 0;; hop++) { + const response = await fetch(current, { + redirect: "manual", + signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS), + headers: { accept: "*/*" } + }); + const redirected = response.status >= 300 && response.status < 400; + if (!redirected || hop >= budget) + return response; + const location = response.headers.get("location"); + if (location === null) { + throw new Error(`${current} answered ${response.status} with no location header`); + } + current = requireHttps(new URL(location, current).href, "redirect"); + } +} +function requireHttps(url, kind) { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + throw new Error(`refusing non-HTTPS ${kind}: ${url}`); + } + return parsed.href; +} +async function resolveLatestTag() { + const response = await fetchHttps(LATEST, { maxRedirects: 0 }); + if (response.status < 300 || response.status >= 400) { + throw new Error(`expected ${LATEST} to redirect to a tag, got HTTP ${response.status}`); + } + const location = response.headers.get("location"); + if (location === null) { + throw new Error(`${LATEST} answered ${response.status} with no location header`); + } + const resolved = new URL(location, LATEST); + if (resolved.protocol !== "https:") { + throw new Error(`refusing non-HTTPS release location: ${resolved.href}`); + } + const prefix = `/${UPSTREAM_REPO}/releases/tag/`; + if (!resolved.pathname.startsWith(prefix)) { + throw new Error(`unexpected release location: ${resolved.href}`); + } + const tag = decodeURIComponent(resolved.pathname.slice(prefix.length)); + if (tag === "" || tag.includes("/")) { + throw new Error(`unexpected release tag in location: ${resolved.href}`); + } + return tag; +} +function parseChecksums(body, archive) { + if (body.byteLength > CHECKSUMS_LIMIT_BYTES) { + throw new Error(`checksums.txt is ${body.byteLength} bytes, over the ${CHECKSUMS_LIMIT_BYTES} byte safety limit`); + } + const text = new TextDecoder("utf-8", { fatal: false }).decode(body); + let digest; + for (const line of text.split(` +`)) { + const fields = line.trim().split(/\s+/u); + if (fields.length < 2) + continue; + const name = fields[1] ?? ""; + if (name !== archive && name !== `*${archive}`) + continue; + const candidate = (fields[0] ?? "").toLowerCase(); + if (!/^[0-9a-f]{64}$/u.test(candidate)) { + throw new Error(`invalid SHA-256 digest for ${archive}: ${fields[0] ?? ""}`); + } + if (digest !== undefined && digest !== candidate) { + throw new Error(`conflicting SHA-256 digests for ${archive} in checksums.txt`); + } + digest = candidate; + } + if (digest === undefined) { + throw new Error(`no SHA-256 digest for ${archive} in checksums.txt`); + } + return digest; +} +function githubReleaseSource() { + return { + latestTag: resolveLatestTag, + checksums: (tag) => download(`${RELEASES}/download/${encodeURIComponent(tag)}/checksums.txt`), + asset: (tag, name) => download(`${RELEASES}/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`) + }; +} +async function download(url) { + const response = await fetchHttps(url); + if (!response.ok) { + throw new Error(`GET ${url} answered HTTP ${response.status}`); + } + return new Uint8Array(await response.arrayBuffer()); +} + +// src/exec.ts +var DEFAULT_TIMEOUT_MS2 = 30000; +async function run(argv, options = {}) { + try { + const child = Bun.spawn([...argv], { + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS2, + ...options.cwd === undefined ? {} : { cwd: options.cwd } + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited + ]); + return { ok: exitCode === 0, exitCode, stdout, stderr }; + } catch (error) { + return { + ok: false, + exitCode: -1, + stdout: "", + stderr: "", + spawnError: error instanceof Error ? error.message : String(error) + }; + } +} +async function readVersion(executable) { + const result = await run([executable, "--version"], { timeoutMs: 1e4 }); + if (!result.ok) + return null; + const reported = `${result.stdout}${result.stderr}`.trim(); + return reported === "" ? null : reported.split(` +`)[0]?.trim() ?? null; +} +function haveTool(tool, pathEnv) { + return Bun.which(tool, pathEnv === undefined ? {} : { PATH: pathEnv }) !== null; +} + +// src/state.ts +import { mkdir } from "fs/promises"; +import path2 from "path"; +var EMPTY = {}; +async function readState(host) { + const file = Bun.file(statePath(host)); + let text; + try { + text = await file.text(); + } catch { + return EMPTY; + } + let parsed; + try { + parsed = JSON.parse(text); + } catch { + return EMPTY; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + return EMPTY; + const record = parsed; + const state = {}; + for (const key of ["managedVersion", "managedDigest", "pin", "upstreamVersion", "wroteCommand"]) { + const value = record[key]; + if (typeof value === "string" && value !== "") + state[key] = value; + } + const lastCheckedAt = record["lastCheckedAt"]; + if (typeof lastCheckedAt === "number" && Number.isFinite(lastCheckedAt)) { + state["lastCheckedAt"] = lastCheckedAt; + } + return state; +} +async function writeState(host, next) { + const file = statePath(host); + await mkdir(path2.dirname(file), { recursive: true }); + await Bun.write(file, `${JSON.stringify(next, null, 2)} +`); +} +async function updateState(host, patch) { + const next = { ...await readState(host), ...patch }; + await writeState(host, next); + return next; +} + +// src/resolve.ts +import path3 from "path"; +var NO_EXECUTABLE_REASON = `no ${EXECUTABLE_NAME} executable found on PATH, in ~/.local/bin, or under this package's own root. ` + "Run /cbm install to download a managed copy, or install it yourself with " + "`curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash` " + "and this package will adopt it."; +async function managedCopy(host, state) { + const recorded = (state ?? await readState(host)).managedVersion; + if (recorded === undefined) + return null; + const executable = managedExecutable(host, recorded); + return await Bun.file(executable).exists() ? { version: recorded, executable } : null; +} +async function resolveExecutable(host, state) { + const current = state ?? await readState(host); + const pin = current.pin; + if (pin !== undefined) { + const pinned = managedExecutable(host, pin); + if (await Bun.file(pinned).exists()) { + return { ok: true, resolved: { executable: pinned, source: "pin", origin: pin } }; + } + } + const onPath = Bun.which(EXECUTABLE_NAME, pathOption(host)); + if (onPath !== null) { + return { + ok: true, + resolved: { executable: path3.resolve(onPath), source: "system", origin: "PATH" } + }; + } + const upstream = path3.join(upstreamInstallDir(host), EXECUTABLE_NAME); + if (await Bun.file(upstream).exists()) { + return { + ok: true, + resolved: { executable: upstream, source: "system", origin: "~/.local/bin" } + }; + } + const managed = await managedCopy(host, current); + if (managed !== null) { + return { + ok: true, + resolved: { + executable: managed.executable, + source: "managed", + origin: path3.join(path3.basename(managedBinRoot(host)), managed.version) + } + }; + } + return { ok: false, reason: NO_EXECUTABLE_REASON }; +} +async function resolvedVersion(resolved) { + return await readVersion(resolved.executable); +} +function pathOption(host) { + return { PATH: host.env["PATH"] ?? "" }; +} + +// src/scheduler.ts +function schedulerFrom(ctx) { + return { + after(callback, ms) { + return ctx.setTimeout(callback, ms); + }, + cancel(handle) { + ctx.clearTimer(handle); + } + }; +} + +// src/lifecycle.ts +import { rm as rm2 } from "fs/promises"; + +// src/acquire.ts +import { chmod, lstat, mkdtemp, mkdir as mkdir2, rm } from "fs/promises"; +import { tmpdir } from "os"; +import path4 from "path"; +var VERSION_PATTERN = /^[0-9][0-9A-Za-z.+-]*$/u; +function normalizeVersion(value) { + const trimmed = value.trim().replace(/^v/iu, ""); + if (!VERSION_PATTERN.test(trimmed)) { + throw new Error(`not a usable version: ${value}`); + } + return trimmed; +} +function tagFor(version) { + return `v${version}`; +} +async function acquire(request) { + const { host, target, source } = request; + if (target.container !== "tar.gz") { + throw new UnsupportedPlatformError(`cannot extract a ${target.container} archive; only tar.gz is implemented`); + } + if (!haveTool("tar", host.env["PATH"])) { + throw new Error("tar is required to extract the release archive, and is not on PATH"); + } + const version = request.version === undefined ? normalizeVersion(await source.latestTag()) : normalizeVersion(request.version); + const tag = tagFor(version); + const expected = parseChecksums(await source.checksums(tag), target.archive); + const bytes = await source.asset(tag, target.archive); + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(bytes); + const actual = hasher.digest("hex"); + if (actual !== expected) { + throw new Error(`SHA-256 mismatch for ${target.archive} at ${tag}: published ${expected}, downloaded ${actual}`); + } + const scratch = await mkdtemp(path4.join(tmpdir(), "omp-codebase-memory-")); + try { + const archive = path4.join(scratch, target.archive); + await Bun.write(archive, bytes); + await assertArchiveMembers(archive, target); + await extract(archive, scratch, target); + const candidate = path4.join(scratch, target.executable); + await chmod(candidate, 493); + if (target.os === "darwin") + await repairMacOsSignature(host, candidate); + const reportedVersion = await smokeCheck(candidate, target); + return await adopt(host, { version, digest: expected, candidate, reportedVersion }); + } finally { + await rm(scratch, { recursive: true, force: true }); + } +} +async function assertArchiveMembers(archive, target) { + const listed = await run(["tar", "-tzf", archive]); + if (!listed.ok) { + throw new Error(`could not enumerate ${path4.basename(archive)}: ${listed.stderr.trim() || listed.spawnError || `tar exited ${listed.exitCode}`}`); + } + const seen = new Map; + for (const raw of listed.stdout.split(` +`)) { + const member = raw.trim().replace(/^\.\//u, "").replace(/\/+$/u, ""); + if (member === "") + continue; + if (!target.members.includes(member)) { + throw new Error(`release archive contains unexpected member: ${member}`); + } + seen.set(member, (seen.get(member) ?? 0) + 1); + } + for (const member of target.members) { + const count = seen.get(member) ?? 0; + if (count === 1) + continue; + throw new Error(count === 0 ? `release archive is missing member: ${member}` : `release archive contains member ${member} ${count} times`); + } +} +async function extract(archive, into, target) { + const extracted = await run(["tar", "--no-same-owner", "-xzf", archive, "-C", into]); + if (!extracted.ok) { + throw new Error(`could not extract ${path4.basename(archive)}: ${extracted.stderr.trim() || extracted.spawnError || `tar exited ${extracted.exitCode}`}`); + } + for (const member of target.members) { + const entry = path4.join(into, member); + let stats; + try { + stats = await lstat(entry); + } catch { + throw new Error(`release member is missing after extraction: ${member}`); + } + if (stats.isSymbolicLink() || !stats.isFile()) { + throw new Error(`release member is not a regular file: ${member}`); + } + } +} +function repairHint(candidate) { + return `xattr -cr ${candidate} && codesign --force --sign - ${candidate}`; +} +async function repairMacOsSignature(host, candidate) { + const pathEnv = host.env["PATH"]; + for (const tool of ["xattr", "codesign"]) { + if (!haveTool(tool, pathEnv)) { + throw new Error(`${tool} is required to prepare a macOS binary and is not on PATH; ` + `install the Xcode command line tools, or repair the candidate yourself with \`${repairHint(candidate)}\``); + } + } + await run(["xattr", "-d", "com.apple.quarantine", candidate], { timeoutMs: 1e4 }); + const signed = await run(["codesign", "--sign", "-", "--force", candidate], { + timeoutMs: 60000 + }); + if (!signed.ok) { + throw new Error(`could not ad-hoc sign the candidate: ${signed.stderr.trim() || `codesign exited ${signed.exitCode}`}. ` + `Repair it by hand with \`${repairHint(candidate)}\``); + } +} +async function smokeCheck(candidate, target) { + const reported = await readVersion(candidate); + if (reported !== null) + return reported; + const suffix = target.os === "darwin" ? ` If macOS is refusing to run it, try \`${repairHint(candidate)}\`.` : ""; + throw new Error(`the downloaded executable failed to run \`--version\`.${suffix}`); +} +async function adopt(host, candidate) { + const destination = path4.join(managedBinRoot(host), candidate.version); + await mkdir2(destination, { recursive: true }); + const executable = path4.join(destination, path4.basename(candidate.candidate)); + await Bun.write(executable, Bun.file(candidate.candidate)); + await chmod(executable, 493); + return { + version: candidate.version, + digest: candidate.digest, + executable, + reportedVersion: candidate.reportedVersion + }; +} + +// src/mcp-config.ts +import { mkdir as mkdir3 } from "fs/promises"; +import path5 from "path"; +var MCP_SCHEMA_URL = "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json"; +async function readMcpFile(host) { + const file = mcpConfigPath(host); + let text; + try { + text = await Bun.file(file).text(); + } catch { + return { + ok: true, + file: { path: file, text: null, document: {}, indent: " ", trailingNewline: true } + }; + } + let parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + return { + ok: false, + reason: `${file} is not parseable JSON, so it was left untouched: ${error instanceof Error ? error.message : String(error)}` + }; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { ok: false, reason: `${file} does not hold a JSON object, so it was left untouched` }; + } + return { + ok: true, + file: { + path: file, + text, + document: parsed, + indent: detectIndent(text), + trailingNewline: text.endsWith(` +`) + } + }; +} +async function upsertEntry(host, command, previouslyWrote) { + const read = await readMcpFile(host); + if (!read.ok) + return read; + const { file } = read; + const servers = serverMap(file.document); + const existing = servers[SERVER_NAME]; + if (existing !== undefined) { + const currentCommand = commandOf(existing); + const ours = currentCommand === command || currentCommand === previouslyWrote; + if (!ours) { + return { + ok: false, + reason: `${file.path} already defines ${SERVER_NAME} with command ${currentCommand ?? "(none)"}, ` + `which this package did not write. It was left untouched; the executable this package resolved is ${command}.` + }; + } + } + const next = { ...file.document }; + if (file.text === null) + next["$schema"] = MCP_SCHEMA_URL; + const entry = { + ...typeof existing === "object" && existing !== null && !Array.isArray(existing) ? existing : {}, + type: "stdio", + command, + args: [] + }; + next["mcpServers"] = { ...servers, [SERVER_NAME]: entry }; + const rendered = render(next, file); + if (file.text === rendered) + return { ok: true, change: "unchanged" }; + await mkdir3(path5.dirname(file.path), { recursive: true }); + await Bun.write(file.path, rendered); + return { ok: true, change: file.text === null ? "created" : "updated" }; +} +async function removeEntry(host, wroteCommand) { + const read = await readMcpFile(host); + if (!read.ok) + return read; + const { file } = read; + if (file.text === null) + return { ok: true, change: "absent" }; + const servers = serverMap(file.document); + const existing = servers[SERVER_NAME]; + if (existing === undefined) + return { ok: true, change: "absent" }; + const currentCommand = commandOf(existing); + if (wroteCommand === undefined || currentCommand !== wroteCommand) { + return { + ok: false, + reason: `${file.path} defines ${SERVER_NAME} with command ${currentCommand ?? "(none)"}, ` + `which is not what this package wrote (${wroteCommand ?? "nothing recorded"}). It was left in place.` + }; + } + const remaining = { ...servers }; + delete remaining[SERVER_NAME]; + const next = { ...file.document, mcpServers: remaining }; + await Bun.write(file.path, render(next, file)); + return { ok: true, change: "removed" }; +} +async function entryStatus(host, resolvedCommand) { + const read = await readMcpFile(host); + if (!read.ok) { + return { path: mcpConfigPath(host), present: false, current: false, problem: read.reason }; + } + const existing = serverMap(read.file.document)[SERVER_NAME]; + if (existing === undefined) { + return { path: read.file.path, present: false, current: false }; + } + const command = commandOf(existing); + return { + path: read.file.path, + present: true, + ...command === undefined ? {} : { command }, + current: command !== undefined && command === resolvedCommand + }; +} +function serverMap(document) { + const servers = document["mcpServers"]; + return typeof servers === "object" && servers !== null && !Array.isArray(servers) ? servers : {}; +} +function commandOf(entry) { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) + return; + const command = entry["command"]; + return typeof command === "string" ? command : undefined; +} +function detectIndent(text) { + const match = /\n([ \t]+)"/u.exec(text); + return match?.[1] ?? " "; +} +function render(document, file) { + const body = JSON.stringify(document, null, file.indent); + return file.trailingNewline ? `${body} +` : body; +} + +// src/lifecycle.ts +var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +async function status(lifecycle) { + const { host } = lifecycle; + const state = await readState(host); + const resolution = await resolveExecutable(host, state); + const resolved = resolution.ok ? resolution.resolved : null; + const lines = []; + if (!resolution.ok) { + lines.push(`executable: none (${resolution.reason})`); + } else { + const version = await resolvedVersion(resolution.resolved) ?? "unknown (it did not run)"; + lines.push(`executable: ${resolution.resolved.executable}`); + lines.push(`source: ${resolution.resolved.source} (${resolution.resolved.origin})`); + lines.push(`version: ${version}`); + } + const managed = await managedCopy(host, state); + lines.push(managed === null ? "managed: none under this package's root" : `managed: ${managed.version} at ${managed.executable}` + (resolved?.executable === managed.executable ? "" : " (present, not resolved)")); + lines.push(`upstream: ${state.upstreamVersion ?? "not checked yet"}`); + lines.push(`pin: ${state.pin ?? "none"}`); + lines.push(`agent dir: ${agentDir(host)}`); + const entry = await entryStatus(host, resolved?.executable ?? null); + if (entry.problem !== undefined) { + lines.push(`mcp entry: unreadable -- ${entry.problem}`); + } else if (!entry.present) { + lines.push(`mcp entry: absent from ${entry.path}`); + } else { + lines.push(`mcp entry: ${entry.current ? "current" : `stale, names ${entry.command ?? "(no command)"}`} in ${entry.path}`); + } + return { lines, resolved }; +} +async function install(lifecycle, version) { + const { host } = lifecycle; + let acquired; + try { + acquired = await acquire({ + host, + target: lifecycle.target, + source: lifecycle.source, + ...version === undefined ? {} : { version: normalizeVersion(version) } + }); + } catch (error) { + return { ok: false, message: `install failed: ${describe(error)}` }; + } + const state = await updateState(host, { + managedVersion: acquired.version, + managedDigest: acquired.digest, + upstreamVersion: acquired.version, + lastCheckedAt: Date.now() + }); + const resolution = await resolveExecutable(host, state); + if (!resolution.ok) { + return { + ok: false, + message: `adopted ${acquired.version} at ${acquired.executable}, but resolution then found nothing: ${resolution.reason}` + }; + } + const wiring = await wire(lifecycle, resolution.resolved, state); + const adopted = resolution.resolved.executable === acquired.executable ? `adopted ${acquired.version} (${acquired.reportedVersion}) at ${acquired.executable}` : `adopted ${acquired.version} at ${acquired.executable}, but resolution prefers ` + `${resolution.resolved.executable} (${resolution.resolved.source})`; + return { ok: true, message: `${adopted}. ${wiring.message}` }; +} +async function update(lifecycle) { + const { host } = lifecycle; + const state = await readState(host); + const resolution = await resolveExecutable(host, state); + if (resolution.ok && resolution.resolved.source === "system") { + const check = await checkUpstream(lifecycle, { force: true }); + return { + ok: true, + message: `${resolution.resolved.executable} is a system installation this package adopted, so it is not replaced here. ` + `Run \`${resolution.resolved.executable} update\` to update it. ${check.message}` + }; + } + if (state.pin !== undefined) { + const check = await checkUpstream(lifecycle, { force: true }); + return { + ok: true, + message: `version ${state.pin} is pinned, so nothing was adopted. ${check.message} Run /cbm unpin to release it.` + }; + } + return await install(lifecycle); +} +async function pin(lifecycle, version) { + let normalized; + try { + normalized = normalizeVersion(version); + } catch (error) { + return { ok: false, message: `pin failed: ${describe(error)}` }; + } + await updateState(lifecycle.host, { pin: normalized }); + const managed = await managedCopy(lifecycle.host); + const note = managed?.version === normalized ? "It is already on disk, so resolution will prefer it." : `No managed copy of ${normalized} is on disk yet; run \`/cbm install ${normalized}\` to place one.`; + return { ok: true, message: `pinned version ${normalized}. ${note}` }; +} +async function unpin(lifecycle) { + const state = await readState(lifecycle.host); + if (state.pin === undefined) + return { ok: true, message: "no version was pinned." }; + const { pin: _released, ...remaining } = state; + await writeState(lifecycle.host, remaining); + return { ok: true, message: `released the pin on version ${state.pin}.` }; +} +async function uninstall(lifecycle) { + const { host } = lifecycle; + const state = await readState(host); + const removal = await removeEntry(host, state.wroteCommand); + const entryMessage = removal.ok ? removal.change === "removed" ? "removed the owned MCP entry" : "there was no owned MCP entry to remove" : `left the MCP entry alone: ${removal.reason}`; + const root = packageRoot(host); + const managed = await managedCopy(host, state); + await rm2(root, { recursive: true, force: true }); + const copyMessage = managed === null ? "no managed copy was present" : `removed the managed copy of ${managed.version}`; + const systemNote = await systemStillPresent(host); + return { ok: true, message: `${copyMessage}, ${entryMessage}, and deleted ${root}.${systemNote}` }; +} +async function syncEntry(lifecycle) { + const { host } = lifecycle; + const state = await readState(host); + const resolution = await resolveExecutable(host, state); + if (!resolution.ok) { + return { + kind: "unresolved", + message: `${resolution.reason} No MCP entry was written or changed.` + }; + } + const before = await entryStatus(host, resolution.resolved.executable); + const outcome = await wire(lifecycle, resolution.resolved, state); + if (!outcome.ok) + return { kind: "refused", message: outcome.message }; + if (!before.present) + return { kind: "wired", message: outcome.message }; + return before.current ? { kind: "unchanged", message: outcome.message } : { kind: "rewired", message: outcome.message }; +} +async function checkUpstream(lifecycle, options = {}) { + const { host } = lifecycle; + const now = options.now ?? Date.now(); + const state = await readState(host); + const last = state.lastCheckedAt; + if (options.force !== true && last !== undefined && now - last < CHECK_INTERVAL_MS) { + return { + kind: "skipped", + message: `upstream was last checked ${Math.round((now - last) / 60000)} minutes ago; skipping.` + }; + } + let upstream; + try { + upstream = normalizeVersion(await lifecycle.source.latestTag()); + } catch (error) { + await updateState(host, { lastCheckedAt: now }); + return { kind: "failed", message: `upstream version check failed: ${describe(error)}` }; + } + const next = await updateState(host, { upstreamVersion: upstream, lastCheckedAt: now }); + const resolution = await resolveExecutable(host, next); + const local = resolution.ok ? await resolvedVersion(resolution.resolved) : null; + if (local !== null && local.includes(upstream)) { + return { kind: "current", message: `upstream ${upstream} matches the local executable.` }; + } + const remedy = !resolution.ok ? "Run /cbm install to place a managed copy." : next.pin !== undefined ? `Version ${next.pin} is pinned, so nothing will be adopted. Run /cbm unpin to release it.` : resolution.resolved.source === "system" ? `Run \`${resolution.resolved.executable} update\` to update the system installation.` : "Run /cbm update to adopt it."; + return { + kind: "newer", + message: `upstream release is ${upstream}; local is ${local ?? "unknown"}. ${remedy}` + }; +} +async function wire(lifecycle, resolved, state) { + const outcome = await upsertEntry(lifecycle.host, resolved.executable, state.wroteCommand); + if (!outcome.ok) + return { ok: false, message: outcome.reason }; + if (state.wroteCommand !== resolved.executable) { + await updateState(lifecycle.host, { wroteCommand: resolved.executable }); + } + switch (outcome.change) { + case "created": + return { ok: true, message: `Wrote the MCP entry naming ${resolved.executable}.` }; + case "updated": + return { + ok: true, + message: `Corrected the MCP entry to ${resolved.executable}; run /mcp reload so this session picks it up.` + }; + case "unchanged": + return { ok: true, message: `The MCP entry already names ${resolved.executable}.` }; + } +} +async function systemStillPresent(host) { + const resolution = await resolveExecutable(host, {}); + return resolution.ok && resolution.resolved.source === "system" ? ` The system installation at ${resolution.resolved.executable} was left in place.` : ""; +} +function describe(error) { + return error instanceof Error ? error.message : String(error); +} + +// src/index.ts +var CHECK_DELAY_MS = 20000; +var SUBCOMMANDS = [ + "status", + "install", + "update", + "pin", + "unpin", + "uninstall" +]; +function ompCodebaseMemory(pi) { + const host = processHost(); + const native = nativeExtensionPath(host); + if (existsSync(native)) { + pi.logger.info("omp-codebase-memory: standing down", { native }); + return; + } + pi.setLabel("Codebase Memory"); + let lifecycle = null; + let unsupported = null; + try { + lifecycle = { host, target: hostTarget(), source: githubReleaseSource() }; + } catch (error) { + unsupported = error instanceof UnsupportedPlatformError ? error.message : `platform detection failed: ${error instanceof Error ? error.message : String(error)}`; + } + const notified = new Set; + const notifyOnce = (ctx, message, type) => { + if (notified.has(message)) + return; + notified.add(message); + notify(ctx, message, type); + }; + const notify = (ctx, message, type) => { + try { + ctx.ui.notify(message, type); + } catch (error) { + pi.logger.error("omp-codebase-memory: notification failed", { + error: error instanceof Error ? error.message : String(error), + message + }); + } + }; + const report = (ctx, outcome) => { + notify(ctx, `/cbm: ${outcome.message}`, outcome.ok ? "info" : "error"); + }; + pi.registerCommand("cbm", { + description: "codebase-memory-mcp lifecycle: status, install, update, pin, unpin, uninstall", + getArgumentCompletions: (prefix) => { + const matches = SUBCOMMANDS.filter((name) => name.startsWith(prefix.trimStart())); + return matches.length === 0 ? null : matches.map((name) => ({ value: name, label: `/cbm ${name}` })); + }, + handler: async (args, ctx) => { + const [subcommand = "status", ...rest] = args.trim().split(/\s+/u).filter((part) => part !== ""); + if (lifecycle === null) { + notify(ctx, `/cbm: ${unsupported ?? "unavailable on this platform"}`, "error"); + return; + } + switch (subcommand) { + case "status": { + const report_ = await status(lifecycle); + notify(ctx, ["codebase-memory-mcp", ...report_.lines].join(` +`), "info"); + return; + } + case "install": + report(ctx, await runInstall(ctx, lifecycle, rest[0])); + return; + case "update": + report(ctx, await update(lifecycle)); + return; + case "pin": { + const version = rest[0]; + report(ctx, version === undefined ? { ok: false, message: "pin needs a version, e.g. `/cbm pin 0.10.8`." } : await pin(lifecycle, version)); + return; + } + case "unpin": + report(ctx, await unpin(lifecycle)); + return; + case "uninstall": + report(ctx, await uninstall(lifecycle)); + return; + default: + notify(ctx, `/cbm: unknown subcommand \`${subcommand}\`. Use one of: ${SUBCOMMANDS.join(", ")}.`, "error"); + } + } + }); + const runInstall = async (ctx, active, version) => { + const resolution = await resolveExecutable(active.host, await readState(active.host)); + const hazard = resolution.ok && resolution.resolved.source === "system" ? `${resolution.resolved.executable} already resolves (${resolution.resolved.origin}).` : null; + if (hazard !== null) { + const explanation = `${hazard} CBM resolves one canonical cache root per account and refuses to run when a ` + "process is configured with a different root while another CBM session is active, so a " + "second executable of a different version produces mismatched index generations. " + "Adopting the installation you already have is the safe default."; + if (!ctx.hasUI) { + return { + ok: false, + message: `${explanation} This session has no interactive UI, so the confirmation this needs cannot be asked; nothing was downloaded.` + }; + } + const confirmed = await ctx.ui.confirm("Install a second codebase-memory-mcp?", explanation); + if (!confirmed) + return { ok: true, message: `${explanation} Nothing was downloaded.` }; + } + return await install(active, version); + }; + pi.on("session_start", async (_event, ctx) => { + if (lifecycle === null) + return; + const active = lifecycle; + try { + const sync = await syncEntry(active); + switch (sync.kind) { + case "rewired": + case "wired": + notifyOnce(ctx, `codebase-memory-mcp: ${sync.message}`, "info"); + break; + case "unresolved": + case "refused": + notifyOnce(ctx, `codebase-memory-mcp: ${sync.message}`, "warning"); + break; + case "unchanged": + break; + } + } catch (error) { + pi.logger.error("omp-codebase-memory: session start sync failed", { + error: error instanceof Error ? error.message : String(error) + }); + } + const scheduler = schedulerFrom(ctx); + scheduler.after(() => { + checkUpstream(active).then((check) => { + if (check.kind === "newer") + notifyOnce(ctx, `codebase-memory-mcp: ${check.message}`, "info"); + else + pi.logger.info("omp-codebase-memory: version check", { check: check.message }); + }).catch((error) => { + pi.logger.info("omp-codebase-memory: version check failed", { + error: error instanceof Error ? error.message : String(error) + }); + }); + }, CHECK_DELAY_MS); + }); +} +export { + ompCodebaseMemory as default +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..2c0c64d --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "omp-codebase-memory", + "version": "0.1.0", + "private": true, + "license": "MIT", + "type": "module", + "description": "OMP extension that owns the codebase-memory-mcp executable lifecycle and wires exactly one MCP server entry into OMP's native user configuration.", + "repository": { + "type": "git", + "url": "git+https://github.com/pashifika/omp-codebase-memory.git" + }, + "omp": { + "extensions": [ + "./dist/index.js" + ] + }, + "scripts": { + "build": "bun build src/index.ts --target=bun --format=esm --outfile=dist/index.js", + "test": "bun test", + "test:unit": "bun test test/unit", + "test:packaging": "bun run build && bun test test/packaging", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@oh-my-pi/pi-coding-agent": "18.0.8", + "@types/bun": "1.3.14", + "typescript": "5.9.3" + } +} diff --git a/src/acquire.ts b/src/acquire.ts new file mode 100644 index 0000000..f4f833b --- /dev/null +++ b/src/acquire.ts @@ -0,0 +1,284 @@ +import type { Stats } from "node:fs"; +import { chmod, lstat, mkdtemp, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { haveTool, readVersion, run } from "./exec.ts"; +import { managedBinRoot, type Host } from "./paths.ts"; +import { parseChecksums, type ReleaseSource } from "./release.ts"; +import { UnsupportedPlatformError, type Target } from "./platform.ts"; + +/** + * Acquisition: everything between "there is a release" and "this package owns a + * verified executable". + * + * The sequence reproduces upstream's `install.sh` step for step. Each step is + * here because that installer has it, and each is a refusal rather than a + * warning, because a step that merely warns is a verification property that has + * been removed while still appearing to be present. + * + * Nothing is written under the package-owned root until every step has passed: + * the candidate lives in a temporary directory, so a failure leaves whatever + * executable was resolved before still resolved. + */ + +/** What a completed acquisition produced. */ +export interface Acquired { + /** The version adopted, which is also its directory name under `bin/`. */ + readonly version: string; + /** The archive digest it was verified against. */ + readonly digest: string; + /** The adopted executable's absolute path. */ + readonly executable: string; + /** What the adopted executable reports for `--version`. */ + readonly reportedVersion: string; +} + +export interface AcquireRequest { + readonly host: Host; + readonly target: Target; + readonly source: ReleaseSource; + /** + * The version to acquire. Omitted means the newest release, resolved through + * the `releases/latest` redirect. + */ + readonly version?: string; +} + +/** + * A version string that is safe as a URL segment and a directory name. + * + * Validated rather than trusted because the value can arrive from a redirect's + * `location` or from an operator's `/cbm install 0.10.8`, and it is used for + * both. Refusing `..` and separators here is what keeps a release tag from + * naming a directory outside `bin/`. + */ +const VERSION_PATTERN = /^[0-9][0-9A-Za-z.+-]*$/u; + +/** `v0.10.8` and `0.10.8` both mean version `0.10.8`. */ +export function normalizeVersion(value: string): string { + const trimmed = value.trim().replace(/^v/iu, ""); + if (!VERSION_PATTERN.test(trimmed)) { + throw new Error(`not a usable version: ${value}`); + } + return trimmed; +} + +/** The release tag for a normalized version. */ +export function tagFor(version: string): string { + return `v${version}`; +} + +/** + * Downloads, verifies, and adopts one release. + * + * @throws when any verification step fails, having written nothing under the + * package-owned root. + */ +export async function acquire(request: AcquireRequest): Promise { + const { host, target, source } = request; + + if (target.container !== "tar.gz") { + throw new UnsupportedPlatformError( + `cannot extract a ${target.container} archive; only tar.gz is implemented`, + ); + } + if (!haveTool("tar", host.env["PATH"])) { + throw new Error("tar is required to extract the release archive, and is not on PATH"); + } + + const version = + request.version === undefined + ? normalizeVersion(await source.latestTag()) + : normalizeVersion(request.version); + const tag = tagFor(version); + + const expected = parseChecksums(await source.checksums(tag), target.archive); + const bytes = await source.asset(tag, target.archive); + + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(bytes); + const actual = hasher.digest("hex"); + if (actual !== expected) { + throw new Error( + `SHA-256 mismatch for ${target.archive} at ${tag}: published ${expected}, downloaded ${actual}`, + ); + } + + const scratch = await mkdtemp(path.join(tmpdir(), "omp-codebase-memory-")); + try { + const archive = path.join(scratch, target.archive); + await Bun.write(archive, bytes); + + await assertArchiveMembers(archive, target); + await extract(archive, scratch, target); + + const candidate = path.join(scratch, target.executable); + await chmod(candidate, 0o755); + + if (target.os === "darwin") await repairMacOsSignature(host, candidate); + + const reportedVersion = await smokeCheck(candidate, target); + return await adopt(host, { version, digest: expected, candidate, reportedVersion }); + } finally { + await rm(scratch, { recursive: true, force: true }); + } +} + +/** + * Refuses any archive whose member list is not exactly the expected four. + * + * Enumerated before extraction, not after, because the point is to never write + * an unexpected member to disk. Upstream's installer does the same, and treats + * an extra member as a release-integrity failure rather than a sidecar to + * ignore -- a fifth member means the archive is not the artifact this code + * knows how to verify, whatever it contains. + */ +export async function assertArchiveMembers(archive: string, target: Target): Promise { + const listed = await run(["tar", "-tzf", archive]); + if (!listed.ok) { + throw new Error( + `could not enumerate ${path.basename(archive)}: ${listed.stderr.trim() || listed.spawnError || `tar exited ${listed.exitCode}`}`, + ); + } + + const seen = new Map(); + for (const raw of listed.stdout.split("\n")) { + const member = raw.trim().replace(/^\.\//u, "").replace(/\/+$/u, ""); + if (member === "") continue; + if (!target.members.includes(member)) { + throw new Error(`release archive contains unexpected member: ${member}`); + } + seen.set(member, (seen.get(member) ?? 0) + 1); + } + + for (const member of target.members) { + const count = seen.get(member) ?? 0; + if (count === 1) continue; + throw new Error( + count === 0 + ? `release archive is missing member: ${member}` + : `release archive contains member ${member} ${count} times`, + ); + } +} + +/** + * Extracts into `into` and requires every member to be a plain regular file. + * + * `--no-same-owner` matches upstream's installer: without it a root extraction + * would honour the ownership recorded in the archive. The symlink check is + * separate from the member-name check above because a name can be in the closed + * set while the entry it names is a link -- and a link is how an extraction is + * made to write, or to be read from, somewhere it was never listed as touching. + */ +async function extract(archive: string, into: string, target: Target): Promise { + const extracted = await run(["tar", "--no-same-owner", "-xzf", archive, "-C", into]); + if (!extracted.ok) { + throw new Error( + `could not extract ${path.basename(archive)}: ${extracted.stderr.trim() || extracted.spawnError || `tar exited ${extracted.exitCode}`}`, + ); + } + + for (const member of target.members) { + const entry = path.join(into, member); + let stats: Stats; + try { + stats = await lstat(entry); + } catch { + throw new Error(`release member is missing after extraction: ${member}`); + } + if (stats.isSymbolicLink() || !stats.isFile()) { + throw new Error(`release member is not a regular file: ${member}`); + } + } +} + +/** The commands an operator can run by hand to repair a quarantined binary. */ +function repairHint(candidate: string): string { + return `xattr -cr ${candidate} && codesign --force --sign - ${candidate}`; +} + +/** + * Removes the quarantine attribute and applies an ad-hoc signature. + * + * Both are prerequisites rather than optional polish: an unsigned Mach-O binary + * downloaded from the network is refused by Gatekeeper, and the resulting + * failure is a kill signal with no message that explains it. Upstream's + * installer does both and ignores their failures; this one reports a *missing + * tool* by name, because that is a diagnosable host problem, while tolerating a + * missing attribute, which is the normal case for an archive fetched over + * HTTPS rather than by a browser. + */ +async function repairMacOsSignature(host: Host, candidate: string): Promise { + const pathEnv = host.env["PATH"]; + for (const tool of ["xattr", "codesign"] as const) { + if (!haveTool(tool, pathEnv)) { + throw new Error( + `${tool} is required to prepare a macOS binary and is not on PATH; ` + + `install the Xcode command line tools, or repair the candidate yourself with \`${repairHint(candidate)}\``, + ); + } + } + + // A missing attribute is the common case and prints "No such xattr" on stderr; + // that is not a failure, so only the tool's absence above is refused. + await run(["xattr", "-d", "com.apple.quarantine", candidate], { timeoutMs: 10_000 }); + + const signed = await run(["codesign", "--sign", "-", "--force", candidate], { + timeoutMs: 60_000, + }); + if (!signed.ok) { + throw new Error( + `could not ad-hoc sign the candidate: ${signed.stderr.trim() || `codesign exited ${signed.exitCode}`}. ` + + `Repair it by hand with \`${repairHint(candidate)}\``, + ); + } +} + +/** + * Runs the candidate before anything depends on it. + * + * This is the step that catches a build for the wrong glibc, a truncated + * download that still hashed correctly because the digest file was also + * substituted, and a macOS binary the repair above did not fix. On macOS the + * failure names the repair commands, because that is overwhelmingly the cause + * and the operator can act on it directly. + */ +async function smokeCheck(candidate: string, target: Target): Promise { + const reported = await readVersion(candidate); + if (reported !== null) return reported; + + const suffix = + target.os === "darwin" + ? ` If macOS is refusing to run it, try \`${repairHint(candidate)}\`.` + : ""; + throw new Error(`the downloaded executable failed to run \`--version\`.${suffix}`); +} + +/** + * Places the verified candidate under the package-owned root. + * + * The first write outside the temporary directory happens here, after every + * check has passed. The previous version's directory is left in place: it is + * what resolution falls back to if this adoption's pointer update does not + * land, and removing it would make a failed update worse than a skipped one. + */ +async function adopt( + host: Host, + candidate: { version: string; digest: string; candidate: string; reportedVersion: string }, +): Promise { + const destination = path.join(managedBinRoot(host), candidate.version); + await mkdir(destination, { recursive: true }); + + const executable = path.join(destination, path.basename(candidate.candidate)); + await Bun.write(executable, Bun.file(candidate.candidate)); + await chmod(executable, 0o755); + + return { + version: candidate.version, + digest: candidate.digest, + executable, + reportedVersion: candidate.reportedVersion, + }; +} diff --git a/src/exec.ts b/src/exec.ts new file mode 100644 index 0000000..0ef18be --- /dev/null +++ b/src/exec.ts @@ -0,0 +1,83 @@ +/** + * The one place this package starts a subprocess. + * + * Acquisition needs `tar`, macOS repair needs `xattr` and `codesign`, and + * resolution needs the candidate's own `--version`. All four go through + * {@link run}, so the timeout, the output capture, and the "is this tool even + * installed" question have one answer rather than four. + */ + +/** Default per-process deadline. Nothing here should take longer. */ +const DEFAULT_TIMEOUT_MS = 30_000; + +export interface RunResult { + readonly ok: boolean; + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + /** Set when the process could not be started at all, e.g. a missing tool. */ + readonly spawnError?: string; +} + +export interface RunOptions { + readonly timeoutMs?: number; + readonly cwd?: string; +} + +/** + * Runs `argv` and captures its output. + * + * Never throws for a non-zero exit or a missing executable: both are ordinary + * outcomes here -- a candidate that will not run, a host without `xattr` -- and + * each caller reports them differently. A thrown spawn failure is folded into + * the result as {@link RunResult.spawnError} so the distinction survives. + */ +export async function run(argv: readonly string[], options: RunOptions = {}): Promise { + try { + const child = Bun.spawn([...argv], { + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { ok: exitCode === 0, exitCode, stdout, stderr }; + } catch (error) { + return { + ok: false, + exitCode: -1, + stdout: "", + stderr: "", + spawnError: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * The version string an executable reports, or `null` when it will not run. + * + * `null` rather than a throw because every caller treats "will not run" as a + * fact about the executable rather than an error in this package: resolution + * reports it, acquisition refuses to adopt over it. + */ +export async function readVersion(executable: string): Promise { + const result = await run([executable, "--version"], { timeoutMs: 10_000 }); + if (!result.ok) return null; + const reported = `${result.stdout}${result.stderr}`.trim(); + return reported === "" ? null : reported.split("\n")[0]?.trim() ?? null; +} + +/** + * Whether `tool` exists on `PATH`. + * + * Used to turn a missing prerequisite into a named refusal before a command is + * attempted, rather than a spawn error the operator has to decode. + */ +export function haveTool(tool: string, pathEnv: string | undefined): boolean { + return Bun.which(tool, pathEnv === undefined ? {} : { PATH: pathEnv }) !== null; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4d8db75 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,284 @@ +import { existsSync } from "node:fs"; + +import { hostTarget, UnsupportedPlatformError } from "./platform.ts"; +import { nativeExtensionPath, processHost } from "./paths.ts"; +import { githubReleaseSource } from "./release.ts"; +import { resolveExecutable } from "./resolve.ts"; +import { readState } from "./state.ts"; +import { schedulerFrom } from "./scheduler.ts"; +import { + checkUpstream, + install, + pin, + status, + syncEntry, + uninstall, + unpin, + update, + type ActionReport, + type Lifecycle, +} from "./lifecycle.ts"; + +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, +} from "@oh-my-pi/pi-coding-agent"; + +/** + * The extension entry: registration only, and deliberately narrow. + * + * Two things are absent on purpose. + * + * There is no `tool_call` handler. OMP treats a throwing or blocking + * `tool_call` handler as a refusal of the tool call, so a context provider + * registered there could deny an operator's `grep` because a subprocess timed + * out. A provider that adds nothing is better than one that can take something + * away, so the event is not registered at all -- and the output augmentation a + * later change adds belongs on `tool_result`, which is documented as + * middleware-style and explicitly allowed to replace content. + * + * There is no runtime action called during load. OMP wires those after the + * factory returns, and calling one here throws + * `ExtensionRuntimeNotInitializedError`. + */ + +/** + * How long after session start the version check runs. + * + * Off the blocking path by construction: session start returns, and the check + * happens later on a managed timer. Long enough that a session doing real work + * in its first seconds is not competing with a network request. + */ +const CHECK_DELAY_MS = 20_000; + +/** The subcommands `/cbm` accepts, for the help text and for completions. */ +const SUBCOMMANDS = [ + "status", + "install", + "update", + "pin", + "unpin", + "uninstall", +] as const; + +export default function ompCodebaseMemory(pi: ExtensionAPI): void { + const host = processHost(); + + // A future upstream `--clients=omp` would write its own native extension to + // this path. Extension modules are deduplicated by absolute path, so both + // would load and a later change's output augmentation would be applied twice. + // Cheap to check now; impossible to retrofit once the duplicate is in the + // field. + const native = nativeExtensionPath(host); + if (existsSync(native)) { + pi.logger.info("omp-codebase-memory: standing down", { native }); + return; + } + + pi.setLabel("Codebase Memory"); + + /** + * The lifecycle, or the reason there is none. + * + * Platform detection is the one thing that can fail before any command runs, + * and it must not fail the load: an unsupported platform is a reason to + * report on demand, not a reason for the extension to error out of a session + * it could otherwise leave alone. + */ + let lifecycle: Lifecycle | null = null; + let unsupported: string | null = null; + try { + lifecycle = { host, target: hostTarget(), source: githubReleaseSource() }; + } catch (error) { + unsupported = + error instanceof UnsupportedPlatformError + ? error.message + : `platform detection failed: ${error instanceof Error ? error.message : String(error)}`; + } + + /** Messages already shown in this session, so a repeat is not shown again. */ + const notified = new Set(); + + const notifyOnce = ( + ctx: ExtensionContext, + message: string, + type: "info" | "warning" | "error", + ): void => { + if (notified.has(message)) return; + notified.add(message); + notify(ctx, message, type); + }; + + /** + * Every notification goes through here. + * + * A UI failure is this package's problem, not the operator's: an overlay that + * cannot render must not turn a successful lifecycle action into a thrown + * error, and on `session_start` it must not surface as a failed handler. + */ + const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" | "error"): void => { + try { + ctx.ui.notify(message, type); + } catch (error) { + pi.logger.error("omp-codebase-memory: notification failed", { + error: error instanceof Error ? error.message : String(error), + message, + }); + } + }; + + /** Reports an action, or the reason no action is possible on this platform. */ + const report = (ctx: ExtensionContext, outcome: ActionReport): void => { + notify(ctx, `/cbm: ${outcome.message}`, outcome.ok ? "info" : "error"); + }; + + pi.registerCommand("cbm", { + description: "codebase-memory-mcp lifecycle: status, install, update, pin, unpin, uninstall", + getArgumentCompletions: (prefix) => { + const matches = SUBCOMMANDS.filter((name) => name.startsWith(prefix.trimStart())); + return matches.length === 0 + ? null + : matches.map((name) => ({ value: name, label: `/cbm ${name}` })); + }, + handler: async (args, ctx) => { + const [subcommand = "status", ...rest] = args.trim().split(/\s+/u).filter((part) => part !== ""); + + if (lifecycle === null) { + notify(ctx, `/cbm: ${unsupported ?? "unavailable on this platform"}`, "error"); + return; + } + + switch (subcommand) { + case "status": { + const report_ = await status(lifecycle); + notify(ctx, ["codebase-memory-mcp", ...report_.lines].join("\n"), "info"); + return; + } + case "install": + report(ctx, await runInstall(ctx, lifecycle, rest[0])); + return; + case "update": + report(ctx, await update(lifecycle)); + return; + case "pin": { + const version = rest[0]; + report( + ctx, + version === undefined + ? { ok: false, message: "pin needs a version, e.g. `/cbm pin 0.10.8`." } + : await pin(lifecycle, version), + ); + return; + } + case "unpin": + report(ctx, await unpin(lifecycle)); + return; + case "uninstall": + report(ctx, await uninstall(lifecycle)); + return; + default: + notify( + ctx, + `/cbm: unknown subcommand \`${subcommand}\`. Use one of: ${SUBCOMMANDS.join(", ")}.`, + "error", + ); + } + }, + }); + + /** + * Acquires a managed copy, with the shared-cache-root hazard made explicit. + * + * The confirmation is not a formality. CBM resolves one canonical per-account + * cache root and refuses to run when a process is configured with a different + * root while any CBM session or command is active, so two executables of + * different versions produce mismatched index generations. An operator who + * already has a working installation should be told that before a second one + * exists, not after. + * + * With no interactive UI the command fails with that reason rather than + * waiting: nothing here may block on input that cannot arrive. + */ + const runInstall = async ( + ctx: ExtensionCommandContext, + active: Lifecycle, + version: string | undefined, + ): Promise => { + const resolution = await resolveExecutable(active.host, await readState(active.host)); + const hazard = + resolution.ok && resolution.resolved.source === "system" + ? `${resolution.resolved.executable} already resolves (${resolution.resolved.origin}).` + : null; + + if (hazard !== null) { + const explanation = + `${hazard} CBM resolves one canonical cache root per account and refuses to run when a ` + + "process is configured with a different root while another CBM session is active, so a " + + "second executable of a different version produces mismatched index generations. " + + "Adopting the installation you already have is the safe default."; + + if (!ctx.hasUI) { + return { + ok: false, + message: `${explanation} This session has no interactive UI, so the confirmation this needs cannot be asked; nothing was downloaded.`, + }; + } + + const confirmed = await ctx.ui.confirm("Install a second codebase-memory-mcp?", explanation); + if (!confirmed) return { ok: true, message: `${explanation} Nothing was downloaded.` }; + } + + return await install(active, version); + }; + + /** + * Session start: verify the owned entry, correct it, and never block. + * + * The version check is deferred onto a managed timer rather than awaited + * here, because a session must start at the speed of the filesystem and not + * at the speed of the network. + */ + pi.on("session_start", async (_event, ctx) => { + if (lifecycle === null) return; + const active = lifecycle; + + try { + const sync = await syncEntry(active); + switch (sync.kind) { + case "rewired": + case "wired": + notifyOnce(ctx, `codebase-memory-mcp: ${sync.message}`, "info"); + break; + case "unresolved": + case "refused": + notifyOnce(ctx, `codebase-memory-mcp: ${sync.message}`, "warning"); + break; + case "unchanged": + break; + } + } catch (error) { + // Session start is not the place to fail. Anything unexpected here is + // recorded and the session continues without a corrected entry. + pi.logger.error("omp-codebase-memory: session start sync failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + + const scheduler = schedulerFrom(ctx); + scheduler.after(() => { + void checkUpstream(active) + .then((check) => { + if (check.kind === "newer") notifyOnce(ctx, `codebase-memory-mcp: ${check.message}`, "info"); + else pi.logger.info("omp-codebase-memory: version check", { check: check.message }); + }) + .catch((error: unknown) => { + // Debug log only: a failed check must not reach the operator, and + // must not reach the session's error channel either. + pi.logger.info("omp-codebase-memory: version check failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + }, CHECK_DELAY_MS); + }); +} diff --git a/src/lifecycle.ts b/src/lifecycle.ts new file mode 100644 index 0000000..08b709d --- /dev/null +++ b/src/lifecycle.ts @@ -0,0 +1,382 @@ +import { rm } from "node:fs/promises"; + +import { acquire, normalizeVersion, type Acquired } from "./acquire.ts"; +import { agentDir, packageRoot, type Host } from "./paths.ts"; +import { entryStatus, removeEntry, upsertEntry } from "./mcp-config.ts"; +import { resolvedVersion, managedCopy, resolveExecutable, type Resolved } from "./resolve.ts"; +import { readState, updateState, writeState, type State } from "./state.ts"; +import type { ReleaseSource } from "./release.ts"; +import type { Target } from "./platform.ts"; + +/** + * The operator-visible lifecycle, composed from the layers below it. + * + * Everything here is a decision an operator can name: what resolves, what gets + * downloaded, what the MCP entry says, what a pin holds. No function here + * touches a UI -- each returns a message and, where a caller needs to branch, a + * discriminant -- so the same operation is reachable from a slash command, from + * session start, and from a test with no terminal at all. + */ + +/** What every lifecycle operation needs to reach the outside world. */ +export interface Lifecycle { + readonly host: Host; + readonly target: Target; + readonly source: ReleaseSource; +} + +/** + * How long a recorded check suppresses the next one. + * + * The check exists to tell an operator a newer release is out; it does not need + * to be fresh to the minute, and a session-start network request per session is + * a cost with no matching benefit. + */ +export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +/** Everything `/cbm status` reports. */ +export interface StatusReport { + readonly lines: readonly string[]; + readonly resolved: Resolved | null; +} + +/** The outcome of one lifecycle action, ready to show an operator. */ +export interface ActionReport { + readonly ok: boolean; + readonly message: string; +} + +/** What session start did about the owned entry. */ +export interface SyncReport { + readonly kind: "unchanged" | "wired" | "rewired" | "unresolved" | "refused"; + readonly message: string; +} + +/** What one rate-limited upstream check established. */ +export interface CheckReport { + readonly kind: "skipped" | "current" | "newer" | "failed"; + readonly message: string; +} + +/** + * The full resolution, with everything an operator needs to reason about it. + * + * A managed copy is reported even when it is not the resolved one, because + * "system wins over managed" is a policy whose consequences should never be + * invisible: an operator with both should be able to see both. + */ +export async function status(lifecycle: Lifecycle): Promise { + const { host } = lifecycle; + const state = await readState(host); + const resolution = await resolveExecutable(host, state); + const resolved = resolution.ok ? resolution.resolved : null; + + const lines: string[] = []; + if (!resolution.ok) { + lines.push(`executable: none (${resolution.reason})`); + } else { + const version = (await resolvedVersion(resolution.resolved)) ?? "unknown (it did not run)"; + lines.push(`executable: ${resolution.resolved.executable}`); + lines.push(`source: ${resolution.resolved.source} (${resolution.resolved.origin})`); + lines.push(`version: ${version}`); + } + + const managed = await managedCopy(host, state); + lines.push( + managed === null + ? "managed: none under this package's root" + : `managed: ${managed.version} at ${managed.executable}` + + (resolved?.executable === managed.executable ? "" : " (present, not resolved)"), + ); + + lines.push(`upstream: ${state.upstreamVersion ?? "not checked yet"}`); + lines.push(`pin: ${state.pin ?? "none"}`); + lines.push(`agent dir: ${agentDir(host)}`); + + const entry = await entryStatus(host, resolved?.executable ?? null); + if (entry.problem !== undefined) { + lines.push(`mcp entry: unreadable -- ${entry.problem}`); + } else if (!entry.present) { + lines.push(`mcp entry: absent from ${entry.path}`); + } else { + lines.push( + `mcp entry: ${entry.current ? "current" : `stale, names ${entry.command ?? "(no command)"}`} in ${entry.path}`, + ); + } + + return { lines, resolved }; +} + +/** + * Acquires a version, adopts it, and points the MCP entry at it. + * + * The pointer is only advanced once the copy is on disk, and resolution is then + * re-run rather than assumed: the adopted path is only correct if it is what + * resolution actually returns, and a system executable appearing on `PATH` + * between the download and the pointer update legitimately changes that answer. + */ +export async function install(lifecycle: Lifecycle, version?: string): Promise { + const { host } = lifecycle; + let acquired: Acquired; + try { + acquired = await acquire({ + host, + target: lifecycle.target, + source: lifecycle.source, + ...(version === undefined ? {} : { version: normalizeVersion(version) }), + }); + } catch (error) { + return { ok: false, message: `install failed: ${describe(error)}` }; + } + + const state = await updateState(host, { + managedVersion: acquired.version, + managedDigest: acquired.digest, + upstreamVersion: acquired.version, + lastCheckedAt: Date.now(), + }); + + const resolution = await resolveExecutable(host, state); + if (!resolution.ok) { + return { + ok: false, + message: `adopted ${acquired.version} at ${acquired.executable}, but resolution then found nothing: ${resolution.reason}`, + }; + } + + const wiring = await wire(lifecycle, resolution.resolved, state); + const adopted = + resolution.resolved.executable === acquired.executable + ? `adopted ${acquired.version} (${acquired.reportedVersion}) at ${acquired.executable}` + : `adopted ${acquired.version} at ${acquired.executable}, but resolution prefers ` + + `${resolution.resolved.executable} (${resolution.resolved.source})`; + + return { ok: true, message: `${adopted}. ${wiring.message}` }; +} + +/** + * Updates a managed copy, and only reports on an adopted system one. + * + * The asymmetry is not caution for its own sake. CBM's own `install`/`update` + * drains active sessions and performs a transactional target swap; a second + * writer replacing the same file mid-swap corrupts exactly the thing the + * transaction exists to protect. So a system installation is CBM's to update, + * and this package's job is to say so. + */ +export async function update(lifecycle: Lifecycle): Promise { + const { host } = lifecycle; + const state = await readState(host); + const resolution = await resolveExecutable(host, state); + + if (resolution.ok && resolution.resolved.source === "system") { + const check = await checkUpstream(lifecycle, { force: true }); + return { + ok: true, + message: + `${resolution.resolved.executable} is a system installation this package adopted, so it is not replaced here. ` + + `Run \`${resolution.resolved.executable} update\` to update it. ${check.message}`, + }; + } + + if (state.pin !== undefined) { + const check = await checkUpstream(lifecycle, { force: true }); + return { + ok: true, + message: `version ${state.pin} is pinned, so nothing was adopted. ${check.message} Run /cbm unpin to release it.`, + }; + } + + return await install(lifecycle); +} + +/** Records a pin so update checks report without adopting. */ +export async function pin(lifecycle: Lifecycle, version: string): Promise { + let normalized: string; + try { + normalized = normalizeVersion(version); + } catch (error) { + return { ok: false, message: `pin failed: ${describe(error)}` }; + } + + await updateState(lifecycle.host, { pin: normalized }); + const managed = await managedCopy(lifecycle.host); + const note = + managed?.version === normalized + ? "It is already on disk, so resolution will prefer it." + : `No managed copy of ${normalized} is on disk yet; run \`/cbm install ${normalized}\` to place one.`; + return { ok: true, message: `pinned version ${normalized}. ${note}` }; +} + +/** Releases a pin, leaving every managed version on disk. */ +export async function unpin(lifecycle: Lifecycle): Promise { + const state = await readState(lifecycle.host); + if (state.pin === undefined) return { ok: true, message: "no version was pinned." }; + + // Whole-document write, not a merge: a merge can only add or replace a key, + // so releasing a pin has to replace the document that held it. + const { pin: _released, ...remaining } = state; + await writeState(lifecycle.host, remaining); + return { ok: true, message: `released the pin on version ${state.pin}.` }; +} + +/** + * Removes what this package placed, and nothing else. + * + * The entry goes first, because taking it back needs the state that the second + * step deletes. An adopted system executable, CBM's cache, and every other MCP + * server in the file are left exactly as they were. + */ +export async function uninstall(lifecycle: Lifecycle): Promise { + const { host } = lifecycle; + const state = await readState(host); + + const removal = await removeEntry(host, state.wroteCommand); + const entryMessage = removal.ok + ? removal.change === "removed" + ? "removed the owned MCP entry" + : "there was no owned MCP entry to remove" + : `left the MCP entry alone: ${removal.reason}`; + + const root = packageRoot(host); + const managed = await managedCopy(host, state); + await rm(root, { recursive: true, force: true }); + + const copyMessage = + managed === null + ? "no managed copy was present" + : `removed the managed copy of ${managed.version}`; + const systemNote = await systemStillPresent(host); + + return { ok: true, message: `${copyMessage}, ${entryMessage}, and deleted ${root}.${systemNote}` }; +} + +/** + * Verifies the owned entry against what resolution returns now, and corrects it. + * + * Correction is required rather than optional because the resolved path + * legitimately changes underneath a written entry: a managed update moves it to + * a new version directory, a system executable appearing on `PATH` is preferred + * over a managed copy, and a profile change moves the file being written. + */ +export async function syncEntry(lifecycle: Lifecycle): Promise { + const { host } = lifecycle; + const state = await readState(host); + const resolution = await resolveExecutable(host, state); + + if (!resolution.ok) { + return { + kind: "unresolved", + message: `${resolution.reason} No MCP entry was written or changed.`, + }; + } + + const before = await entryStatus(host, resolution.resolved.executable); + const outcome = await wire(lifecycle, resolution.resolved, state); + if (!outcome.ok) return { kind: "refused", message: outcome.message }; + + if (!before.present) return { kind: "wired", message: outcome.message }; + return before.current + ? { kind: "unchanged", message: outcome.message } + : { kind: "rewired", message: outcome.message }; +} + +/** + * Asks upstream what the newest release is, at most once per day. + * + * A failure is not propagated. The check is a convenience; a session that + * starts without knowing whether a newer version exists is fully functional, + * and a network that is down must not make it less so. + * + * The attempt time is recorded even when the request failed, so a host with no + * route to GitHub tries once a day rather than once a session. + */ +export async function checkUpstream( + lifecycle: Lifecycle, + options: { readonly force?: boolean; readonly now?: number } = {}, +): Promise { + const { host } = lifecycle; + const now = options.now ?? Date.now(); + const state = await readState(host); + + const last = state.lastCheckedAt; + if (options.force !== true && last !== undefined && now - last < CHECK_INTERVAL_MS) { + return { + kind: "skipped", + message: `upstream was last checked ${Math.round((now - last) / 60_000)} minutes ago; skipping.`, + }; + } + + let upstream: string; + try { + upstream = normalizeVersion(await lifecycle.source.latestTag()); + } catch (error) { + await updateState(host, { lastCheckedAt: now }); + return { kind: "failed", message: `upstream version check failed: ${describe(error)}` }; + } + + const next = await updateState(host, { upstreamVersion: upstream, lastCheckedAt: now }); + const resolution = await resolveExecutable(host, next); + const local = resolution.ok ? await resolvedVersion(resolution.resolved) : null; + + if (local !== null && local.includes(upstream)) { + return { kind: "current", message: `upstream ${upstream} matches the local executable.` }; + } + + const remedy = !resolution.ok + ? "Run /cbm install to place a managed copy." + : next.pin !== undefined + ? `Version ${next.pin} is pinned, so nothing will be adopted. Run /cbm unpin to release it.` + : resolution.resolved.source === "system" + ? `Run \`${resolution.resolved.executable} update\` to update the system installation.` + : "Run /cbm update to adopt it."; + + return { + kind: "newer", + message: `upstream release is ${upstream}; local is ${local ?? "unknown"}. ${remedy}`, + }; +} + +/** + * Writes the owned entry for `resolved` and records what was written. + * + * The recorded `command` is what makes the entry decidably ours on a later run, + * including after the resolved path has moved -- which is exactly when an + * ownership test based on the current resolution alone would refuse to correct + * its own entry. + */ +async function wire( + lifecycle: Lifecycle, + resolved: Resolved, + state: State, +): Promise { + const outcome = await upsertEntry(lifecycle.host, resolved.executable, state.wroteCommand); + if (!outcome.ok) return { ok: false, message: outcome.reason }; + + if (state.wroteCommand !== resolved.executable) { + await updateState(lifecycle.host, { wroteCommand: resolved.executable }); + } + + switch (outcome.change) { + case "created": + return { ok: true, message: `Wrote the MCP entry naming ${resolved.executable}.` }; + case "updated": + return { + ok: true, + message: `Corrected the MCP entry to ${resolved.executable}; run /mcp reload so this session picks it up.`, + }; + case "unchanged": + return { ok: true, message: `The MCP entry already names ${resolved.executable}.` }; + } +} + +/** A note naming an adopted system executable uninstall deliberately left alone. */ +async function systemStillPresent(host: Host): Promise { + const resolution = await resolveExecutable(host, {}); + return resolution.ok && resolution.resolved.source === "system" + ? ` The system installation at ${resolution.resolved.executable} was left in place.` + : ""; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/mcp-config.ts b/src/mcp-config.ts new file mode 100644 index 0000000..88a9274 --- /dev/null +++ b/src/mcp-config.ts @@ -0,0 +1,263 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; + +import { mcpConfigPath, SERVER_NAME, type Host } from "./paths.ts"; + +/** + * Sole ownership of one key in OMP's native user MCP configuration. + * + * This is the only operator file this package writes, and the write is + * constrained accordingly: one key, idempotent, siblings and formatting + * preserved, fail closed on a `command` value this package did not write, and + * removal only when the value still matches. + * + * It has to be the native file rather than a plugin-root `.mcp.json`. The entry + * must name an absolute path to a home-relative or package-owned executable, + * and a plugin-root MCP file gets no `${VAR}` expansion while `command` gets no + * pre-connect environment resolution at all -- so a committed file cannot + * express the path, and requiring the executable on `PATH` would defeat the + * system-first policy's own fallback. The native file can carry the absolute + * path, and sits at MCP discovery priority 1 so its entry always wins. + */ + +/** The `$schema` OMP writes into its own managed MCP files. */ +export const MCP_SCHEMA_URL = + "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json"; + +/** What one read of the file established. */ +export interface McpFile { + readonly path: string; + /** Raw text, or `null` when the file does not exist. */ + readonly text: string | null; + /** Parsed document; an empty object when the file does not exist. */ + readonly document: Record; + /** Indentation the file already used, reproduced on write. */ + readonly indent: string; + /** Whether the file ended with a newline, reproduced on write. */ + readonly trailingNewline: boolean; +} + +export type ReadResult = + | { readonly ok: true; readonly file: McpFile } + /** A structural refusal: the file exists but cannot be parsed. */ + | { readonly ok: false; readonly reason: string }; + +/** What one write attempt did, or refused to do. */ +export type WriteOutcome = + | { readonly ok: true; readonly change: "created" | "updated" | "unchanged" } + | { readonly ok: false; readonly reason: string }; + +/** What one removal attempt did, or refused to do. */ +export type RemoveOutcome = + | { readonly ok: true; readonly change: "removed" | "absent" } + | { readonly ok: false; readonly reason: string }; + +/** What the file currently says about the owned entry. */ +export interface EntryStatus { + readonly path: string; + /** Whether an entry under this package's key exists at all. */ + readonly present: boolean; + /** The `command` that entry names, when present. */ + readonly command?: string; + /** Whether that command equals the currently resolved executable. */ + readonly current: boolean; + /** Set when the file could not be read structurally. */ + readonly problem?: string; +} + +/** + * Reads the file for a read-modify-write. + * + * A missing file is not a problem -- it is the first-run case, and the write + * creates it. An unparseable file is refused rather than replaced: a hand edit + * in progress, or another writer's partial write, is not something to resolve + * by discarding the operator's content. + */ +export async function readMcpFile(host: Host): Promise { + const file = mcpConfigPath(host); + let text: string; + try { + text = await Bun.file(file).text(); + } catch { + return { + ok: true, + file: { path: file, text: null, document: {}, indent: " ", trailingNewline: true }, + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + return { + ok: false, + reason: `${file} is not parseable JSON, so it was left untouched: ${error instanceof Error ? error.message : String(error)}`, + }; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { ok: false, reason: `${file} does not hold a JSON object, so it was left untouched` }; + } + + return { + ok: true, + file: { + path: file, + text, + document: parsed as Record, + indent: detectIndent(text), + trailingNewline: text.endsWith("\n"), + }, + }; +} + +/** + * Upserts the owned entry so it names `command`. + * + * `previouslyWrote` is the `command` this package last wrote, from its own + * state. It is what makes ownership decidable: an entry naming that value, or + * naming the currently resolved executable, is this package's to update. + * Anything else belongs to CBM's own installer, another tool, or a hand edit, + * and is left alone with both paths reported -- silently replacing it would + * discard a working configuration and hide that two owners exist. + */ +export async function upsertEntry( + host: Host, + command: string, + previouslyWrote: string | undefined, +): Promise { + const read = await readMcpFile(host); + if (!read.ok) return read; + + const { file } = read; + const servers = serverMap(file.document); + const existing = servers[SERVER_NAME]; + + if (existing !== undefined) { + const currentCommand = commandOf(existing); + const ours = currentCommand === command || currentCommand === previouslyWrote; + if (!ours) { + return { + ok: false, + reason: + `${file.path} already defines ${SERVER_NAME} with command ${currentCommand ?? "(none)"}, ` + + `which this package did not write. It was left untouched; the executable this package resolved is ${command}.`, + }; + } + } + + const next: Record = { ...file.document }; + if (file.text === null) next["$schema"] = MCP_SCHEMA_URL; + + // The `command` is the only field replaced on an update. Every other field an + // operator added to the entry -- a `timeout`, an `env` -- is theirs to keep. + const entry: Record = { + ...(typeof existing === "object" && existing !== null && !Array.isArray(existing) + ? (existing as Record) + : {}), + type: "stdio", + command, + args: [], + }; + next["mcpServers"] = { ...servers, [SERVER_NAME]: entry }; + + const rendered = render(next, file); + if (file.text === rendered) return { ok: true, change: "unchanged" }; + + await mkdir(path.dirname(file.path), { recursive: true }); + await Bun.write(file.path, rendered); + return { ok: true, change: file.text === null ? "created" : "updated" }; +} + +/** + * Removes the owned entry, and only when it still names what was written. + * + * The file itself stays in place whenever anything else remains in it -- other + * servers, a `disabledServers` list, an `$schema` line. Deleting a file this + * package did not create in order to take back one key would remove + * configuration it never owned. + */ +export async function removeEntry( + host: Host, + wroteCommand: string | undefined, +): Promise { + const read = await readMcpFile(host); + if (!read.ok) return read; + + const { file } = read; + if (file.text === null) return { ok: true, change: "absent" }; + + const servers = serverMap(file.document); + const existing = servers[SERVER_NAME]; + if (existing === undefined) return { ok: true, change: "absent" }; + + const currentCommand = commandOf(existing); + if (wroteCommand === undefined || currentCommand !== wroteCommand) { + return { + ok: false, + reason: + `${file.path} defines ${SERVER_NAME} with command ${currentCommand ?? "(none)"}, ` + + `which is not what this package wrote (${wroteCommand ?? "nothing recorded"}). It was left in place.`, + }; + } + + const remaining: Record = { ...servers }; + delete remaining[SERVER_NAME]; + + const next: Record = { ...file.document, mcpServers: remaining }; + await Bun.write(file.path, render(next, file)); + return { ok: true, change: "removed" }; +} + +/** What the file says about the owned entry, without writing anything. */ +export async function entryStatus(host: Host, resolvedCommand: string | null): Promise { + const read = await readMcpFile(host); + if (!read.ok) { + return { path: mcpConfigPath(host), present: false, current: false, problem: read.reason }; + } + + const existing = serverMap(read.file.document)[SERVER_NAME]; + if (existing === undefined) { + return { path: read.file.path, present: false, current: false }; + } + + const command = commandOf(existing); + return { + path: read.file.path, + present: true, + ...(command === undefined ? {} : { command }), + current: command !== undefined && command === resolvedCommand, + }; +} + +/** The `mcpServers` map, or an empty one when the file has no usable map. */ +function serverMap(document: Record): Record { + const servers = document["mcpServers"]; + return typeof servers === "object" && servers !== null && !Array.isArray(servers) + ? (servers as Record) + : {}; +} + +/** The `command` an entry names, when it names a usable one. */ +function commandOf(entry: unknown): string | undefined { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return undefined; + const command = (entry as Record)["command"]; + return typeof command === "string" ? command : undefined; +} + +/** + * The file's own indentation, so a rewrite does not reformat it. + * + * JSON cannot carry comments and `JSON.parse` discards whitespace, so + * indentation is as much of the operator's formatting as this package can + * preserve. A two-space file -- which is what OMP's own writer produces -- comes + * back byte-identical apart from the key that changed. + */ +function detectIndent(text: string): string { + const match = /\n([ \t]+)"/u.exec(text); + return match?.[1] ?? " "; +} + +function render(document: Record, file: McpFile): string { + const body = JSON.stringify(document, null, file.indent); + return file.trailingNewline ? `${body}\n` : body; +} diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..dc2c09a --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,121 @@ +import { homedir } from "node:os"; +import path from "node:path"; + +/** + * Where this package reads and writes, and how it decides. + * + * Every path is derived from an explicit {@link Host} rather than from ambient + * `process.env` and `os.homedir()`, so a test can run the whole lifecycle + * against a scratch `HOME` and then assert what was *not* written -- which is + * the only way to prove `~/.local/bin` is left alone. + */ + +/** The ambient facts every path derives from. */ +export interface Host { + readonly home: string; + readonly env: Readonly>; +} + +/** The real host: this process's home directory and environment. */ +export function processHost(): Host { + return { home: homedir(), env: process.env }; +} + +/** The executable's name on PATH and inside the release archive. */ +export const EXECUTABLE_NAME = "codebase-memory-mcp"; + +/** The MCP server key this package owns, and nothing else in the file. */ +export const SERVER_NAME = "codebase-memory-mcp"; + +/** + * OMP's config directory name, `PI_CONFIG_DIR` included. + * + * Honoured because every path below hangs off it: a process started with + * `PI_CONFIG_DIR` set writes its agent configuration somewhere else entirely, + * and an entry written to `~/.omp/agent/mcp.json` would be read by nobody. + */ +function configDirName(host: Host): string { + const override = host.env["PI_CONFIG_DIR"]; + return override !== undefined && override !== "" ? override : ".omp"; +} + +/** + * The active OMP agent directory, resolved the way OMP resolves it. + * + * `PI_CODING_AGENT_DIR` first is not a guess about precedence: OMP's CLI calls + * `setProfile(...)` on every start -- with the `--profile` value, or with + * `OMP_PROFILE`/`PI_PROFILE` from the environment -- and a named profile makes + * that call write `PI_CODING_AGENT_DIR` back as the profile's own agent + * directory. So inside a session the variable is already OMP's answer, and + * reading it first reproduces that answer exactly. + * + * The profile branch is the fallback for a process that never went through + * OMP's CLI: `OMP_PROFILE` is canonical and `PI_PROFILE` is consulted only when + * `OMP_PROFILE` is undefined, which is OMP's own rule -- an explicitly empty + * `OMP_PROFILE` selects the default profile rather than inheriting the legacy + * variable. + */ +export function agentDir(host: Host): string { + const explicit = host.env["PI_CODING_AGENT_DIR"]; + if (explicit !== undefined && explicit !== "") return path.resolve(explicit); + + const omp = host.env["OMP_PROFILE"]; + const profile = omp !== undefined ? omp : host.env["PI_PROFILE"]; + const root = path.join(host.home, configDirName(host)); + return profile !== undefined && profile !== "" + ? path.join(root, "profiles", profile, "agent") + : path.join(root, "agent"); +} + +/** The native user MCP file this package owns one key in. */ +export function mcpConfigPath(host: Host): string { + return path.join(agentDir(host), "mcp.json"); +} + +/** + * The path a future upstream `--clients=omp` would write its own extension to. + * + * Checked on load so a CBM-written native extension and this package's entry + * cannot both apply the next change's output augmentation. Extension modules + * are deduplicated by absolute path, so both would load. + */ +export function nativeExtensionPath(host: Host): string { + return path.join(agentDir(host), "extensions", "codebase-memory.ts"); +} + +/** + * The root this package owns, and the only place it writes an executable. + * + * Outside the plugin tree on purpose: OMP caches plugins in version-qualified + * directories and replaces them on reinstall, so a managed executable stored + * inside would be discarded by a routine plugin upgrade and re-downloaded every + * time. Outside the agent directory too, so it survives a profile switch. + */ +export function packageRoot(host: Host): string { + return path.join(host.home, configDirName(host), "codebase-memory"); +} + +/** Where managed versions live, one directory per version. */ +export function managedBinRoot(host: Host): string { + return path.join(packageRoot(host), "bin"); +} + +/** The managed executable for one version. */ +export function managedExecutable(host: Host, version: string): string { + return path.join(managedBinRoot(host), version, EXECUTABLE_NAME); +} + +/** This package's state file: source, version, digest, pin, last check. */ +export function statePath(host: Host): string { + return path.join(packageRoot(host), "state.json"); +} + +/** + * Upstream's own install directory, which this package reads and never writes. + * + * Present here so the resolution order can consult it, and so the test that + * proves it is untouched has one name to check rather than a literal. + */ +export function upstreamInstallDir(host: Host): string { + return path.join(host.home, ".local", "bin"); +} diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 0000000..aab0d14 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,137 @@ +/** + * The one seam that knows how a release asset is named and what it contains. + * + * Every platform-specific fact about upstream's release layout lives here: + * archive naming, the container format, the executable's name inside it, and + * the closed member set the archive must match. Acquisition, verification, and + * resolution all read those facts from a {@link Target} rather than deriving + * them again, so adding a platform means extending this file and nothing else. + */ +import { cpus } from "node:os"; + +/** The operating systems upstream publishes an archive for. */ +export type TargetOs = "darwin" | "linux" | "windows"; + +/** The architectures upstream publishes an archive for. */ +export type TargetArch = "arm64" | "amd64"; + +/** Everything downstream code needs to know about one release target. */ +export interface Target { + readonly os: TargetOs; + readonly arch: TargetArch; + /** Release asset name, exactly as `checksums.txt` spells it. */ + readonly archive: string; + /** Container format, which decides the enumeration and extraction tool. */ + readonly container: "tar.gz" | "zip"; + /** The executable's name inside the archive, and on disk once adopted. */ + readonly executable: string; + /** The platform installer script inside the archive. */ + readonly installer: string; + /** + * The complete set of members the archive may contain, in no particular + * order. Upstream's installer treats anything outside it as a release + * integrity failure rather than a sidecar to ignore, and so does this + * package; see {@link enumerateArchiveMembers}. + */ + readonly members: readonly string[]; +} + +/** + * Raised for a platform this package names but does not implement. + * + * Windows reaches this: {@link describeTarget} knows its archive naming, so a + * later change has one seam to extend rather than a shape to reverse-engineer, + * but zip extraction, the executable suffix on disk, and Windows path handling + * are absent. One explicit refusal is honest about that; a half-implemented + * branch would download an archive it cannot open. + */ +export class UnsupportedPlatformError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsupportedPlatformError"; + } +} + +/** + * The release target for one OS/architecture pair. + * + * Pure, and deliberately total over {@link TargetOs}: the Windows shape is + * described here and refused in {@link detectTarget}, which keeps the naming + * under test without claiming support for it. + * + * The Linux `-portable` suffix is not cosmetic. The standard Linux build + * dynamically links glibc 2.38 or newer and fails outright on Debian 11, + * RHEL 8, and Ubuntu 20.04; the portable build is fully static. macOS and + * Windows publish no such variant, so the suffix must be absent there or the + * asset name names nothing. + */ +export function describeTarget(os: TargetOs, arch: TargetArch): Target { + const container = os === "windows" ? "zip" : "tar.gz"; + const executable = os === "windows" ? "codebase-memory-mcp.exe" : "codebase-memory-mcp"; + const installer = os === "windows" ? "install.ps1" : "install.sh"; + const portable = os === "linux" ? "-portable" : ""; + const archive = `codebase-memory-mcp-${os}-${arch}${portable}.${container}`; + + return { + os, + arch, + archive, + container, + executable, + installer, + members: [executable, "LICENSE", installer, "THIRD_PARTY_NOTICES.md"], + }; +} + +/** + * The release target for the host this process runs on. + * + * `platform` and `arch` are `process.platform` and `process.arch`; `cpuModel` + * is the host's CPU brand string (`os.cpus()[0]?.model`). The last one exists + * for Rosetta: an x64 process on Apple Silicon reports `arch === "x64"`, and + * selecting the amd64 archive there installs a translated binary that works + * but runs slowly beside a native one. Upstream's installer makes the same + * correction from the same brand string. + */ +export function detectTarget(platform: string, arch: string, cpuModel: string | undefined): Target { + let os: TargetOs; + switch (platform) { + case "darwin": + os = "darwin"; + break; + case "linux": + os = "linux"; + break; + case "win32": + throw new UnsupportedPlatformError( + "Windows is not supported yet: the release archive is a zip this package cannot extract, " + + "and the executable suffix and path handling are unimplemented. " + + "Install codebase-memory-mcp with upstream's install.ps1 and this package will adopt it from PATH.", + ); + default: + throw new UnsupportedPlatformError( + `unsupported operating system: ${platform} (supported: darwin, linux)`, + ); + } + + let target: TargetArch; + switch (arch) { + case "arm64": + target = "arm64"; + break; + case "x64": + target = os === "darwin" && /apple/i.test(cpuModel ?? "") ? "arm64" : "amd64"; + break; + default: + throw new UnsupportedPlatformError( + `unsupported architecture: ${arch} (supported: arm64, x64)`, + ); + } + + return describeTarget(os, target); +} + +/** The release target for this process, read from `process` and `os.cpus()`. */ +export function hostTarget(): Target { + return detectTarget(process.platform, process.arch, cpus()[0]?.model); +} diff --git a/src/release.ts b/src/release.ts new file mode 100644 index 0000000..b16d29a --- /dev/null +++ b/src/release.ts @@ -0,0 +1,206 @@ +/** + * Upstream's release surface: the only network entry point in this package, + * plus the two pieces of published metadata acquisition depends on. + * + * Every verification property here exists because upstream's `install.sh` has + * it. A step dropped is a security property silently removed, so each one + * names the upstream behaviour it reproduces. + */ + +/** The repository this package acquires from. */ +export const UPSTREAM_REPO = "DeusData/codebase-memory-mcp"; + +const RELEASES = `https://github.com/${UPSTREAM_REPO}/releases`; + +/** Where `releases/latest` redirects a tag out of. */ +const LATEST = `${RELEASES}/latest`; + +/** + * Redirect hops `fetchHttps` will follow. + * + * Matches the `--max-redirs 5` upstream's installer passes to curl. The bound + * exists so a redirect loop fails as a bounded error rather than hanging. + */ +const MAX_REDIRECTS = 5; + +/** Default per-request deadline. A version check must not hang a session. */ +const DEFAULT_TIMEOUT_MS = 20_000; + +/** + * `checksums.txt` above this size is refused unread. + * + * Upstream's installer refuses the same 1 MiB, for the same reason: the digest + * file is a few kilobytes of text, so anything larger is not the file this + * package believes it is parsing. + */ +export const CHECKSUMS_LIMIT_BYTES = 1_048_576; + +export interface FetchOptions { + /** + * Redirect hops to follow, capped at {@link MAX_REDIRECTS}. + * + * `0` returns the first response as received, redirect status and `location` + * header included -- which is how the release tag is read. + */ + readonly maxRedirects?: number; + readonly timeoutMs?: number; +} + +/** + * The only place this package opens a network connection. + * + * HTTPS is required for the initial request and re-checked on every redirect + * hop, because a redirect is the one place a transport downgrade can arrive + * from outside the URL this code chose. `redirect: "manual"` is what makes + * that possible: the platform's own redirect following would take the hop + * before this code could look at it. + */ +export async function fetchHttps(url: string, options: FetchOptions = {}): Promise { + const budget = Math.min(options.maxRedirects ?? MAX_REDIRECTS, MAX_REDIRECTS); + let current = requireHttps(url, "request"); + + for (let hop = 0; ; hop++) { + const response = await fetch(current, { + redirect: "manual", + signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS), + headers: { accept: "*/*" }, + }); + + const redirected = response.status >= 300 && response.status < 400; + if (!redirected || hop >= budget) return response; + + const location = response.headers.get("location"); + if (location === null) { + throw new Error(`${current} answered ${response.status} with no location header`); + } + current = requireHttps(new URL(location, current).href, "redirect"); + } +} + +/** `url` when it is HTTPS; otherwise a refusal naming which hop downgraded. */ +function requireHttps(url: string, kind: "request" | "redirect"): string { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + throw new Error(`refusing non-HTTPS ${kind}: ${url}`); + } + return parsed.href; +} + +/** + * The newest release tag, read from the `releases/latest` redirect. + * + * Not the GitHub API: that answer is rate-limited per IP, which turns a + * background version check into an intermittent failure on a shared network + * and would need a token for something requiring no authentication at all. + * The redirect needs neither, and asset downloads already use the same + * `releases/` mechanism. + * + * The `location` is validated rather than trusted. A redirect is attacker- + * adjacent input, and the tag it yields is later interpolated into a download + * URL and used as an on-disk directory name. + */ +export async function resolveLatestTag(): Promise { + const response = await fetchHttps(LATEST, { maxRedirects: 0 }); + if (response.status < 300 || response.status >= 400) { + throw new Error( + `expected ${LATEST} to redirect to a tag, got HTTP ${response.status}`, + ); + } + + const location = response.headers.get("location"); + if (location === null) { + throw new Error(`${LATEST} answered ${response.status} with no location header`); + } + + const resolved = new URL(location, LATEST); + if (resolved.protocol !== "https:") { + throw new Error(`refusing non-HTTPS release location: ${resolved.href}`); + } + + const prefix = `/${UPSTREAM_REPO}/releases/tag/`; + if (!resolved.pathname.startsWith(prefix)) { + throw new Error(`unexpected release location: ${resolved.href}`); + } + + const tag = decodeURIComponent(resolved.pathname.slice(prefix.length)); + if (tag === "" || tag.includes("/")) { + throw new Error(`unexpected release tag in location: ${resolved.href}`); + } + return tag; +} + +/** + * The published SHA-256 digest for exactly `archive`. + * + * Reproduces upstream's `awk '$2 == archive || $2 == "*" archive'` selection, + * and its three refusals. Exactness is load-bearing rather than pedantic: + * a real `checksums.txt` lists `codebase-memory-mcp-ui-darwin-arm64.tar.gz` + * beside `codebase-memory-mcp-darwin-arm64.tar.gz`, so a substring or prefix + * match would silently verify one asset's bytes against another's line. + */ +export function parseChecksums(body: Uint8Array, archive: string): string { + if (body.byteLength > CHECKSUMS_LIMIT_BYTES) { + throw new Error( + `checksums.txt is ${body.byteLength} bytes, over the ${CHECKSUMS_LIMIT_BYTES} byte safety limit`, + ); + } + + const text = new TextDecoder("utf-8", { fatal: false }).decode(body); + let digest: string | undefined; + + for (const line of text.split("\n")) { + const fields = line.trim().split(/\s+/u); + if (fields.length < 2) continue; + + const name = fields[1] ?? ""; + if (name !== archive && name !== `*${archive}`) continue; + + const candidate = (fields[0] ?? "").toLowerCase(); + if (!/^[0-9a-f]{64}$/u.test(candidate)) { + throw new Error(`invalid SHA-256 digest for ${archive}: ${fields[0] ?? ""}`); + } + if (digest !== undefined && digest !== candidate) { + throw new Error(`conflicting SHA-256 digests for ${archive} in checksums.txt`); + } + digest = candidate; + } + + if (digest === undefined) { + throw new Error(`no SHA-256 digest for ${archive} in checksums.txt`); + } + return digest; +} + +/** + * The published artifacts acquisition reads. + * + * An interface rather than three free functions so the failure paths -- digest + * mismatch, an unexpected archive member, a candidate that will not run -- are + * reachable from a test without a network or a mutated release. + */ +export interface ReleaseSource { + /** The newest release tag. */ + latestTag(): Promise; + /** `checksums.txt` for `tag`, as bytes so its size can be refused unread. */ + checksums(tag: string): Promise; + /** Release asset `name` published under `tag`. */ + asset(tag: string, name: string): Promise; +} + +/** The real release source, over {@link fetchHttps}. */ +export function githubReleaseSource(): ReleaseSource { + return { + latestTag: resolveLatestTag, + checksums: (tag) => download(`${RELEASES}/download/${encodeURIComponent(tag)}/checksums.txt`), + asset: (tag, name) => + download(`${RELEASES}/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`), + }; +} + +async function download(url: string): Promise { + const response = await fetchHttps(url); + if (!response.ok) { + throw new Error(`GET ${url} answered HTTP ${response.status}`); + } + return new Uint8Array(await response.arrayBuffer()); +} diff --git a/src/resolve.ts b/src/resolve.ts new file mode 100644 index 0000000..a46a86f --- /dev/null +++ b/src/resolve.ts @@ -0,0 +1,144 @@ +import { readVersion } from "./exec.ts"; +import { + EXECUTABLE_NAME, + managedBinRoot, + managedExecutable, + upstreamInstallDir, + type Host, +} from "./paths.ts"; +import { readState, type State } from "./state.ts"; + +import path from "node:path"; + +/** + * Which of the four places an executable was found. + * + * `system` covers both `PATH` and `~/.local/bin`: they are the same policy + * decision -- an installation this package did not place and must not touch -- + * and {@link Resolved.origin} names which one it was for the status report. + */ +export type ResolutionSource = "pin" | "system" | "managed"; + +export interface Resolved { + /** Absolute path to the executable. */ + readonly executable: string; + readonly source: ResolutionSource; + /** Where it was found, for the status report: `PATH`, `~/.local/bin`, a version. */ + readonly origin: string; +} + +/** A managed copy on disk, whether or not it is the resolved one. */ +export interface ManagedCopy { + readonly version: string; + readonly executable: string; +} + +export type Resolution = + | { readonly ok: true; readonly resolved: Resolved } + | { readonly ok: false; readonly reason: string }; + +/** + * The remedy an unresolved lookup names. + * + * Both paths are offered because they lead to different outcomes and the + * operator owns the choice: `/cbm install` places a copy this package manages + * and can update, upstream's installer places one at `~/.local/bin` that CBM's + * own `update` owns. This package adopts either. + */ +const NO_EXECUTABLE_REASON = + `no ${EXECUTABLE_NAME} executable found on PATH, in ~/.local/bin, or under this package's own root. ` + + "Run /cbm install to download a managed copy, or install it yourself with " + + "`curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash` " + + "and this package will adopt it."; + +/** + * The managed copy the state's pointer names, when it is still on disk. + * + * The pointer is a field of the state file rather than a separate symlink or + * `current` file. One document means one write to keep consistent, and it + * sidesteps the question of what a symlink means on a platform this package + * will eventually support. + */ +export async function managedCopy(host: Host, state?: State): Promise { + const recorded = (state ?? (await readState(host))).managedVersion; + if (recorded === undefined) return null; + + const executable = managedExecutable(host, recorded); + return (await Bun.file(executable).exists()) ? { version: recorded, executable } : null; +} + +/** + * The executable this package will point the MCP entry at. + * + * Order is pin, `PATH`, `~/.local/bin`, managed copy -- system before managed, + * deliberately, and the reason is the index rather than tidiness. CBM resolves + * one canonical per-account cache root and refuses to run when a process is + * configured with a different root while any CBM session or command is active. + * Two executables of different versions sharing that root produce mismatched + * index generations, so adopting whatever the operator already runs is the only + * safe default. A private cache root for the managed copy would avoid the + * conflict by re-indexing every repository a second time, which for a large + * tree is hours of work to hold the same answers twice. + * + * A pin comes first because it is explicit operator intent, and it can only + * ever select a managed copy -- this package does not relocate or re-version a + * system installation. + */ +export async function resolveExecutable(host: Host, state?: State): Promise { + const current = state ?? (await readState(host)); + + const pin = current.pin; + if (pin !== undefined) { + const pinned = managedExecutable(host, pin); + if (await Bun.file(pinned).exists()) { + return { ok: true, resolved: { executable: pinned, source: "pin", origin: pin } }; + } + } + + const onPath = Bun.which(EXECUTABLE_NAME, pathOption(host)); + if (onPath !== null) { + return { + ok: true, + resolved: { executable: path.resolve(onPath), source: "system", origin: "PATH" }, + }; + } + + const upstream = path.join(upstreamInstallDir(host), EXECUTABLE_NAME); + if (await Bun.file(upstream).exists()) { + return { + ok: true, + resolved: { executable: upstream, source: "system", origin: "~/.local/bin" }, + }; + } + + const managed = await managedCopy(host, current); + if (managed !== null) { + return { + ok: true, + resolved: { + executable: managed.executable, + source: "managed", + origin: path.join(path.basename(managedBinRoot(host)), managed.version), + }, + }; + } + + return { ok: false, reason: NO_EXECUTABLE_REASON }; +} + +/** The version the resolved executable reports, or `null` when it will not run. */ +export async function resolvedVersion(resolved: Resolved): Promise { + return await readVersion(resolved.executable); +} + +/** + * `Bun.which`'s options for this host. + * + * Threaded from {@link Host} rather than read from `process.env` so a test can + * point `PATH` at a scratch directory; `Bun.which` falls back to the process + * environment when no `PATH` is supplied, which would make such a test read the + * developer's own installation. + */ +function pathOption(host: Host): { PATH: string } { + return { PATH: host.env["PATH"] ?? "" }; +} diff --git a/src/scheduler.ts b/src/scheduler.ts new file mode 100644 index 0000000..840bad0 --- /dev/null +++ b/src/scheduler.ts @@ -0,0 +1,41 @@ +import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent"; + +/** + * The deferred-work seam, over OMP's managed timers. + * + * Extensions run in-process with no isolation, and a raw `setTimeout` callback + * that throws escapes handler dispatch entirely: it surfaces as a process-level + * `uncaughtException`, and OMP's postmortem handler treats that as fatal and + * tears down the whole session. The handler context's timer methods run their + * callback with the same isolation as handler dispatch, are `unref`'d, and are + * cleared on `session_shutdown`. + * + * So the platform timer globals are not called anywhere in this package, and + * this adapter is the reason there is nowhere convenient to call them from. + */ + +/** + * An opaque handle to one scheduled callback. + * + * `Timer` is the type OMP's managed methods return, and a global in `bun-types`. + */ +export type TimerHandle = Timer; + +export interface Scheduler { + /** Runs `callback` once, after `ms`. */ + after(callback: () => void, ms: number): TimerHandle; + /** Cancels a callback that has not run yet. */ + cancel(handle: TimerHandle): void; +} + +/** The scheduler backed by one handler context's managed timers. */ +export function schedulerFrom(ctx: ExtensionContext): Scheduler { + return { + after(callback, ms) { + return ctx.setTimeout(callback, ms); + }, + cancel(handle) { + ctx.clearTimer(handle); + }, + }; +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..ac9ddaf --- /dev/null +++ b/src/state.ts @@ -0,0 +1,88 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; + +import { statePath, type Host } from "./paths.ts"; + +/** + * This package's own state, outside the plugin tree because the managed + * executable it describes outlives a plugin reinstall. + * + * Every field is optional and every read tolerates a missing or unreadable + * file. State here is a cache and a record of operator intent, never a + * prerequisite: resolution works from an empty state, and the worst a lost + * state file costs is one extra version check and a forgotten pin. + */ +export interface State { + /** The managed version the current pointer names, when one was adopted. */ + readonly managedVersion?: string; + /** The archive digest that version was verified against. */ + readonly managedDigest?: string; + /** A version the operator pinned; update checks report but never adopt. */ + readonly pin?: string; + /** The newest upstream version the last successful check saw. */ + readonly upstreamVersion?: string; + /** When the last upstream check completed, successfully or not, in epoch ms. */ + readonly lastCheckedAt?: number; + /** The absolute `command` this package last wrote into `mcp.json`. */ + readonly wroteCommand?: string; +} + +const EMPTY: State = {}; + +/** + * The recorded state, or an empty state. + * + * A missing file is the first-run case. An unparseable one is treated the same + * way rather than refused: the file is this package's own cache, so the + * recoverable reading is "forget what was cached", and failing a session start + * over a corrupted cache entry would be a worse outcome than re-checking. + */ +export async function readState(host: Host): Promise { + const file = Bun.file(statePath(host)); + let text: string; + try { + text = await file.text(); + } catch { + return EMPTY; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return EMPTY; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return EMPTY; + + const record = parsed as Record; + const state: Record = {}; + for (const key of ["managedVersion", "managedDigest", "pin", "upstreamVersion", "wroteCommand"]) { + const value = record[key]; + if (typeof value === "string" && value !== "") state[key] = value; + } + const lastCheckedAt = record["lastCheckedAt"]; + if (typeof lastCheckedAt === "number" && Number.isFinite(lastCheckedAt)) { + state["lastCheckedAt"] = lastCheckedAt; + } + return state as State; +} + +/** + * Replaces the recorded state with `next`. + * + * Whole-document rather than field-wise because the document is small and one + * writer owns it; a partial update would need a lock this package does not + * have. Callers read, spread, and write. + */ +export async function writeState(host: Host, next: State): Promise { + const file = statePath(host); + await mkdir(path.dirname(file), { recursive: true }); + await Bun.write(file, `${JSON.stringify(next, null, 2)}\n`); +} + +/** Merges `patch` into the recorded state and writes the result. */ +export async function updateState(host: Host, patch: State): Promise { + const next = { ...(await readState(host)), ...patch }; + await writeState(host, next); + return next; +} diff --git a/test/fixtures/checksums-v0.10.8.txt b/test/fixtures/checksums-v0.10.8.txt new file mode 100644 index 0000000..7f2490b --- /dev/null +++ b/test/fixtures/checksums-v0.10.8.txt @@ -0,0 +1,25 @@ +2b193085410af3801634a522f4b17dcd6699695e015a068393c87817c1d260d4 codebase-memory-mcp-darwin-amd64.tar.gz +9bd840dfb3ec7eaef4f310382057adaa5b0e904df883104d03ffcf39836afd07 codebase-memory-mcp-darwin-arm64.tar.gz +6eef49652bc0c7820f43114125044d40bf7f4d97c11b2592f6b0f6a307702325 codebase-memory-mcp-linux-amd64-portable.tar.gz +e5cba4cad6ca8254a85f45041fc8a831908d7d5cb64f98fc3f8eb70a58671793 codebase-memory-mcp-linux-amd64.tar.gz +5697d986d9716c913163b4bff7b3a294287f3b843e993bc1ff71e78dcdc21781 codebase-memory-mcp-linux-arm64-portable.tar.gz +e2804a20f5a6fc392af361525a232703e351b7d1aacb81b88eef806eec5959fa codebase-memory-mcp-linux-arm64.tar.gz +b43ad982994c4d829670749e08d3b622a74bb20041fc0a7d02bef6113f81c34d codebase-memory-mcp-windows-amd64.zip +254b26e819f00bab7f430c5f809d37d22b07bb3eb6427e290e5a27ba5b8e983e codebase-memory-mcp-windows-arm64.zip +110f85f12632d7f6749a6e8e07f8ab66719bbf779c2e53a2b5e08291d86df1c7 codebase-memory-mcp-darwin-amd64.mcpb +3a810bb817f628ae4e88634f985305428a887745f437295871bbacd2edf2deb2 codebase-memory-mcp-darwin-arm64.mcpb +13a33ed0ee74d15c2b3ec49c8e0dc66d2ef61e980731786db25b8d61af3522b3 codebase-memory-mcp-linux-amd64-portable.mcpb +33a0999b986c9d6c566db5360393807b3a6c767fdc0ac2d669a257c8709a781f codebase-memory-mcp-linux-arm64-portable.mcpb +7bcc606174bd4d0730ea5d51ab90cae296c50968599eceaf3a2c1db61357e214 codebase-memory-mcp-windows-amd64.mcpb +a3916edabb4e99dabe0c9d3d4eccce0e0df7247d927bb011bc98900e67ef9948 codebase-memory-mcp-windows-arm64.mcpb +05192881288fd561de482e069f014657dd7f1e05d8484158e6a41c81cdc6ee78 release-candidates.tsv +159e63784246b6d4a5d23e6854fe5ba06e3086eddedba3442c45f8d9924dc54f virustotal-candidate-results.tsv +c73fd277802b0d8b499a71600d1abcf3230b147ae1cbec8a5cbe69c8b04b4fbe release-selection.tsv +2b193085410af3801634a522f4b17dcd6699695e015a068393c87817c1d260d4 codebase-memory-mcp-ui-darwin-amd64.tar.gz +9bd840dfb3ec7eaef4f310382057adaa5b0e904df883104d03ffcf39836afd07 codebase-memory-mcp-ui-darwin-arm64.tar.gz +6eef49652bc0c7820f43114125044d40bf7f4d97c11b2592f6b0f6a307702325 codebase-memory-mcp-ui-linux-amd64-portable.tar.gz +e5cba4cad6ca8254a85f45041fc8a831908d7d5cb64f98fc3f8eb70a58671793 codebase-memory-mcp-ui-linux-amd64.tar.gz +5697d986d9716c913163b4bff7b3a294287f3b843e993bc1ff71e78dcdc21781 codebase-memory-mcp-ui-linux-arm64-portable.tar.gz +e2804a20f5a6fc392af361525a232703e351b7d1aacb81b88eef806eec5959fa codebase-memory-mcp-ui-linux-arm64.tar.gz +b43ad982994c4d829670749e08d3b622a74bb20041fc0a7d02bef6113f81c34d codebase-memory-mcp-ui-windows-amd64.zip +254b26e819f00bab7f430c5f809d37d22b07bb3eb6427e290e5a27ba5b8e983e codebase-memory-mcp-ui-windows-arm64.zip diff --git a/test/packaging/bundle.test.ts b/test/packaging/bundle.test.ts new file mode 100644 index 0000000..e72e82b --- /dev/null +++ b/test/packaging/bundle.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import { copyFile, mkdtemp, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; + +import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent"; + +/** + * The committed bundle, loaded the way OMP loads it. + * + * `test:packaging` rebuilds `dist/index.js` first, so a green run here proves a + * fresh bundle builds and registers what it claims. It says nothing about + * whether the *committed* file matches the source beside it -- that is the + * `git diff --exit-code -- dist/index.js` step in CI, and the two checks are + * not substitutes for each other. + */ + +const MANIFEST = "package.json"; +const BUNDLE = "dist/index.js"; + +interface Manifest { + readonly omp?: { readonly extensions?: readonly string[] }; +} + +async function declaredEntries(): Promise { + const manifest = (await Bun.file(MANIFEST).json()) as Manifest; + return manifest.omp?.extensions ?? []; +} + +/** + * Loads the bundle from a directory holding nothing else. + * + * The isolation is the point: a bundle that had quietly kept a runtime + * dependency would fail to import rather than resolve it from this + * repository's own `node_modules`. + */ +async function loadIsolated(): Promise { + const directory = await mkdtemp(join(tmpdir(), "cbm-bundle-")); + const copied = join(directory, "index.js"); + await copyFile(resolve(BUNDLE), copied); + expect(await readdir(directory)).toEqual(["index.js"]); + return await loadExtensions([copied], directory); +} + +describe("the declared extension entries", () => { + test("every omp.extensions entry resolves on disk in the built tree", async () => { + const entries = await declaredEntries(); + expect(entries.length).toBeGreaterThan(0); + + for (const entry of entries) { + expect(await Bun.file(resolve(entry)).exists()).toBe(true); + } + }); + + test("every declared entry default-exports a factory function", async () => { + const entries = await declaredEntries(); + expect(entries.length).toBeGreaterThan(0); + + for (const entry of entries) { + // Dynamic by necessity: the specifier is whatever the manifest declares, + // which is the thing under test. A static import would check a literal + // this test wrote instead of the entry OMP's installer will resolve. + const module = (await import(resolve(entry))) as { default?: unknown }; + expect(typeof module.default).toBe("function"); + } + }); +}); + +describe("the standalone bundle", () => { + test("loads through OMP's own loader with no errors", async () => { + const loaded = await loadIsolated(); + expect(loaded.errors).toEqual([]); + expect(loaded.extensions).toHaveLength(1); + }); + + test("registers the /cbm command and no tools", async () => { + const loaded = await loadIsolated(); + expect([...(loaded.extensions[0]?.commands.keys() ?? [])]).toEqual(["cbm"]); + expect([...(loaded.extensions[0]?.tools.keys() ?? [])]).toEqual([]); + }); + + /** + * The load-bearing negative. OMP treats a throwing or blocking `tool_call` + * handler as a refusal of the tool call, so a handler registered here could + * deny an operator's `grep` because a subprocess timed out. The event is not + * registered at all, and this is the assertion that keeps it that way. + */ + test("registers no tool_call handler", async () => { + const loaded = await loadIsolated(); + const handlers = [...(loaded.extensions[0]?.handlers.keys() ?? [])]; + expect(handlers).not.toContain("tool_call"); + expect(handlers.sort()).toEqual(["session_start"]); + }); +}); diff --git a/test/support/release.ts b/test/support/release.ts new file mode 100644 index 0000000..8b4e3fe --- /dev/null +++ b/test/support/release.ts @@ -0,0 +1,119 @@ +import { chmod, mkdir, mkdtemp, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { run } from "../../src/exec.ts"; +import type { ReleaseSource } from "../../src/release.ts"; +import type { Target } from "../../src/platform.ts"; + +/** + * Release archives built with the same `tar` acquisition reads them with. + * + * A hand-written archive parser in the test would agree with a hand-written one + * in the source; a real archive built by the real tool is what makes "reject an + * unexpected member" and "reject a symlinked member" claims about behaviour + * rather than about two implementations of the same guess. + */ + +/** One member of a built archive. */ +export interface Member { + readonly name: string; + /** File contents; mutually exclusive with {@link Member.symlinkTo}. */ + readonly contents?: string; + /** Makes the member a symbolic link to this target instead of a file. */ + readonly symlinkTo?: string; + readonly mode?: number; +} + +/** An archive on disk, with its digest. */ +export interface BuiltArchive { + readonly file: string; + readonly bytes: Uint8Array; + readonly digest: string; +} + +/** + * The four members a genuine release archive holds, with an executable that + * runs and prints `version`. + */ +export function releaseMembers(target: Target, version: string): Member[] { + return [ + { + name: target.executable, + contents: `#!/bin/sh\necho "codebase-memory-mcp ${version}"\n`, + mode: 0o755, + }, + { name: "LICENSE", contents: "MIT\n" }, + { name: target.installer, contents: "#!/usr/bin/env bash\nexit 0\n", mode: 0o755 }, + { name: "THIRD_PARTY_NOTICES.md", contents: "# notices\n" }, + ]; +} + +/** Builds a `.tar.gz` holding exactly `members`. */ +export async function buildArchive(name: string, members: readonly Member[]): Promise { + const staging = await mkdtemp(path.join(tmpdir(), "cbm-archive-")); + const content = path.join(staging, "content"); + await mkdir(content, { recursive: true }); + + for (const member of members) { + const entry = path.join(content, member.name); + await mkdir(path.dirname(entry), { recursive: true }); + if (member.symlinkTo !== undefined) { + await symlink(member.symlinkTo, entry); + continue; + } + await Bun.write(entry, member.contents ?? ""); + if (member.mode !== undefined) await chmod(entry, member.mode); + } + + const file = path.join(staging, name); + const packed = await run([ + "tar", + "-czf", + file, + "-C", + content, + ...members.map((member) => member.name), + ]); + if (!packed.ok) { + throw new Error(`could not build ${name}: ${packed.stderr || packed.spawnError}`); + } + + const bytes = new Uint8Array(await Bun.file(file).arrayBuffer()); + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(bytes); + return { file, bytes, digest: hasher.digest("hex") }; +} + +export interface FakeSourceOptions { + readonly tag: string; + readonly archiveName: string; + readonly bytes: Uint8Array; + /** + * The digest `checksums.txt` publishes. Defaults to the archive's real + * digest; a different value is how the mismatch path is reached. + */ + readonly publishedDigest: string; +} + +/** A {@link ReleaseSource} serving one prepared archive, with a call log. */ +export interface FakeSource extends ReleaseSource { + /** Every asset name requested, in order. */ + readonly requested: string[]; +} + +export function fakeSource(options: FakeSourceOptions): FakeSource { + const requested: string[] = []; + const encoder = new TextEncoder(); + return { + requested, + latestTag: async () => options.tag, + checksums: async () => + encoder.encode(`${options.publishedDigest} ${options.archiveName}\n`), + asset: async (_tag, name) => { + requested.push(name); + if (name !== options.archiveName) throw new Error(`no such asset: ${name}`); + return options.bytes; + }, + }; +} diff --git a/test/support/scratch.ts b/test/support/scratch.ts new file mode 100644 index 0000000..2cb5445 --- /dev/null +++ b/test/support/scratch.ts @@ -0,0 +1,82 @@ +import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import type { Host } from "../../src/paths.ts"; + +/** + * A throwaway `HOME` and `PATH`, so a test can run the real lifecycle and then + * assert what was *not* written. + * + * Every path in this package derives from an explicit {@link Host} rather than + * from `os.homedir()` and `process.env`, which is what makes this possible -- + * and what makes "no code path writes to `~/.local/bin`" a checkable claim + * instead of a code-reading exercise. + */ +export interface Scratch { + /** The temporary root holding everything this scratch owns. */ + readonly root: string; + /** The fake home directory. */ + readonly home: string; + /** The only directory on the scratch `PATH`. */ + readonly pathDir: string; + /** The host every call under test is given. */ + readonly host: Host; +} + +/** + * The system directories acquisition's own prerequisites live in: `tar` + * everywhere, plus `xattr` and `codesign` on macOS. + * + * Kept out of the default `PATH` so a resolution test cannot be decided by a + * real `codebase-memory-mcp` installation on the machine running the suite. + * A test that needs the tools asks for them, and says so by asking. + */ +const SYSTEM_TOOL_PATH = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]; + +export interface ScratchOptions { + /** Appends {@link SYSTEM_TOOL_PATH} to the scratch `PATH`. */ + readonly systemTools?: boolean; + /** Extra environment variables, e.g. `OMP_PROFILE`. */ + readonly env?: Readonly>; +} + +/** + * Creates a scratch host. + * + * `PATH` names exactly one empty directory unless a test opts into the system + * tool directories. Inheriting the developer's own `PATH` would let a real + * `codebase-memory-mcp` installation decide the outcome of a resolution test. + */ +export async function makeScratch(options: ScratchOptions = {}): Promise { + const root = await mkdtemp(path.join(tmpdir(), "cbm-scratch-")); + const home = path.join(root, "home"); + const pathDir = path.join(root, "path-bin"); + await mkdir(home, { recursive: true }); + await mkdir(pathDir, { recursive: true }); + + const searchPath = [pathDir, ...(options.systemTools === true ? SYSTEM_TOOL_PATH : [])]; + return { + root, + home, + pathDir, + host: { home, env: { HOME: home, PATH: searchPath.join(":"), ...options.env } }, + }; +} + +/** Removes a scratch root. */ +export async function dropScratch(scratch: Scratch): Promise { + await rm(scratch.root, { recursive: true, force: true }); +} + +/** + * Writes an executable shell script at `file`, creating its parent. + * + * Stands in for the real binary wherever a test only needs something that runs + * and prints a version -- resolution ordering, the smoke check, adoption. + */ +export async function writeFakeExecutable(file: string, body: string): Promise { + await mkdir(path.dirname(file), { recursive: true }); + await Bun.write(file, `#!/bin/sh\n${body}\n`); + await chmod(file, 0o755); +} diff --git a/test/unit/acquire.test.ts b/test/unit/acquire.test.ts new file mode 100644 index 0000000..afbc706 --- /dev/null +++ b/test/unit/acquire.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { readdir } from "node:fs/promises"; + +import { acquire, normalizeVersion } from "../../src/acquire.ts"; +import { describeTarget } from "../../src/platform.ts"; +import { managedBinRoot, packageRoot } from "../../src/paths.ts"; +import { + buildArchive, + fakeSource, + releaseMembers, + type Member, +} from "../support/release.ts"; +import { dropScratch, makeScratch, type Scratch } from "../support/scratch.ts"; + +/** + * The host target is used rather than a fixed one so the macOS repair branch -- + * `xattr` then `codesign` -- is exercised where it exists and skipped where it + * does not. Both are the real path for the machine running the suite. + */ +const TARGET = describeTarget(process.platform === "darwin" ? "darwin" : "linux", "arm64"); +const VERSION = "0.10.8"; +const TAG = `v${VERSION}`; + +let scratch: Scratch; + +beforeEach(async () => { + // Acquisition shells out to `tar`, and on macOS to `xattr` and `codesign`. + scratch = await makeScratch({ systemTools: true }); +}); + +afterEach(async () => { + await dropScratch(scratch); +}); + +/** Whether anything at all exists under the root this package owns. */ +async function packageRootEntries(): Promise { + try { + return await readdir(packageRoot(scratch.host)); + } catch { + return []; + } +} + +interface AbortCase { + readonly scenario: string; + /** The archive members, which is what each failure is really about. */ + readonly members: (target: typeof TARGET) => Member[]; + /** + * The digest `checksums.txt` publishes. + * + * `"real"` publishes the archive's own digest; a literal is how the mismatch + * path is reached without also corrupting the archive. + */ + readonly publishedDigest: "real" | string; + /** The text the refusal must name. */ + readonly reported: RegExp; +} + +const aborts: AbortCase[] = [ + { + scenario: "a digest mismatch aborts and names both digests", + members: (target) => releaseMembers(target, VERSION), + publishedDigest: "0".repeat(64), + reported: /SHA-256 mismatch for .*: published 0{64}, downloaded [0-9a-f]{64}/u, + }, + { + scenario: "an unexpected archive member aborts before extraction", + members: (target) => [ + ...releaseMembers(target, VERSION), + { name: "postinstall.sh", contents: "#!/bin/sh\nexit 0\n", mode: 0o755 }, + ], + publishedDigest: "real", + reported: /unexpected member: postinstall\.sh/u, + }, + { + scenario: "a duplicated archive member aborts rather than last-one-wins", + members: (target) => [...releaseMembers(target, VERSION), { name: "LICENSE", contents: "MIT\n" }], + publishedDigest: "real", + reported: /contains member LICENSE 2 times/u, + }, + { + scenario: "a missing archive member aborts", + members: (target) => releaseMembers(target, VERSION).filter((m) => m.name !== "LICENSE"), + publishedDigest: "real", + reported: /missing member: LICENSE/u, + }, + { + scenario: "a symlinked executable aborts even though its name is expected", + members: (target) => [ + ...releaseMembers(target, VERSION).filter((m) => m.name !== target.executable), + { name: target.executable, symlinkTo: "/bin/sh" }, + ], + publishedDigest: "real", + reported: /not a regular file: codebase-memory-mcp/u, + }, + { + scenario: "an executable that fails --version aborts before adoption", + members: (target) => [ + ...releaseMembers(target, VERSION).filter((m) => m.name !== target.executable), + { name: target.executable, contents: "#!/bin/sh\nexit 3\n", mode: 0o755 }, + ], + publishedDigest: "real", + reported: /failed to run `--version`/u, + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = aborts.map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("a failed acquisition writes nothing under the package-owned root", () => { + test.each(aborts)("$scenario", async ({ members, publishedDigest, reported }) => { + const archive = await buildArchive(TARGET.archive, members(TARGET)); + const source = fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: publishedDigest === "real" ? archive.digest : publishedDigest, + }); + + const attempt = acquire({ host: scratch.host, target: TARGET, source }); + await expect(attempt).rejects.toThrow(reported); + expect(await packageRootEntries()).toEqual([]); + }); +}); + +describe("a verified acquisition", () => { + test("adopts the executable under bin/ and reports what it runs", async () => { + const archive = await buildArchive(TARGET.archive, releaseMembers(TARGET, VERSION)); + const source = fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: archive.digest, + }); + + const acquired = await acquire({ host: scratch.host, target: TARGET, source }); + + expect(acquired.version).toBe(VERSION); + expect(acquired.digest).toBe(archive.digest); + expect(acquired.reportedVersion).toBe(`codebase-memory-mcp ${VERSION}`); + expect(acquired.executable).toBe( + `${managedBinRoot(scratch.host)}/${VERSION}/${TARGET.executable}`, + ); + expect(await Bun.file(acquired.executable).exists()).toBe(true); + expect(source.requested).toEqual([TARGET.archive]); + }); + + test("resolves the newest tag when no version is requested", async () => { + const archive = await buildArchive(TARGET.archive, releaseMembers(TARGET, VERSION)); + const source = fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: archive.digest, + }); + + expect((await acquire({ host: scratch.host, target: TARGET, source })).version).toBe(VERSION); + }); +}); + +interface VersionCase { + readonly scenario: string; + readonly input: string; + readonly expected: string; +} + +const versions: VersionCase[] = [ + { scenario: "a bare version passes through", input: "0.10.8", expected: "0.10.8" }, + { scenario: "a leading v is stripped", input: "v0.10.8", expected: "0.10.8" }, + { scenario: "surrounding whitespace is trimmed", input: " v1.2.3 ", expected: "1.2.3" }, + { + scenario: "a prerelease suffix survives", + input: "v1.2.3-rc.1", + expected: "1.2.3-rc.1", + }, +]; + +interface BadVersionCase { + readonly scenario: string; + readonly input: string; +} + +/** + * A version reaches the filesystem as a directory name and the network as a URL + * segment, and it can arrive from a redirect's `location`. Each of these would + * escape `bin/` or name something else entirely. + */ +const badVersions: BadVersionCase[] = [ + { scenario: "a parent-directory traversal is refused", input: "../../etc" }, + { scenario: "an embedded separator is refused", input: "1.0/../../evil" }, + { scenario: "an empty version is refused", input: "" }, + { scenario: "a version that is only a v is refused", input: "v" }, + { scenario: "a leading dot is refused", input: ".hidden" }, +]; + +test("every version case names itself distinctly", () => { + const scenarios = [...versions, ...badVersions].map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("version normalization", () => { + test.each(versions)("$scenario", ({ input, expected }) => { + expect(normalizeVersion(input)).toBe(expected); + }); + + test.each(badVersions)("$scenario", ({ input }) => { + expect(() => normalizeVersion(input)).toThrow(/not a usable version/u); + }); +}); diff --git a/test/unit/checksums.test.ts b/test/unit/checksums.test.ts new file mode 100644 index 0000000..c52ee67 --- /dev/null +++ b/test/unit/checksums.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test"; + +import { CHECKSUMS_LIMIT_BYTES, parseChecksums } from "../../src/release.ts"; + +/** The `checksums.txt` v0.10.8 actually published, byte for byte. */ +const PUBLISHED = await Bun.file("test/fixtures/checksums-v0.10.8.txt").text(); + +const encoder = new TextEncoder(); + +const DARWIN_ARM64 = "codebase-memory-mcp-darwin-arm64.tar.gz"; +const DARWIN_ARM64_DIGEST = "9bd840dfb3ec7eaef4f310382057adaa5b0e904df883104d03ffcf39836afd07"; + +/** Matches the digest column of the darwin/arm64 line, for mutating it. */ +const DARWIN_ARM64_LINE = /^[0-9a-f]{64}(\s+codebase-memory-mcp-darwin-arm64\.tar\.gz)$/mu; + +interface PublishedDigestCase { + readonly scenario: string; + readonly archive: string; + readonly digest: string; +} + +const publishedDigests: PublishedDigestCase[] = [ + { + scenario: "the darwin/arm64 archive resolves to its published digest", + archive: DARWIN_ARM64, + digest: DARWIN_ARM64_DIGEST, + }, + { + scenario: "the darwin/amd64 archive resolves to its published digest", + archive: "codebase-memory-mcp-darwin-amd64.tar.gz", + digest: "2b193085410af3801634a522f4b17dcd6699695e015a068393c87817c1d260d4", + }, + { + scenario: "the linux/arm64 portable archive resolves to its published digest", + archive: "codebase-memory-mcp-linux-arm64-portable.tar.gz", + digest: "5697d986d9716c913163b4bff7b3a294287f3b843e993bc1ff71e78dcdc21781", + }, + { + scenario: "the linux/amd64 portable archive resolves to its published digest", + archive: "codebase-memory-mcp-linux-amd64-portable.tar.gz", + digest: "6eef49652bc0c7820f43114125044d40bf7f4d97c11b2592f6b0f6a307702325", + }, + { + // The published file lists the non-portable Linux names beside the portable + // ones. A prefix match would verify one asset's bytes against the other's + // line, so the distinct digest is the assertion that selection is exact. + scenario: "the non-portable linux/arm64 archive resolves to a different digest", + archive: "codebase-memory-mcp-linux-arm64.tar.gz", + digest: "e2804a20f5a6fc392af361525a232703e351b7d1aacb81b88eef806eec5959fa", + }, + { + // Upstream publishes a `-ui-` alias carrying the same bytes. It matters only + // that the name resolves through its own line rather than a neighbour's. + scenario: "the -ui- alias for linux/arm64 portable resolves through its own line", + archive: "codebase-memory-mcp-ui-linux-arm64-portable.tar.gz", + digest: "5697d986d9716c913163b4bff7b3a294287f3b843e993bc1ff71e78dcdc21781", + }, +]; + +interface RefusalCase { + readonly scenario: string; + /** The body handed to the parser, built from the published file where relevant. */ + readonly body: Uint8Array; + readonly archive: string; + /** The text the refusal must name. */ + readonly reported: RegExp; +} + +const refusals: RefusalCase[] = [ + { + scenario: "an archive absent from the file is refused rather than defaulted", + body: encoder.encode(PUBLISHED), + archive: "codebase-memory-mcp-plan9-arm64.tar.gz", + reported: /no SHA-256 digest for codebase-memory-mcp-plan9-arm64\.tar\.gz/u, + }, + { + scenario: "an archive whose line was removed is refused", + body: encoder.encode( + PUBLISHED.split("\n") + .filter((line) => !line.includes(DARWIN_ARM64)) + .join("\n"), + ), + archive: DARWIN_ARM64, + reported: /no SHA-256 digest/u, + }, + { + scenario: "a digest holding a non-hex character is refused rather than truncated", + body: encoder.encode( + PUBLISHED.replace( + DARWIN_ARM64_LINE, + `${DARWIN_ARM64_DIGEST.slice(0, 62)}ZZ$1`, + ), + ), + archive: DARWIN_ARM64, + reported: /invalid SHA-256 digest/u, + }, + { + scenario: "a digest of the wrong length is refused", + body: encoder.encode( + PUBLISHED.replace(DARWIN_ARM64_LINE, `${DARWIN_ARM64_DIGEST.slice(0, 62)}$1`), + ), + archive: DARWIN_ARM64, + reported: /invalid SHA-256 digest/u, + }, + { + scenario: "two different digests for one archive are refused rather than last-wins", + body: encoder.encode( + `${PUBLISHED}${"0".repeat(64)} ${DARWIN_ARM64}\n`, + ), + archive: DARWIN_ARM64, + reported: /conflicting SHA-256 digests/u, + }, + { + scenario: "a body over the 1 MiB limit is refused unread", + body: new Uint8Array(CHECKSUMS_LIMIT_BYTES + 1), + archive: DARWIN_ARM64, + reported: /over the 1048576 byte safety limit/u, + }, +]; + +interface AcceptedSpellingCase { + readonly scenario: string; + /** One `checksums.txt` line, spelled the way the case is about. */ + readonly line: string; + readonly digest: string; +} + +const acceptedSpellings: AcceptedSpellingCase[] = [ + { + scenario: "an uppercase digest is normalized rather than rejected", + line: `${DARWIN_ARM64_DIGEST.toUpperCase()} ${DARWIN_ARM64}`, + digest: DARWIN_ARM64_DIGEST, + }, + { + // Upstream's installer accepts `$2 == "*" archive`, which is how sha256sum + // spells a binary-mode line. + scenario: "the BSD binary-mode marker before the name is accepted", + line: `${DARWIN_ARM64_DIGEST} *${DARWIN_ARM64}`, + digest: DARWIN_ARM64_DIGEST, + }, + { + scenario: "the same digest repeated for one archive is accepted", + line: `${DARWIN_ARM64_DIGEST} ${DARWIN_ARM64}\n${DARWIN_ARM64_DIGEST} ${DARWIN_ARM64}`, + digest: DARWIN_ARM64_DIGEST, + }, + { + scenario: "a trailing field after the name does not prevent selection", + line: `${DARWIN_ARM64_DIGEST} ${DARWIN_ARM64} ignored`, + digest: DARWIN_ARM64_DIGEST, + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = [...publishedDigests, ...refusals, ...acceptedSpellings].map( + (entry) => entry.scenario, + ); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("the real published checksums.txt", () => { + test.each(publishedDigests)("$scenario", ({ archive, digest }) => { + expect(parseChecksums(encoder.encode(PUBLISHED), archive)).toBe(digest); + }); +}); + +describe("refusals", () => { + test.each(refusals)("$scenario", ({ body, archive, reported }) => { + expect(() => parseChecksums(body, archive)).toThrow(reported); + }); +}); + +describe("accepted digest spellings", () => { + test.each(acceptedSpellings)("$scenario", ({ line, digest }) => { + expect(parseChecksums(encoder.encode(`${line}\n`), DARWIN_ARM64)).toBe(digest); + }); +}); diff --git a/test/unit/lifecycle.test.ts b/test/unit/lifecycle.test.ts new file mode 100644 index 0000000..08a4aa9 --- /dev/null +++ b/test/unit/lifecycle.test.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; + +import { + checkUpstream, + CHECK_INTERVAL_MS, + install, + pin, + status, + syncEntry, + uninstall, + unpin, + update, + type Lifecycle, +} from "../../src/lifecycle.ts"; +import { entryStatus } from "../../src/mcp-config.ts"; +import { + EXECUTABLE_NAME, + mcpConfigPath, + packageRoot, + upstreamInstallDir, +} from "../../src/paths.ts"; +import { describeTarget, type Target } from "../../src/platform.ts"; +import { readState, updateState } from "../../src/state.ts"; +import { buildArchive, fakeSource, releaseMembers } from "../support/release.ts"; +import { dropScratch, makeScratch, writeFakeExecutable, type Scratch } from "../support/scratch.ts"; + +import type { ReleaseSource } from "../../src/release.ts"; + +const TARGET: Target = describeTarget(process.platform === "darwin" ? "darwin" : "linux", "arm64"); +const VERSION = "0.10.8"; + +let scratch: Scratch; + +beforeEach(async () => { + // The lifecycle shells out to `tar`, and on macOS to `xattr` and `codesign`. + scratch = await makeScratch({ systemTools: true }); +}); + +afterEach(async () => { + await dropScratch(scratch); +}); + +/** A release source serving one genuine archive for `version`. */ +async function servedSource(version: string): Promise { + const archive = await buildArchive(TARGET.archive, releaseMembers(TARGET, version)); + return fakeSource({ + tag: `v${version}`, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: archive.digest, + }); +} + +async function lifecycleFor(version: string): Promise { + return { host: scratch.host, target: TARGET, source: await servedSource(version) }; +} + +/** A source whose every call fails the test: it must never be reached. */ +const forbiddenSource: ReleaseSource = { + latestTag: async () => { + throw new Error("the network was reached when it should not have been"); + }, + checksums: async () => { + throw new Error("the network was reached when it should not have been"); + }, + asset: async () => { + throw new Error("the network was reached when it should not have been"); + }, +}; + +describe("no lifecycle operation writes to ~/.local/bin", () => { + /** + * `~/.local/bin` is upstream's install directory. This package reads it -- it + * is third in the resolution order -- and must never write it: an executable + * there belongs to CBM's own installer and updater, whose activation path + * drains sessions and swaps the target transactionally. + */ + test("running install, sync, check, pin, unpin and uninstall leaves it absent", async () => { + const lifecycle = await lifecycleFor(VERSION); + + expect((await install(lifecycle)).ok).toBe(true); + expect((await syncEntry(lifecycle)).kind).toBe("unchanged"); + expect((await checkUpstream(lifecycle, { force: true })).kind).not.toBe("failed"); + expect((await pin(lifecycle, VERSION)).ok).toBe(true); + expect((await update(lifecycle)).ok).toBe(true); + expect((await unpin(lifecycle)).ok).toBe(true); + expect((await status(lifecycle)).resolved).not.toBeNull(); + expect((await uninstall(lifecycle)).ok).toBe(true); + + await expect(readdir(upstreamInstallDir(scratch.host))).rejects.toThrow(); + }); + + test("an executable already there is neither replaced nor removed", async () => { + const adopted = path.join(upstreamInstallDir(scratch.host), EXECUTABLE_NAME); + await writeFakeExecutable(adopted, `echo "codebase-memory-mcp 0.9.0"`); + const before = await stat(adopted); + const contents = await Bun.file(adopted).text(); + + const lifecycle = await lifecycleFor(VERSION); + expect((await syncEntry(lifecycle)).kind).toBe("wired"); + // The system copy resolves, so update only reports. + expect((await update(lifecycle)).message).toContain("is a system installation"); + expect((await uninstall(lifecycle)).ok).toBe(true); + + expect(await Bun.file(adopted).exists()).toBe(true); + expect(await Bun.file(adopted).text()).toBe(contents); + expect((await stat(adopted)).mtimeMs).toBe(before.mtimeMs); + }); +}); + +describe("the update check is rate-limited", () => { + test("a check recorded under 24 hours ago makes no network request", async () => { + const now = Date.now(); + await updateState(scratch.host, { lastCheckedAt: now - CHECK_INTERVAL_MS + 60_000 }); + + const report = await checkUpstream( + { host: scratch.host, target: TARGET, source: forbiddenSource }, + { now }, + ); + expect(report.kind).toBe("skipped"); + }); + + test("a check recorded over 24 hours ago is performed", async () => { + const now = Date.now(); + await updateState(scratch.host, { lastCheckedAt: now - CHECK_INTERVAL_MS - 1 }); + + const report = await checkUpstream( + { host: scratch.host, target: TARGET, source: await servedSource("0.11.0") }, + { now }, + ); + expect(report.kind).toBe("newer"); + expect((await readState(scratch.host)).upstreamVersion).toBe("0.11.0"); + }); + + test("a failed check is recorded so a broken network is retried daily, not per session", async () => { + const now = Date.now(); + const report = await checkUpstream( + { host: scratch.host, target: TARGET, source: forbiddenSource }, + { now }, + ); + + expect(report.kind).toBe("failed"); + expect((await readState(scratch.host)).lastCheckedAt).toBe(now); + }); + + test("a pinned version is reported rather than adopted", async () => { + const lifecycle = await lifecycleFor(VERSION); + await install(lifecycle); + await pin(lifecycle, VERSION); + + const newer = { host: scratch.host, target: TARGET, source: await servedSource("0.11.0") }; + const report = await checkUpstream(newer, { force: true }); + expect(report.kind).toBe("newer"); + expect(report.message).toContain("is pinned"); + + // The pin holds: `update` reports and adopts nothing. + expect((await update(newer)).message).toContain("is pinned"); + expect((await readState(scratch.host)).managedVersion).toBe(VERSION); + }); +}); + +describe("install wires the MCP entry", () => { + test("the entry names the adopted absolute path and re-running changes nothing", async () => { + const lifecycle = await lifecycleFor(VERSION); + const first = await install(lifecycle); + expect(first.ok).toBe(true); + + const file = mcpConfigPath(scratch.host); + const written = await Bun.file(file).text(); + const state = await readState(scratch.host); + const adopted = path.join(packageRoot(scratch.host), "bin", VERSION, EXECUTABLE_NAME); + + expect(state.wroteCommand).toBe(adopted); + expect((await entryStatus(scratch.host, adopted)).current).toBe(true); + + expect((await syncEntry(lifecycle)).kind).toBe("unchanged"); + expect(await Bun.file(file).text()).toBe(written); + }); + + test("a managed update moves the entry and says the session needs a reload", async () => { + await install(await lifecycleFor(VERSION)); + const report = await update(await lifecycleFor("0.11.0")); + + expect(report.ok).toBe(true); + expect(report.message).toContain("/mcp reload"); + expect((await readState(scratch.host)).managedVersion).toBe("0.11.0"); + }); +}); + +describe("uninstall", () => { + test("removes the managed copy, its state, and the owned entry", async () => { + const lifecycle = await lifecycleFor(VERSION); + await install(lifecycle); + + const report = await uninstall(lifecycle); + expect(report.ok).toBe(true); + expect(report.message).toContain("removed the owned MCP entry"); + expect(await Bun.file(packageRoot(scratch.host)).exists()).toBe(false); + expect((await entryStatus(scratch.host, null)).present).toBe(false); + }); + + test("leaves an unrelated MCP server in place", async () => { + const lifecycle = await lifecycleFor(VERSION); + await install(lifecycle); + + const file = mcpConfigPath(scratch.host); + const document = JSON.parse(await Bun.file(file).text()) as { + mcpServers: Record; + }; + document.mcpServers["filesystem"] = { command: "npx", args: [] }; + await Bun.write(file, `${JSON.stringify(document, null, 2)}\n`); + + await uninstall(lifecycle); + + const after = JSON.parse(await Bun.file(file).text()) as { + mcpServers: Record; + }; + expect(Object.keys(after.mcpServers)).toEqual(["filesystem"]); + }); + + test("succeeds when nothing was ever installed", async () => { + const report = await uninstall({ host: scratch.host, target: TARGET, source: forbiddenSource }); + expect(report.ok).toBe(true); + expect(report.message).toContain("no managed copy was present"); + }); +}); + +describe("status", () => { + test("reports a managed copy that is present but not resolved", async () => { + await install(await lifecycleFor(VERSION)); + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp 0.9.0"`, + ); + + const report = await status({ host: scratch.host, target: TARGET, source: forbiddenSource }); + const text = report.lines.join("\n"); + + expect(text).toContain("source: system (PATH)"); + expect(text).toContain(`managed: ${VERSION}`); + expect(text).toContain("(present, not resolved)"); + }); + + test("names the resolved agent directory so a profile-scoped write is visible", async () => { + const report = await status({ host: scratch.host, target: TARGET, source: forbiddenSource }); + expect(report.lines.join("\n")).toContain(`agent dir: ${path.join(scratch.home, ".omp/agent")}`); + }); +}); diff --git a/test/unit/mcp-config.test.ts b/test/unit/mcp-config.test.ts new file mode 100644 index 0000000..8f04350 --- /dev/null +++ b/test/unit/mcp-config.test.ts @@ -0,0 +1,363 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; + +import { entryStatus, MCP_SCHEMA_URL, removeEntry, upsertEntry } from "../../src/mcp-config.ts"; +import { mcpConfigPath } from "../../src/paths.ts"; +import { dropScratch, makeScratch, type Scratch } from "../support/scratch.ts"; + +const OURS = "/home/scratch/.omp/codebase-memory/bin/0.10.8/codebase-memory-mcp"; +const MOVED = "/home/scratch/.omp/codebase-memory/bin/0.10.9/codebase-memory-mcp"; + +/** + * The two unrelated servers and the denylist, spelled the way OMP's own writer + * spells them: two-space indentation, trailing newline. + * + * Held as separate constants so a test can assert each one appears verbatim + * after a write, which is the property the whole "one owned key" constraint + * exists to deliver. + */ +const FILESYSTEM_BLOCK = ` "filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/srv" + ] + }`; + +const GITHUB_BLOCK = ` "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/" + }`; + +const DISABLED_BLOCK = ` "disabledServers": [ + "github" + ]`; + +const NEIGHBOURS = `{ + "$schema": "${MCP_SCHEMA_URL}", + "mcpServers": { +${FILESYSTEM_BLOCK}, +${GITHUB_BLOCK} + }, +${DISABLED_BLOCK} +} +`; + +/** + * How the file looks before the operation under test. + * + * `"none"` is no file at all. `"owned"` is an entry written through this + * package, which is the only way an entry it can decidably take back comes to + * exist. Anything else is literal file contents. + */ +type Before = "none" | "owned" | (string & {}); + +let scratch: Scratch; + +beforeEach(async () => { + scratch = await makeScratch(); +}); + +afterEach(async () => { + await dropScratch(scratch); +}); + +/** Puts the scratch agent directory into the state `before` describes. */ +async function arrange(before: Before): Promise { + if (before === "none") return; + if (before === "owned") { + await upsertEntry(scratch.host, OURS, undefined); + return; + } + const file = mcpConfigPath(scratch.host); + await mkdir(path.dirname(file), { recursive: true }); + await Bun.write(file, before); +} + +interface WriteCase { + readonly scenario: string; + readonly before: Before; + /** The `command` this package's state says it last wrote. */ + readonly previouslyWrote: string | undefined; + readonly command: string; + readonly change: "created" | "updated" | "unchanged"; +} + +const writes: WriteCase[] = [ + { + scenario: "creating the file writes the single owned entry", + before: "none", + previouslyWrote: undefined, + command: OURS, + change: "created", + }, + { + scenario: "adding to a file with unrelated servers reports an update", + before: NEIGHBOURS, + previouslyWrote: undefined, + command: OURS, + change: "updated", + }, + { + scenario: "an entry that already names the resolved path is left as it is", + before: "owned", + previouslyWrote: OURS, + command: OURS, + change: "unchanged", + }, + { + scenario: "a moved executable updates the command", + before: "owned", + previouslyWrote: OURS, + command: MOVED, + change: "updated", + }, + { + // An entry naming the currently resolved path is ours to adopt even with + // nothing recorded, which is how a lost state file recovers. + scenario: "an entry naming the resolved path is adopted with no recorded write", + before: "owned", + previouslyWrote: undefined, + command: OURS, + change: "unchanged", + }, +]; + +interface WriteRefusalCase { + readonly scenario: string; + readonly before: string; + readonly previouslyWrote: string | undefined; + /** The text the refusal must name. */ + readonly reported: RegExp; +} + +const writeRefusals: WriteRefusalCase[] = [ + { + scenario: "an entry this package did not write is refused with both paths named", + before: `{ + "mcpServers": { + "codebase-memory-mcp": { + "command": "/opt/homebrew/bin/codebase-memory-mcp" + } + } +} +`, + previouslyWrote: undefined, + reported: + /already defines codebase-memory-mcp with command \/opt\/homebrew\/bin\/codebase-memory-mcp/u, + }, + { + scenario: "an unparseable file is refused as a structural problem naming the path", + before: '{ "mcpServers": { "codebase-memory-mcp": ', + previouslyWrote: OURS, + reported: /mcp\.json is not parseable JSON, so it was left untouched/u, + }, + { + scenario: "a document that is not a JSON object is refused", + before: "[]\n", + previouslyWrote: OURS, + reported: /does not hold a JSON object/u, + }, +]; + +interface RemovalCase { + readonly scenario: string; + readonly before: Before; + readonly wroteCommand: string | undefined; + readonly change: "removed" | "absent"; +} + +const removals: RemovalCase[] = [ + { + scenario: "removal deletes the owned key", + before: "owned", + wroteCommand: OURS, + change: "removed", + }, + { + scenario: "removal succeeds when there is no file at all", + before: "none", + wroteCommand: OURS, + change: "absent", + }, + { + scenario: "removal succeeds when the file holds no owned key", + before: NEIGHBOURS, + wroteCommand: OURS, + change: "absent", + }, +]; + +interface RemovalRefusalCase { + readonly scenario: string; + readonly wroteCommand: string | undefined; + readonly reported: RegExp; +} + +const removalRefusals: RemovalRefusalCase[] = [ + { + scenario: "removal refuses an entry whose command someone else changed", + wroteCommand: MOVED, + reported: /is not what this package wrote/u, + }, + { + scenario: "removal refuses when nothing was ever recorded as written", + wroteCommand: undefined, + reported: /nothing recorded/u, + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = [...writes, ...writeRefusals, ...removals, ...removalRefusals].map( + (entry) => entry.scenario, + ); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("upsert", () => { + test.each(writes)("$scenario", async ({ before, previouslyWrote, command, change }) => { + await arrange(before); + + const outcome = await upsertEntry(scratch.host, command, previouslyWrote); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.change).toBe(change); + + const written = JSON.parse(await Bun.file(mcpConfigPath(scratch.host)).text()) as { + mcpServers: Record; + }; + expect(written.mcpServers["codebase-memory-mcp"]).toEqual({ + type: "stdio", + command, + args: [], + }); + }); + + test.each(writeRefusals)("$scenario", async ({ before, previouslyWrote, reported }) => { + await arrange(before); + const outcome = await upsertEntry(scratch.host, OURS, previouslyWrote); + + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.reason).toMatch(reported); + expect(await Bun.file(mcpConfigPath(scratch.host)).text()).toBe(before); + }); + + test("a created file carries the schema OMP writes for its own managed files", async () => { + await upsertEntry(scratch.host, OURS, undefined); + const written = JSON.parse(await Bun.file(mcpConfigPath(scratch.host)).text()) as { + $schema?: string; + }; + expect(written.$schema).toBe(MCP_SCHEMA_URL); + }); + + test("unrelated servers and disabledServers survive a write byte for byte", async () => { + await arrange(NEIGHBOURS); + await upsertEntry(scratch.host, OURS, undefined); + + const after = await Bun.file(mcpConfigPath(scratch.host)).text(); + expect(after).toContain(FILESYSTEM_BLOCK); + expect(after).toContain(GITHUB_BLOCK); + expect(after).toContain(DISABLED_BLOCK); + expect(after.endsWith("\n")).toBe(true); + }); + + test("a no-op re-run leaves the file byte-identical", async () => { + await arrange("owned"); + const file = mcpConfigPath(scratch.host); + const first = await Bun.file(file).text(); + + await upsertEntry(scratch.host, OURS, OURS); + expect(await Bun.file(file).text()).toBe(first); + }); + + test("a path change rewrites the command and nothing else", async () => { + await arrange(NEIGHBOURS); + await upsertEntry(scratch.host, OURS, undefined); + const before = await Bun.file(mcpConfigPath(scratch.host)).text(); + + await upsertEntry(scratch.host, MOVED, OURS); + expect(await Bun.file(mcpConfigPath(scratch.host)).text()).toBe(before.replace(OURS, MOVED)); + }); + + test("a four-space file keeps its own indentation", async () => { + await arrange('{\n "mcpServers": {}\n}\n'); + await upsertEntry(scratch.host, OURS, undefined); + expect(await Bun.file(mcpConfigPath(scratch.host)).text()).toContain('\n "mcpServers"'); + }); + + test("fields the operator added to the owned entry are preserved", async () => { + await arrange( + `{\n "mcpServers": {\n "codebase-memory-mcp": {\n "command": "${OURS}",\n "timeout": 120000\n }\n }\n}\n`, + ); + await upsertEntry(scratch.host, MOVED, OURS); + + const written = JSON.parse(await Bun.file(mcpConfigPath(scratch.host)).text()) as { + mcpServers: Record; + }; + expect(written.mcpServers["codebase-memory-mcp"]?.timeout).toBe(120000); + expect(written.mcpServers["codebase-memory-mcp"]?.command).toBe(MOVED); + }); +}); + +describe("removal", () => { + test.each(removals)("$scenario", async ({ before, wroteCommand, change }) => { + await arrange(before); + + const outcome = await removeEntry(scratch.host, wroteCommand); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.change).toBe(change); + }); + + test.each(removalRefusals)("$scenario", async ({ wroteCommand, reported }) => { + await arrange("owned"); + const before = await Bun.file(mcpConfigPath(scratch.host)).text(); + + const outcome = await removeEntry(scratch.host, wroteCommand); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.reason).toMatch(reported); + expect(await Bun.file(mcpConfigPath(scratch.host)).text()).toBe(before); + }); + + test("every other key is byte-identical after the owned key is removed", async () => { + await arrange(NEIGHBOURS); + await upsertEntry(scratch.host, OURS, undefined); + await removeEntry(scratch.host, OURS); + + expect(await Bun.file(mcpConfigPath(scratch.host)).text()).toBe(NEIGHBOURS); + }); + + test("the file stays in place when other servers remain", async () => { + await arrange(NEIGHBOURS); + await upsertEntry(scratch.host, OURS, undefined); + await removeEntry(scratch.host, OURS); + + expect(await Bun.file(mcpConfigPath(scratch.host)).exists()).toBe(true); + }); +}); + +describe("status", () => { + test("reports an absent entry without creating one", async () => { + const reported = await entryStatus(scratch.host, OURS); + expect(reported.present).toBe(false); + expect(reported.current).toBe(false); + expect(await Bun.file(mcpConfigPath(scratch.host)).exists()).toBe(false); + }); + + test("reports a stale entry as present but not current", async () => { + await arrange("owned"); + const reported = await entryStatus(scratch.host, MOVED); + expect(reported.present).toBe(true); + expect(reported.command).toBe(OURS); + expect(reported.current).toBe(false); + }); + + test("reports an unreadable file as a problem rather than an absent entry", async () => { + await arrange("{ oops"); + expect((await entryStatus(scratch.host, OURS)).problem).toMatch(/not parseable JSON/u); + }); +}); diff --git a/test/unit/paths.test.ts b/test/unit/paths.test.ts new file mode 100644 index 0000000..34cae7d --- /dev/null +++ b/test/unit/paths.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import path from "node:path"; + +import { upsertEntry } from "../../src/mcp-config.ts"; +import { agentDir, mcpConfigPath, packageRoot } from "../../src/paths.ts"; +import { dropScratch, makeScratch, type Scratch } from "../support/scratch.ts"; + +const COMMAND = "/scratch/.omp/codebase-memory/bin/0.10.8/codebase-memory-mcp"; + +interface AgentDirCase { + readonly scenario: string; + /** The environment beyond `HOME` and `PATH`. */ + readonly env: Readonly>; + /** The agent directory, relative to the scratch home. `null` means absolute. */ + readonly relative: string | null; + /** Used only when {@link AgentDirCase.relative} is `null`. */ + readonly absolute?: string; +} + +/** + * OMP's own precedence, reproduced. + * + * `PI_CODING_AGENT_DIR` first is not a guess: OMP's CLI calls `setProfile(...)` + * on every start, and a named profile makes that call write the variable back + * as the profile's own agent directory. Inside a session the variable is + * therefore already OMP's answer. The profile branch is the fallback for a + * process that never went through that CLI. + */ +const agentDirs: AgentDirCase[] = [ + { + scenario: "no profile and no override resolves the default agent directory", + env: {}, + relative: ".omp/agent", + }, + { + scenario: "OMP_PROFILE resolves that profile's agent directory", + env: { OMP_PROFILE: "work" }, + relative: ".omp/profiles/work/agent", + }, + { + scenario: "PI_PROFILE is honoured as the legacy fallback", + env: { PI_PROFILE: "legacy" }, + relative: ".omp/profiles/legacy/agent", + }, + { + // OMP resolves the canonical variable first and consults the legacy one only + // when the canonical one is undefined. + scenario: "OMP_PROFILE wins over PI_PROFILE", + env: { OMP_PROFILE: "work", PI_PROFILE: "legacy" }, + relative: ".omp/profiles/work/agent", + }, + { + // An explicitly empty OMP_PROFILE selects the default profile rather than + // silently inheriting the legacy variable, which is OMP's own rule. + scenario: "an empty OMP_PROFILE selects the default profile, not PI_PROFILE", + env: { OMP_PROFILE: "", PI_PROFILE: "legacy" }, + relative: ".omp/agent", + }, + { + scenario: "PI_CODING_AGENT_DIR takes precedence over a profile variable", + env: { PI_CODING_AGENT_DIR: "/tmp/cbm-explicit-agent", OMP_PROFILE: "work" }, + relative: null, + absolute: "/tmp/cbm-explicit-agent", + }, + { + scenario: "PI_CONFIG_DIR renames the config directory every path hangs off", + env: { PI_CONFIG_DIR: ".omp-alt" }, + relative: ".omp-alt/agent", + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = agentDirs.map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("agent directory resolution", () => { + test.each(agentDirs)("$scenario", async ({ env, relative, absolute }) => { + const scratch = await makeScratch({ env }); + try { + expect(agentDir(scratch.host)).toBe( + relative === null ? (absolute ?? "") : path.join(scratch.home, relative), + ); + } finally { + await dropScratch(scratch); + } + }); +}); + +describe("the package-owned root follows the config directory", () => { + test("the default config directory puts it under ~/.omp", async () => { + const scratch = await makeScratch(); + try { + expect(packageRoot(scratch.host)).toBe(path.join(scratch.home, ".omp", "codebase-memory")); + } finally { + await dropScratch(scratch); + } + }); + + test("a profile does not move it, so a managed copy survives a profile switch", async () => { + const plain = await makeScratch(); + const profiled = await makeScratch({ env: { OMP_PROFILE: "work" } }); + try { + expect(path.relative(plain.home, packageRoot(plain.host))).toBe( + path.relative(profiled.home, packageRoot(profiled.host)), + ); + } finally { + await dropScratch(plain); + await dropScratch(profiled); + } + }); +}); + +describe("the write target follows the agent directory", () => { + let scratch: Scratch; + + beforeEach(async () => { + scratch = await makeScratch({ env: { OMP_PROFILE: "work" } }); + }); + + afterEach(async () => { + await dropScratch(scratch); + }); + + test("an active profile is written to, and the default agent directory is not", async () => { + const outcome = await upsertEntry(scratch.host, COMMAND, undefined); + expect(outcome.ok && outcome.change).toBe("created"); + + const profileFile = path.join(scratch.home, ".omp/profiles/work/agent/mcp.json"); + const defaultFile = path.join(scratch.home, ".omp/agent/mcp.json"); + + expect(mcpConfigPath(scratch.host)).toBe(profileFile); + expect(await Bun.file(profileFile).exists()).toBe(true); + expect(await Bun.file(defaultFile).exists()).toBe(false); + }); + + test("an explicit agent directory is written to instead of the profile's", async () => { + const explicit = path.join(scratch.root, "explicit-agent"); + const host = { ...scratch.host, env: { ...scratch.host.env, PI_CODING_AGENT_DIR: explicit } }; + + const outcome = await upsertEntry(host, COMMAND, undefined); + expect(outcome.ok && outcome.change).toBe("created"); + + expect(await Bun.file(path.join(explicit, "mcp.json")).exists()).toBe(true); + expect( + await Bun.file(path.join(scratch.home, ".omp/profiles/work/agent/mcp.json")).exists(), + ).toBe(false); + }); +}); diff --git a/test/unit/platform.test.ts b/test/unit/platform.test.ts new file mode 100644 index 0000000..b216771 --- /dev/null +++ b/test/unit/platform.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test"; + +import { + describeTarget, + detectTarget, + UnsupportedPlatformError, + type TargetArch, + type TargetOs, +} from "../../src/platform.ts"; + +/** + * Every asset name `checksums.txt` listed for v0.10.8. + * + * Read from the recorded fixture rather than rebuilt from a template: the + * property under test is that this package's construction agrees with what + * upstream actually published, and a table built the same way as the + * implementation would agree with a typo in it too. + */ +const PUBLISHED_NAMES = new Set( + (await Bun.file("test/fixtures/checksums-v0.10.8.txt").text()) + .split("\n") + .map((line) => line.trim().split(/\s+/u)[1]) + .filter((name): name is string => name !== undefined), +); + +interface ArchiveNameCase { + readonly scenario: string; + readonly os: TargetOs; + readonly arch: TargetArch; + /** The asset name upstream publishes for this pair. */ + readonly archive: string; + /** + * Whether the name carries the `-portable` suffix. + * + * Linux must: the standard Linux build dynamically links glibc 2.38 or newer + * and fails on Debian 11, RHEL 8, and Ubuntu 20.04. macOS must not: no such + * variant is published, so a suffix there names nothing. + */ + readonly portable: boolean; + /** Whether the name appears in the recorded published fixture. */ + readonly published: boolean; +} + +const archiveNames: ArchiveNameCase[] = [ + { + scenario: "darwin/arm64 selects the plain macOS archive", + os: "darwin", + arch: "arm64", + archive: "codebase-memory-mcp-darwin-arm64.tar.gz", + portable: false, + published: true, + }, + { + scenario: "darwin/amd64 selects the plain macOS archive", + os: "darwin", + arch: "amd64", + archive: "codebase-memory-mcp-darwin-amd64.tar.gz", + portable: false, + published: true, + }, + { + scenario: "linux/arm64 selects the portable Linux archive", + os: "linux", + arch: "arm64", + archive: "codebase-memory-mcp-linux-arm64-portable.tar.gz", + portable: true, + published: true, + }, + { + scenario: "linux/amd64 selects the portable Linux archive", + os: "linux", + arch: "amd64", + archive: "codebase-memory-mcp-linux-amd64-portable.tar.gz", + portable: true, + published: true, + }, + { + // Described so a later change extends one seam rather than reverse-engineering + // the naming; `detectTarget` refuses the platform, so nothing acquires it. + scenario: "windows/amd64 names the zip archive it does not yet support", + os: "windows", + arch: "amd64", + archive: "codebase-memory-mcp-windows-amd64.zip", + portable: false, + published: true, + }, +]; + +interface ArchiveContentsCase { + readonly scenario: string; + readonly os: TargetOs; + readonly arch: TargetArch; + readonly container: "tar.gz" | "zip"; + readonly executable: string; + /** The closed member set, sorted; anything outside it is an integrity failure. */ + readonly members: readonly string[]; +} + +const archiveContents: ArchiveContentsCase[] = [ + { + scenario: "a POSIX target names the executable, licence, shell installer and notices", + os: "darwin", + arch: "arm64", + container: "tar.gz", + executable: "codebase-memory-mcp", + members: ["LICENSE", "THIRD_PARTY_NOTICES.md", "codebase-memory-mcp", "install.sh"], + }, + { + scenario: "a Windows target names the .exe and the PowerShell installer", + os: "windows", + arch: "amd64", + container: "zip", + executable: "codebase-memory-mcp.exe", + members: ["LICENSE", "THIRD_PARTY_NOTICES.md", "codebase-memory-mcp.exe", "install.ps1"], + }, +]; + +interface DetectionCase { + readonly scenario: string; + /** `process.platform` as the host reports it. */ + readonly platform: string; + /** `process.arch` as the host reports it. */ + readonly arch: string; + /** `os.cpus()[0]?.model`, which is how Rosetta is detected. */ + readonly cpuModel: string | undefined; + readonly expected: string; +} + +const detections: DetectionCase[] = [ + { + scenario: "an arm64 process on Apple Silicon resolves natively", + platform: "darwin", + arch: "arm64", + cpuModel: "Apple M1 Pro", + expected: "codebase-memory-mcp-darwin-arm64.tar.gz", + }, + { + scenario: "an x64 process on Apple Silicon is corrected to arm64 rather than translated", + platform: "darwin", + arch: "x64", + cpuModel: "Apple M1 Pro", + expected: "codebase-memory-mcp-darwin-arm64.tar.gz", + }, + { + scenario: "an x64 process on an Intel Mac stays amd64", + platform: "darwin", + arch: "x64", + cpuModel: "Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz", + expected: "codebase-memory-mcp-darwin-amd64.tar.gz", + }, + { + scenario: "Linux never applies the Rosetta correction, whatever the brand string says", + platform: "linux", + arch: "x64", + cpuModel: "Apple M1 Pro", + expected: "codebase-memory-mcp-linux-amd64-portable.tar.gz", + }, + { + scenario: "a missing brand string leaves an x64 Mac on amd64", + platform: "darwin", + arch: "x64", + cpuModel: undefined, + expected: "codebase-memory-mcp-darwin-amd64.tar.gz", + }, +]; + +interface RefusalCase { + readonly scenario: string; + readonly platform: string; + readonly arch: string; + /** The text the refusal must name, so the operator knows what to do next. */ + readonly reported: RegExp; +} + +const refusals: RefusalCase[] = [ + { + scenario: "Windows is refused explicitly rather than half-implemented", + platform: "win32", + arch: "x64", + reported: /Windows is not supported yet/u, + }, + { + scenario: "an unsupported operating system is refused by name", + platform: "freebsd", + arch: "arm64", + reported: /unsupported operating system: freebsd/u, + }, + { + scenario: "an unsupported architecture is refused by name", + platform: "linux", + arch: "riscv64", + reported: /unsupported architecture: riscv64/u, + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = [ + ...archiveNames, + ...archiveContents, + ...detections, + ...refusals, + ].map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("archive name construction", () => { + test.each(archiveNames)("$scenario", ({ os, arch, archive, portable, published }) => { + const target = describeTarget(os, arch); + expect(target.archive).toBe(archive); + expect(target.archive.includes("-portable")).toBe(portable); + expect(PUBLISHED_NAMES.has(target.archive)).toBe(published); + }); +}); + +describe("archive contents", () => { + test.each(archiveContents)("$scenario", ({ os, arch, container, executable, members }) => { + const target = describeTarget(os, arch); + expect(target.container).toBe(container); + expect(target.executable).toBe(executable); + expect([...target.members].sort()).toEqual([...members]); + }); +}); + +describe("host detection", () => { + test.each(detections)("$scenario", ({ platform, arch, cpuModel, expected }) => { + expect(detectTarget(platform, arch, cpuModel).archive).toBe(expected); + }); + + test.each(refusals)("$scenario", ({ platform, arch, reported }) => { + expect(() => detectTarget(platform, arch, undefined)).toThrow(UnsupportedPlatformError); + expect(() => detectTarget(platform, arch, undefined)).toThrow(reported); + }); +}); diff --git a/test/unit/resolve.test.ts b/test/unit/resolve.test.ts new file mode 100644 index 0000000..ddd92af --- /dev/null +++ b/test/unit/resolve.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import path from "node:path"; + +import { + EXECUTABLE_NAME, + managedExecutable, + upstreamInstallDir, +} from "../../src/paths.ts"; +import { managedCopy, resolveExecutable } from "../../src/resolve.ts"; +import { writeState } from "../../src/state.ts"; +import { dropScratch, makeScratch, writeFakeExecutable, type Scratch } from "../support/scratch.ts"; + +/** + * Which copies exist for one case. + * + * Written as the world an operator would have -- something on `PATH`, something + * upstream installed, something this package downloaded -- rather than as the + * state fields resolution happens to read. + */ +interface Layout { + /** A copy on the scratch `PATH`, reporting this version. */ + readonly onPath?: string; + /** A copy at `~/.local/bin`, where upstream's installer puts one. */ + readonly localBin?: string; + /** A managed copy under this package's root, recorded as the pointer. */ + readonly managed?: string; + /** A recorded pin. */ + readonly pin?: string; + /** A recorded pointer with no file behind it, e.g. after a manual delete. */ + readonly danglingPointer?: string; +} + +async function place(scratch: Scratch, layout: Layout): Promise { + if (layout.onPath !== undefined) { + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp ${layout.onPath}"`, + ); + } + if (layout.localBin !== undefined) { + await writeFakeExecutable( + path.join(upstreamInstallDir(scratch.host), EXECUTABLE_NAME), + `echo "codebase-memory-mcp ${layout.localBin}"`, + ); + } + if (layout.managed !== undefined) { + await writeFakeExecutable( + managedExecutable(scratch.host, layout.managed), + `echo "codebase-memory-mcp ${layout.managed}"`, + ); + } + const pointer = layout.danglingPointer ?? layout.managed; + await writeState(scratch.host, { + ...(pointer === undefined ? {} : { managedVersion: pointer }), + ...(layout.pin === undefined ? {} : { pin: layout.pin }), + }); +} + +interface OrderCase { + readonly scenario: string; + readonly layout: Layout; + readonly source: "pin" | "system" | "managed"; + readonly origin: string; + /** The absolute path resolution must return for this layout. */ + readonly executable: (scratch: Scratch) => string; +} + +const ordering: OrderCase[] = [ + { + scenario: "a copy on PATH is adopted as a system installation", + layout: { onPath: "0.10.8" }, + source: "system", + origin: "PATH", + executable: (scratch) => path.join(scratch.pathDir, EXECUTABLE_NAME), + }, + { + scenario: "a copy in ~/.local/bin is adopted when PATH has none", + layout: { localBin: "0.10.8" }, + source: "system", + origin: "~/.local/bin", + executable: (scratch) => path.join(upstreamInstallDir(scratch.host), EXECUTABLE_NAME), + }, + { + scenario: "PATH wins over ~/.local/bin", + layout: { onPath: "0.10.8", localBin: "0.9.0" }, + source: "system", + origin: "PATH", + executable: (scratch) => path.join(scratch.pathDir, EXECUTABLE_NAME), + }, + { + scenario: "a managed copy resolves when no system copy exists", + layout: { managed: "0.10.8" }, + source: "managed", + origin: "bin/0.10.8", + executable: (scratch) => managedExecutable(scratch.host, "0.10.8"), + }, + { + // The whole point of the ordering: CBM owns one canonical cache root, so + // adopting the operator's existing installation is the only safe default. + scenario: "a system copy beats a managed copy that is also present", + layout: { onPath: "0.9.0", managed: "0.10.8" }, + source: "system", + origin: "PATH", + executable: (scratch) => path.join(scratch.pathDir, EXECUTABLE_NAME), + }, + { + scenario: "a pin overrides both a system and a managed copy", + layout: { onPath: "0.9.0", managed: "0.10.8", pin: "0.10.8" }, + source: "pin", + origin: "0.10.8", + executable: (scratch) => managedExecutable(scratch.host, "0.10.8"), + }, + { + scenario: "a pin with no managed copy behind it falls through to PATH", + layout: { onPath: "0.9.0", pin: "0.10.8" }, + source: "system", + origin: "PATH", + executable: (scratch) => path.join(scratch.pathDir, EXECUTABLE_NAME), + }, +]; + +let scratch: Scratch; + +beforeEach(async () => { + scratch = await makeScratch(); +}); + +afterEach(async () => { + await dropScratch(scratch); +}); + +test("every case names itself distinctly", () => { + const scenarios = ordering.map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("resolution order", () => { + test.each(ordering)("$scenario", async ({ layout, source, origin, executable }) => { + await place(scratch, layout); + + const resolution = await resolveExecutable(scratch.host); + expect(resolution.ok).toBe(true); + if (!resolution.ok) return; + + expect(resolution.resolved.source).toBe(source); + expect(resolution.resolved.origin).toBe(origin); + expect(resolution.resolved.executable).toBe(executable(scratch)); + }); +}); + +describe("a managed copy is reported even when it is not resolved", () => { + test("resolution prefers the system copy while the managed one stays on disk", async () => { + await place(scratch, { onPath: "0.9.0", managed: "0.10.8" }); + + const resolution = await resolveExecutable(scratch.host); + const managed = await managedCopy(scratch.host); + + expect(resolution.ok && resolution.resolved.source).toBe("system"); + expect(managed?.version).toBe("0.10.8"); + expect(await Bun.file(managed?.executable ?? "").exists()).toBe(true); + }); + + test("a pointer with no file behind it reports no managed copy", async () => { + await place(scratch, { onPath: "0.9.0", danglingPointer: "0.10.8" }); + expect(await managedCopy(scratch.host)).toBeNull(); + }); +}); + +describe("nothing resolves", () => { + test("the failure names both remedies", async () => { + await place(scratch, {}); + + const resolution = await resolveExecutable(scratch.host); + expect(resolution.ok).toBe(false); + if (resolution.ok) return; + + expect(resolution.reason).toContain("/cbm install"); + expect(resolution.reason).toContain("install.sh"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0fc149a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,43 @@ +{ + // Type checking only. Bun executes TypeScript directly and `bun build` + // produces `dist/index.js`, so this project never emits through tsc: + // `tsc --noEmit` is the type gate, `bun test` is the behavior gate. + // + // `@types/bun` supplies every runtime type this package uses -- `Bun.which`, + // `Bun.spawn`, `Bun.CryptoHasher`, `Bun.file` -- so its version moves in step + // with the Bun version `.github/workflows/ci.yml` pins. + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext"], + "module": "Preserve", + "moduleResolution": "bundler", + "moduleDetection": "force", + "types": ["bun"], + + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + + // `strict` alone permits two mistakes this package cannot afford. + // + // `noUncheckedIndexedAccess` matters for `checksums.txt` parsing and + // archive-member accounting, both of which index into split results whose + // length they have just tested; without it a wrong test still type-checks. + // + // `exactOptionalPropertyTypes` matters because the owned `mcp.json` entry + // must be written with absent fields *omitted* rather than present as + // `undefined`: `JSON.stringify` drops an explicit `undefined`, so without + // this flag a key the writer believes it set can silently vanish from the + // operator's configuration. + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} From 58a82d9343cee7d0ad91a4bae13955b1cefc20a1 Mon Sep 17 00:00:00 2001 From: pashifika Date: Fri, 28 Aug 2026 12:29:13 +0900 Subject: [PATCH 2/9] fix(release): refuse a release location from a foreign origin `tagFromLocation` checked the redirect's path prefix but not its origin, so a `releases/latest` response pointing at `https://elsewhere/DeusData/codebase-memory-mcp/releases/tag/v9.9.9` was mined for a version string this package then treated as the newest release. The path prefix says nothing about who answered. Found by the test added here, which was written to cover the spec scenario that had no coverage. The blast radius was limited -- asset URLs are built from a hard-coded `releases/` base rather than from the redirect -- so the effect was a wrong version string, not a download from an attacker's host. It is still the "reject an unexpected Location" refusal the spec asks for. Two pieces of validation were extracted so they are reachable from a test at all: `nextHop` is the whole transport-downgrade defence, and `tagFromLocation` parses attacker-adjacent input into something used as a URL segment and an on-disk directory name. Neither could be exercised through a real request without a TLS origin that redirects to plain HTTP. The install confirmation moved from the extension entry into `confirmedInstall`/`installHazard` behind a `Confirmer` seam, for the same reason: the spec requires that a session with no interactive UI report the hazard and download nothing rather than block, and that branch was previously only reachable from a real session. `src/index.ts` now only adapts `ctx.hasUI` and `ctx.ui.confirm` to that seam. Coverage added, all previously uncovered spec scenarios: - redirect transport downgrade, missing and empty location headers, and eight release-location refusals including a path separator in the tag - install confirmation: no hazard, declined, accepted, and no interactive UI - session start with nothing resolving, and with a foreign entry present --- dist/index.js | 330 ++++++++++++++-------------- src/index.ts | 57 +---- src/lifecycle.ts | 63 ++++++ src/release.ts | 73 +++--- test/unit/lifecycle.test.ts | 120 ++++++++++ test/unit/release-transport.test.ts | 210 ++++++++++++++++++ 6 files changed, 623 insertions(+), 230 deletions(-) create mode 100644 test/unit/release-transport.test.ts diff --git a/dist/index.js b/dist/index.js index 152e1d5..97c5294 100644 --- a/dist/index.js +++ b/dist/index.js @@ -120,12 +120,14 @@ async function fetchHttps(url, options = {}) { const redirected = response.status >= 300 && response.status < 400; if (!redirected || hop >= budget) return response; - const location = response.headers.get("location"); - if (location === null) { - throw new Error(`${current} answered ${response.status} with no location header`); - } - current = requireHttps(new URL(location, current).href, "redirect"); + current = nextHop(current, response.status, response.headers.get("location")); + } +} +function nextHop(current, status, location) { + if (location === null || location === "") { + throw new Error(`${current} answered ${status} with no location header`); } + return requireHttps(new URL(location, current).href, "redirect"); } function requireHttps(url, kind) { const parsed = new URL(url); @@ -134,21 +136,19 @@ function requireHttps(url, kind) { } return parsed.href; } -async function resolveLatestTag() { - const response = await fetchHttps(LATEST, { maxRedirects: 0 }); - if (response.status < 300 || response.status >= 400) { - throw new Error(`expected ${LATEST} to redirect to a tag, got HTTP ${response.status}`); +function tagFromLocation(status, location) { + if (status < 300 || status >= 400) { + throw new Error(`expected ${LATEST} to redirect to a tag, got HTTP ${status}`); } - const location = response.headers.get("location"); - if (location === null) { - throw new Error(`${LATEST} answered ${response.status} with no location header`); + if (location === null || location === "") { + throw new Error(`${LATEST} answered ${status} with no location header`); } const resolved = new URL(location, LATEST); if (resolved.protocol !== "https:") { throw new Error(`refusing non-HTTPS release location: ${resolved.href}`); } const prefix = `/${UPSTREAM_REPO}/releases/tag/`; - if (!resolved.pathname.startsWith(prefix)) { + if (resolved.origin !== new URL(LATEST).origin || !resolved.pathname.startsWith(prefix)) { throw new Error(`unexpected release location: ${resolved.href}`); } const tag = decodeURIComponent(resolved.pathname.slice(prefix.length)); @@ -157,6 +157,10 @@ async function resolveLatestTag() { } return tag; } +async function resolveLatestTag() { + const response = await fetchHttps(LATEST, { maxRedirects: 0 }); + return tagFromLocation(response.status, response.headers.get("location")); +} function parseChecksums(body, archive) { if (body.byteLength > CHECKSUMS_LIMIT_BYTES) { throw new Error(`checksums.txt is ${body.byteLength} bytes, over the ${CHECKSUMS_LIMIT_BYTES} byte safety limit`); @@ -200,6 +204,26 @@ async function download(url) { return new Uint8Array(await response.arrayBuffer()); } +// src/scheduler.ts +function schedulerFrom(ctx) { + return { + after(callback, ms) { + return ctx.setTimeout(callback, ms); + }, + cancel(handle) { + ctx.clearTimer(handle); + } + }; +} + +// src/lifecycle.ts +import { rm as rm2 } from "fs/promises"; + +// src/acquire.ts +import { chmod, lstat, mkdtemp, mkdir, rm } from "fs/promises"; +import { tmpdir } from "os"; +import path2 from "path"; + // src/exec.ts var DEFAULT_TIMEOUT_MS2 = 30000; async function run(argv, options = {}) { @@ -239,123 +263,7 @@ function haveTool(tool, pathEnv) { return Bun.which(tool, pathEnv === undefined ? {} : { PATH: pathEnv }) !== null; } -// src/state.ts -import { mkdir } from "fs/promises"; -import path2 from "path"; -var EMPTY = {}; -async function readState(host) { - const file = Bun.file(statePath(host)); - let text; - try { - text = await file.text(); - } catch { - return EMPTY; - } - let parsed; - try { - parsed = JSON.parse(text); - } catch { - return EMPTY; - } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) - return EMPTY; - const record = parsed; - const state = {}; - for (const key of ["managedVersion", "managedDigest", "pin", "upstreamVersion", "wroteCommand"]) { - const value = record[key]; - if (typeof value === "string" && value !== "") - state[key] = value; - } - const lastCheckedAt = record["lastCheckedAt"]; - if (typeof lastCheckedAt === "number" && Number.isFinite(lastCheckedAt)) { - state["lastCheckedAt"] = lastCheckedAt; - } - return state; -} -async function writeState(host, next) { - const file = statePath(host); - await mkdir(path2.dirname(file), { recursive: true }); - await Bun.write(file, `${JSON.stringify(next, null, 2)} -`); -} -async function updateState(host, patch) { - const next = { ...await readState(host), ...patch }; - await writeState(host, next); - return next; -} - -// src/resolve.ts -import path3 from "path"; -var NO_EXECUTABLE_REASON = `no ${EXECUTABLE_NAME} executable found on PATH, in ~/.local/bin, or under this package's own root. ` + "Run /cbm install to download a managed copy, or install it yourself with " + "`curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash` " + "and this package will adopt it."; -async function managedCopy(host, state) { - const recorded = (state ?? await readState(host)).managedVersion; - if (recorded === undefined) - return null; - const executable = managedExecutable(host, recorded); - return await Bun.file(executable).exists() ? { version: recorded, executable } : null; -} -async function resolveExecutable(host, state) { - const current = state ?? await readState(host); - const pin = current.pin; - if (pin !== undefined) { - const pinned = managedExecutable(host, pin); - if (await Bun.file(pinned).exists()) { - return { ok: true, resolved: { executable: pinned, source: "pin", origin: pin } }; - } - } - const onPath = Bun.which(EXECUTABLE_NAME, pathOption(host)); - if (onPath !== null) { - return { - ok: true, - resolved: { executable: path3.resolve(onPath), source: "system", origin: "PATH" } - }; - } - const upstream = path3.join(upstreamInstallDir(host), EXECUTABLE_NAME); - if (await Bun.file(upstream).exists()) { - return { - ok: true, - resolved: { executable: upstream, source: "system", origin: "~/.local/bin" } - }; - } - const managed = await managedCopy(host, current); - if (managed !== null) { - return { - ok: true, - resolved: { - executable: managed.executable, - source: "managed", - origin: path3.join(path3.basename(managedBinRoot(host)), managed.version) - } - }; - } - return { ok: false, reason: NO_EXECUTABLE_REASON }; -} -async function resolvedVersion(resolved) { - return await readVersion(resolved.executable); -} -function pathOption(host) { - return { PATH: host.env["PATH"] ?? "" }; -} - -// src/scheduler.ts -function schedulerFrom(ctx) { - return { - after(callback, ms) { - return ctx.setTimeout(callback, ms); - }, - cancel(handle) { - ctx.clearTimer(handle); - } - }; -} - -// src/lifecycle.ts -import { rm as rm2 } from "fs/promises"; - // src/acquire.ts -import { chmod, lstat, mkdtemp, mkdir as mkdir2, rm } from "fs/promises"; -import { tmpdir } from "os"; -import path4 from "path"; var VERSION_PATTERN = /^[0-9][0-9A-Za-z.+-]*$/u; function normalizeVersion(value) { const trimmed = value.trim().replace(/^v/iu, ""); @@ -385,13 +293,13 @@ async function acquire(request) { if (actual !== expected) { throw new Error(`SHA-256 mismatch for ${target.archive} at ${tag}: published ${expected}, downloaded ${actual}`); } - const scratch = await mkdtemp(path4.join(tmpdir(), "omp-codebase-memory-")); + const scratch = await mkdtemp(path2.join(tmpdir(), "omp-codebase-memory-")); try { - const archive = path4.join(scratch, target.archive); + const archive = path2.join(scratch, target.archive); await Bun.write(archive, bytes); await assertArchiveMembers(archive, target); await extract(archive, scratch, target); - const candidate = path4.join(scratch, target.executable); + const candidate = path2.join(scratch, target.executable); await chmod(candidate, 493); if (target.os === "darwin") await repairMacOsSignature(host, candidate); @@ -404,7 +312,7 @@ async function acquire(request) { async function assertArchiveMembers(archive, target) { const listed = await run(["tar", "-tzf", archive]); if (!listed.ok) { - throw new Error(`could not enumerate ${path4.basename(archive)}: ${listed.stderr.trim() || listed.spawnError || `tar exited ${listed.exitCode}`}`); + throw new Error(`could not enumerate ${path2.basename(archive)}: ${listed.stderr.trim() || listed.spawnError || `tar exited ${listed.exitCode}`}`); } const seen = new Map; for (const raw of listed.stdout.split(` @@ -427,10 +335,10 @@ async function assertArchiveMembers(archive, target) { async function extract(archive, into, target) { const extracted = await run(["tar", "--no-same-owner", "-xzf", archive, "-C", into]); if (!extracted.ok) { - throw new Error(`could not extract ${path4.basename(archive)}: ${extracted.stderr.trim() || extracted.spawnError || `tar exited ${extracted.exitCode}`}`); + throw new Error(`could not extract ${path2.basename(archive)}: ${extracted.stderr.trim() || extracted.spawnError || `tar exited ${extracted.exitCode}`}`); } for (const member of target.members) { - const entry = path4.join(into, member); + const entry = path2.join(into, member); let stats; try { stats = await lstat(entry); @@ -468,9 +376,9 @@ async function smokeCheck(candidate, target) { throw new Error(`the downloaded executable failed to run \`--version\`.${suffix}`); } async function adopt(host, candidate) { - const destination = path4.join(managedBinRoot(host), candidate.version); - await mkdir2(destination, { recursive: true }); - const executable = path4.join(destination, path4.basename(candidate.candidate)); + const destination = path2.join(managedBinRoot(host), candidate.version); + await mkdir(destination, { recursive: true }); + const executable = path2.join(destination, path2.basename(candidate.candidate)); await Bun.write(executable, Bun.file(candidate.candidate)); await chmod(executable, 493); return { @@ -482,8 +390,8 @@ async function adopt(host, candidate) { } // src/mcp-config.ts -import { mkdir as mkdir3 } from "fs/promises"; -import path5 from "path"; +import { mkdir as mkdir2 } from "fs/promises"; +import path3 from "path"; var MCP_SCHEMA_URL = "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json"; async function readMcpFile(host) { const file = mcpConfigPath(host); @@ -550,7 +458,7 @@ async function upsertEntry(host, command, previouslyWrote) { const rendered = render(next, file); if (file.text === rendered) return { ok: true, change: "unchanged" }; - await mkdir3(path5.dirname(file.path), { recursive: true }); + await mkdir2(path3.dirname(file.path), { recursive: true }); await Bun.write(file.path, rendered); return { ok: true, change: file.text === null ? "created" : "updated" }; } @@ -615,6 +523,104 @@ function render(document, file) { ` : body; } +// src/state.ts +import { mkdir as mkdir3 } from "fs/promises"; +import path4 from "path"; +var EMPTY = {}; +async function readState(host) { + const file = Bun.file(statePath(host)); + let text; + try { + text = await file.text(); + } catch { + return EMPTY; + } + let parsed; + try { + parsed = JSON.parse(text); + } catch { + return EMPTY; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + return EMPTY; + const record = parsed; + const state = {}; + for (const key of ["managedVersion", "managedDigest", "pin", "upstreamVersion", "wroteCommand"]) { + const value = record[key]; + if (typeof value === "string" && value !== "") + state[key] = value; + } + const lastCheckedAt = record["lastCheckedAt"]; + if (typeof lastCheckedAt === "number" && Number.isFinite(lastCheckedAt)) { + state["lastCheckedAt"] = lastCheckedAt; + } + return state; +} +async function writeState(host, next) { + const file = statePath(host); + await mkdir3(path4.dirname(file), { recursive: true }); + await Bun.write(file, `${JSON.stringify(next, null, 2)} +`); +} +async function updateState(host, patch) { + const next = { ...await readState(host), ...patch }; + await writeState(host, next); + return next; +} + +// src/resolve.ts +import path5 from "path"; +var NO_EXECUTABLE_REASON = `no ${EXECUTABLE_NAME} executable found on PATH, in ~/.local/bin, or under this package's own root. ` + "Run /cbm install to download a managed copy, or install it yourself with " + "`curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash` " + "and this package will adopt it."; +async function managedCopy(host, state) { + const recorded = (state ?? await readState(host)).managedVersion; + if (recorded === undefined) + return null; + const executable = managedExecutable(host, recorded); + return await Bun.file(executable).exists() ? { version: recorded, executable } : null; +} +async function resolveExecutable(host, state) { + const current = state ?? await readState(host); + const pin = current.pin; + if (pin !== undefined) { + const pinned = managedExecutable(host, pin); + if (await Bun.file(pinned).exists()) { + return { ok: true, resolved: { executable: pinned, source: "pin", origin: pin } }; + } + } + const onPath = Bun.which(EXECUTABLE_NAME, pathOption(host)); + if (onPath !== null) { + return { + ok: true, + resolved: { executable: path5.resolve(onPath), source: "system", origin: "PATH" } + }; + } + const upstream = path5.join(upstreamInstallDir(host), EXECUTABLE_NAME); + if (await Bun.file(upstream).exists()) { + return { + ok: true, + resolved: { executable: upstream, source: "system", origin: "~/.local/bin" } + }; + } + const managed = await managedCopy(host, current); + if (managed !== null) { + return { + ok: true, + resolved: { + executable: managed.executable, + source: "managed", + origin: path5.join(path5.basename(managedBinRoot(host)), managed.version) + } + }; + } + return { ok: false, reason: NO_EXECUTABLE_REASON }; +} +async function resolvedVersion(resolved) { + return await readVersion(resolved.executable); +} +function pathOption(host) { + return { PATH: host.env["PATH"] ?? "" }; +} + // src/lifecycle.ts var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; async function status(lifecycle) { @@ -646,6 +652,25 @@ async function status(lifecycle) { } return { lines, resolved }; } +async function installHazard(lifecycle) { + const resolution = await resolveExecutable(lifecycle.host, await readState(lifecycle.host)); + if (!resolution.ok || resolution.resolved.source !== "system") + return null; + return `${resolution.resolved.executable} already resolves (${resolution.resolved.origin}). ` + "CBM resolves one canonical cache root per account and refuses to run when a process is " + "configured with a different root while another CBM session is active, so a second executable " + "of a different version produces mismatched index generations. Adopting the installation you " + "already have is the safe default."; +} +async function confirmedInstall(lifecycle, version, confirmer) { + const hazard = await installHazard(lifecycle); + if (hazard === null) + return await install(lifecycle, version); + if (!confirmer.available) { + return { + ok: false, + message: `${hazard} This session has no interactive UI, so the confirmation this needs cannot be asked; nothing was downloaded.` + }; + } + const confirmed = await confirmer.ask("Install a second codebase-memory-mcp?", hazard); + return confirmed ? await install(lifecycle, version) : { ok: true, message: `${hazard} Nothing was downloaded.` }; +} async function install(lifecycle, version) { const { host } = lifecycle; let acquired; @@ -868,7 +893,7 @@ function ompCodebaseMemory(pi) { return; } case "install": - report(ctx, await runInstall(ctx, lifecycle, rest[0])); + report(ctx, await confirmedInstall(lifecycle, rest[0], confirmerFrom(ctx))); return; case "update": report(ctx, await update(lifecycle)); @@ -889,23 +914,10 @@ function ompCodebaseMemory(pi) { } } }); - const runInstall = async (ctx, active, version) => { - const resolution = await resolveExecutable(active.host, await readState(active.host)); - const hazard = resolution.ok && resolution.resolved.source === "system" ? `${resolution.resolved.executable} already resolves (${resolution.resolved.origin}).` : null; - if (hazard !== null) { - const explanation = `${hazard} CBM resolves one canonical cache root per account and refuses to run when a ` + "process is configured with a different root while another CBM session is active, so a " + "second executable of a different version produces mismatched index generations. " + "Adopting the installation you already have is the safe default."; - if (!ctx.hasUI) { - return { - ok: false, - message: `${explanation} This session has no interactive UI, so the confirmation this needs cannot be asked; nothing was downloaded.` - }; - } - const confirmed = await ctx.ui.confirm("Install a second codebase-memory-mcp?", explanation); - if (!confirmed) - return { ok: true, message: `${explanation} Nothing was downloaded.` }; - } - return await install(active, version); - }; + const confirmerFrom = (ctx) => ({ + available: ctx.hasUI, + ask: (title, message) => ctx.ui.confirm(title, message) + }); pi.on("session_start", async (_event, ctx) => { if (lifecycle === null) return; diff --git a/src/index.ts b/src/index.ts index 4d8db75..6599163 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,10 @@ import { existsSync } from "node:fs"; import { hostTarget, UnsupportedPlatformError } from "./platform.ts"; import { nativeExtensionPath, processHost } from "./paths.ts"; import { githubReleaseSource } from "./release.ts"; -import { resolveExecutable } from "./resolve.ts"; -import { readState } from "./state.ts"; import { schedulerFrom } from "./scheduler.ts"; import { checkUpstream, - install, + confirmedInstall, pin, status, syncEntry, @@ -16,6 +14,7 @@ import { unpin, update, type ActionReport, + type Confirmer, type Lifecycle, } from "./lifecycle.ts"; @@ -156,7 +155,7 @@ export default function ompCodebaseMemory(pi: ExtensionAPI): void { return; } case "install": - report(ctx, await runInstall(ctx, lifecycle, rest[0])); + report(ctx, await confirmedInstall(lifecycle, rest[0], confirmerFrom(ctx))); return; case "update": report(ctx, await update(lifecycle)); @@ -188,49 +187,17 @@ export default function ompCodebaseMemory(pi: ExtensionAPI): void { }); /** - * Acquires a managed copy, with the shared-cache-root hazard made explicit. + * Adapts one command context to the lifecycle's confirmation seam. * - * The confirmation is not a formality. CBM resolves one canonical per-account - * cache root and refuses to run when a process is configured with a different - * root while any CBM session or command is active, so two executables of - * different versions produce mismatched index generations. An operator who - * already has a working installation should be told that before a second one - * exists, not after. - * - * With no interactive UI the command fails with that reason rather than - * waiting: nothing here may block on input that cannot arrive. + * The decision that needs the answer -- whether a second executable is a + * hazard, what to tell the operator, and what to do when no UI exists -- lives + * in `confirmedInstall`, where a test can reach it. This is the whole of the + * translation. */ - const runInstall = async ( - ctx: ExtensionCommandContext, - active: Lifecycle, - version: string | undefined, - ): Promise => { - const resolution = await resolveExecutable(active.host, await readState(active.host)); - const hazard = - resolution.ok && resolution.resolved.source === "system" - ? `${resolution.resolved.executable} already resolves (${resolution.resolved.origin}).` - : null; - - if (hazard !== null) { - const explanation = - `${hazard} CBM resolves one canonical cache root per account and refuses to run when a ` + - "process is configured with a different root while another CBM session is active, so a " + - "second executable of a different version produces mismatched index generations. " + - "Adopting the installation you already have is the safe default."; - - if (!ctx.hasUI) { - return { - ok: false, - message: `${explanation} This session has no interactive UI, so the confirmation this needs cannot be asked; nothing was downloaded.`, - }; - } - - const confirmed = await ctx.ui.confirm("Install a second codebase-memory-mcp?", explanation); - if (!confirmed) return { ok: true, message: `${explanation} Nothing was downloaded.` }; - } - - return await install(active, version); - }; + const confirmerFrom = (ctx: ExtensionCommandContext): Confirmer => ({ + available: ctx.hasUI, + ask: (title, message) => ctx.ui.confirm(title, message), + }); /** * Session start: verify the owned entry, correct it, and never block. diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 08b709d..a15dd0f 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -107,6 +107,69 @@ export async function status(lifecycle: Lifecycle): Promise { return { lines, resolved }; } +/** + * How a lifecycle operation asks the operator a yes/no question. + * + * An interface rather than a direct `ctx.ui.confirm` call so the decision that + * needs the answer lives here, under test, instead of in the extension entry + * where the only way to reach it is a real session. `available` is `ctx.hasUI`: + * when there is no interactive UI the operation must report why and stop, never + * block on an answer that cannot arrive. + */ +export interface Confirmer { + readonly available: boolean; + ask(title: string, message: string): Promise; +} + +/** + * The hazard `/cbm install` must explain before a second executable exists. + * + * CBM resolves one canonical per-account cache root and refuses to run when a + * process is configured with a different root while another CBM session is + * active, so two executables of different versions produce mismatched index + * generations. An operator who already has a working installation should learn + * that before the second one exists, not after. + */ +export async function installHazard(lifecycle: Lifecycle): Promise { + const resolution = await resolveExecutable(lifecycle.host, await readState(lifecycle.host)); + if (!resolution.ok || resolution.resolved.source !== "system") return null; + + return ( + `${resolution.resolved.executable} already resolves (${resolution.resolved.origin}). ` + + "CBM resolves one canonical cache root per account and refuses to run when a process is " + + "configured with a different root while another CBM session is active, so a second executable " + + "of a different version produces mismatched index generations. Adopting the installation you " + + "already have is the safe default." + ); +} + +/** + * `install`, gated on explicit confirmation when a system copy already resolves. + * + * With no interactive UI this reports the hazard and downloads nothing rather + * than waiting: no command here may block on input that cannot arrive. + */ +export async function confirmedInstall( + lifecycle: Lifecycle, + version: string | undefined, + confirmer: Confirmer, +): Promise { + const hazard = await installHazard(lifecycle); + if (hazard === null) return await install(lifecycle, version); + + if (!confirmer.available) { + return { + ok: false, + message: `${hazard} This session has no interactive UI, so the confirmation this needs cannot be asked; nothing was downloaded.`, + }; + } + + const confirmed = await confirmer.ask("Install a second codebase-memory-mcp?", hazard); + return confirmed + ? await install(lifecycle, version) + : { ok: true, message: `${hazard} Nothing was downloaded.` }; +} + /** * Acquires a version, adopts it, and points the MCP entry at it. * diff --git a/src/release.ts b/src/release.ts index b16d29a..645a182 100644 --- a/src/release.ts +++ b/src/release.ts @@ -69,12 +69,24 @@ export async function fetchHttps(url: string, options: FetchOptions = {}): Promi const redirected = response.status >= 300 && response.status < 400; if (!redirected || hop >= budget) return response; - const location = response.headers.get("location"); - if (location === null) { - throw new Error(`${current} answered ${response.status} with no location header`); - } - current = requireHttps(new URL(location, current).href, "redirect"); + current = nextHop(current, response.status, response.headers.get("location")); + } +} + +/** + * The URL one redirect hop leads to, or a refusal. + * + * Separated from {@link fetchHttps} because it is the whole transport-downgrade + * defence and the only part of it a test can reach without a TLS origin that + * redirects to plain HTTP. A relative `location` is resolved against the URL it + * came from, which is also what makes a scheme-relative `//host/path` inherit + * HTTPS rather than slip through as protocol-less. + */ +export function nextHop(current: string, status: number, location: string | null): string { + if (location === null || location === "") { + throw new Error(`${current} answered ${status} with no location header`); } + return requireHttps(new URL(location, current).href, "redirect"); } /** `url` when it is HTTPS; otherwise a refusal naming which hop downgraded. */ @@ -87,29 +99,20 @@ function requireHttps(url: string, kind: "request" | "redirect"): string { } /** - * The newest release tag, read from the `releases/latest` redirect. + * The release tag a `releases/latest` response names. * - * Not the GitHub API: that answer is rate-limited per IP, which turns a - * background version check into an intermittent failure on a shared network - * and would need a token for something requiring no authentication at all. - * The redirect needs neither, and asset downloads already use the same - * `releases/` mechanism. - * - * The `location` is validated rather than trusted. A redirect is attacker- - * adjacent input, and the tag it yields is later interpolated into a download - * URL and used as an on-disk directory name. + * Separated from {@link resolveLatestTag} for the same reason as + * {@link nextHop}: this is validation of attacker-adjacent input -- the tag it + * yields is interpolated into a download URL and used as an on-disk directory + * name -- and a function is the only way to test the refusals without standing + * up a server that impersonates GitHub. */ -export async function resolveLatestTag(): Promise { - const response = await fetchHttps(LATEST, { maxRedirects: 0 }); - if (response.status < 300 || response.status >= 400) { - throw new Error( - `expected ${LATEST} to redirect to a tag, got HTTP ${response.status}`, - ); +export function tagFromLocation(status: number, location: string | null): string { + if (status < 300 || status >= 400) { + throw new Error(`expected ${LATEST} to redirect to a tag, got HTTP ${status}`); } - - const location = response.headers.get("location"); - if (location === null) { - throw new Error(`${LATEST} answered ${response.status} with no location header`); + if (location === null || location === "") { + throw new Error(`${LATEST} answered ${status} with no location header`); } const resolved = new URL(location, LATEST); @@ -117,8 +120,12 @@ export async function resolveLatestTag(): Promise { throw new Error(`refusing non-HTTPS release location: ${resolved.href}`); } + // The origin is checked as well as the path. Without it a redirect to + // `https://elsewhere/DeusData/codebase-memory-mcp/releases/tag/v9.9.9` would + // be mined for a version string this package then treats as the newest + // release -- the path prefix alone says nothing about who answered. const prefix = `/${UPSTREAM_REPO}/releases/tag/`; - if (!resolved.pathname.startsWith(prefix)) { + if (resolved.origin !== new URL(LATEST).origin || !resolved.pathname.startsWith(prefix)) { throw new Error(`unexpected release location: ${resolved.href}`); } @@ -129,6 +136,20 @@ export async function resolveLatestTag(): Promise { return tag; } +/** + * The newest release tag, read from the `releases/latest` redirect. + * + * Not the GitHub API: that answer is rate-limited per IP, which turns a + * background version check into an intermittent failure on a shared network + * and would need a token for something requiring no authentication at all. + * The redirect needs neither, and asset downloads already use the same + * `releases/` mechanism. + */ +export async function resolveLatestTag(): Promise { + const response = await fetchHttps(LATEST, { maxRedirects: 0 }); + return tagFromLocation(response.status, response.headers.get("location")); +} + /** * The published SHA-256 digest for exactly `archive`. * diff --git a/test/unit/lifecycle.test.ts b/test/unit/lifecycle.test.ts index 08a4aa9..a1a4598 100644 --- a/test/unit/lifecycle.test.ts +++ b/test/unit/lifecycle.test.ts @@ -5,13 +5,16 @@ import path from "node:path"; import { checkUpstream, CHECK_INTERVAL_MS, + confirmedInstall, install, + installHazard, pin, status, syncEntry, uninstall, unpin, update, + type Confirmer, type Lifecycle, } from "../../src/lifecycle.ts"; import { entryStatus } from "../../src/mcp-config.ts"; @@ -248,3 +251,120 @@ describe("status", () => { expect(report.lines.join("\n")).toContain(`agent dir: ${path.join(scratch.home, ".omp/agent")}`); }); }); + +/** A confirmer whose answer is fixed, recording whether it was consulted. */ +function fixedConfirmer(available: boolean, answer: boolean): Confirmer & { asked: string[] } { + const asked: string[] = []; + return { + available, + asked, + ask: async (_title, message) => { + asked.push(message); + return answer; + }, + }; +} + +describe("install is gated on confirmation when a system copy already resolves", () => { + test("with nothing resolving there is no hazard and no question is asked", async () => { + const confirmer = fixedConfirmer(true, false); + expect(await installHazard(await lifecycleFor(VERSION))).toBeNull(); + + const report = await confirmedInstall(await lifecycleFor(VERSION), undefined, confirmer); + expect(report.ok).toBe(true); + expect(confirmer.asked).toEqual([]); + expect((await readState(scratch.host)).managedVersion).toBe(VERSION); + }); + + test("the hazard names the resolved executable and the shared cache root", async () => { + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp 0.9.0"`, + ); + + const hazard = await installHazard(await lifecycleFor(VERSION)); + expect(hazard).toContain(path.join(scratch.pathDir, EXECUTABLE_NAME)); + expect(hazard).toContain("one canonical cache root per account"); + }); + + test("declining downloads nothing", async () => { + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp 0.9.0"`, + ); + const confirmer = fixedConfirmer(true, false); + + const report = await confirmedInstall(await lifecycleFor(VERSION), undefined, confirmer); + expect(report.ok).toBe(true); + expect(report.message).toContain("Nothing was downloaded"); + expect(confirmer.asked).toHaveLength(1); + expect(await Bun.file(packageRoot(scratch.host)).exists()).toBe(false); + }); + + test("accepting downloads and adopts a second copy", async () => { + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp 0.9.0"`, + ); + const confirmer = fixedConfirmer(true, true); + + const report = await confirmedInstall(await lifecycleFor(VERSION), undefined, confirmer); + expect(report.ok).toBe(true); + expect(confirmer.asked).toHaveLength(1); + expect((await readState(scratch.host)).managedVersion).toBe(VERSION); + }); + + /** + * The spec is explicit that no command may block on input that cannot arrive. + * A session with no interactive UI must therefore report the hazard and stop. + */ + test("with no interactive UI it reports the reason and never asks", async () => { + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp 0.9.0"`, + ); + const confirmer = fixedConfirmer(false, true); + + const report = await confirmedInstall(await lifecycleFor(VERSION), undefined, confirmer); + expect(report.ok).toBe(false); + expect(report.message).toContain("no interactive UI"); + expect(report.message).toContain("nothing was downloaded"); + expect(confirmer.asked).toEqual([]); + expect(await Bun.file(packageRoot(scratch.host)).exists()).toBe(false); + }); +}); + +describe("session start with nothing resolving", () => { + test("writes no entry and names the install command", async () => { + const report = await syncEntry({ + host: scratch.host, + target: TARGET, + source: forbiddenSource, + }); + + expect(report.kind).toBe("unresolved"); + expect(report.message).toContain("/cbm install"); + expect(report.message).toContain("No MCP entry was written or changed"); + expect(await Bun.file(mcpConfigPath(scratch.host)).exists()).toBe(false); + }); + + test("a pre-existing foreign entry is refused rather than corrected", async () => { + const file = mcpConfigPath(scratch.host); + const foreign = `{\n "mcpServers": {\n "codebase-memory-mcp": {\n "command": "/opt/homebrew/bin/codebase-memory-mcp"\n }\n }\n}\n`; + await Bun.write(file, foreign); + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp 0.9.0"`, + ); + + const report = await syncEntry({ + host: scratch.host, + target: TARGET, + source: forbiddenSource, + }); + + expect(report.kind).toBe("refused"); + expect(report.message).toContain("/opt/homebrew/bin/codebase-memory-mcp"); + expect(await Bun.file(file).text()).toBe(foreign); + }); +}); diff --git a/test/unit/release-transport.test.ts b/test/unit/release-transport.test.ts new file mode 100644 index 0000000..8e03196 --- /dev/null +++ b/test/unit/release-transport.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "bun:test"; + +import { fetchHttps, nextHop, tagFromLocation } from "../../src/release.ts"; + +/** + * The transport-downgrade defence and the release-tag parser. + * + * Both validate input this package does not control -- a redirect's `location` + * header -- and the tag one of them yields is interpolated into a download URL + * and used as an on-disk directory name. Neither is reachable through a real + * request without a TLS origin that redirects to plain HTTP, which is why the + * validation is a function. + */ + +const HTTPS_ORIGIN = "https://github.com/DeusData/codebase-memory-mcp/releases/latest"; + +interface HopCase { + readonly scenario: string; + /** The `location` header the hop carries. */ + readonly location: string | null; + /** The URL the hop must lead to. */ + readonly expected: string; +} + +const acceptedHops: HopCase[] = [ + { + scenario: "an absolute HTTPS location is followed as given", + location: "https://objects.githubusercontent.com/asset", + expected: "https://objects.githubusercontent.com/asset", + }, + { + scenario: "a path-relative location is resolved against the URL it came from", + location: "/DeusData/codebase-memory-mcp/releases/tag/v0.10.8", + expected: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/v0.10.8", + }, + { + // A scheme-relative location inherits the current scheme, which is HTTPS -- + // resolving against the current URL is what makes that true rather than + // leaving the hop protocol-less. + scenario: "a scheme-relative location inherits HTTPS", + location: "//objects.githubusercontent.com/asset", + expected: "https://objects.githubusercontent.com/asset", + }, +]; + +interface HopRefusalCase { + readonly scenario: string; + readonly location: string | null; + readonly reported: RegExp; +} + +const refusedHops: HopRefusalCase[] = [ + { + scenario: "a plain HTTP location is refused without being followed", + location: "http://objects.githubusercontent.com/asset", + reported: /refusing non-HTTPS redirect: http:\/\/objects\.githubusercontent\.com\/asset/u, + }, + { + scenario: "a loopback HTTP location is refused like any other downgrade", + location: "http://127.0.0.1:8080/asset", + reported: /refusing non-HTTPS redirect/u, + }, + { + scenario: "a non-HTTP scheme is refused", + location: "file:///etc/passwd", + reported: /refusing non-HTTPS redirect: file:\/\/\/etc\/passwd/u, + }, + { + scenario: "a missing location header is refused rather than retried", + location: null, + reported: /answered 302 with no location header/u, + }, + { + scenario: "an empty location header is refused", + location: "", + reported: /answered 302 with no location header/u, + }, +]; + +interface TagCase { + readonly scenario: string; + readonly status: number; + readonly location: string; + readonly tag: string; +} + +const acceptedTags: TagCase[] = [ + { + // The value GitHub actually answered when this was measured. + scenario: "the measured 302 to a tag yields that tag", + status: 302, + location: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/v0.10.8", + tag: "v0.10.8", + }, + { + scenario: "a relative location to a tag yields that tag", + status: 302, + location: "/DeusData/codebase-memory-mcp/releases/tag/v1.0.0", + tag: "v1.0.0", + }, + { + scenario: "a 301 is accepted as a redirect", + status: 301, + location: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/v0.9.0", + tag: "v0.9.0", + }, + { + scenario: "a percent-encoded tag is decoded", + status: 302, + location: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/v1.0.0%2Bbuild", + tag: "v1.0.0+build", + }, +]; + +interface TagRefusalCase { + readonly scenario: string; + readonly status: number; + readonly location: string | null; + readonly reported: RegExp; +} + +const refusedTags: TagRefusalCase[] = [ + { + scenario: "a 200 is refused, because a tag can only come from a redirect", + status: 200, + location: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/v0.10.8", + reported: /to redirect to a tag, got HTTP 200/u, + }, + { + scenario: "a 404 is refused", + status: 404, + location: null, + reported: /got HTTP 404/u, + }, + { + scenario: "a redirect with no location is refused", + status: 302, + location: null, + reported: /no location header/u, + }, + { + scenario: "a non-HTTPS release location is refused", + status: 302, + location: "http://github.com/DeusData/codebase-memory-mcp/releases/tag/v0.10.8", + reported: /refusing non-HTTPS release location/u, + }, + { + // A redirect that leads somewhere else entirely must not be mined for a + // path segment that happens to look like a version. + scenario: "a location outside the repository's tag path is refused", + status: 302, + location: "https://evil.example/DeusData/codebase-memory-mcp/releases/tag/v0.10.8", + reported: /unexpected release location/u, + }, + { + scenario: "a location naming another repository is refused", + status: 302, + location: "https://github.com/attacker/repo/releases/tag/v0.10.8", + reported: /unexpected release location/u, + }, + { + scenario: "a location with no tag after the prefix is refused", + status: 302, + location: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/", + reported: /unexpected release tag in location/u, + }, + { + // The tag becomes a directory name under `bin/`, so a separator in it is a + // traversal attempt whatever it decodes from. + scenario: "a tag holding a path separator is refused", + status: 302, + location: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/v1%2F..%2F..%2Fetc", + reported: /unexpected release tag in location/u, + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = [...acceptedHops, ...refusedHops, ...acceptedTags, ...refusedTags].map( + (entry) => entry.scenario, + ); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("redirect hops", () => { + test.each(acceptedHops)("$scenario", ({ location, expected }) => { + expect(nextHop(HTTPS_ORIGIN, 302, location)).toBe(expected); + }); + + test.each(refusedHops)("$scenario", ({ location, reported }) => { + expect(() => nextHop(HTTPS_ORIGIN, 302, location)).toThrow(reported); + }); +}); + +describe("the initial request", () => { + test("a non-HTTPS URL is refused before any connection is opened", async () => { + await expect(fetchHttps("http://127.0.0.1:1/never")).rejects.toThrow( + /refusing non-HTTPS request: http:\/\/127\.0\.0\.1:1\/never/u, + ); + }); +}); + +describe("release tag resolution", () => { + test.each(acceptedTags)("$scenario", ({ status, location, tag }) => { + expect(tagFromLocation(status, location)).toBe(tag); + }); + + test.each(refusedTags)("$scenario", ({ status, location, reported }) => { + expect(() => tagFromLocation(status, location)).toThrow(reported); + }); +}); From b0be21315316367b702b14eca044c69107c0cd75 Mon Sep 17 00:00:00 2001 From: pashifika Date: Fri, 28 Aug 2026 14:08:32 +0900 Subject: [PATCH 3/9] fix(exec): bound subprocess output and make the deadline authoritative `run` is the only `Bun.spawn` in the codebase and sits on the session-start `readVersion` path, so both of its limits have to hold against a process that does not cooperate. Neither did. Output was buffered through `new Response(child.stdout).text()` with no byte bound, so a hostile candidate or a member-heavy `tar` could exhaust the OMP process's memory well inside the timeout. Both pipes are now drained through bounded readers that stop the child once either exceeds the cap. The deadline was the worse of the two. It was awaited only against the pipe drain, so a descendant that inherited a pipe held the read open and the timeout never fired: `(sleep 2) & printf ok` under a 100ms deadline took 2014ms and reported success. Racing the drain alone was still not enough, because a child that closes both pipes early and keeps running settles the drain at once and left `child.exited` unbounded: that shape took 2018ms and also reported success. The deadline now races `Promise.all([drained, child.exited])`, so neither can beat it, and every deadline win reaches the SIGKILL escalation before the readers are released. The child is spawned with `detached: true` and reaped by process group so an ordinary descendant dies with it. The trade-off is deliberate and accepted: our subprocesses leave the terminal's foreground group, so Ctrl-C on OMP no longer reaches an in-flight `tar` or `codesign`, and such a child can outlive OMP by seconds. That beats the alternative, where the descendant is never reaped at all. Both reproducers now return in ~100ms with zero surviving descendants. --- dist/index.js | 104 +++++++++++++++++-- src/exec.ts | 224 +++++++++++++++++++++++++++++++++++++++-- test/unit/exec.test.ts | 211 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 523 insertions(+), 16 deletions(-) create mode 100644 test/unit/exec.test.ts diff --git a/dist/index.js b/dist/index.js index 97c5294..7173025 100644 --- a/dist/index.js +++ b/dist/index.js @@ -226,21 +226,111 @@ import path2 from "path"; // src/exec.ts var DEFAULT_TIMEOUT_MS2 = 30000; +var OUTPUT_LIMIT_BYTES = 262144; +function capture(stream, onOverflow) { + if (stream === undefined) { + return { captured: Promise.resolve({ text: "", overflowed: false }), release: () => {} }; + } + const reader = stream.getReader(); + const drain = async () => { + const chunks = []; + let total = 0; + let overflowed = false; + try { + for (;; ) { + const { done, value } = await reader.read(); + if (done) + break; + const room = OUTPUT_LIMIT_BYTES - total; + if (value.byteLength > room) { + chunks.push(value.subarray(0, room)); + total = OUTPUT_LIMIT_BYTES; + overflowed = true; + onOverflow(); + break; + } + chunks.push(value); + total += value.byteLength; + } + } finally { + await reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { text: new TextDecoder().decode(bytes), overflowed }; + }; + return { + captured: drain(), + release: () => { + reader.cancel().catch(() => {}); + } + }; +} +async function deadlineWon(work, deadline) { + if (deadline.aborted) + return true; + const expired = new Promise((resolve) => { + deadline.addEventListener("abort", () => resolve(true), { once: true }); + }); + return await Promise.race([work.then(() => false), expired]); +} async function run(argv, options = {}) { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS2; + const name = argv[0] ?? "the process"; try { + const deadline = AbortSignal.timeout(timeoutMs); const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", stdin: "ignore", - timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS2, + signal: deadline, + detached: true, ...options.cwd === undefined ? {} : { cwd: options.cwd } }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited - ]); - return { ok: exitCode === 0, exitCode, stdout, stderr }; + const reap = (signal) => { + try { + process.kill(-child.pid, signal); + } catch {} + child.kill(signal); + }; + const stopFlood = () => { + reap("SIGTERM"); + }; + const stdout = capture(child.stdout, stopFlood); + const stderr = capture(child.stderr, stopFlood); + const drained = Promise.all([stdout.captured, stderr.captured]); + const overran = await deadlineWon(Promise.all([drained, child.exited]), deadline); + if (overran) { + reap("SIGKILL"); + stdout.release(); + stderr.release(); + } + const [out, err] = await drained; + const exitCode = await child.exited; + if (overran) { + return { + ok: false, + exitCode, + stdout: out.text, + stderr: err.text, + spawnError: `${name} did not finish within ${timeoutMs}ms and was killed` + }; + } + if (out.overflowed || err.overflowed) { + const flooded = out.overflowed ? "stdout" : "stderr"; + return { + ok: false, + exitCode, + stdout: out.text, + stderr: err.text, + spawnError: `${name} wrote more than ${OUTPUT_LIMIT_BYTES} bytes to ${flooded} and was killed` + }; + } + return { ok: exitCode === 0, exitCode, stdout: out.text, stderr: err.text }; } catch (error) { return { ok: false, diff --git a/src/exec.ts b/src/exec.ts index 0ef18be..d5f032c 100644 --- a/src/exec.ts +++ b/src/exec.ts @@ -10,12 +10,30 @@ /** Default per-process deadline. Nothing here should take longer. */ const DEFAULT_TIMEOUT_MS = 30_000; +/** + * Per-stream capture cap. + * + * `timeout` bounds how long a child runs, not how much it writes, and both + * pipes are read into this process's memory. The bound has to clear the largest + * output any real invocation produces, and those are tiny: a `tar -tzf` listing + * of a release archive is four names, `--version` is one line, and `codesign`'s + * complaints are a sentence. A quarter of a megabyte is three orders of + * magnitude of headroom over all of them, and small enough that an over-cap + * stream is still quotable in the refusal it causes. + */ +export const OUTPUT_LIMIT_BYTES = 262_144; + export interface RunResult { readonly ok: boolean; readonly exitCode: number; readonly stdout: string; readonly stderr: string; - /** Set when the process could not be started at all, e.g. a missing tool. */ + /** + * Set when the result is not the process's own answer: it could not be + * started at all (a missing tool), it wrote past + * {@link OUTPUT_LIMIT_BYTES}, or it did not finish inside its deadline. + * In every case the captured output is a fragment and must not be parsed. + */ readonly spawnError?: string; } @@ -24,29 +42,217 @@ export interface RunOptions { readonly cwd?: string; } +/** One captured pipe: its text, and whether the cap cut it short. */ +interface Captured { + readonly text: string; + readonly overflowed: boolean; +} + +/** A pipe being drained, and the handle that stops the drain. */ +interface Pipe { + /** Settles when the pipe closes, the cap is reached, or `release` is called. */ + readonly captured: Promise; + /** + * Cancels the reader, which settles a pending `read()` as `done` and so + * settles {@link Pipe.captured} with whatever had arrived. Needed because a + * pipe is closed by its *last* writer: a descendant that inherited it holds + * the read open after the child this code spawned is gone, so the read has + * to be abandonable rather than merely awaited. + */ + release(): void; +} + +/** + * Starts draining one pipe, capped at {@link OUTPUT_LIMIT_BYTES}. + * + * Read chunk by chunk rather than through `new Response(stream).text()`, which + * allocates the whole stream before anything can weigh it. The last chunk is + * clipped to the cap rather than kept whole, so the retained fragment is + * exactly bounded by the constant the refusal names. + * + * The reader never leaves this function: `child.stdout`'s reader type differs + * between the platform and `node:stream/web` declarations, and inference is + * what keeps that difference from having to be named. + */ +function capture(stream: ReadableStream | undefined, onOverflow: () => void): Pipe { + if (stream === undefined) { + return { captured: Promise.resolve({ text: "", overflowed: false }), release: () => {} }; + } + + const reader = stream.getReader(); + const drain = async (): Promise => { + const chunks: Uint8Array[] = []; + let total = 0; + let overflowed = false; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const room = OUTPUT_LIMIT_BYTES - total; + // `>` rather than `>=`: a stream whose total lands exactly on the cap + // has not overflowed, and the next read settles which it was. + if (value.byteLength > room) { + chunks.push(value.subarray(0, room)); + total = OUTPUT_LIMIT_BYTES; + overflowed = true; + onOverflow(); + break; + } + chunks.push(value); + total += value.byteLength; + } + } finally { + await reader.cancel().catch(() => {}); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { text: new TextDecoder().decode(bytes), overflowed }; + }; + + return { + captured: drain(), + release: () => { + void reader.cancel().catch(() => {}); + }, + }; +} + +/** + * Whether `deadline` fired before `work` settled. + * + * `work` is handed back to the caller rather than discarded, so the caller can + * release the readers and then await the same promise: a `Promise.race` against + * a rejecting timer would abandon it still pending. + * + * Built on {@link AbortSignal.timeout} rather than `setTimeout`, for the reason + * `src/scheduler.ts` gives: a raw timer callback that throws escapes handler + * dispatch and takes the session with it. There is no callback here, and + * `src/release.ts` already bounds a request the same way. + */ +async function deadlineWon(work: Promise, deadline: AbortSignal): Promise { + if (deadline.aborted) return true; + const expired = new Promise((resolve) => { + deadline.addEventListener("abort", () => resolve(true), { once: true }); + }); + return await Promise.race([work.then(() => false), expired]); +} + /** * Runs `argv` and captures its output. * * Never throws for a non-zero exit or a missing executable: both are ordinary * outcomes here -- a candidate that will not run, a host without `xattr` -- and * each caller reports them differently. A thrown spawn failure is folded into - * the result as {@link RunResult.spawnError} so the distinction survives. + * the result as {@link RunResult.spawnError} so the distinction survives, and + * so are the two bounds: {@link OUTPUT_LIMIT_BYTES} and the deadline. + * + * The deadline is authoritative rather than advisory. Draining both pipes + * before observing `child.exited` made it advisory, because a pipe closes when + * its *last* writer does: `sh -c "(sleep 2) & printf ok"` returns its direct + * child immediately and leaves a descendant holding the read, and the read was + * awaited unconditionally. Measured before the fix: 2014 ms against a 100 ms + * timeout, reported as a success. + * + * So the deadline does two things rather than one. It reaps the child's process + * group, which is where an ordinary descendant is, *and* it releases the + * readers. Both are needed: a descendant that starts its own session -- a + * double fork, or another `detached` spawn -- leaves that group and survives + * the reap while still holding the pipe, and there is no portable way to find + * it. Releasing the readers is what makes the bound hold anyway, rather than + * pretending such a descendant is gone. */ export async function run(argv: readonly string[], options: RunOptions = {}): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const name = argv[0] ?? "the process"; try { + // One deadline object, not two: `Bun.spawn` kills the direct child when the + // signal fires, and the same signal is what stops this side waiting on a + // read nothing in the child controls any more. + const deadline = AbortSignal.timeout(timeoutMs); const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", stdin: "ignore", - timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + signal: deadline, + // `setsid`, so the child leads its own process group and the descendants + // that inherited its pipes can be reaped with it. Without this the + // backgrounded `sleep` above survives every signal reachable from + // `child`. + detached: true, ...(options.cwd === undefined ? {} : { cwd: options.cwd }), }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]); - return { ok: exitCode === 0, exitCode, stdout, stderr }; + + /** + * Signals the child's whole process group, then the child itself. + * + * The deadline path passes SIGKILL, which is why `Bun.spawn` keeps its + * default `killSignal` of SIGTERM rather than being given one: a child + * doing `trap '' TERM` outlives Bun's own kill and is ended here instead. + * Measured both ways -- that child comes back with exit 137, and adding + * `killSignal: "SIGKILL"` changes nothing any test can see, while + * downgrading this call to SIGTERM makes it survive its deadline. + */ + const reap = (signal: NodeJS.Signals): void => { + try { + // Negative pid: the group, which `detached` made this child's own and + // which is therefore the only group this can reach. + process.kill(-child.pid, signal); + } catch { + // ESRCH -- the group is already empty, which is the desired state. + } + child.kill(signal); + }; + + // Killed on overflow rather than merely left unread: a child whose pipe has + // stopped being drained blocks on its next write, so the cap has to end the + // process, not just the reading of it. + const stopFlood = (): void => { + reap("SIGTERM"); + }; + const stdout = capture(child.stdout, stopFlood); + const stderr = capture(child.stderr, stopFlood); + const drained = Promise.all([stdout.captured, stderr.captured]); + + // Both halves, because either one alone leaves a hole. Racing only the + // drain lets a child that hands its pipes back and keeps running set its + // own duration -- `exec 1>/dev/null 2>/dev/null; trap '' TERM; sleep 2` + // settled the reads at once and then took 2012 ms, reported as a success. + // Racing only the exit lets a descendant that inherited a pipe hold the + // read open after the child is gone. + const overran = await deadlineWon(Promise.all([drained, child.exited]), deadline); + if (overran) { + reap("SIGKILL"); + stdout.release(); + stderr.release(); + } + const [out, err] = await drained; + const exitCode = await child.exited; + + if (overran) { + return { + ok: false, + exitCode, + stdout: out.text, + stderr: err.text, + spawnError: `${name} did not finish within ${timeoutMs}ms and was killed`, + }; + } + if (out.overflowed || err.overflowed) { + const flooded = out.overflowed ? "stdout" : "stderr"; + return { + ok: false, + exitCode, + stdout: out.text, + stderr: err.text, + spawnError: `${name} wrote more than ${OUTPUT_LIMIT_BYTES} bytes to ${flooded} and was killed`, + }; + } + return { ok: exitCode === 0, exitCode, stdout: out.text, stderr: err.text }; } catch (error) { return { ok: false, diff --git a/test/unit/exec.test.ts b/test/unit/exec.test.ts new file mode 100644 index 0000000..ce2c018 --- /dev/null +++ b/test/unit/exec.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; + +import { OUTPUT_LIMIT_BYTES, run } from "../../src/exec.ts"; + +/** + * The one place this package starts a subprocess, and the two bounds it owes + * its callers: a deadline, and a byte cap. + * + * The child is `process.execPath` -- whatever is running this suite can also + * run `-e` -- rather than a coreutils pipeline, so the flood is the same size + * on every host and needs nothing on `PATH`. + * + * These cases measure the real clock, which the project otherwise avoids in + * tests. There is no deterministic substitute: the properties are that a real + * subprocess is *ended* rather than waited out and that a real deadline wins + * over a real kernel pipe, and a fake clock advances neither a process's + * lifetime nor a descendant's grip on a file descriptor. The cost is paid only + * when the bound is broken -- every assertion below passes in tens of + * milliseconds and only a regression makes the suite wait. + */ + +/** Comfortably past the cap, and small enough to produce in milliseconds. */ +const FLOOD_BYTES = OUTPUT_LIMIT_BYTES * 3; + +/** + * How long a lingering child stays alive after it has written. + * + * Long enough that waiting for it instead of killing it is unmistakable in the + * wall clock, and short enough that a broken run still ends. + */ +const LINGER_MS = 8_000; + +interface FloodCase { + readonly scenario: string; + /** What the child writes, and which pipe it writes it to. */ + readonly emit: string; + /** The stream that must come back bounded. */ + readonly stream: "stdout" | "stderr"; + /** The refusal must name the stream that overflowed. */ + readonly reported: RegExp; +} + +const floods: FloodCase[] = [ + { + scenario: "a stdout flood is capped and the child is killed", + emit: `process.stdout.write("a".repeat(${FLOOD_BYTES}))`, + stream: "stdout", + reported: /wrote more than \d+ bytes to stdout/u, + }, + { + scenario: "a stderr flood is capped and the child is killed", + emit: `process.stderr.write("a".repeat(${FLOOD_BYTES}))`, + stream: "stderr", + reported: /wrote more than \d+ bytes to stderr/u, + }, + { + // The case that separates "stopped reading it" from "ended it". A child + // that floods and then goes on living costs `run` the child's whole + // remaining lifetime -- inside its deadline the entire time, so no other + // bound catches it. Without the kill the reviewer measured 8022 ms here. + scenario: "a child still running after it floods is killed rather than waited out", + emit: `process.stdout.write("a".repeat(${FLOOD_BYTES})); setTimeout(() => {}, ${LINGER_MS})`, + stream: "stdout", + reported: /wrote more than \d+ bytes to stdout/u, + }, +]; + +/** + * Children that close both pipes at once and then keep running. + * + * `exec 1>/dev/null 2>/dev/null` ends both reads immediately, so a bound that + * races only the drain lets the deadline pass unnoticed -- process lifetime has + * to be inside the bound too. Measured before the fix: 2012 ms and `ok: true` + * for the trapping form, and 102 ms with no `spawnError` at all for the other, + * which reported a deadline as an anonymous signal death. + */ +const closedPipes = [ + { + scenario: "a child that closes its pipes and traps SIGTERM is killed at the deadline", + script: "exec 1>/dev/null 2>/dev/null; trap '' TERM; sleep 2", + }, + { + scenario: "a child that closes its pipes and dies on SIGTERM still reports the deadline", + script: "exec 1>/dev/null 2>/dev/null; sleep 2", + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = [...floods, ...closedPipes].map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("captured output", () => { + test.each(floods)("$scenario", async ({ emit, stream, reported }) => { + const started = Date.now(); + const result = await run([process.execPath, "-e", emit], { timeoutMs: 20_000 }); + const elapsed = Date.now() - started; + + // The deadline bounds how long a child runs, not how much it writes, so a + // candidate that answers `--version` with a gigabyte would be inside its + // timeout the whole time it was exhausting this process's memory. + expect(result.ok).toBe(false); + expect(result.spawnError).toMatch(reported); + expect(result[stream].length).toBeLessThanOrEqual(OUTPUT_LIMIT_BYTES); + expect(elapsed).toBeLessThan(LINGER_MS / 2); + }); + + test("output under the cap comes back whole", async () => { + const result = await run([process.execPath, "-e", 'process.stdout.write("small")']); + + expect(result.ok).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("small"); + expect(result.spawnError).toBeUndefined(); + }); + + test("a missing executable is a result rather than a throw", async () => { + const result = await run(["definitely-not-a-tool-on-this-host"]); + + expect(result.ok).toBe(false); + expect(result.spawnError).toBeDefined(); + }); +}); + +describe("the deadline", () => { + test("a descendant holding the pipe open does not outlast the deadline", async () => { + const started = Date.now(); + // `sh` exits at once; the backgrounded `sleep` inherits the pipe and keeps + // it open. Draining both pipes before observing the child's exit made the + // deadline advisory rather than authoritative -- 2014 ms measured against a + // 100 ms timeout, with `ok` returned as if the read had completed. + const result = await run(["sh", "-c", "(sleep 2) & printf ok"], { timeoutMs: 100 }); + const elapsed = Date.now() - started; + + expect(elapsed).toBeLessThan(1_000); + expect(result.ok).toBe(false); + expect(result.spawnError).toMatch(/did not finish within 100ms/u); + }); + + test("a child ignoring SIGTERM is gone when the deadline fires", async () => { + const started = Date.now(); + // An ignored disposition survives `exec`, so the whole pipeline ignores + // SIGTERM. A deadline a `trap` can outlive is not a deadline. + const result = await run(["sh", "-c", "trap '' TERM; sleep 5"], { timeoutMs: 200 }); + const elapsed = Date.now() - started; + + expect(elapsed).toBeLessThan(2_000); + expect(result.ok).toBe(false); + expect(result.spawnError).toMatch(/did not finish within 200ms/u); + }); + + test("a descendant is reaped with the child rather than left running", async () => { + // `sh` backgrounds the sleep, prints its pid, and exits. The pid is on + // stdout even though the deadline fired, because those bytes had already + // arrived -- releasing a read abandons what has not come, not what has. + const held = await run(["sh", "-c", 'sleep 30 & printf %s "$!"'], { timeoutMs: 200 }); + + expect(held.ok).toBe(false); + const descendant = held.stdout.trim(); + expect(descendant).toMatch(/^\d+$/u); + + // `kill -0` asks whether the pid is still there without signalling it, so + // this needs no wait: the group kill has already happened or it has not. + const alive = await run( + ["sh", "-c", `kill -0 ${descendant} 2>/dev/null && echo alive || echo gone`], + { timeoutMs: 5_000 }, + ); + + expect(alive.stdout.trim()).toBe("gone"); + }); + + test("a descendant the reap cannot reach still does not hold the deadline", async () => { + // The residual case, and the one that separates releasing the readers from + // only killing. The grandchild is itself `detached`, so it leads its own + // session, leaves the group the reap can signal, and survives -- while + // still holding the stdout it inherited. Nothing portable can find it, so + // the bound has to come from abandoning the read. + const escaping = + 'const held = Bun.spawn(["sleep", "30"], ' + + '{ stdout: "inherit", stderr: "ignore", stdin: "ignore", detached: true }); ' + + "process.stdout.write(String(held.pid));"; + + const started = Date.now(); + const result = await run([process.execPath, "-e", escaping], { timeoutMs: 200 }); + const elapsed = Date.now() - started; + + try { + expect(elapsed).toBeLessThan(2_000); + expect(result.ok).toBe(false); + expect(result.spawnError).toMatch(/did not finish within 200ms/u); + } finally { + // It outlives the reap by design, so this test has to end it itself. + await run(["sh", "-c", `kill -9 ${result.stdout.trim()} 2>/dev/null || true`], { + timeoutMs: 5_000, + }); + } + }); + + test.each(closedPipes)("$scenario", async ({ script }) => { + const started = Date.now(); + const result = await run(["sh", "-c", script], { timeoutMs: 100 }); + const elapsed = Date.now() - started; + + // The read settling is not the process finishing. Racing only the drain + // left `child.exited` awaited with nothing bounding it, so a child that + // hands back its pipes and lives on set its own duration. + expect(elapsed).toBeLessThan(1_000); + expect(result.ok).toBe(false); + expect(result.spawnError).toMatch(/did not finish within 100ms/u); + }); +}); From b478c516139dedefe04c7d9bc8186ff468553e1b Mon Sep 17 00:00:00 2001 From: pashifika Date: Fri, 28 Aug 2026 14:08:53 +0900 Subject: [PATCH 4/9] fix(release): enforce the checksum size limit while streaming The 1 MiB bound on `checksums.txt` was checked in `parseChecksums`, which only runs after `download` has already done `await response.arrayBuffer()` on the whole body. The limit therefore provided none of the resource safety it was written for: a large or chunked response could consume arbitrary memory and take the OMP process down before the refusal ever executed. The checksum fetch now goes through a bounded reader that consumes `response.body` chunk by chunk and throws the moment the running total passes the cap, cancelling the reader. There is deliberately no `Content-Length` precheck: a chunked response carries none, so it would add a second code path that the streaming cap has to cover anyway. `parseChecksums` keeps its own byte-length refusal as a belt-and-braces check behind the streaming one. Also retitles the `-ui-` checksums case, which was documented as proving exact-name selection but could not: its expected digest is byte-identical to the one published for the non-`-ui-` archive, so a parser that resolved either name through the other's line would still pass. The neighbouring case carries that proof with a genuinely different digest. --- dist/index.js | 37 +++++++++++++- src/release.ts | 76 ++++++++++++++++++++++++++++- test/unit/checksums.test.ts | 11 +++-- test/unit/release-transport.test.ts | 55 ++++++++++++++++++++- 4 files changed, 173 insertions(+), 6 deletions(-) diff --git a/dist/index.js b/dist/index.js index 7173025..574736f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -192,7 +192,7 @@ function parseChecksums(body, archive) { function githubReleaseSource() { return { latestTag: resolveLatestTag, - checksums: (tag) => download(`${RELEASES}/download/${encodeURIComponent(tag)}/checksums.txt`), + checksums: (tag) => downloadBounded(`${RELEASES}/download/${encodeURIComponent(tag)}/checksums.txt`, CHECKSUMS_LIMIT_BYTES, "checksums.txt"), asset: (tag, name) => download(`${RELEASES}/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`) }; } @@ -203,6 +203,41 @@ async function download(url) { } return new Uint8Array(await response.arrayBuffer()); } +async function downloadBounded(url, limitBytes, what) { + const response = await fetchHttps(url); + if (!response.ok) { + throw new Error(`GET ${url} answered HTTP ${response.status}`); + } + return await readBounded(response.body, limitBytes, what); +} +async function readBounded(body, limitBytes, what) { + if (body === null) + return new Uint8Array; + const reader = body.getReader(); + const chunks = []; + let total = 0; + try { + for (;; ) { + const { done, value } = await reader.read(); + if (done) + break; + total += value.byteLength; + if (total > limitBytes) { + throw new Error(`${what} is over the ${limitBytes} byte safety limit`); + } + chunks.push(value); + } + } finally { + await reader.cancel().catch(() => {}); + } + const joined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + return joined; +} // src/scheduler.ts function schedulerFrom(ctx) { diff --git a/src/release.ts b/src/release.ts index 645a182..466d4dc 100644 --- a/src/release.ts +++ b/src/release.ts @@ -32,6 +32,12 @@ const DEFAULT_TIMEOUT_MS = 20_000; * Upstream's installer refuses the same 1 MiB, for the same reason: the digest * file is a few kilobytes of text, so anything larger is not the file this * package believes it is parsing. + * + * Enforced twice on purpose. {@link readBounded} stops the download at the + * limit, which is the only place the memory is actually saved, and + * {@link parseChecksums} refuses an over-limit buffer whatever produced it -- + * because the parser is reachable from a {@link ReleaseSource} that never went + * through the download at all. */ export const CHECKSUMS_LIMIT_BYTES = 1_048_576; @@ -212,7 +218,12 @@ export interface ReleaseSource { export function githubReleaseSource(): ReleaseSource { return { latestTag: resolveLatestTag, - checksums: (tag) => download(`${RELEASES}/download/${encodeURIComponent(tag)}/checksums.txt`), + checksums: (tag) => + downloadBounded( + `${RELEASES}/download/${encodeURIComponent(tag)}/checksums.txt`, + CHECKSUMS_LIMIT_BYTES, + "checksums.txt", + ), asset: (tag, name) => download(`${RELEASES}/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`), }; @@ -225,3 +236,66 @@ async function download(url: string): Promise { } return new Uint8Array(await response.arrayBuffer()); } + +/** + * A {@link download} whose body is refused the moment it passes `limitBytes`. + * + * Separated from {@link download} because only the digest file has a published + * size to hold it to; the archive's size is whatever upstream built. + */ +async function downloadBounded(url: string, limitBytes: number, what: string): Promise { + const response = await fetchHttps(url); + if (!response.ok) { + throw new Error(`GET ${url} answered HTTP ${response.status}`); + } + return await readBounded(response.body, limitBytes, what); +} + +/** + * A response body, read no further than `limitBytes`. + * + * The bound is enforced while the body arrives rather than once it has, which + * is the whole point of having one: `response.arrayBuffer()` allocates every + * byte before anything can look at the total, so a limit checked afterwards + * refuses a body the process has already paid for. A `content-length` + * precheck is not a substitute either -- a chunked response carries no length + * -- so the count is kept over the chunks themselves. + * + * Exported for the same reason as {@link nextHop}: the refusal is only + * reachable from a test that can hand it a body, and standing up an HTTPS + * origin that streams a megabyte is not something a unit test should need. + */ +export async function readBounded( + body: ReadableStream | null, + limitBytes: number, + what: string, +): Promise { + if (body === null) return new Uint8Array(); + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limitBytes) { + throw new Error(`${what} is over the ${limitBytes} byte safety limit`); + } + chunks.push(value); + } + } finally { + // Cancelling is what drops the connection on the refusal path; on the + // ordinary path the stream is already done and this is a no-op. + await reader.cancel().catch(() => {}); + } + + const joined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + return joined; +} diff --git a/test/unit/checksums.test.ts b/test/unit/checksums.test.ts index c52ee67..097d5e0 100644 --- a/test/unit/checksums.test.ts +++ b/test/unit/checksums.test.ts @@ -49,9 +49,14 @@ const publishedDigests: PublishedDigestCase[] = [ digest: "e2804a20f5a6fc392af361525a232703e351b7d1aacb81b88eef806eec5959fa", }, { - // Upstream publishes a `-ui-` alias carrying the same bytes. It matters only - // that the name resolves through its own line rather than a neighbour's. - scenario: "the -ui- alias for linux/arm64 portable resolves through its own line", + // Upstream publishes a `-ui-` alias for every asset, carrying the same + // bytes and therefore the same digest as the archive it aliases (fixture + // lines 5 and 22 are identical but for the name). So this case can only + // show the alias entry is there and gets selected -- a parser that resolved + // it through its neighbour's line would return the same digest and pass. + // Exactness is proven by the case above, whose digest genuinely differs + // from the name it sits beside. + scenario: "the -ui- alias for linux/arm64 portable is present and selectable", archive: "codebase-memory-mcp-ui-linux-arm64-portable.tar.gz", digest: "5697d986d9716c913163b4bff7b3a294287f3b843e993bc1ff71e78dcdc21781", }, diff --git a/test/unit/release-transport.test.ts b/test/unit/release-transport.test.ts index 8e03196..5671f76 100644 --- a/test/unit/release-transport.test.ts +++ b/test/unit/release-transport.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { fetchHttps, nextHop, tagFromLocation } from "../../src/release.ts"; +import { + CHECKSUMS_LIMIT_BYTES, + fetchHttps, + nextHop, + readBounded, + tagFromLocation, +} from "../../src/release.ts"; /** * The transport-downgrade defence and the release-tag parser. @@ -14,6 +20,9 @@ import { fetchHttps, nextHop, tagFromLocation } from "../../src/release.ts"; const HTTPS_ORIGIN = "https://github.com/DeusData/codebase-memory-mcp/releases/latest"; +/** One chunk of a streamed body, the size a real one arrives in. */ +const CHUNK_BYTES = 64 * 1024; + interface HopCase { readonly scenario: string; /** The `location` header the hop carries. */ @@ -208,3 +217,47 @@ describe("release tag resolution", () => { expect(() => tagFromLocation(status, location)).toThrow(reported); }); }); + +/** A body of `chunks` 64 KiB chunks that records how many were pulled. */ +function countedBody(chunks: number): { body: ReadableStream; pulled: () => number } { + const chunk = new Uint8Array(CHUNK_BYTES).fill(0x61); + let pulled = 0; + const body = new ReadableStream({ + pull(controller) { + pulled += 1; + if (pulled > chunks) { + controller.close(); + return; + } + controller.enqueue(chunk); + }, + }); + return { body, pulled: () => pulled }; +} + +describe("the bounded body reader", () => { + test("a body under the limit is returned whole", async () => { + const { body } = countedBody(2); + expect((await readBounded(body, CHECKSUMS_LIMIT_BYTES, "checksums.txt")).byteLength).toBe( + 2 * CHUNK_BYTES, + ); + }); + + test("an oversized body is refused without being read to the end", async () => { + // 4 MiB offered against a 128 KiB limit. Counting the pulls is what + // separates a limit enforced while the body arrives from one checked after + // `arrayBuffer()` has already allocated every byte of it -- both refuse, + // and only one of them refuses before paying. + const { body, pulled } = countedBody(64); + const limit = 2 * CHUNK_BYTES; + + await expect(readBounded(body, limit, "checksums.txt")).rejects.toThrow( + /checksums\.txt is over the 131072 byte safety limit/u, + ); + expect(pulled()).toBeLessThanOrEqual(4); + }); + + test("a response with no body at all reads as empty", async () => { + expect((await readBounded(null, CHECKSUMS_LIMIT_BYTES, "checksums.txt")).byteLength).toBe(0); + }); +}); From df41a25d6609a44eb72208a0bb7bba692f1a385c Mon Sep 17 00:00:00 2001 From: pashifika Date: Fri, 28 Aug 2026 14:09:19 +0900 Subject: [PATCH 5/9] fix(acquire): close the archive member set and commit adoption atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three verification steps did not hold the property they were written for. The closed four-member check was not closed. Each `tar -tzf` record went through `raw.trim().replace(/^\.\//, "").replace(/\/+$/, "")` and an empty result was skipped, so the real spellings `./`, `/` and a whitespace-only name all normalised to `""` and were silently dropped rather than refused — an archive carrying the four required files plus one of those passed the gate. Records are now either an exact allowlist member or its `./` spelling, or they are rejected, and the refusal quotes the name so an otherwise invisible record is identifiable. Only the terminal line delimiter is skipped. A skipped record is a hole in a closed set, which is the whole point of the check. Adoption was not transactional. `adopt` wrote and chmodded directly at the final `bin/` path with no staging and no rename, and the scratch cleanup in `finally` could reject after the executable was already in place, turning a committed adoption into a reported failure. Re-adopting the version that is currently resolved therefore rewrote the live executable, contradicting the requirement that any failure leave the previously resolved executable untouched. The candidate is now staged inside `managedBinRoot` (same filesystem, so the `rename` is atomic), a single rename is the only commit point, staging is removed on every pre-commit failure, and cleanup can no longer convert success into failure. `xattr` failures were indistinguishable from the tolerated missing-attribute case: the `RunResult` was discarded, so a non-zero exit, a timeout and a spawn failure all looked like "the attribute was not there", while the `codesign` result immediately below was checked. Only the no-such-attribute outcome is tolerated now. Also gives `buildArchive` a single staging root with an `afterAll` removal. It had no matching cleanup and leaked 25 directories per unit run; 553 had accumulated on the review machine. --- dist/index.js | 42 ++++++--- src/acquire.ts | 103 +++++++++++++++++----- test/support/release.ts | 39 +++++++- test/unit/acquire.test.ts | 181 ++++++++++++++++++++++++++++++++++---- 4 files changed, 313 insertions(+), 52 deletions(-) diff --git a/dist/index.js b/dist/index.js index 574736f..51a08b4 100644 --- a/dist/index.js +++ b/dist/index.js @@ -255,7 +255,7 @@ function schedulerFrom(ctx) { import { rm as rm2 } from "fs/promises"; // src/acquire.ts -import { chmod, lstat, mkdtemp, mkdir, rm } from "fs/promises"; +import { chmod, lstat, mkdtemp, mkdir, rename, rm } from "fs/promises"; import { tmpdir } from "os"; import path2 from "path"; @@ -431,7 +431,7 @@ async function acquire(request) { const reportedVersion = await smokeCheck(candidate, target); return await adopt(host, { version, digest: expected, candidate, reportedVersion }); } finally { - await rm(scratch, { recursive: true, force: true }); + await rm(scratch, { recursive: true, force: true }).catch(() => {}); } } async function assertArchiveMembers(archive, target) { @@ -439,14 +439,15 @@ async function assertArchiveMembers(archive, target) { if (!listed.ok) { throw new Error(`could not enumerate ${path2.basename(archive)}: ${listed.stderr.trim() || listed.spawnError || `tar exited ${listed.exitCode}`}`); } + const records = listed.stdout.split(` +`); + if (records[records.length - 1] === "") + records.pop(); const seen = new Map; - for (const raw of listed.stdout.split(` -`)) { - const member = raw.trim().replace(/^\.\//u, "").replace(/\/+$/u, ""); - if (member === "") - continue; + for (const raw of records) { + const member = raw.startsWith("./") ? raw.slice(2) : raw; if (!target.members.includes(member)) { - throw new Error(`release archive contains unexpected member: ${member}`); + throw new Error(`release archive contains unexpected member: ${JSON.stringify(raw)}`); } seen.set(member, (seen.get(member) ?? 0) + 1); } @@ -485,7 +486,10 @@ async function repairMacOsSignature(host, candidate) { throw new Error(`${tool} is required to prepare a macOS binary and is not on PATH; ` + `install the Xcode command line tools, or repair the candidate yourself with \`${repairHint(candidate)}\``); } } - await run(["xattr", "-d", "com.apple.quarantine", candidate], { timeoutMs: 1e4 }); + const cleared = await run(["xattr", "-cr", candidate], { timeoutMs: 1e4 }); + if (!cleared.ok) { + throw new Error(`could not clear the candidate's extended attributes: ${cleared.stderr.trim() || cleared.spawnError || `xattr exited ${cleared.exitCode}`}. ` + `Repair it by hand with \`${repairHint(candidate)}\``); + } const signed = await run(["codesign", "--sign", "-", "--force", candidate], { timeoutMs: 60000 }); @@ -501,11 +505,21 @@ async function smokeCheck(candidate, target) { throw new Error(`the downloaded executable failed to run \`--version\`.${suffix}`); } async function adopt(host, candidate) { - const destination = path2.join(managedBinRoot(host), candidate.version); - await mkdir(destination, { recursive: true }); - const executable = path2.join(destination, path2.basename(candidate.candidate)); - await Bun.write(executable, Bun.file(candidate.candidate)); - await chmod(executable, 493); + const binRoot = managedBinRoot(host); + const destination = path2.join(binRoot, candidate.version); + const name = path2.basename(candidate.candidate); + const executable = path2.join(destination, name); + await mkdir(binRoot, { recursive: true }); + const staging = await mkdtemp(path2.join(binRoot, ".staging-")); + try { + const staged = path2.join(staging, name); + await Bun.write(staged, Bun.file(candidate.candidate)); + await chmod(staged, 493); + await mkdir(destination, { recursive: true }); + await rename(staged, executable); + } finally { + await rm(staging, { recursive: true, force: true }).catch(() => {}); + } return { version: candidate.version, digest: candidate.digest, diff --git a/src/acquire.ts b/src/acquire.ts index f4f833b..ac5237c 100644 --- a/src/acquire.ts +++ b/src/acquire.ts @@ -1,5 +1,5 @@ import type { Stats } from "node:fs"; -import { chmod, lstat, mkdtemp, mkdir, rm } from "node:fs/promises"; +import { chmod, lstat, mkdtemp, mkdir, rename, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -121,7 +121,11 @@ export async function acquire(request: AcquireRequest): Promise { const reportedVersion = await smokeCheck(candidate, target); return await adopt(host, { version, digest: expected, candidate, reportedVersion }); } finally { - await rm(scratch, { recursive: true, force: true }); + // Swallowed rather than awaited into the result: by the time this runs the + // adoption has either committed or thrown, and letting a cleanup rejection + // replace a completed adoption's return value would report a working + // managed copy as a failed acquisition. + await rm(scratch, { recursive: true, force: true }).catch(() => {}); } } @@ -142,12 +146,26 @@ export async function assertArchiveMembers(archive: string, target: Target): Pro ); } + // `split("\n")` yields one empty trailing record for the final newline, and + // that record is the delimiter rather than a member. Every other record is + // accounted for or refused: the previous normalization ran `trim()` and + // stripped trailing slashes first, which collapsed the real tar spellings + // `./`, `/`, and a whitespace-only name to the empty string and skipped + // them -- and a record skipped here is a hole in a closed set, because it + // reaches extraction without ever having been compared to the allowlist. + const records = listed.stdout.split("\n"); + if (records[records.length - 1] === "") records.pop(); + const seen = new Map(); - for (const raw of listed.stdout.split("\n")) { - const member = raw.trim().replace(/^\.\//u, "").replace(/\/+$/u, ""); - if (member === "") continue; + for (const raw of records) { + // The `./name` spelling is the same member as `name`; nothing else is + // normalized, so a trailing slash, surrounding whitespace, or a bare `.` + // stays a name the allowlist does not hold. + const member = raw.startsWith("./") ? raw.slice(2) : raw; if (!target.members.includes(member)) { - throw new Error(`release archive contains unexpected member: ${member}`); + // Quoted, because the records this refusal exists to catch are `/`, `./` + // and a whitespace-only name, and unquoted those name nothing at all. + throw new Error(`release archive contains unexpected member: ${JSON.stringify(raw)}`); } seen.set(member, (seen.get(member) ?? 0) + 1); } @@ -206,11 +224,14 @@ function repairHint(candidate: string): string { * downloaded from the network is refused by Gatekeeper, and the resulting * failure is a kill signal with no message that explains it. Upstream's * installer does both and ignores their failures; this one reports a *missing - * tool* by name, because that is a diagnosable host problem, while tolerating a - * missing attribute, which is the normal case for an archive fetched over - * HTTPS rather than by a browser. + * tool* by name, because that is a diagnosable host problem. + * + * Exported so a test can reach the two refusals: `run` resolves `xattr` and + * `codesign` from the process's own `PATH` rather than {@link Host}'s, so + * neither can be substituted, and the argument is the only thing a test + * controls. */ -async function repairMacOsSignature(host: Host, candidate: string): Promise { +export async function repairMacOsSignature(host: Host, candidate: string): Promise { const pathEnv = host.env["PATH"]; for (const tool of ["xattr", "codesign"] as const) { if (!haveTool(tool, pathEnv)) { @@ -221,9 +242,21 @@ async function repairMacOsSignature(host: Host, candidate: string): Promise { * Places the verified candidate under the package-owned root. * * The first write outside the temporary directory happens here, after every - * check has passed. The previous version's directory is left in place: it is - * what resolution falls back to if this adoption's pointer update does not - * land, and removing it would make a failed update worse than a skipped one. + * check has passed. The copy is written and made executable inside a unique + * staging directory, and one `rename` onto the final path is the operation's + * only commit point. Writing straight to `bin//` cannot offer that: + * re-adopting the version currently resolved would rewrite the live executable, + * so a failed `chmod` behind that write would leave the previously resolved + * executable truncated or unrunnable -- and the requirement is that every + * failure leaves it in use. + * + * The staging directory sits under `bin/` rather than in `$TMPDIR` because + * `rename` is atomic only within one filesystem, and `$TMPDIR` is routinely on + * another one. `bin//` itself is created before the rename, but an + * empty directory is not a resolvable executable, so nothing can observe it as + * a half-finished adoption. + * + * The previous version's directory is left in place: it is what resolution + * falls back to if this adoption's pointer update does not land, and removing + * it would make a failed update worse than a skipped one. */ async function adopt( host: Host, candidate: { version: string; digest: string; candidate: string; reportedVersion: string }, ): Promise { - const destination = path.join(managedBinRoot(host), candidate.version); - await mkdir(destination, { recursive: true }); + const binRoot = managedBinRoot(host); + const destination = path.join(binRoot, candidate.version); + const name = path.basename(candidate.candidate); + const executable = path.join(destination, name); - const executable = path.join(destination, path.basename(candidate.candidate)); - await Bun.write(executable, Bun.file(candidate.candidate)); - await chmod(executable, 0o755); + await mkdir(binRoot, { recursive: true }); + const staging = await mkdtemp(path.join(binRoot, ".staging-")); + try { + const staged = path.join(staging, name); + await Bun.write(staged, Bun.file(candidate.candidate)); + await chmod(staged, 0o755); + await mkdir(destination, { recursive: true }); + await rename(staged, executable); + } finally { + // Before the rename this removes a candidate nothing has seen; after it, + // an empty directory. Either way a failure to remove it is not this + // operation's result -- swallowing it is what keeps a committed adoption + // from being reported as a failed one. + await rm(staging, { recursive: true, force: true }).catch(() => {}); + } return { version: candidate.version, diff --git a/test/support/release.ts b/test/support/release.ts index 8b4e3fe..9b843ad 100644 --- a/test/support/release.ts +++ b/test/support/release.ts @@ -1,3 +1,4 @@ +import { mkdtempSync, rmSync } from "node:fs"; import { chmod, mkdir, mkdtemp, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -49,9 +50,45 @@ export function releaseMembers(target: Target, version: string): Member[] { ]; } +/** + * One staging root for every archive this process builds. + * + * A `mkdtemp` per archive had no matching removal, so every case that built one + * left a `.tar.gz` and an extracted tree in `$TMPDIR` -- 25 per unit run, and + * unbounded over a machine's life. One root means the cleanup is one removal + * rather than one per case, and it means a test file that forgets + * {@link dropBuiltArchives} leaks a single directory instead of a directory per + * archive. `process.on("exit")` would have needed no caller cooperation at all, + * but `bun test` never fires it. + */ +let stagingRoot: string | undefined; + +function stagingRootOnce(): string { + if (stagingRoot === undefined) { + // Created synchronously so two concurrent builds cannot each allocate a + // root and leave one of them without an owner. + stagingRoot = mkdtempSync(path.join(tmpdir(), "cbm-archive-")); + } + return stagingRoot; +} + +/** + * Removes everything {@link buildArchive} staged, for an `afterAll`. + * + * Re-arming rather than one-shot: `bun test` evaluates this module once for the + * whole run, so each test file's `afterAll` sees a root the previous file's + * `afterAll` already removed, and the next `buildArchive` has to be able to + * allocate a fresh one. + */ +export function dropBuiltArchives(): void { + if (stagingRoot === undefined) return; + rmSync(stagingRoot, { recursive: true, force: true }); + stagingRoot = undefined; +} + /** Builds a `.tar.gz` holding exactly `members`. */ export async function buildArchive(name: string, members: readonly Member[]): Promise { - const staging = await mkdtemp(path.join(tmpdir(), "cbm-archive-")); + const staging = await mkdtemp(path.join(stagingRootOnce(), "archive-")); const content = path.join(staging, "content"); await mkdir(content, { recursive: true }); diff --git a/test/unit/acquire.test.ts b/test/unit/acquire.test.ts index afbc706..9f13b9d 100644 --- a/test/unit/acquire.test.ts +++ b/test/unit/acquire.test.ts @@ -1,13 +1,16 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { readdir } from "node:fs/promises"; +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmod, readdir, rm, stat } from "node:fs/promises"; +import path from "node:path"; -import { acquire, normalizeVersion } from "../../src/acquire.ts"; +import { acquire, normalizeVersion, repairMacOsSignature } from "../../src/acquire.ts"; import { describeTarget } from "../../src/platform.ts"; -import { managedBinRoot, packageRoot } from "../../src/paths.ts"; +import { managedBinRoot, managedExecutable, packageRoot } from "../../src/paths.ts"; import { buildArchive, + dropBuiltArchives, fakeSource, releaseMembers, + type BuiltArchive, type Member, } from "../support/release.ts"; import { dropScratch, makeScratch, type Scratch } from "../support/scratch.ts"; @@ -19,6 +22,7 @@ import { dropScratch, makeScratch, type Scratch } from "../support/scratch.ts"; */ const TARGET = describeTarget(process.platform === "darwin" ? "darwin" : "linux", "arm64"); const VERSION = "0.10.8"; +const NEWER = "0.11.0"; const TAG = `v${VERSION}`; let scratch: Scratch; @@ -32,6 +36,10 @@ afterEach(async () => { await dropScratch(scratch); }); +afterAll(() => { + dropBuiltArchives(); +}); + /** Whether anything at all exists under the root this package owns. */ async function packageRootEntries(): Promise { try { @@ -46,12 +54,13 @@ interface AbortCase { /** The archive members, which is what each failure is really about. */ readonly members: (target: typeof TARGET) => Member[]; /** - * The digest `checksums.txt` publishes. + * The digest `checksums.txt` publishes, resolved from the built archive. * - * `"real"` publishes the archive's own digest; a literal is how the mismatch - * path is reached without also corrupting the archive. + * A column rather than a sentinel the body decodes: every case but the + * mismatch publishes the archive's own digest, and a literal is how the + * mismatch path is reached without also corrupting the archive. */ - readonly publishedDigest: "real" | string; + readonly publishedDigest: (archive: BuiltArchive) => string; /** The text the refusal must name. */ readonly reported: RegExp; } @@ -60,7 +69,7 @@ const aborts: AbortCase[] = [ { scenario: "a digest mismatch aborts and names both digests", members: (target) => releaseMembers(target, VERSION), - publishedDigest: "0".repeat(64), + publishedDigest: () => "0".repeat(64), reported: /SHA-256 mismatch for .*: published 0{64}, downloaded [0-9a-f]{64}/u, }, { @@ -69,19 +78,19 @@ const aborts: AbortCase[] = [ ...releaseMembers(target, VERSION), { name: "postinstall.sh", contents: "#!/bin/sh\nexit 0\n", mode: 0o755 }, ], - publishedDigest: "real", - reported: /unexpected member: postinstall\.sh/u, + publishedDigest: (archive) => archive.digest, + reported: /unexpected member: "postinstall\.sh"/u, }, { scenario: "a duplicated archive member aborts rather than last-one-wins", members: (target) => [...releaseMembers(target, VERSION), { name: "LICENSE", contents: "MIT\n" }], - publishedDigest: "real", + publishedDigest: (archive) => archive.digest, reported: /contains member LICENSE 2 times/u, }, { scenario: "a missing archive member aborts", members: (target) => releaseMembers(target, VERSION).filter((m) => m.name !== "LICENSE"), - publishedDigest: "real", + publishedDigest: (archive) => archive.digest, reported: /missing member: LICENSE/u, }, { @@ -90,7 +99,7 @@ const aborts: AbortCase[] = [ ...releaseMembers(target, VERSION).filter((m) => m.name !== target.executable), { name: target.executable, symlinkTo: "/bin/sh" }, ], - publishedDigest: "real", + publishedDigest: (archive) => archive.digest, reported: /not a regular file: codebase-memory-mcp/u, }, { @@ -99,9 +108,20 @@ const aborts: AbortCase[] = [ ...releaseMembers(target, VERSION).filter((m) => m.name !== target.executable), { name: target.executable, contents: "#!/bin/sh\nexit 3\n", mode: 0o755 }, ], - publishedDigest: "real", + publishedDigest: (archive) => archive.digest, reported: /failed to run `--version`/u, }, + { + // `./`, `/`, and a whitespace-only name are all real tar spellings, and all + // three collapsed to the empty string under the previous normalization and + // were skipped -- which let an archive holding a fifth member satisfy a + // four-member closed set. This is the one of the three a real file on disk + // can be made to produce. + scenario: "a member whose name is only whitespace is refused rather than skipped", + members: (target) => [...releaseMembers(target, VERSION), { name: " ", contents: "ws\n" }], + publishedDigest: (archive) => archive.digest, + reported: /unexpected member: " "/u, + }, ]; test("every case names itself distinctly", () => { @@ -116,7 +136,7 @@ describe("a failed acquisition writes nothing under the package-owned root", () tag: TAG, archiveName: TARGET.archive, bytes: archive.bytes, - publishedDigest: publishedDigest === "real" ? archive.digest : publishedDigest, + publishedDigest: publishedDigest(archive), }); const attempt = acquire({ host: scratch.host, target: TARGET, source }); @@ -158,6 +178,135 @@ describe("a verified acquisition", () => { expect((await acquire({ host: scratch.host, target: TARGET, source })).version).toBe(VERSION); }); + + test("the ./ spelling every tar writes for a member is accepted", async () => { + const members = releaseMembers(TARGET, VERSION).map((member) => ({ + ...member, + name: `./${member.name}`, + })); + const archive = await buildArchive(TARGET.archive, members); + const source = fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: archive.digest, + }); + + const acquired = await acquire({ host: scratch.host, target: TARGET, source }); + + expect(acquired.version).toBe(VERSION); + expect(await Bun.file(acquired.executable).exists()).toBe(true); + }); + + test("adopting a newer version leaves the previous version's executable in place", async () => { + const first = await buildArchive(TARGET.archive, releaseMembers(TARGET, VERSION)); + await acquire({ + host: scratch.host, + target: TARGET, + source: fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: first.bytes, + publishedDigest: first.digest, + }), + }); + + const second = await buildArchive(TARGET.archive, releaseMembers(TARGET, NEWER)); + const acquired = await acquire({ + host: scratch.host, + target: TARGET, + source: fakeSource({ + tag: `v${NEWER}`, + archiveName: TARGET.archive, + bytes: second.bytes, + publishedDigest: second.digest, + }), + }); + + expect(acquired.version).toBe(NEWER); + expect(await Bun.file(managedExecutable(scratch.host, NEWER)).exists()).toBe(true); + // The previous version is what resolution falls back to if the pointer + // update does not land, so adopting over it is a worse failure than not + // adopting at all. + expect(await Bun.file(managedExecutable(scratch.host, VERSION)).exists()).toBe(true); + }); + + test("re-adopting a version replaces its executable instead of rewriting it in place", async () => { + const archive = await buildArchive(TARGET.archive, releaseMembers(TARGET, VERSION)); + const source = fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: archive.digest, + }); + + const first = await acquire({ host: scratch.host, target: TARGET, source }); + const before = await stat(first.executable); + const second = await acquire({ host: scratch.host, target: TARGET, source }); + const after = await stat(second.executable); + + // A different inode is the observable difference between "staged, then + // committed by one rename" and "written straight onto the live path". Only + // the first leaves the previously resolved executable whole when the write + // or the chmod behind it fails. + expect(after.ino).not.toBe(before.ino); + expect(after.mode & 0o777).toBe(0o755); + }); + + test("a scratch directory that cannot be removed does not fail a committed adoption", async () => { + // The smoke check runs the candidate, which is the only place a test can + // reach acquisition's own temporary directory. The candidate reports its + // directory and then makes it unremovable, so the `finally` cleanup fails + // after the executable has already been adopted. + const marker = path.join(scratch.root, "candidate-dir"); + const members: Member[] = [ + { + name: TARGET.executable, + mode: 0o755, + contents: + `#!/bin/sh\necho "codebase-memory-mcp ${VERSION}"\n` + + `dir=$(dirname "$0")\nprintf '%s' "$dir" > "${marker}"\nchmod 0500 "$dir"\n`, + }, + ...releaseMembers(TARGET, VERSION).filter((member) => member.name !== TARGET.executable), + ]; + const archive = await buildArchive(TARGET.archive, members); + const source = fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: archive.digest, + }); + + try { + const acquired = await acquire({ host: scratch.host, target: TARGET, source }); + expect(await Bun.file(acquired.executable).exists()).toBe(true); + } finally { + if (await Bun.file(marker).exists()) { + const locked = await Bun.file(marker).text(); + await chmod(locked, 0o700); + await rm(locked, { recursive: true, force: true }); + } + } + }); +}); + +/** + * `xattr` and `codesign` exist only on macOS, and `run` resolves them from the + * process's own `PATH` rather than the scratch one, so this pair cannot be + * substituted. A path that does not exist is what makes both tools fail for a + * reason the test controls. + */ +describe.skipIf(process.platform !== "darwin")("the macOS repair step", () => { + test("a failed quarantine removal is refused rather than read as a missing attribute", async () => { + const missing = path.join(scratch.root, "not-extracted", "codebase-memory-mcp"); + + // Named `xattr`, not `codesign`: before the fix the discarded `xattr` + // result let the failure surface two steps later as a signing failure, + // which reports the wrong cause and only by accident reports at all. + await expect(repairMacOsSignature(scratch.host, missing)).rejects.toThrow( + /could not clear the candidate's extended attributes/u, + ); + }); }); interface VersionCase { From 8e9f69c3fa12258ee8dc75809058ba60c01452f0 Mon Sep 17 00:00:00 2001 From: pashifika Date: Fri, 28 Aug 2026 14:09:57 +0900 Subject: [PATCH 6/9] fix(config): write the operator's files durably and own them by path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This package writes exactly one key into a file it does not own, so every failure mode here costs the operator configuration they did not ask us to touch. Several of them did. `mcp.json` and `state.json` were written with `Bun.write` straight at the destination, which keeps the inode: `open(O_TRUNC)` plus write, not temp plus rename. OMP writes the very same file atomically and says why, and OMP's reader drops the whole file silently on a parse failure — so an interrupted write cost the operator every user-level MCP server with no warning, while this package then refused the file forever as unparseable. The design's risk section promises the two-writer race degrades to "the entry is missing", which a truncated file is not. Both writers now stage a per-writer temp file in the same directory and rename it into place. The staging file is chmodded to the destination's own mode first, defaulting to 0600 only when there is no destination: `rename` replaces rather than truncates, so without that step a file OMP had written 0600 came back 0644, and that file can carry per-server `env` secrets and `auth.clientSecret`. Ownership of the entry was decided by exact string match against what state recorded, so an entry whose `command` is provably under `managedBinRoot` — a path only this package ever writes — was reported to the operator as somebody else's, and session start never corrected it. Reachable with no hand editing, because a truncated `state.json` falls back to empty state. Ownership is now decidable from the path as well, via `path.relative` containment rather than a prefix match, so a lookalike sibling stays foreign. Idempotence was decided on re-rendered bytes, so a compact or CRLF file whose entry was already correct came back "updated" and was rewritten reformatted, failing "re-running with no change does not rewrite the file" and needlessly re-exposing the write. It is decided on the parsed entry now. `uninstall` deleted the managed executable even when it had just refused to remove the entry naming it, leaving OMP spawning a deleted file with the state that could reclaim the key gone too — unrecoverable without a hand edit. It now keeps both halves when the refusal would strand the entry, and only when it would: a refusal for an entry legitimately pointing at a system CBM lets the copy go, so the command is not blocked by an entry it never owned. Smaller, same theme: a `mcp.json` that exists but cannot be read was reported as absent instead of as a structural refusal naming the errno; a non-object `mcpServers` was silently discarded and overwritten rather than failing closed; a future-dated `lastCheckedAt` suppressed the upstream check for the whole clock skew; `install ` recorded the requested version as the newest upstream one and suppressed the real check for 24 hours; and uninstall left behind an empty `mcp.json` the operator never had. Tests: `state.ts` had no test file at all, and its sanitisation layer — the only thing between a corrupted `state.json` and a recorded value joined straight into a path — could be deleted with the suite green. Three more mutants that survived are now each caught by one named test: `install()` deleting the package root on failure, `syncEntry` collapsing `rewired` into `unchanged`, and `adopt()` wiping previous version directories. The `~/.local/bin` proof was a bare `rejects.toThrow()` that any rejection satisfied, including the path existing as a regular file. `resolve.ts` is unchanged apart from a comment: resolution deliberately does not execute the candidate, because running an unknown binary at every session start is worse than adopting it, and `/cbm status` reads the version separately. A test pins that contract. --- dist/index.js | 118 ++++++++++++--- src/lifecycle.ts | 71 ++++++++- src/mcp-config.ts | 156 +++++++++++++++++--- src/paths.ts | 18 +++ src/resolve.ts | 7 + src/state.ts | 37 ++++- test/unit/lifecycle.test.ts | 198 ++++++++++++++++++++++++- test/unit/mcp-config.test.ts | 275 ++++++++++++++++++++++++++++++++++- test/unit/paths.test.ts | 27 ++-- test/unit/resolve.test.ts | 20 +++ test/unit/state.test.ts | 196 +++++++++++++++++++++++++ 11 files changed, 1056 insertions(+), 67 deletions(-) create mode 100644 test/unit/state.test.ts diff --git a/dist/index.js b/dist/index.js index 51a08b4..e265b5d 100644 --- a/dist/index.js +++ b/dist/index.js @@ -94,6 +94,12 @@ function managedBinRoot(host) { function managedExecutable(host, version) { return path.join(managedBinRoot(host), version, EXECUTABLE_NAME); } +function insideManagedBinRoot(host, candidate) { + if (!path.isAbsolute(candidate)) + return false; + const inside = path.relative(managedBinRoot(host), candidate); + return inside !== "" && !inside.startsWith("..") && !path.isAbsolute(inside); +} function statePath(host) { return path.join(packageRoot(host), "state.json"); } @@ -252,7 +258,7 @@ function schedulerFrom(ctx) { } // src/lifecycle.ts -import { rm as rm2 } from "fs/promises"; +import { rm as rm4 } from "fs/promises"; // src/acquire.ts import { chmod, lstat, mkdtemp, mkdir, rename, rm } from "fs/promises"; @@ -529,7 +535,8 @@ async function adopt(host, candidate) { } // src/mcp-config.ts -import { mkdir as mkdir2 } from "fs/promises"; +import { randomUUID } from "crypto"; +import { chmod as chmod2, mkdir as mkdir2, rename as rename2, rm as rm2, stat } from "fs/promises"; import path3 from "path"; var MCP_SCHEMA_URL = "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json"; async function readMcpFile(host) { @@ -537,7 +544,14 @@ async function readMcpFile(host) { let text; try { text = await Bun.file(file).text(); - } catch { + } catch (error) { + const code = error?.code; + if (code !== "ENOENT" && code !== "ENOTDIR") { + return { + ok: false, + reason: `${file} could not be read (${code ?? "no errno"}), so it was left untouched: ` + `${error instanceof Error ? error.message : String(error)}` + }; + } return { ok: true, file: { path: file, text: null, document: {}, indent: " ", trailingNewline: true } @@ -555,6 +569,14 @@ async function readMcpFile(host) { if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { return { ok: false, reason: `${file} does not hold a JSON object, so it was left untouched` }; } + const servers = parsed["mcpServers"]; + const shaped = typeof servers === "object" && servers !== null && !Array.isArray(servers); + if (servers !== undefined && !shaped) { + return { + ok: false, + reason: `${file} holds an mcpServers value that is not a JSON object, so it was left untouched` + }; + } return { ok: true, file: { @@ -576,13 +598,15 @@ async function upsertEntry(host, command, previouslyWrote) { const existing = servers[SERVER_NAME]; if (existing !== undefined) { const currentCommand = commandOf(existing); - const ours = currentCommand === command || currentCommand === previouslyWrote; + const ours = currentCommand === command || currentCommand === previouslyWrote || currentCommand !== undefined && insideManagedBinRoot(host, currentCommand); if (!ours) { return { ok: false, reason: `${file.path} already defines ${SERVER_NAME} with command ${currentCommand ?? "(none)"}, ` + `which this package did not write. It was left untouched; the executable this package resolved is ${command}.` }; } + if (isCurrentEntry(existing, command)) + return { ok: true, change: "unchanged" }; } const next = { ...file.document }; if (file.text === null) @@ -594,11 +618,8 @@ async function upsertEntry(host, command, previouslyWrote) { args: [] }; next["mcpServers"] = { ...servers, [SERVER_NAME]: entry }; - const rendered = render(next, file); - if (file.text === rendered) - return { ok: true, change: "unchanged" }; await mkdir2(path3.dirname(file.path), { recursive: true }); - await Bun.write(file.path, rendered); + await writeDurably(file.path, render(next, file)); return { ok: true, change: file.text === null ? "created" : "updated" }; } async function removeEntry(host, wroteCommand) { @@ -613,7 +634,8 @@ async function removeEntry(host, wroteCommand) { if (existing === undefined) return { ok: true, change: "absent" }; const currentCommand = commandOf(existing); - if (wroteCommand === undefined || currentCommand !== wroteCommand) { + const ours = wroteCommand !== undefined && currentCommand === wroteCommand || currentCommand !== undefined && insideManagedBinRoot(host, currentCommand); + if (!ours) { return { ok: false, reason: `${file.path} defines ${SERVER_NAME} with command ${currentCommand ?? "(none)"}, ` + `which is not what this package wrote (${wroteCommand ?? "nothing recorded"}). It was left in place.` @@ -621,8 +643,14 @@ async function removeEntry(host, wroteCommand) { } const remaining = { ...servers }; delete remaining[SERVER_NAME]; + const others = Object.keys(file.document).filter((key) => key !== "mcpServers"); + const ourCreation = others.length === 1 && others[0] === "$schema" && file.document["$schema"] === MCP_SCHEMA_URL; + if (Object.keys(remaining).length === 0 && ourCreation) { + await rm2(file.path, { force: true }); + return { ok: true, change: "removed" }; + } const next = { ...file.document, mcpServers: remaining }; - await Bun.write(file.path, render(next, file)); + await writeDurably(file.path, render(next, file)); return { ok: true, change: "removed" }; } async function entryStatus(host, resolvedCommand) { @@ -646,6 +674,13 @@ function serverMap(document) { const servers = document["mcpServers"]; return typeof servers === "object" && servers !== null && !Array.isArray(servers) ? servers : {}; } +function isCurrentEntry(entry, command) { + if (commandOf(entry) !== command) + return false; + const record = entry; + const args = record["args"]; + return record["type"] === "stdio" && Array.isArray(args) && args.length === 0; +} function commandOf(entry) { if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return; @@ -661,9 +696,29 @@ function render(document, file) { return file.trailingNewline ? `${body} ` : body; } +async function writeDurably(file, contents) { + const staging = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + let mode = 384; + try { + mode = (await stat(file)).mode & 511; + } catch (error) { + const code = error?.code; + if (code !== "ENOENT") + throw error; + } + await Bun.write(staging, contents); + await chmod2(staging, mode); + await rename2(staging, file); + } catch (error) { + await rm2(staging, { force: true }); + throw error; + } +} // src/state.ts -import { mkdir as mkdir3 } from "fs/promises"; +import { randomUUID as randomUUID2 } from "crypto"; +import { chmod as chmod3, mkdir as mkdir3, rename as rename3, rm as rm3, stat as stat2 } from "fs/promises"; import path4 from "path"; var EMPTY = {}; async function readState(host) { @@ -698,8 +753,24 @@ async function readState(host) { async function writeState(host, next) { const file = statePath(host); await mkdir3(path4.dirname(file), { recursive: true }); - await Bun.write(file, `${JSON.stringify(next, null, 2)} + const staging = `${file}.${process.pid}.${randomUUID2()}.tmp`; + try { + let mode = 384; + try { + mode = (await stat2(file)).mode & 511; + } catch (error) { + const code = error?.code; + if (code !== "ENOENT") + throw error; + } + await Bun.write(staging, `${JSON.stringify(next, null, 2)} `); + await chmod3(staging, mode); + await rename3(staging, file); + } catch (error) { + await rm3(staging, { force: true }); + throw error; + } } async function updateState(host, patch) { const next = { ...await readState(host), ...patch }; @@ -826,8 +897,7 @@ async function install(lifecycle, version) { const state = await updateState(host, { managedVersion: acquired.version, managedDigest: acquired.digest, - upstreamVersion: acquired.version, - lastCheckedAt: Date.now() + ...version === undefined ? { upstreamVersion: acquired.version, lastCheckedAt: Date.now() } : {} }); const resolution = await resolveExecutable(host, state); if (!resolution.ok) { @@ -884,10 +954,16 @@ async function uninstall(lifecycle) { const { host } = lifecycle; const state = await readState(host); const removal = await removeEntry(host, state.wroteCommand); + if (!removal.ok && await wouldDangle(host)) { + return { + ok: false, + message: `left the MCP entry alone: ${removal.reason} The managed copy and this package's state were ` + "kept with it, so nothing is left naming a file this command deleted. Resolve that, then run " + "/cbm uninstall again." + }; + } const entryMessage = removal.ok ? removal.change === "removed" ? "removed the owned MCP entry" : "there was no owned MCP entry to remove" : `left the MCP entry alone: ${removal.reason}`; const root = packageRoot(host); const managed = await managedCopy(host, state); - await rm2(root, { recursive: true, force: true }); + await rm4(root, { recursive: true, force: true }); const copyMessage = managed === null ? "no managed copy was present" : `removed the managed copy of ${managed.version}`; const systemNote = await systemStillPresent(host); return { ok: true, message: `${copyMessage}, ${entryMessage}, and deleted ${root}.${systemNote}` }; @@ -914,11 +990,11 @@ async function checkUpstream(lifecycle, options = {}) { const { host } = lifecycle; const now = options.now ?? Date.now(); const state = await readState(host); - const last = state.lastCheckedAt; - if (options.force !== true && last !== undefined && now - last < CHECK_INTERVAL_MS) { + const age = state.lastCheckedAt === undefined ? undefined : now - state.lastCheckedAt; + if (options.force !== true && age !== undefined && age >= 0 && age < CHECK_INTERVAL_MS) { return { kind: "skipped", - message: `upstream was last checked ${Math.round((now - last) / 60000)} minutes ago; skipping.` + message: `upstream was last checked ${Math.round(age / 60000)} minutes ago; skipping.` }; } let upstream; @@ -959,6 +1035,12 @@ async function wire(lifecycle, resolved, state) { return { ok: true, message: `The MCP entry already names ${resolved.executable}.` }; } } +async function wouldDangle(host) { + const entry = await entryStatus(host, null); + if (entry.problem !== undefined) + return true; + return entry.command !== undefined && insideManagedBinRoot(host, entry.command); +} async function systemStillPresent(host) { const resolution = await resolveExecutable(host, {}); return resolution.ok && resolution.resolved.source === "system" ? ` The system installation at ${resolution.resolved.executable} was left in place.` : ""; diff --git a/src/lifecycle.ts b/src/lifecycle.ts index a15dd0f..60bca3a 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -1,7 +1,7 @@ import { rm } from "node:fs/promises"; import { acquire, normalizeVersion, type Acquired } from "./acquire.ts"; -import { agentDir, packageRoot, type Host } from "./paths.ts"; +import { agentDir, insideManagedBinRoot, packageRoot, type Host } from "./paths.ts"; import { entryStatus, removeEntry, upsertEntry } from "./mcp-config.ts"; import { resolvedVersion, managedCopy, resolveExecutable, type Resolved } from "./resolve.ts"; import { readState, updateState, writeState, type State } from "./state.ts"; @@ -192,11 +192,16 @@ export async function install(lifecycle: Lifecycle, version?: string): Promise { const state = await readState(host); const removal = await removeEntry(host, state.wroteCommand); + if (!removal.ok && (await wouldDangle(host))) { + // Only a refusal whose entry names something this command is about to + // delete makes deleting it harmful: OMP would spawn the removed path at + // every session start, and the same `rm` takes the state that says the + // entry was ever this package's, so the key could never be reclaimed + // either. Keeping both halves is recoverable; keeping only the entry is not. + return { + ok: false, + message: + `left the MCP entry alone: ${removal.reason} The managed copy and this package's state were ` + + "kept with it, so nothing is left naming a file this command deleted. Resolve that, then run " + + "/cbm uninstall again.", + }; + } + const entryMessage = removal.ok ? removal.change === "removed" ? "removed the owned MCP entry" @@ -361,11 +381,16 @@ export async function checkUpstream( const now = options.now ?? Date.now(); const state = await readState(host); - const last = state.lastCheckedAt; - if (options.force !== true && last !== undefined && now - last < CHECK_INTERVAL_MS) { + // Bounded on both sides, because a recorded time in the future is not "under + // 24 hours old": a backwards clock correction -- a restored VM snapshot, an + // NTP step, a host that booted with a bad RTC -- makes the age negative, and a + // one-sided test would then suppress the daily check for the whole skew and + // report a negative age to the operator. + const age = state.lastCheckedAt === undefined ? undefined : now - state.lastCheckedAt; + if (options.force !== true && age !== undefined && age >= 0 && age < CHECK_INTERVAL_MS) { return { kind: "skipped", - message: `upstream was last checked ${Math.round((now - last) / 60_000)} minutes ago; skipping.`, + message: `upstream was last checked ${Math.round(age / 60_000)} minutes ago; skipping.`, }; } @@ -432,6 +457,40 @@ async function wire( } } +/** + * Whether removing the package-owned root would leave the owned entry naming a + * file that no longer exists. + * + * Asked only when {@link removeEntry} refused, and it is what separates the two + * kinds of refusal. An entry inside the managed bin root -- or a file this + * package could not read structurally, where it cannot know what the entry + * names -- is the dangerous kind. An entry naming a system CBM is not: that + * entry is correctly wired to an executable uninstall never touches, so + * blocking on it would make the managed copy unremovable by the one command + * whose job is removing it. + * + * The managed-root half is unreachable through {@link removeEntry} as it stands: + * ownership there is decided from the path as well as from recorded state, so a + * readable entry naming something under the managed root is this package's and + * is removed rather than refused. Every refusal that reaches here today is a + * file-level one and answers on `problem`. + * + * It is kept anyway, because this predicate states the property it is named for + * rather than a fact about how `removeEntry` currently decides ownership. + * Dropping it would make uninstall's safety depend on an invariant that lives in + * another function, and a later narrowing of that ownership rule would silently + * reintroduce the dangling entry this guard exists to prevent -- a failure whose + * cost is a deleted executable OMP keeps spawning and a key that can never be + * reclaimed. There is deliberately no test reaching this half: one could only be + * written by contriving a refusal `removeEntry` does not produce, which would + * assert the scaffolding rather than the property. + */ +async function wouldDangle(host: Host): Promise { + const entry = await entryStatus(host, null); + if (entry.problem !== undefined) return true; + return entry.command !== undefined && insideManagedBinRoot(host, entry.command); +} + /** A note naming an adopted system executable uninstall deliberately left alone. */ async function systemStillPresent(host: Host): Promise { const resolution = await resolveExecutable(host, {}); diff --git a/src/mcp-config.ts b/src/mcp-config.ts index 88a9274..3199217 100644 --- a/src/mcp-config.ts +++ b/src/mcp-config.ts @@ -1,7 +1,8 @@ -import { mkdir } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { chmod, mkdir, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; -import { mcpConfigPath, SERVER_NAME, type Host } from "./paths.ts"; +import { insideManagedBinRoot, mcpConfigPath, SERVER_NAME, type Host } from "./paths.ts"; /** * Sole ownership of one key in OMP's native user MCP configuration. @@ -72,13 +73,29 @@ export interface EntryStatus { * creates it. An unparseable file is refused rather than replaced: a hand edit * in progress, or another writer's partial write, is not something to resolve * by discarding the operator's content. + * + * A file that exists and cannot be read is refused for the same reason. Only + * `ENOENT` and `ENOTDIR` mean "there is no file here"; every other errno -- + * `EACCES` after a `sudo omp`, `EISDIR`, an I/O error -- means the content is + * unknown, and reporting that as an absent entry would tell the operator the + * entry is missing from a file this package could not open, and then write a + * fresh document over whatever was in it. */ export async function readMcpFile(host: Host): Promise { const file = mcpConfigPath(host); let text: string; try { text = await Bun.file(file).text(); - } catch { + } catch (error) { + const code = (error as { code?: string } | null | undefined)?.code; + if (code !== "ENOENT" && code !== "ENOTDIR") { + return { + ok: false, + reason: + `${file} could not be read (${code ?? "no errno"}), so it was left untouched: ` + + `${error instanceof Error ? error.message : String(error)}`, + }; + } return { ok: true, file: { path: file, text: null, document: {}, indent: " ", trailingNewline: true }, @@ -98,6 +115,20 @@ export async function readMcpFile(host: Host): Promise { return { ok: false, reason: `${file} does not hold a JSON object, so it was left untouched` }; } + // Refused rather than replaced by an empty map, which is the one malformed + // shape this reader used to recognise by discarding: the write below spreads + // the map it is given, so an `mcpServers` array or scalar would be silently + // deleted by the code path whose entire purpose is refusing to overwrite + // operator content. + const servers = (parsed as Record)["mcpServers"]; + const shaped = typeof servers === "object" && servers !== null && !Array.isArray(servers); + if (servers !== undefined && !shaped) { + return { + ok: false, + reason: `${file} holds an mcpServers value that is not a JSON object, so it was left untouched`, + }; + } + return { ok: true, file: { @@ -114,8 +145,14 @@ export async function readMcpFile(host: Host): Promise { * Upserts the owned entry so it names `command`. * * `previouslyWrote` is the `command` this package last wrote, from its own - * state. It is what makes ownership decidable: an entry naming that value, or - * naming the currently resolved executable, is this package's to update. + * state. It is one of the two things that make ownership decidable: an entry + * naming that value, or naming the currently resolved executable, is this + * package's to update. The other is the path itself -- an entry whose `command` + * is inside this package's own managed bin root can only have been written by + * this package, and deciding on state alone would report it as somebody else's + * whenever the state file was lost, which is the one case the self-correcting + * session-start check exists for. + * * Anything else belongs to CBM's own installer, another tool, or a hand edit, * and is left alone with both paths reported -- silently replacing it would * discard a working configuration and hide that two owners exist. @@ -134,7 +171,10 @@ export async function upsertEntry( if (existing !== undefined) { const currentCommand = commandOf(existing); - const ours = currentCommand === command || currentCommand === previouslyWrote; + const ours = + currentCommand === command || + currentCommand === previouslyWrote || + (currentCommand !== undefined && insideManagedBinRoot(host, currentCommand)); if (!ours) { return { ok: false, @@ -143,6 +183,13 @@ export async function upsertEntry( `which this package did not write. It was left untouched; the executable this package resolved is ${command}.`, }; } + + // The no-op is decided on the parsed entry, never on re-rendered bytes: a + // file this package did not format -- compact, CRLF, mixed indentation -- + // never equals its own rendering, so comparing bytes would reformat the + // operator's file on a run that had nothing to change, and expose it to a + // needless write. + if (isCurrentEntry(existing, command)) return { ok: true, change: "unchanged" }; } const next: Record = { ...file.document }; @@ -160,21 +207,23 @@ export async function upsertEntry( }; next["mcpServers"] = { ...servers, [SERVER_NAME]: entry }; - const rendered = render(next, file); - if (file.text === rendered) return { ok: true, change: "unchanged" }; - await mkdir(path.dirname(file.path), { recursive: true }); - await Bun.write(file.path, rendered); + await writeDurably(file.path, render(next, file)); return { ok: true, change: file.text === null ? "created" : "updated" }; } /** - * Removes the owned entry, and only when it still names what was written. + * Removes the owned entry, and only when it is still decidably this package's. + * + * Ownership is the same two-sided test the upsert uses: the recorded write, or + * a `command` inside this package's own managed bin root. Deciding on the + * recorded write alone made an entry naming a path only this package writes + * permanently unreclaimable once the state file was gone. * * The file itself stays in place whenever anything else remains in it -- other - * servers, a `disabledServers` list, an `$schema` line. Deleting a file this - * package did not create in order to take back one key would remove - * configuration it never owned. + * servers, a `disabledServers` list, an `$schema` value this package did not + * write. Deleting a file this package did not create in order to take back one + * key would remove configuration it never owned. */ export async function removeEntry( host: Host, @@ -191,7 +240,10 @@ export async function removeEntry( if (existing === undefined) return { ok: true, change: "absent" }; const currentCommand = commandOf(existing); - if (wroteCommand === undefined || currentCommand !== wroteCommand) { + const ours = + (wroteCommand !== undefined && currentCommand === wroteCommand) || + (currentCommand !== undefined && insideManagedBinRoot(host, currentCommand)); + if (!ours) { return { ok: false, reason: @@ -203,8 +255,20 @@ export async function removeEntry( const remaining: Record = { ...servers }; delete remaining[SERVER_NAME]; + // A file whose only other key is the `$schema` this package writes on create + // is a file this package created, so taking back its last key means removing + // it: rolling back is supposed to return the machine to its pre-install state, + // and an empty `{"mcpServers": {}}` the operator never had is not that. + const others = Object.keys(file.document).filter((key) => key !== "mcpServers"); + const ourCreation = + others.length === 1 && others[0] === "$schema" && file.document["$schema"] === MCP_SCHEMA_URL; + if (Object.keys(remaining).length === 0 && ourCreation) { + await rm(file.path, { force: true }); + return { ok: true, change: "removed" }; + } + const next: Record = { ...file.document, mcpServers: remaining }; - await Bun.write(file.path, render(next, file)); + await writeDurably(file.path, render(next, file)); return { ok: true, change: "removed" }; } @@ -229,7 +293,13 @@ export async function entryStatus(host: Host, resolvedCommand: string | null): P }; } -/** The `mcpServers` map, or an empty one when the file has no usable map. */ +/** + * The `mcpServers` map, or an empty one when the file has none. + * + * The shape check is what narrows the type; {@link readMcpFile} has already + * refused every value but a map and `undefined`, so the fallback is only the + * missing key. + */ function serverMap(document: Record): Record { const servers = document["mcpServers"]; return typeof servers === "object" && servers !== null && !Array.isArray(servers) @@ -237,6 +307,14 @@ function serverMap(document: Record): Record { : {}; } +/** Whether the existing entry already says exactly what a write would say. */ +function isCurrentEntry(entry: unknown, command: string): boolean { + if (commandOf(entry) !== command) return false; + const record = entry as Record; + const args = record["args"]; + return record["type"] === "stdio" && Array.isArray(args) && args.length === 0; +} + /** The `command` an entry names, when it names a usable one. */ function commandOf(entry: unknown): string | undefined { if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return undefined; @@ -261,3 +339,47 @@ function render(document: Record, file: McpFile): string { const body = JSON.stringify(document, null, file.indent); return file.trailingNewline ? `${body}\n` : body; } + +/** + * Replaces `file` by staging the new content beside it and renaming it in. + * + * `Bun.write` to an existing path truncates it in place, so an interrupted + * write -- a crash, a `SIGKILL` at shutdown, `ENOSPC` -- leaves a half-written + * document. OMP reads this file with a bare `JSON.parse` and drops the whole + * document when it fails, so that window costs the operator every user-level + * MCP server with no warning, and this package then refuses the file forever as + * unparseable. `rename` within one directory is atomic: a reader sees either + * the old document or the new one. + * + * The staging name carries the pid and a random suffix so two concurrent + * writers never share it, which is what OMP's own writer does for the same + * file. It is removed on failure so a failed write leaves nothing behind. + * + * The destination's own mode is reproduced before the rename, because `rename` + * replaces the destination rather than truncating it: the visible file would + * otherwise inherit the staging file's default, which is 0644 under the usual + * umask. This file can carry per-server `env` secrets, remote `headers` and an + * `auth.clientSecret`, and OMP's own writer creates it 0600 -- so any file last + * written by `/mcp add|enable|disable` is 0600 on disk, and publishing it 0644 + * would be a disclosure. A mode the operator widened deliberately is reproduced + * too, not narrowed; only a file this write creates gets 0600 by default. + */ +async function writeDurably(file: string, contents: string): Promise { + const staging = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + let mode = 0o600; + try { + mode = (await stat(file)).mode & 0o777; + } catch (error) { + const code = (error as { code?: string } | null | undefined)?.code; + if (code !== "ENOENT") throw error; + } + + await Bun.write(staging, contents); + await chmod(staging, mode); + await rename(staging, file); + } catch (error) { + await rm(staging, { force: true }); + throw error; + } +} diff --git a/src/paths.ts b/src/paths.ts index dc2c09a..02b9061 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -105,6 +105,24 @@ export function managedExecutable(host: Host, version: string): string { return path.join(managedBinRoot(host), version, EXECUTABLE_NAME); } +/** + * Whether `candidate` names a file under {@link managedBinRoot}. + * + * The one path predicate that decides ownership: nothing but this package ever + * writes under that root, so a `command` inside it is decidably this package's + * own even after the state that recorded it was lost. `path.relative` rather + * than a `startsWith` on the raw string, because a sibling directory whose name + * merely shares the prefix -- `bin-backup`, `bin.old` -- passes the string test + * and is not inside the root; adopting one would be exactly the silent + * overwrite the ownership test exists to prevent. A relative `command` is never + * ours: every path this package writes is absolute. + */ +export function insideManagedBinRoot(host: Host, candidate: string): boolean { + if (!path.isAbsolute(candidate)) return false; + const inside = path.relative(managedBinRoot(host), candidate); + return inside !== "" && !inside.startsWith("..") && !path.isAbsolute(inside); +} + /** This package's state file: source, version, digest, pin, last check. */ export function statePath(host: Host): string { return path.join(packageRoot(host), "state.json"); diff --git a/src/resolve.ts b/src/resolve.ts index a46a86f..c1c87c5 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -83,6 +83,13 @@ export async function managedCopy(host: Host, state?: State): Promise { const current = state ?? (await readState(host)); diff --git a/src/state.ts b/src/state.ts index ac9ddaf..ef56729 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,4 +1,5 @@ -import { mkdir } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { chmod, mkdir, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; import { statePath, type Host } from "./paths.ts"; @@ -73,11 +74,43 @@ export async function readState(host: Host): Promise { * Whole-document rather than field-wise because the document is small and one * writer owns it; a partial update would need a lock this package does not * have. Callers read, spread, and write. + * + * Staged beside the file and renamed in, rather than truncated in place: a + * write interrupted by a crash or a `SIGKILL` at shutdown leaves a document + * that no longer parses, and this file degrades silently -- the reader above + * falls back to the empty state, which forgets the operator's pin and the + * `wroteCommand` that decides whether the MCP entry is this package's to take + * back. `rename` within one directory is atomic, so a reader sees either the + * old document or the new one. The staging name carries the pid and a random + * suffix so two writers never share it. + * + * The destination's own mode is reproduced before the rename, because `rename` + * replaces the destination rather than truncating it and the visible file would + * otherwise inherit the staging file's default. Nothing recorded here is a + * secret, but a package-private cache is not the operator's to have widened by a + * write they did not ask for; a file this write creates gets 0600. */ export async function writeState(host: Host, next: State): Promise { const file = statePath(host); await mkdir(path.dirname(file), { recursive: true }); - await Bun.write(file, `${JSON.stringify(next, null, 2)}\n`); + + const staging = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + let mode = 0o600; + try { + mode = (await stat(file)).mode & 0o777; + } catch (error) { + const code = (error as { code?: string } | null | undefined)?.code; + if (code !== "ENOENT") throw error; + } + + await Bun.write(staging, `${JSON.stringify(next, null, 2)}\n`); + await chmod(staging, mode); + await rename(staging, file); + } catch (error) { + await rm(staging, { force: true }); + throw error; + } } /** Merges `patch` into the recorded state and writes the result. */ diff --git a/test/unit/lifecycle.test.ts b/test/unit/lifecycle.test.ts index a1a4598..f991e65 100644 --- a/test/unit/lifecycle.test.ts +++ b/test/unit/lifecycle.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test"; import { readdir, stat } from "node:fs/promises"; import path from "node:path"; @@ -20,13 +20,14 @@ import { import { entryStatus } from "../../src/mcp-config.ts"; import { EXECUTABLE_NAME, + managedExecutable, mcpConfigPath, packageRoot, upstreamInstallDir, } from "../../src/paths.ts"; import { describeTarget, type Target } from "../../src/platform.ts"; import { readState, updateState } from "../../src/state.ts"; -import { buildArchive, fakeSource, releaseMembers } from "../support/release.ts"; +import { buildArchive, dropBuiltArchives, fakeSource, releaseMembers } from "../support/release.ts"; import { dropScratch, makeScratch, writeFakeExecutable, type Scratch } from "../support/scratch.ts"; import type { ReleaseSource } from "../../src/release.ts"; @@ -45,6 +46,12 @@ afterEach(async () => { await dropScratch(scratch); }); +// `bun test` never fires `process.on("exit")`, so the archive staging root has +// to be dropped per test file or it outlives the run. +afterAll(() => { + dropBuiltArchives(); +}); + /** A release source serving one genuine archive for `version`. */ async function servedSource(version: string): Promise { const archive = await buildArchive(TARGET.archive, releaseMembers(TARGET, version)); @@ -60,6 +67,29 @@ async function lifecycleFor(version: string): Promise { return { host: scratch.host, target: TARGET, source: await servedSource(version) }; } +/** + * A lifecycle whose source serves a genuine archive under a digest that does + * not match it. + * + * The cheapest way to fail an acquisition at the last verification step that + * still involves the network, an archive and a real `tar` -- which is what a + * digest mismatch, an unexpected member and a failed smoke check all are from + * the lifecycle's point of view. + */ +async function mismatchedLifecycleFor(version: string): Promise { + const archive = await buildArchive(TARGET.archive, releaseMembers(TARGET, version)); + return { + host: scratch.host, + target: TARGET, + source: fakeSource({ + tag: `v${version}`, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: "0".repeat(64), + }), + }; +} + /** A source whose every call fails the test: it must never be reached. */ const forbiddenSource: ReleaseSource = { latestTag: async () => { @@ -92,7 +122,14 @@ describe("no lifecycle operation writes to ~/.local/bin", () => { expect((await status(lifecycle)).resolved).not.toBeNull(); expect((await uninstall(lifecycle)).ok).toBe(true); - await expect(readdir(upstreamInstallDir(scratch.host))).rejects.toThrow(); + // Positively, by code: a bare `rejects.toThrow()` is satisfied by any + // rejection, including the ENOTDIR a lifecycle operation would raise by + // creating `~/.local/bin` as a regular file -- which is the very write this + // case exists to forbid. + expect( + await Bun.file(path.join(upstreamInstallDir(scratch.host), EXECUTABLE_NAME)).exists(), + ).toBe(false); + await expect(readdir(upstreamInstallDir(scratch.host))).rejects.toThrow(/ENOENT/u); }); test("an executable already there is neither replaced nor removed", async () => { @@ -137,6 +174,37 @@ describe("the update check is rate-limited", () => { expect((await readState(scratch.host)).upstreamVersion).toBe("0.11.0"); }); + test("a check timestamp in the future reads as stale rather than suppressing the check", async () => { + const now = Date.now(); + // A restored VM snapshot, an NTP step or a bad RTC moves the clock + // backwards, and a one-sided age test then reads the recorded time as + // "checked in 30 days' time" and honours it for the whole skew. + await updateState(scratch.host, { lastCheckedAt: now + 30 * CHECK_INTERVAL_MS }); + + const report = await checkUpstream( + { host: scratch.host, target: TARGET, source: await servedSource("0.11.0") }, + { now }, + ); + expect(report.kind).toBe("newer"); + expect((await readState(scratch.host)).lastCheckedAt).toBe(now); + }); + + /** + * `acquire` returns the version it was asked for, so recording it as the + * newest upstream release would report an old build as the newest one and + * suppress the real check for a day. Only a check establishes that field. + */ + test("an explicitly requested version is not recorded as the newest upstream release", async () => { + const report = await install(await lifecycleFor("0.9.0"), "0.9.0"); + expect(report.ok).toBe(true); + + const state = await readState(scratch.host); + expect(state.managedVersion).toBe("0.9.0"); + expect(state.managedDigest).toBeDefined(); + expect(state.upstreamVersion).toBeUndefined(); + expect(state.lastCheckedAt).toBeUndefined(); + }); + test("a failed check is recorded so a broken network is retried daily, not per session", async () => { const now = Date.now(); const report = await checkUpstream( @@ -190,6 +258,80 @@ describe("install wires the MCP entry", () => { expect(report.message).toContain("/mcp reload"); expect((await readState(scratch.host)).managedVersion).toBe("0.11.0"); }); + + /** + * Asserted through `syncEntry` rather than through `update`, because the + * discriminant is what the extension entry branches on: `rewired` is notified + * and `unchanged` is silent. A session that rewrites `mcp.json` to a new + * version directory and says nothing leaves MCP talking to the previous path + * for the rest of the session. + */ + test("session start rewires a drifted entry and reports the discriminant", async () => { + await install(await lifecycleFor(VERSION)); + + const moved = managedExecutable(scratch.host, "0.11.0"); + await writeFakeExecutable(moved, `echo "codebase-memory-mcp 0.11.0"`); + await updateState(scratch.host, { managedVersion: "0.11.0" }); + + const report = await syncEntry({ + host: scratch.host, + target: TARGET, + source: forbiddenSource, + }); + expect(report.kind).toBe("rewired"); + expect(report.message).toContain("/mcp reload"); + + const written = JSON.parse(await Bun.file(mcpConfigPath(scratch.host)).text()) as { + mcpServers: Record; + }; + expect(written.mcpServers["codebase-memory-mcp"]?.command).toBe(moved); + }); +}); + +/** + * A failed acquisition is the case in which everything already working has to + * survive: the managed copy resolution falls back to, the operator's pin, the + * receipt that decides whether the MCP entry is this package's to take back, + * and the entry itself. + */ +describe("a failed acquisition leaves the working installation alone", () => { + test("install keeps the managed copy, the pin, the receipt and mcp.json", async () => { + const lifecycle = await lifecycleFor(VERSION); + await install(lifecycle); + await pin(lifecycle, VERSION); + + const file = mcpConfigPath(scratch.host); + const before = await Bun.file(file).text(); + const recorded = await readState(scratch.host); + + const report = await install(await mismatchedLifecycleFor("0.11.0"), "0.11.0"); + expect(report.ok).toBe(false); + expect(report.message).toContain("install failed"); + + expect(await Bun.file(managedExecutable(scratch.host, VERSION)).exists()).toBe(true); + const after = await readState(scratch.host); + expect(after.managedVersion).toBe(VERSION); + expect(after.pin).toBe(VERSION); + expect(after.wroteCommand).toBe(recorded.wroteCommand); + expect(await Bun.file(file).text()).toBe(before); + }); + + test("update keeps the managed copy, the receipt and mcp.json", async () => { + await install(await lifecycleFor(VERSION)); + + const file = mcpConfigPath(scratch.host); + const before = await Bun.file(file).text(); + const recorded = await readState(scratch.host); + + const report = await update(await mismatchedLifecycleFor("0.11.0")); + expect(report.ok).toBe(false); + + expect(await Bun.file(managedExecutable(scratch.host, VERSION)).exists()).toBe(true); + const after = await readState(scratch.host); + expect(after.managedVersion).toBe(VERSION); + expect(after.wroteCommand).toBe(recorded.wroteCommand); + expect(await Bun.file(file).text()).toBe(before); + }); }); describe("uninstall", () => { @@ -228,6 +370,56 @@ describe("uninstall", () => { expect(report.ok).toBe(true); expect(report.message).toContain("no managed copy was present"); }); + + /** + * The two halves go together only when keeping the entry would leave it + * naming a file this command deleted. A file this package cannot read is that + * case: it cannot know what the entry names, and deleting the managed copy + * plus the state that identifies it would leave nothing able to reclaim the + * key either. Removing neither half is recoverable; removing only one is not. + */ + test("an unreadable file keeps the managed copy and the state that identifies it", async () => { + const lifecycle = await lifecycleFor(VERSION); + await install(lifecycle); + + const file = mcpConfigPath(scratch.host); + const unparseable = '{ "mcpServers": { "codebase-memory-mcp": '; + await Bun.write(file, unparseable); + + const report = await uninstall(lifecycle); + expect(report.ok).toBe(false); + expect(report.message).toMatch(/not parseable JSON/u); + expect(report.message).toContain("were kept"); + + expect(await Bun.file(managedExecutable(scratch.host, VERSION)).exists()).toBe(true); + expect((await readState(scratch.host)).managedVersion).toBe(VERSION); + expect(await Bun.file(file).text()).toBe(unparseable); + }); + + /** + * A refused entry naming a system CBM is a different case entirely: it is + * correctly wired to an executable this command never touches, so nothing + * dangles when the managed copy goes. Blocking here would make the managed + * copy unremovable by the one command whose job is removing it, and would + * tell the operator to re-point an entry that is already right. + */ + test("a foreign entry naming a system path still lets the managed copy go", async () => { + const lifecycle = await lifecycleFor(VERSION); + await install(lifecycle); + + const file = mcpConfigPath(scratch.host); + const foreign = `{\n "mcpServers": {\n "codebase-memory-mcp": {\n "command": "/usr/local/bin/codebase-memory-mcp"\n }\n }\n}\n`; + await Bun.write(file, foreign); + + const report = await uninstall(lifecycle); + expect(report.ok).toBe(true); + expect(report.message).toContain(`removed the managed copy of ${VERSION}`); + expect(report.message).toContain("left the MCP entry alone"); + expect(report.message).toContain("/usr/local/bin/codebase-memory-mcp"); + + expect(await Bun.file(packageRoot(scratch.host)).exists()).toBe(false); + expect(await Bun.file(file).text()).toBe(foreign); + }); }); describe("status", () => { diff --git a/test/unit/mcp-config.test.ts b/test/unit/mcp-config.test.ts index 8f04350..4969e4b 100644 --- a/test/unit/mcp-config.test.ts +++ b/test/unit/mcp-config.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdir } from "node:fs/promises"; +import { chmod, mkdir, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { entryStatus, MCP_SCHEMA_URL, removeEntry, upsertEntry } from "../../src/mcp-config.ts"; -import { mcpConfigPath } from "../../src/paths.ts"; +import { managedBinRoot, managedExecutable, mcpConfigPath } from "../../src/paths.ts"; import { dropScratch, makeScratch, type Scratch } from "../support/scratch.ts"; const OURS = "/home/scratch/.omp/codebase-memory/bin/0.10.8/codebase-memory-mcp"; @@ -45,6 +45,45 @@ ${DISABLED_BLOCK} } `; +/** + * A file holding nothing but the `$schema` this package writes and the owned + * entry -- which is exactly what a file this package created looks like. + */ +const OURS_ONLY = `{ + "$schema": "${MCP_SCHEMA_URL}", + "mcpServers": { + "codebase-memory-mcp": { + "type": "stdio", + "command": "${OURS}", + "args": [] + } + } +} +`; + +/** The owned entry beside a `disabledServers` list the operator added. */ +const OURS_WITH_DENYLIST = `{ + "$schema": "${MCP_SCHEMA_URL}", + "mcpServers": { + "codebase-memory-mcp": { + "command": "${OURS}" + } + }, + "disabledServers": [] +} +`; + +/** The owned entry in a file whose `$schema` this package did not write. */ +const OURS_WITH_FOREIGN_SCHEMA = `{ + "$schema": "./mcp-schema.json", + "mcpServers": { + "codebase-memory-mcp": { + "command": "${OURS}" + } + } +} +`; + /** * How the file looks before the operation under test. * @@ -76,6 +115,27 @@ async function arrange(before: Before): Promise { await Bun.write(file, before); } +/** + * A file whose owned entry names `command`, written by hand rather than through + * this package. + * + * Needed wherever the `command` is only known once a scratch host exists -- a + * path under that host's own managed bin root -- which a module-level literal + * cannot spell. + */ +function entryNaming(command: string): string { + return `{ + "mcpServers": { + "codebase-memory-mcp": { + "type": "stdio", + "command": "${command}", + "args": [] + } + } +} +`; +} + interface WriteCase { readonly scenario: string; readonly before: Before; @@ -160,6 +220,21 @@ const writeRefusals: WriteRefusalCase[] = [ previouslyWrote: OURS, reported: /does not hold a JSON object/u, }, + { + // The one malformed shape the reader used to recognise by discarding: an + // `mcpServers` value that is not a map was replaced wholesale, taking the + // operator's content with it. + scenario: "an mcpServers value that is not an object is refused rather than replaced", + before: `{ + "mcpServers": [ + { "name": "keeper", "command": "/bin/true" } + ], + "other": 1 +} +`, + previouslyWrote: OURS, + reported: /mcpServers value that is not a JSON object/u, + }, ]; interface RemovalCase { @@ -209,10 +284,81 @@ const removalRefusals: RemovalRefusalCase[] = [ }, ]; +interface RemovalFateCase { + readonly scenario: string; + readonly before: string; + /** Whether the file itself is still there once the owned key is gone. */ + readonly fileRemains: boolean; +} + +/** + * What removal does with the file, not just with the key. + * + * Rolling back is supposed to return the machine to its pre-install state, so a + * file this package created has to go with its last key -- while anything the + * operator had, including a `$schema` value this package did not write, is + * content it never owned and must survive. + */ +const removalFates: RemovalFateCase[] = [ + { + scenario: "a file holding only this package's schema and entry is removed with the key", + before: OURS_ONLY, + fileRemains: false, + }, + { + scenario: "a file holding a disabledServers list the operator added is kept", + before: OURS_WITH_DENYLIST, + fileRemains: true, + }, + { + scenario: "a file whose schema this package did not write is kept", + before: OURS_WITH_FOREIGN_SCHEMA, + fileRemains: true, + }, +]; + +interface UnreadableCase { + readonly scenario: string; + /** Puts something at `file` that exists and cannot be read. */ + readonly place: (file: string) => Promise; + /** The errno the report must name. */ + readonly errno: RegExp; +} + +/** + * A file that is present but unreadable is not an absent entry. + * + * Reachable without a hand edit: root-owned after a `sudo omp`, or mode 0600 + * under another uid. Reporting it as "absent" tells the operator the entry is + * missing from a file this package could not open, where the entry may well be. + */ +const unreadables: UnreadableCase[] = [ + { + scenario: "a file the process may not open is reported with its errno", + place: async (file) => { + await Bun.write(file, OURS_ONLY); + await chmod(file, 0o000); + }, + errno: /EACCES/u, + }, + { + scenario: "a directory where the file belongs is reported with its errno", + place: async (file) => { + await mkdir(file, { recursive: true }); + }, + errno: /EISDIR/u, + }, +]; + test("every case names itself distinctly", () => { - const scenarios = [...writes, ...writeRefusals, ...removals, ...removalRefusals].map( - (entry) => entry.scenario, - ); + const scenarios = [ + ...writes, + ...writeRefusals, + ...removals, + ...removalRefusals, + ...removalFates, + ...unreadables, + ].map((entry) => entry.scenario); expect(new Set(scenarios).size).toBe(scenarios.length); }); @@ -300,6 +446,97 @@ describe("upsert", () => { expect(written.mcpServers["codebase-memory-mcp"]?.timeout).toBe(120000); expect(written.mcpServers["codebase-memory-mcp"]?.command).toBe(MOVED); }); + + /** + * A lost `state.json` -- `readState` falls back to an empty state on a + * truncated file, and uninstall deletes the same file -- leaves no recorded + * write, and then only the path can decide ownership. Nothing but this + * package ever writes under its own managed bin root. + */ + test("an entry under this package's own bin root is ours with nothing recorded", async () => { + const older = managedExecutable(scratch.host, "0.10.8"); + const newer = managedExecutable(scratch.host, "0.10.9"); + await arrange(entryNaming(older)); + + const outcome = await upsertEntry(scratch.host, newer, undefined); + expect(outcome.ok && outcome.change).toBe("updated"); + + const written = JSON.parse(await Bun.file(mcpConfigPath(scratch.host)).text()) as { + mcpServers: Record; + }; + expect(written.mcpServers["codebase-memory-mcp"]?.command).toBe(newer); + }); + + test("a directory whose name merely shares the bin root's prefix is still foreign", async () => { + const lookalike = path.join(`${managedBinRoot(scratch.host)}-backup`, "0.10.8", "cbm"); + await arrange(entryNaming(lookalike)); + + const resolved = managedExecutable(scratch.host, "0.10.9"); + const outcome = await upsertEntry(scratch.host, resolved, undefined); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.reason).toContain(lookalike); + }); + + test("a compact file whose entry already names the resolved path is not rewritten", async () => { + const compact = `{"$schema":"s","mcpServers":{"codebase-memory-mcp":{"type":"stdio","command":"${OURS}","args":[]}}}`; + await arrange(compact); + + const outcome = await upsertEntry(scratch.host, OURS, OURS); + expect(outcome.ok && outcome.change).toBe("unchanged"); + expect(await Bun.file(mcpConfigPath(scratch.host)).text()).toBe(compact); + }); + + test("a rewrite replaces the file rather than truncating it in place", async () => { + await arrange("owned"); + const file = mcpConfigPath(scratch.host); + const before = await stat(file); + + await upsertEntry(scratch.host, MOVED, OURS); + + // A durable write stages a temp file beside the target and renames it into + // place, so the visible file is a new inode. An in-place truncate keeps the + // inode, and the window it opens is one in which OMP reads a half-written + // document and silently loads zero user-level MCP servers. + expect((await stat(file)).ino).not.toBe(before.ino); + }); + + /** + * `rename` replaces the destination rather than truncating it, so the visible + * file takes the staging file's mode unless it is set first. This file can + * carry per-server `env` secrets, remote `headers` and an + * `auth.clientSecret`, and OMP's own writer creates it 0600 -- so any + * `mcp.json` last written by `/mcp add|enable|disable` is 0600 on disk, and a + * session-start correction that published it 0644 would be a disclosure. + */ + test("a rewrite preserves the destination's own mode", async () => { + await arrange("owned"); + const file = mcpConfigPath(scratch.host); + await chmod(file, 0o600); + + await upsertEntry(scratch.host, MOVED, OURS); + expect((await stat(file)).mode & 0o777).toBe(0o600); + }); + + test("a mode the operator widened is reproduced rather than narrowed", async () => { + await arrange("owned"); + const file = mcpConfigPath(scratch.host); + await chmod(file, 0o644); + + await upsertEntry(scratch.host, MOVED, OURS); + expect((await stat(file)).mode & 0o777).toBe(0o644); + }); + + test("a created file is not world-readable", async () => { + await upsertEntry(scratch.host, OURS, undefined); + expect((await stat(mcpConfigPath(scratch.host))).mode & 0o777).toBe(0o600); + }); + + test("a write leaves no staging file behind", async () => { + await arrange("owned"); + await upsertEntry(scratch.host, MOVED, OURS); + expect(await readdir(path.dirname(mcpConfigPath(scratch.host)))).toEqual(["mcp.json"]); + }); }); describe("removal", () => { @@ -338,6 +575,21 @@ describe("removal", () => { expect(await Bun.file(mcpConfigPath(scratch.host)).exists()).toBe(true); }); + + test.each(removalFates)("$scenario", async ({ before, fileRemains }) => { + await arrange(before); + + const outcome = await removeEntry(scratch.host, OURS); + expect(outcome.ok && outcome.change).toBe("removed"); + expect(await Bun.file(mcpConfigPath(scratch.host)).exists()).toBe(fileRemains); + }); + + test("removal takes back an entry under this package's bin root with nothing recorded", async () => { + await arrange(entryNaming(managedExecutable(scratch.host, "0.10.8"))); + + const outcome = await removeEntry(scratch.host, undefined); + expect(outcome.ok && outcome.change).toBe("removed"); + }); }); describe("status", () => { @@ -360,4 +612,17 @@ describe("status", () => { await arrange("{ oops"); expect((await entryStatus(scratch.host, OURS)).problem).toMatch(/not parseable JSON/u); }); + + test.each(unreadables)("$scenario", async ({ place, errno }) => { + const file = mcpConfigPath(scratch.host); + await mkdir(path.dirname(file), { recursive: true }); + await place(file); + + const reported = await entryStatus(scratch.host, OURS); + expect(reported.problem).toMatch(errno); + expect(reported.problem).toContain(file); + + // And no write is attempted against a file whose content is unknown. + expect((await upsertEntry(scratch.host, OURS, OURS)).ok).toBe(false); + }); }); diff --git a/test/unit/paths.test.ts b/test/unit/paths.test.ts index 34cae7d..cc302c2 100644 --- a/test/unit/paths.test.ts +++ b/test/unit/paths.test.ts @@ -11,10 +11,8 @@ interface AgentDirCase { readonly scenario: string; /** The environment beyond `HOME` and `PATH`. */ readonly env: Readonly>; - /** The agent directory, relative to the scratch home. `null` means absolute. */ - readonly relative: string | null; - /** Used only when {@link AgentDirCase.relative} is `null`. */ - readonly absolute?: string; + /** The agent directory this environment must resolve to. */ + readonly expected: (scratch: Scratch) => string; } /** @@ -30,42 +28,41 @@ const agentDirs: AgentDirCase[] = [ { scenario: "no profile and no override resolves the default agent directory", env: {}, - relative: ".omp/agent", + expected: (scratch) => path.join(scratch.home, ".omp/agent"), }, { scenario: "OMP_PROFILE resolves that profile's agent directory", env: { OMP_PROFILE: "work" }, - relative: ".omp/profiles/work/agent", + expected: (scratch) => path.join(scratch.home, ".omp/profiles/work/agent"), }, { scenario: "PI_PROFILE is honoured as the legacy fallback", env: { PI_PROFILE: "legacy" }, - relative: ".omp/profiles/legacy/agent", + expected: (scratch) => path.join(scratch.home, ".omp/profiles/legacy/agent"), }, { // OMP resolves the canonical variable first and consults the legacy one only // when the canonical one is undefined. scenario: "OMP_PROFILE wins over PI_PROFILE", env: { OMP_PROFILE: "work", PI_PROFILE: "legacy" }, - relative: ".omp/profiles/work/agent", + expected: (scratch) => path.join(scratch.home, ".omp/profiles/work/agent"), }, { // An explicitly empty OMP_PROFILE selects the default profile rather than // silently inheriting the legacy variable, which is OMP's own rule. scenario: "an empty OMP_PROFILE selects the default profile, not PI_PROFILE", env: { OMP_PROFILE: "", PI_PROFILE: "legacy" }, - relative: ".omp/agent", + expected: (scratch) => path.join(scratch.home, ".omp/agent"), }, { scenario: "PI_CODING_AGENT_DIR takes precedence over a profile variable", env: { PI_CODING_AGENT_DIR: "/tmp/cbm-explicit-agent", OMP_PROFILE: "work" }, - relative: null, - absolute: "/tmp/cbm-explicit-agent", + expected: () => "/tmp/cbm-explicit-agent", }, { scenario: "PI_CONFIG_DIR renames the config directory every path hangs off", env: { PI_CONFIG_DIR: ".omp-alt" }, - relative: ".omp-alt/agent", + expected: (scratch) => path.join(scratch.home, ".omp-alt/agent"), }, ]; @@ -75,12 +72,10 @@ test("every case names itself distinctly", () => { }); describe("agent directory resolution", () => { - test.each(agentDirs)("$scenario", async ({ env, relative, absolute }) => { + test.each(agentDirs)("$scenario", async ({ env, expected }) => { const scratch = await makeScratch({ env }); try { - expect(agentDir(scratch.host)).toBe( - relative === null ? (absolute ?? "") : path.join(scratch.home, relative), - ); + expect(agentDir(scratch.host)).toBe(expected(scratch)); } finally { await dropScratch(scratch); } diff --git a/test/unit/resolve.test.ts b/test/unit/resolve.test.ts index ddd92af..c798fc5 100644 --- a/test/unit/resolve.test.ts +++ b/test/unit/resolve.test.ts @@ -28,6 +28,11 @@ interface Layout { readonly pin?: string; /** A recorded pointer with no file behind it, e.g. after a manual delete. */ readonly danglingPointer?: string; + /** + * A copy on the scratch `PATH` that runs and exits non-zero -- a stub, a + * half-written file, a shim -- instead of reporting a version. + */ + readonly brokenOnPath?: boolean; } async function place(scratch: Scratch, layout: Layout): Promise { @@ -37,6 +42,9 @@ async function place(scratch: Scratch, layout: Layout): Promise { `echo "codebase-memory-mcp ${layout.onPath}"`, ); } + if (layout.brokenOnPath === true) { + await writeFakeExecutable(path.join(scratch.pathDir, EXECUTABLE_NAME), "exit 7"); + } if (layout.localBin !== undefined) { await writeFakeExecutable( path.join(upstreamInstallDir(scratch.host), EXECUTABLE_NAME), @@ -117,6 +125,18 @@ const ordering: OrderCase[] = [ origin: "PATH", executable: (scratch) => path.join(scratch.pathDir, EXECUTABLE_NAME), }, + { + // Resolution deliberately never runs the candidate -- see the note on + // `resolveExecutable`. Adopting a path that turns out not to run is the + // chosen trade against spawning an unknown binary on every session start, + // and `/cbm status` is where a candidate that will not execute becomes + // visible, by reading its version separately. + scenario: "an executable on PATH that exits non-zero is still adopted", + layout: { brokenOnPath: true }, + source: "system", + origin: "PATH", + executable: (scratch) => path.join(scratch.pathDir, EXECUTABLE_NAME), + }, ]; let scratch: Scratch; diff --git a/test/unit/state.test.ts b/test/unit/state.test.ts new file mode 100644 index 0000000..c4b1e7e --- /dev/null +++ b/test/unit/state.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, readdir, stat } from "node:fs/promises"; +import path from "node:path"; + +import { statePath } from "../../src/paths.ts"; +import { readState, updateState, writeState, type State } from "../../src/state.ts"; +import { dropScratch, makeScratch, type Scratch } from "../support/scratch.ts"; + +/** + * What `readState` must return for a document it did not write. + * + * The filtering this table pins is the only thing between a corrupted + * `state.json` and code that joins a recorded value straight into a path + * (`managedExecutable(host, state.managedVersion)`) or does arithmetic on it + * (the 24-hour check interval). The file is this package's own cache, so the + * recoverable reading of every malformed value is "forget what was cached" + * rather than a refusal that would fail a session start. + */ +interface SanitizationCase { + readonly scenario: string; + /** The file's raw bytes, written verbatim. */ + readonly document: string; + /** Everything the read must return, and nothing else. */ + readonly state: State; +} + +const sanitizations: SanitizationCase[] = [ + { + scenario: "a truncated document reads as empty rather than throwing", + document: '{"managedVersion":"0.10.8","pi', + state: {}, + }, + { + scenario: "a JSON array reads as empty", + document: "[]\n", + state: {}, + }, + { + scenario: "a JSON string reads as empty", + document: '"0.10.8"\n', + state: {}, + }, + { + scenario: "a JSON null reads as empty", + document: "null\n", + state: {}, + }, + { + scenario: "a numeric pin is dropped so no non-string reaches a path segment", + document: '{"managedVersion":"0.10.8","pin":10.8}', + state: { managedVersion: "0.10.8" }, + }, + { + scenario: "an object managedVersion is dropped rather than joined into a path", + document: '{"managedVersion":{"toString":"0.10.8"},"pin":"0.10.8"}', + state: { pin: "0.10.8" }, + }, + { + scenario: "an empty-string managedVersion is dropped so the pointer never names bin/", + document: '{"managedVersion":"","pin":"0.10.8"}', + state: { pin: "0.10.8" }, + }, + { + // `1e999` is valid JSON and parses to `Infinity`, which is how a non-finite + // number reaches the rate-limit arithmetic without a hand-written literal. + scenario: "a non-finite lastCheckedAt is dropped so the check interval never reads Infinity", + document: '{"managedVersion":"0.10.8","lastCheckedAt":1e999}', + state: { managedVersion: "0.10.8" }, + }, + { + scenario: "a string lastCheckedAt is dropped rather than subtracted from a timestamp", + document: '{"managedVersion":"0.10.8","lastCheckedAt":"1700000000000"}', + state: { managedVersion: "0.10.8" }, + }, + { + scenario: "keys this package does not recognise are not carried through", + document: '{"managedVersion":"0.10.8","source":"github","nested":{"pin":"0.9.0"}}', + state: { managedVersion: "0.10.8" }, + }, + { + scenario: "a fully valid document survives field for field", + document: `{ + "managedVersion": "0.10.8", + "managedDigest": "e2804a20", + "pin": "0.10.8", + "upstreamVersion": "0.11.0", + "lastCheckedAt": 1700000000000, + "wroteCommand": "/home/scratch/.omp/codebase-memory/bin/0.10.8/codebase-memory-mcp" +} +`, + state: { + managedVersion: "0.10.8", + managedDigest: "e2804a20", + pin: "0.10.8", + upstreamVersion: "0.11.0", + lastCheckedAt: 1700000000000, + wroteCommand: "/home/scratch/.omp/codebase-memory/bin/0.10.8/codebase-memory-mcp", + }, + }, +]; + +let scratch: Scratch; + +beforeEach(async () => { + scratch = await makeScratch(); +}); + +afterEach(async () => { + await dropScratch(scratch); +}); + +test("every case names itself distinctly", () => { + const scenarios = sanitizations.map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("a recorded state is sanitized on the way in", () => { + test.each(sanitizations)("$scenario", async ({ document, state }) => { + const file = statePath(scratch.host); + await mkdir(path.dirname(file), { recursive: true }); + await Bun.write(file, document); + + expect(await readState(scratch.host)).toEqual(state); + }); + + test("a missing file is the first-run case, not a failure", async () => { + expect(await readState(scratch.host)).toEqual({}); + }); +}); + +describe("the state is written durably", () => { + test("a rewrite replaces the file rather than truncating it in place", async () => { + await writeState(scratch.host, { managedVersion: "0.10.8" }); + const before = await stat(statePath(scratch.host)); + + await writeState(scratch.host, { managedVersion: "0.11.0" }); + + // A durable write stages the document beside the target and renames it in, + // so the visible file is a new inode. An in-place truncate keeps the inode, + // and an interrupted one leaves a document that no longer parses -- which + // this reader degrades to the empty state, forgetting the operator's pin and + // the receipt that decides whether the MCP entry is ours to take back. + expect((await stat(statePath(scratch.host))).ino).not.toBe(before.ino); + }); + + /** + * `rename` replaces the destination rather than truncating it, so the visible + * file takes the staging file's mode unless it is set first. Nothing here is + * a secret today, but a package-private cache is not the operator's to have + * widened by a write they did not ask for. + */ + test("a rewrite preserves the destination's own mode", async () => { + await writeState(scratch.host, { managedVersion: "0.10.8" }); + await chmod(statePath(scratch.host), 0o600); + + await writeState(scratch.host, { managedVersion: "0.11.0" }); + expect((await stat(statePath(scratch.host))).mode & 0o777).toBe(0o600); + }); + + /** + * The row that distinguishes reproducing the destination's mode from + * hardcoding a narrow one: 0600 arranged and 0600 asserted are the same + * assertion a `chmod(staging, 0o600)` would satisfy, so without a widened + * case this file cannot tell the two apart. + */ + test("a mode the operator widened is reproduced rather than narrowed", async () => { + await writeState(scratch.host, { managedVersion: "0.10.8" }); + await chmod(statePath(scratch.host), 0o644); + + await writeState(scratch.host, { managedVersion: "0.11.0" }); + expect((await stat(statePath(scratch.host))).mode & 0o777).toBe(0o644); + }); + + test("a created state file is not world-readable", async () => { + await writeState(scratch.host, { managedVersion: "0.10.8" }); + expect((await stat(statePath(scratch.host))).mode & 0o777).toBe(0o600); + }); + + test("a write leaves no staging file behind", async () => { + await writeState(scratch.host, { managedVersion: "0.10.8" }); + expect(await readdir(path.dirname(statePath(scratch.host)))).toEqual(["state.json"]); + }); + + test("a merge keeps the fields it was not given and replaces the ones it was", async () => { + await writeState(scratch.host, { managedVersion: "0.10.8", pin: "0.10.8" }); + + expect(await updateState(scratch.host, { managedVersion: "0.11.0" })).toEqual({ + managedVersion: "0.11.0", + pin: "0.10.8", + }); + expect(await readState(scratch.host)).toEqual({ + managedVersion: "0.11.0", + pin: "0.10.8", + }); + }); +}); From ff113df8cf77f004b075ab8ef1d3958c3466075d Mon Sep 17 00:00:00 2001 From: pashifika Date: Fri, 28 Aug 2026 14:10:34 +0900 Subject: [PATCH 7/9] test(packaging): isolate the bundle test from the real agent directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `loadIsolated` isolated the module but not the environment. Loading runs the factory, which resolves the agent directory from the real `homedir()` and `process.env` and stands down when `/extensions/codebase-memory.ts` exists, so the two load-bearing assertions — including the `tool_call` negative — were decided by state outside the repository: bun test test/packaging # 5 pass PI_CODING_AGENT_DIR= ... # 3 pass, 2 fail `~/.omp/agent/extensions/` already exists on a developer machine, so the guard was one filename away from firing, and it fires for certain the moment upstream ships the `--clients=omp` writer the guard exists to detect. The packaging gate would then fail for a reason that has nothing to do with the bundle. The load now points at the scratch directory `loadIsolated` already creates, restoring the previous value afterwards, so the stand-down branch is decided by the test. Also removes that scratch directory, which was never cleaned up, and drops `expect(handlers).not.toContain("tool_call")`, which passes vacuously on an empty handler list; the sibling `toEqual(["session_start"])` is what actually catches a regression. --- test/packaging/bundle.test.ts | 45 ++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/test/packaging/bundle.test.ts b/test/packaging/bundle.test.ts index e72e82b..3aade52 100644 --- a/test/packaging/bundle.test.ts +++ b/test/packaging/bundle.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { copyFile, mkdtemp, readdir } from "node:fs/promises"; +import { copyFile, mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -32,16 +32,40 @@ async function declaredEntries(): Promise { /** * Loads the bundle from a directory holding nothing else. * - * The isolation is the point: a bundle that had quietly kept a runtime - * dependency would fail to import rather than resolve it from this - * repository's own `node_modules`. + * The isolation is the point, and it has two halves. The module half: a bundle + * that had quietly kept a runtime dependency would fail to import rather than + * resolve it from this repository's own `node_modules`. The environment half: + * loading *runs* the factory, which stands down when + * `/extensions/codebase-memory.ts` exists, so against the + * developer's real agent directory every assertion below is decided by state + * outside this repository -- and would go red the day upstream ships the + * native extension that guard exists to detect, for a reason having nothing to + * do with the bundle. The scratch directory is the agent directory too; it + * holds `index.js` and nothing else, which is what the assertion below proves. */ async function loadIsolated(): Promise { const directory = await mkdtemp(join(tmpdir(), "cbm-bundle-")); - const copied = join(directory, "index.js"); - await copyFile(resolve(BUNDLE), copied); - expect(await readdir(directory)).toEqual(["index.js"]); - return await loadExtensions([copied], directory); + const previousAgentDir = process.env["PI_CODING_AGENT_DIR"]; + try { + const copied = join(directory, "index.js"); + await copyFile(resolve(BUNDLE), copied); + expect(await readdir(directory)).toEqual(["index.js"]); + + process.env["PI_CODING_AGENT_DIR"] = directory; + return await loadExtensions([copied], directory); + } finally { + // Restored, not deleted: an unset variable and one set to something else + // are different environments, and the suite has no licence to change which + // one the tests after it run in. + if (previousAgentDir === undefined) { + delete process.env["PI_CODING_AGENT_DIR"]; + } else { + process.env["PI_CODING_AGENT_DIR"] = previousAgentDir; + } + // The loaded module is already in memory, so the copy on disk has no + // reader left. Every call here makes a directory; none of them outlive it. + await rm(directory, { recursive: true, force: true }); + } } describe("the declared extension entries", () => { @@ -86,11 +110,14 @@ describe("the standalone bundle", () => { * handler as a refusal of the tool call, so a handler registered here could * deny an operator's `grep` because a subprocess timed out. The event is not * registered at all, and this is the assertion that keeps it that way. + * + * Asserted as the whole registered set rather than as + * `not.toContain("tool_call")`: that form also passes on an *empty* handler + * list, so it would report success for a factory that registered nothing. */ test("registers no tool_call handler", async () => { const loaded = await loadIsolated(); const handlers = [...(loaded.extensions[0]?.handlers.keys() ?? [])]; - expect(handlers).not.toContain("tool_call"); expect(handlers.sort()).toEqual(["session_start"]); }); }); From bc1969e6457d86e40be17d567ce1fdd9b82b274f Mon Sep 17 00:00:00 2001 From: pashifika Date: Fri, 28 Aug 2026 14:10:47 +0900 Subject: [PATCH 8/9] ci(release): drop the persisted push credential from the publish checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `publish` was the only checkout in the workflow set omitting `persist-credentials: false`, and the only job elevating to `permissions: contents: write`. `actions/checkout` therefore wrote an `AUTHORIZATION: basic ` extraheader into `.git/config` and left a push-capable credential on disk for the rest of the job. Nothing in the job needs it: `gh release create --verify-tag` authenticates through the `GH_TOKEN` env var and resolves the tag over the REST API, not through the git remote, and `persist-credentials: false` removes only the extraheader while still configuring `origin`. So the credential was pure blast radius for any step later added to `publish` — an asset build, a notes generator, a `bun install` running a lifecycle script. --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5795f4d..a2d75b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,6 +71,14 @@ jobs: - name: Check out the tagged tree uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 timeout-minutes: 5 + # This is the one job that elevates to `contents: write`, so it is the + # one job where a persisted credential matters: `actions/checkout` + # otherwise writes an `AUTHORIZATION: basic ` extraheader into + # `.git/config` and leaves a push-capable credential on disk for every + # later step. Nothing here needs it -- `gh release create` authenticates + # through `GH_TOKEN` below, not through the git remote. + with: + persist-credentials: false - name: Create the GitHub release timeout-minutes: 5 From 496a60cca4fe7172b09396be71ccf5aed4664aad Mon Sep 17 00:00:00 2001 From: pashifika Date: Fri, 28 Aug 2026 14:11:10 +0900 Subject: [PATCH 9/9] ci: run the documented install commands in install-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README claimed, directly beneath `omp plugin install github:...`, that "this is the command CI installs into a scratch project on every push, so it is verified against the exact ref you type". None of that was true. CI ran `bun add "github:#"` — never `omp plugin install`, always with a `#` suffix the documented spec does not carry, and only on pushes to `main`, tag pushes and manual dispatch. The documented development install `omp plugin link .` was exercised by no job at all, and the job's own comment claimed it did "the same thing OMP's plugin installer validates" while its `check.ts` only asserted `typeof module.default === "function"`. The gap was material rather than cosmetic: `omp plugin install` runs `#validateInstalledExtensions`, which loads the extension and so actually invokes the factory on the installed tree. Rather than narrow the claim, this makes it true. `install-check` now reads the OMP CLI version out of `devDependencies` (one source of truth, so a second copy cannot drift), installs it, and runs both documented commands — `omp plugin install` and `omp plugin link .` — each under its own scratch `HOME`, asserting discovery through `omp plugin list`. Two homes are required, not stylistic: linking on top of a git-spec install of the same package name fails with EPERM. The hand-written `check.ts` heredoc is gone, since what replaced it is strictly stronger. The version read is guarded to an exact pin. A range would resolve to whatever is newest at job time, which is the drift reading the manifest was meant to avoid, and the accepting glob needs a preceding reject arm because it ends in an unanchored `*` — without it `18.0.8 || 19.0.0` matches on its prefix. Scope guards are unchanged: still `push` or `workflow_dispatch` only, because a pull request's merge ref is not an installable remote ref, and still absent from the aggregate gate's `needs`, so a skipped job cannot fail the required check. README now states exactly what CI does, including the `#` form and the trigger set, and points the factory-loads-clean property at the packaging test where it actually lives. --- .github/workflows/ci.yml | 120 +++++++++++++++++++++++++-------------- README.md | 12 +++- 2 files changed, 88 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 519e4a7..43b69ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,75 +203,111 @@ jobs: git ls-files --error-unmatch dist/index.js git diff --exit-code -- dist/index.js - # Runtime job. Proves the install command the README documents actually - # resolves on a clean machine, and that the installed tree exposes a loadable - # extension entry -- the same thing OMP's plugin installer validates before - # enabling a plugin. + # Runtime job. Runs the two install commands the README documents, through + # OMP's own CLI, so the documented path and the verified path are one path. + # `omp plugin install` is strictly more than a `bun add`: after resolving the + # spec it runs `#validateInstalledExtensions`, which loads every declared + # entry against a throwaway registration surface and therefore invokes the + # factory. `omp plugin link` registers a checkout without loading it, so that + # half is discovery only -- that the committed bundle loads clean through + # OMP's loader is `test/packaging/bundle.test.ts`. # # Not a pull-request job: it installs by ref, and a pull request's merge ref # does not exist on the remote as an installable ref. It therefore runs on # manual dispatch and on pushes, which includes the tag push that publishes a # release, so the documented command is verified against the exact ref an - # operator will type. It is deliberately absent from `ci`'s `needs`: a skipped - # job would otherwise fail the required check on every pull request. + # operator can install. It is deliberately absent from `ci`'s `needs`: a + # skipped job would otherwise fail the required check on every pull request. install-check: name: install check if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' runs-on: ubuntu-24.04 timeout-minutes: 15 steps: + - name: Check out the source tree + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + timeout-minutes: 5 + with: + persist-credentials: false + - name: Install the pinned Bun toolchain uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 timeout-minutes: 5 with: bun-version: 1.3.14 - - name: Install the documented git spec into a scratch project + - name: Install the OMP CLI this package is developed against + timeout-minutes: 5 + # Read out of `devDependencies` rather than written again here: the CLI + # that validates the install has to be the one the suite type-checks and + # loads against, and a second copy of the version drifts. + run: | + set -euo pipefail + + version="$(jq -r '.devDependencies["@oh-my-pi/pi-coding-agent"] // empty' package.json)" + case "$version" in + "") + echo "::error::package.json declares no @oh-my-pi/pi-coding-agent devDependency to pin the OMP CLI to" + exit 1 + ;; + # A range would resolve to whatever is newest at job time, which is the + # drift this step reads the manifest to avoid. Only an exact pin will do. + # This arm comes first because the accepting pattern below ends in an + # unanchored `*`: without it `18.0.8 || 19.0.0` matches on its prefix. + *[[:space:]]*|*"|"*) + echo "::error::@oh-my-pi/pi-coding-agent is declared as '$version'; the OMP CLI must be pinned to an exact version, not a range" + exit 1 + ;; + [0-9]*.[0-9]*.[0-9]*) ;; + *) + echo "::error::@oh-my-pi/pi-coding-agent is declared as '$version'; the OMP CLI must be pinned to an exact version, not a range" + exit 1 + ;; + esac + + echo "installing OMP CLI $version" + bun add --global "@oh-my-pi/pi-coding-agent@$version" + omp --version + + - name: Install the documented git spec on a machine with no prior state timeout-minutes: 10 env: SPEC: github:${{ github.repository }}#${{ github.ref_name }} run: | set -euo pipefail - work="$(mktemp -d)" - cd "$work" - printf '{"name":"install-check","private":true}\n' > package.json - echo "installing $SPEC" - bun add "$SPEC" - - cat > check.ts <<'TS' - import { readFileSync } from "node:fs"; + # A scratch HOME, not the runner's. The installer keeps its plugin + # registry under `$HOME/.omp`, and "resolves on a clean machine" is + # only a claim about a machine that holds none of it yet. + HOME="$(mktemp -d)" + export HOME - interface Manifest { - readonly version?: string; - readonly license?: string; - readonly omp?: { readonly extensions?: readonly string[] }; - } + echo "installing $SPEC" + omp plugin install "$SPEC" - const root = "node_modules/omp-codebase-memory"; - const manifest = JSON.parse(readFileSync(`${root}/package.json`, "utf8")) as Manifest; - const entries = manifest.omp?.extensions ?? []; - if (entries.length === 0) { - throw new Error("the installed manifest declares no omp.extensions"); - } + # `install` already fails loudly on a spec that will not resolve or an + # entry that will not load; this is the discovery half, that the + # plugin is registered under the name its manifest declares. + omp plugin list > "$HOME/plugins.txt" + cat "$HOME/plugins.txt" + grep -q 'omp-codebase-memory' "$HOME/plugins.txt" - for (const entry of entries) { - const resolved = new URL( - `${root}/${entry.replace(/^\.\//, "")}`, - `file://${process.cwd()}/`, - ); - const module = (await import(resolved.href)) as { default?: unknown }; - if (typeof module.default !== "function") { - throw new Error(`${entry} does not default-export an extension factory`); - } - console.log(`ok ${entry} -> ${(module.default as { name?: string }).name}`); - } + - name: Link this checkout the way the development install documents + timeout-minutes: 5 + run: | + set -euo pipefail - console.log( - `installed omp-codebase-memory ${manifest.version} with license ${manifest.license}`, - ); - TS - bun check.ts + # Its own scratch HOME. `link` and `install` register the same package + # name, so linking a checkout on top of the git-spec install above + # collides on the plugin's `node_modules` entry -- two documented + # commands, two clean machines. + HOME="$(mktemp -d)" + export HOME + + omp plugin link . + omp plugin list > "$HOME/plugins.txt" + cat "$HOME/plugins.txt" + grep -q 'omp-codebase-memory' "$HOME/plugins.txt" # The single status check branch protection requires. Runtime jobs are never # named there, so changing the matrix never means editing the ruleset. diff --git a/README.md b/README.md index 8b85803..9960690 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,11 @@ This package is the missing path. `omp plugin install` is the whole setup step. omp plugin install github:pashifika/omp-codebase-memory ``` -This is the command CI installs into a scratch project on every push, so it is -verified against the exact ref you type. +CI runs this command, through OMP's own installer, on pushes to `main`, on +release tag pushes, and on manual dispatch — as +`github:pashifika/omp-codebase-memory#`, into a home directory with no +prior plugin state. `omp plugin install` validates what it installed by loading +the declared extension entry, so a ref that installs green also registers green. ### Marketplace @@ -66,6 +69,11 @@ loads without a build step. Run `bun run build` after changing anything under `src/`; CI fails if the committed bundle is not byte-identical to one built from the current source. +CI links its own checkout with this command, so the development install is +verified to be discovered. That the committed bundle then *loads* through OMP's +loader is `test/packaging/bundle.test.ts` — `omp plugin link` registers a +checkout without loading it. + ## Commands | Command | What it does |