diff --git a/.github/workflows/mcp-registry-publish.yml b/.github/workflows/mcp-registry-publish.yml new file mode 100644 index 0000000..afebc36 --- /dev/null +++ b/.github/workflows/mcp-registry-publish.yml @@ -0,0 +1,115 @@ +name: MCP Registry Publish + +# G5: official MCP Registry metadata. Does not host the binary. +# Requires G4: @bytefolk/mem-mcp must already be on npm with matching mcpName. +# Auth is GitHub OIDC against io.github.bytefolk/* — not a personal device login. +# Founder gate: environment mcp-registry (same owner as npm-release). + +on: + workflow_dispatch: + inputs: + version: + description: "Published npm/package version (e.g. 0.1.2, no v prefix)" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: mcp-registry-publish-bytefolk-mem-mcp + cancel-in-progress: false + +jobs: + publish: + name: Publish io.github.bytefolk/mem-mcp (OIDC) + if: github.repository == 'bytefolk/mem' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: mcp-registry + permissions: + contents: read + id-token: write + steps: + - name: Check out default branch for registry manifest + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Require @bytefolk/mem-mcp on npm before Registry write + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ ! "${VERSION}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "expected X.Y.Z, got ${VERSION}" >&2 + exit 1 + fi + meta="$(curl -fsS "https://registry.npmjs.org/@bytefolk/mem-mcp/${VERSION}")" + name="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["name"])' <<<"${meta}")" + mcp="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("mcpName",""))' <<<"${meta}")" + [[ "${name}" == "@bytefolk/mem-mcp" ]] + [[ "${mcp}" == "io.github.bytefolk/mem-mcp" ]] + + - name: Install mcp-publisher + env: + MCP_PUBLISHER_VERSION: v1.8.1 + MCP_PUBLISHER_LINUX_AMD64_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc + MCP_PUBLISHER_LINUX_ARM64_SHA256: 8dd75a6cf6845688b5d4e46df58d3ca26d5c8d233bb0626606e1db82c5e883e4 + run: | + set -euo pipefail + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" + checksum_var="MCP_PUBLISHER_$(printf '%s_%s' "${os}" "${arch}" | tr '[:lower:]' '[:upper:]')_SHA256" + checksum="${!checksum_var:-}" + [[ "${checksum}" =~ ^[0-9a-f]{64}$ ]] || { + echo "no pinned mcp-publisher checksum for ${os}/${arch}" >&2 + exit 1 + } + archive="${RUNNER_TEMP}/mcp-publisher_${os}_${arch}.tar.gz" + curl -fsSL --retry 3 \ + "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_${os}_${arch}.tar.gz" \ + --output "${archive}" + printf '%s %s\n' "${checksum}" "${archive}" | sha256sum --check --strict + tar -xzf "${archive}" mcp-publisher + chmod +x ./mcp-publisher + + - name: Stage official server.json + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + python3 - <<'PY' + import json, os + path = "npm/mcp-registry.server.json" + with open(path) as f: + doc = json.load(f) + version = os.environ["VERSION"] + doc["version"] = version + doc["packages"][0]["version"] = version + with open("server.json", "w") as f: + json.dump(doc, f, indent=2) + f.write("\n") + PY + + - name: Login with GitHub OIDC and publish + run: | + set -euo pipefail + ./mcp-publisher login github-oidc + ./mcp-publisher publish + + - name: Read back Registry search + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + curl -fsS "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.bytefolk/mem-mcp" \ + | tee "${RUNNER_TEMP}/mcp-registry-readback.json" + python3 - <<'PY' + import json, os, sys + data = json.load(open(os.environ["RUNNER_TEMP"] + "/mcp-registry-readback.json")) + servers = data.get("servers") or data.get("result") or [] + if not servers: + sys.exit("HOLD: registry search returned no servers") + print("G5 readback: %s" % json.dumps(servers[0])[:2000]) + PY diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index dc94336..103adac 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,47 +1,54 @@ name: NPM Publish -# G4 (npm publish) lives in its own workflow because scripts/test_release_guards.sh -# forbids `npm publish` inside release.yml and requires the draft publication to -# remain release.yml's final command. G1 (GitHub Release) must exist first: -# install.js pulls the platform binaries from the Release at install time. - +# GitHub binary publication stays in release.yml. A Release created with the +# repository GITHUB_TOKEN may not trigger this workflow; dispatch the exact tag +# explicitly after G1 asset verification (see docs/maintainers/releasing.md). on: release: types: [published] workflow_dispatch: inputs: version: - description: "Existing published release tag to publish to npm (e.g., v0.1.0)" + description: "Existing stable tag, also selected as the workflow ref (v0.1.2)" required: true type: string permissions: contents: read - id-token: write +# Serialize every version of this package: next is shared across releases. concurrency: - group: npm-publish-${{ inputs.version || github.event.release.tag_name }} + group: npm-publish-bytefolk-mem-mcp cancel-in-progress: false jobs: npm-publish: - name: Publish npm package (OIDC Trusted Publishing) + name: Publish verified npm tarball to next (OIDC) + if: github.repository == 'bytefolk/mem' runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 20 + environment: npm-release + permissions: + contents: read + id-token: write + env: + NPM_RELEASE_PROOF: ${{ vars.NPM_RELEASE_PROOF }} steps: - - name: Resolve release tag + - name: Validate exact stable event and tag before checkout id: tag env: INPUT_VERSION: ${{ inputs.version }} RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} run: | set -euo pipefail - TAG="${INPUT_VERSION:-${RELEASE_TAG_NAME}}" - if [[ ! "${TAG}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "expected a release tag such as v0.1.0, got: ${TAG:-}" >&2 + tag="${INPUT_VERSION:-${RELEASE_TAG_NAME}}" + if [[ ! "${tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo 'HOLD: an exact stable vX.Y.Z tag is required' >&2 exit 1 fi - printf 'tag=%s\n' "${TAG}" >> "${GITHUB_OUTPUT}" + [[ "${GITHUB_REF}" == "refs/tags/${tag}" ]] + [[ "${GITHUB_EVENT_NAME}" == release || "${GITHUB_EVENT_NAME}" == workflow_dispatch ]] + printf 'tag=%s\n' "${tag}" >> "${GITHUB_OUTPUT}" - name: Check out exact tag commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -50,22 +57,56 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Require the GitHub Release (G1) to exist before npm publish (G4) + - name: Set up Node 24 without release caches + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24' + package-manager-cache: false + + - name: Require current release-owner org and publisher proof env: - GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ steps.tag.outputs.tag }} + run: | + node --input-type=module -e ' + import { checkProof } from "./scripts/npm-release.mjs"; + checkProof(JSON.parse(process.env.NPM_RELEASE_PROOF || "null"), + process.env.RELEASE_TAG, process.env.GITHUB_SHA); + ' + + - name: Install reviewed npm CLI with lifecycle scripts disabled run: | set -euo pipefail - gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null + node -e ' + const fs = require("node:fs"), p = process.env.RUNNER_TEMP; + fs.writeFileSync(p + "/bootstrap-user.npmrc", "", {flag: "wx", mode: 0o600}); + fs.writeFileSync(p + "/bootstrap-global.npmrc", "", {flag: "wx", mode: 0o600}); + ' + NPM_CONFIG_USERCONFIG="${RUNNER_TEMP}/bootstrap-user.npmrc" \ + NPM_CONFIG_GLOBALCONFIG="${RUNNER_TEMP}/bootstrap-global.npmrc" \ + NPM_CONFIG_CACHE="${RUNNER_TEMP}/bootstrap-npm-cache" \ + npm install --global npm@11.15.0 --ignore-scripts --registry=https://registry.npmjs.org - - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - name: Test release refusal paths and npm wrapper + run: | + node --test scripts/npm-release.test.mjs + npm test --prefix npm + + - name: Set up Go for read-only binary metadata inspection + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: - node-version: 22 + go-version-file: server/go.mod + cache: false + + - name: Preflight, publish next with provenance, verify registry and signatures + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.tag.outputs.tag }} + run: node scripts/npm-release.mjs "${RELEASE_TAG}" - - name: Publish @fullstack-ai-infra/mem-mcp (OIDC) - working-directory: npm - # No NPM_TOKEN / NODE_AUTH_TOKEN: auth is exchanged from the GitHub OIDC - # id-token against the npmjs Trusted Publisher configured for - # org=fullstack-ai-infra / repo=mem / workflow=npm-publish.yml. - run: npm publish --provenance --access public + - name: Record next receipt and separate owner gates + run: | + node <<'NODE' + const fs = require("node:fs"); + const receipt = fs.readFileSync(process.env.RUNNER_TEMP + "/mem-npm-release/receipt.json", "utf8"); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, "Published to next; latest requires release-owner acceptance.\n\n```json\n" + receipt + "\n```\n"); + NODE diff --git a/CHANGELOG.md b/CHANGELOG.md index a4fdaf0..563e24c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The project publishes 0.x prerelease versions; a stable release line is not yet - Ingest cursor locks try non-blocking exclusive locks and give up after 5s so a wedged peer becomes a warning instead of a silent hang. Refs #139. +## [0.1.2] - 2026-09-18 + ### Added - Additive `durable-memory.v1` envelope for derived RoleWeave/mem records @@ -79,17 +81,18 @@ The project publishes 0.x prerelease versions; a stable release line is not yet platform-equivalence table covering macOS, Ubuntu/Debian and WSL2 (`#109`). `mem doctor` already names `deploy/compose` on a machine with no config. - Migrate GitHub repository, Release, issue, badge, and raw-content coordinates - to the canonical `bytefolk` organization while retaining the published npm - scope and the existing cache paths. -- Follow the registry identifier after that rename: `mcpName` becomes - `io.github.bytefolk/mem-mcp`, because the official MCP Registry namespace is - derived from the repository owner and the previous value, naming the - organization this repository used to belong to, cannot resolve. The npm - package name and the installer's cache directory are deliberately unchanged, - so an existing installation keeps working and keeps its cache. - `npm/registry-identity.test.js` now asserts the identifier against the - repository coordinate the installer itself uses, so the next rename cannot - leave a stale identifier behind unnoticed. + to the canonical `bytefolk` organization. +- Rename the npm wrapper to `@bytefolk/mem-mcp@0.1.2` and the MCP registry + identity to `io.github.bytefolk/mem-mcp`. New executable caches use + `bytefolk/mem-mcp`; a matching version/platform in the old + `fullstack-ai-infra/mem-mcp` cache can seed a separately verified copy. + Old cache entries, including 0.1.1, are never changed or removed by this + compatibility lookup. Explicit cache overrides keep their existing meaning. + The old npm package remains available for rollback; migration does not + unpublish it or change stored memories. Update host package arguments using + the migration guide in `npm/README.md`. + `npm/registry-identity.test.js` asserts the identifier against the + repository coordinate the installer itself uses. - Internal: the local ingestion mechanics used by `mem ingest qoder` — deterministic recursive transcript walk, per-path line cursors (atomic rename write, reset when a file is rewritten shorter), the @@ -567,6 +570,7 @@ The project publishes 0.x prerelease versions; a stable release line is not yet - Preserve the primary Web acceptance failure when browser or Vite cleanup also fails. -[Unreleased]: https://github.com/bytefolk/mem/compare/v0.1.1...HEAD +[Unreleased]: https://github.com/bytefolk/mem/compare/v0.1.2...HEAD +[0.1.2]: https://github.com/bytefolk/mem/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/bytefolk/mem/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/bytefolk/mem/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 15df72a..a9b7b2e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Status](https://img.shields.io/badge/status-experimental-orange.svg)](#project-status) [![MCP Server](https://img.shields.io/badge/MCP%20Server-26%20tools-blue?logo=modelcontextprotocol)](docs/mcp.md) -[![smithery](https://smithery.ai/badge/@fullstack-ai-infra/mem-mcp)](https://smithery.ai/server/@fullstack-ai-infra/mem-mcp) +[![smithery](https://smithery.ai/badge/@bytefolk/mem-mcp)](https://smithery.ai/server/@bytefolk/mem-mcp) **A portable, self-hosted memory plane for AI agents.** diff --git a/deploy/helm/mem/Chart.yaml b/deploy/helm/mem/Chart.yaml index c86c3cb..640fa31 100644 --- a/deploy/helm/mem/Chart.yaml +++ b/deploy/helm/mem/Chart.yaml @@ -2,6 +2,6 @@ apiVersion: v2 name: mem description: Production Web, memd, migration, and Worker workloads for mem type: application -version: 0.1.1 -appVersion: "0.1.1" +version: 0.1.2 +appVersion: "0.1.2" kubeVersion: ">=1.28.0-0" diff --git a/deploy/helm/mem/values-production.example.yaml b/deploy/helm/mem/values-production.example.yaml index 3559e42..26f378c 100644 --- a/deploy/helm/mem/values-production.example.yaml +++ b/deploy/helm/mem/values-production.example.yaml @@ -2,13 +2,13 @@ images: server: repository: registry.example.internal/mem/server - tag: "0.1.1" + tag: "0.1.2" worker: repository: registry.example.internal/mem/worker - tag: "0.1.1" + tag: "0.1.2" web: repository: registry.example.internal/mem/web - tag: "0.1.1" + tag: "0.1.2" existingSecret: mem-runtime diff --git a/deploy/helm/mem/values.yaml b/deploy/helm/mem/values.yaml index 90cdb5c..6e68aaf 100644 --- a/deploy/helm/mem/values.yaml +++ b/deploy/helm/mem/values.yaml @@ -14,15 +14,15 @@ runtime: images: server: repository: mem-server - tag: "0.1.1" + tag: "0.1.2" pullPolicy: IfNotPresent worker: repository: mem-worker - tag: "0.1.1" + tag: "0.1.2" pullPolicy: IfNotPresent web: repository: mem-web - tag: "0.1.1" + tag: "0.1.2" pullPolicy: IfNotPresent serviceAccount: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 9bc88bd..b655f8b 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -64,7 +64,7 @@ Use an immutable version for all three images. The example below builds the model-free Worker; optional heavy extras must be explicitly selected. ```bash -export MEM_VERSION=0.1.1 +export MEM_VERSION=0.1.2 export MEM_REVISION="$(git rev-parse HEAD)" export MEM_REGISTRY=registry.example.internal/mem diff --git a/docs/maintainers/releasing.md b/docs/maintainers/releasing.md index 4af8c0b..42b7cef 100644 --- a/docs/maintainers/releasing.md +++ b/docs/maintainers/releasing.md @@ -5,6 +5,21 @@ repository Release workflow publishes multi-platform `mem-mcp` binaries to a GitHub Release. The npm package is published only after that Release has been verified because its runtime bootstrap downloads and verifies those assets. +The [2026-09-10 release decision](https://github.com/bytefolk/mem/issues/153#issuecomment-5612770493) +selects `@bytefolk/mem-mcp@0.1.2`, superseding the earlier keep-old-scope +instruction. [mem#153](https://github.com/bytefolk/mem/issues/153) and +[organization #22](https://github.com/bytefolk/.github/issues/22) govern the +migration. Source preparation does not clear their npm ownership/authentication +HOLD. The 2026-09-10 owner check returned `ENEEDAUTH`; no npm control, publisher +binding or real publication is established by this document or local tests. + +Human contribution provenance: [#154](https://github.com/bytefolk/mem/pull/154) +(`bcd786f`, liyuanyang) supplies the actual package/cache migration; +[#162](https://github.com/bytefolk/mem/pull/162) (waterbro-8) supplies the MCP +registry identity correction only; [#168](https://github.com/bytefolk/mem/pull/168) +(`8a92baa`, waterbro-8) supplies mechanical version alignment. Retain these +sources in the release PR; do not attribute their changes to automated tools. + ## Version policy `mem` is a single-version monorepo. The Go service and clients, Python worker, @@ -102,28 +117,213 @@ creates, moves, or replaces a tag. `sha256sum --check --strict mem-mcp-checksums.txt`, and inspect `go version -m` on each binary for the recorded release commit before starting npm publication. -6. In `npm/`, rerun `npm test`, the npm 12 clean-tarball test, and - `npm pack --dry-run --ignore-scripts`. Confirm - `npm view @fullstack-ai-infra/mem-mcp@VERSION version` does not find the - version, then publish it once with - `npm publish --access public --tag latest`. -7. Install the public package in a clean temporary consumer with lifecycle - scripts disabled, invoke `mem-mcp`, and confirm the checksum-verified binary - is fetched from the matching GitHub Release. Record the Release URL, npm - package URL, checksum result, and smoke-test result on the release issue. - -Trusted Publishing is the target npm credential path. For the bootstrap -publication, prefer interactive npm 2FA. If a token is required, use a one-day -granular npm token restricted to read/write access for -`@fullstack-ai-infra/mem-mcp` only, with no unrelated package or organization -access. Keep it out of repository files, logs, workflow inputs, and issue -comments. Read the published version back from the registry and complete the -clean-install smoke test before revoking the token immediately. - -If GitHub asset upload fails, inspect and remove any incomplete draft before a -reviewed retry; do not move the tag. If npm publication is wrong, deprecate the -bad version when possible and correct it with a new patch version rather than -reusing either the tag or package version. +6. Complete the interactive bootstrap and owner proof below. Run `npm test` + in `npm/`, the npm 12 clean-tarball test on Linux, and + `npm pack --dry-run --ignore-scripts`. Record any skipped platform explicitly. +7. Run `.github/workflows/npm-publish.yml` from the **exact stable tag**, after + approving its `npm-release` environment. Its only registry write publishes + the checked tarball with `--tag next --access public --provenance`. +8. Read back metadata, integrity, OIDC publisher ID, signatures, provenance and + dist-tags. The workflow installs that exact public version with lifecycle + scripts disabled and runs `npm audit signatures`. It does not launch the + binary or promote `latest`. Complete the separate owner gates below. + +## Bootstrap and Trusted Publisher setup (release owner only) + +All commands in this section describe future owner actions, not actions +performed by the source-preparation PR. Use Node 24 and npm >=11.15.0. The +workflow pins npm 11.15.0 and validates the actual versions; a Node upgrade +alone does not prove the npm requirement. + +1. The operator designated by organization #22 (`PeterGuy326`) logs into npm + interactively on an authorized machine with 2FA. Privately inspect + `npm whoami`, `npm org ls bytefolk --json`, organization role, package-name + control, team/access and 2FA policy. Anonymous 404 and GitHub organization + membership are not npm ownership proof. Record only a sanitized verdict on + #153/#22. An error, missing permission or unclear ownership keeps the HOLD. +2. Prepare and independently review the **aligned** `0.1.2-rc.0` source, + annotated `v0.1.2-rc.0` tag and matching seven GitHub assets using the same + binary release gates. Do not just change a stable wrapper's version: the + installer resolves assets for its own exact version. The stable npm workflow + deliberately refuses RC tags. + + **Separate prerequisite, NOT VERIFIED:** this stable-source PR does not + provide an aligned RC checkout, RC Release or bootstrap tarball. The current + binary workflow and source/version validators accept the `-rc.NUMBER` + spelling, but the RC must still have its own reviewed version/changelog + surfaces and successful full gate run. If the selected RC source lacks + those capabilities, a separate reviewed RC-gate implementation is required + first. The following owner commands become usable only after that evidence + exists. Do not merge/tag stable and then retroactively create an RC from + different or unreviewed bytes; coordinate the RC prerequisite before sealing + the stable release commit/tag. Never retag a published version. +3. From that clean RC checkout, pack and inspect the wrapper. After explicit + bootstrap authorization, publish that reviewed tarball with interactive 2FA: + + ```sh + npm publish ./bytefolk-mem-mcp-0.1.2-rc.0.tgz --access public --tag next --ignore-scripts --registry=https://registry.npmjs.org + npm view @bytefolk/mem-mcp@0.1.2-rc.0 name version dist --json --registry=https://registry.npmjs.org + npm dist-tag ls @bytefolk/mem-mcp --registry=https://registry.npmjs.org + ``` + + Verify public access and clean installation against the RC assets. The + interactive bootstrap is not proof of GitHub OIDC provenance. Never assign + this RC to `latest`, reuse a published version, or provide a CI token fallback. +4. On the new package's npm settings page, configure GitHub Trusted Publishing: + organization `bytefolk`, repository `mem`, workflow filename + `npm-publish.yml`, environment `npm-release`, and permission to run direct + `npm publish`. A stage-only publisher is insufficient for this workflow. + Read back the binding (settings UI or `npm trust list @bytefolk/mem-mcp + --json`) and its configuration ID. Do not infer success just because npm + accepted a settings form. No settings are created by the workflow. +5. Configure the GitHub `npm-release` environment with release-owner required + review, prevention of self-review/admin bypass, and permitted release tags; + separately protect tag creation. A tag's ancestry in protected `main` is + checked by code. Record exact-commit CI and independent review before + approving deployment. Environment approval is a gate, not evidence that + npm ownership was verified. +6. Store a sanitized, exact-release JSON attestation as the **environment** + variable `NPM_RELEASE_PROOF`, using the schema below. Fill it only from the + authenticated owner's readback, with a lifetime of at most 24 hours. Do not + put credentials, OTPs, raw account output or private evidence in it. Renew + proof for each tag/commit or after a binding change. + +```json +{ + "schema": 1, + "package": "@bytefolk/mem-mcp", + "tag": "v0.1.2", + "commit": "REPLACE_WITH_EXACT_40_CHARACTER_TAG_COMMIT", + "channel": "next", + "organization": "bytefolk", + "organizationControlVerified": false, + "packageAccessVerified": false, + "twoFactorVerified": false, + "publisherVerified": false, + "allowPublish": false, + "repository": "bytefolk/mem", + "workflow": "npm-publish.yml", + "environment": "npm-release", + "publisherId": "REPLACE_WITH_NPM_OIDC_CONFIG_ID", + "approvedBy": "REPLACE_WITH_HUMAN_GITHUB_LOGIN", + "evidence": "REPLACE_WITH_SANITIZED_OWNER_COMMENT_ON_153_OR_22", + "verifiedAt": "REPLACE_WITH_UTC_TIMESTAMP", + "expiresAt": "REPLACE_WITH_UTC_TIMESTAMP" +} +``` + +This deliberately non-authorizing example must fail. The attestation is a +human-controlled prerequisite, **not an automated npm membership lookup**. +The workflow also requires public bootstrap readback, a successful OIDC +exchange, and the same publisher ID in the published version. Missing or stale +proof blocks publication; no token, legacy scope or alternate registry fallback +exists. Do not fill these fields from the naming decision alone. + +## Stable OIDC publication to next + +Merge the stable `0.1.2` source/version PR, independently approve its exact +commit, and complete the stable GitHub binary Release first. The npm workflow +must exist in both the default branch and the selected stable tag. GitHub +events emitted by `GITHUB_TOKEN` normally do not start another workflow, so a +successful binary Release may need this explicit owner dispatch: + +```sh +gh workflow run npm-publish.yml --repo bytefolk/mem --ref v0.1.2 -f version=v0.1.2 +``` + +Unlike the binary Release workflow's manual entry, this npm entry rejects a +`main` dispatch. GitHub's event SHA/ref, checked-out annotated tag, npm package +and provenance source must agree. Its guard fetches `origin/main` and the exact +tag, rejects non-ancestry, dirty source, mismatched package/MCP/version surfaces, +draft/prerelease/wrong releases, and missing/extra/empty assets. It downloads all +seven assets, validates exactly six checksum rows, checks digests and embedded +Go commit/platform metadata without executing binaries, and packs the exact +six wrapper files with scripts disabled. Source, proof, Release identity, +registry version absence, channels and tarball integrity are rechecked before +the sole publish call. Repository npmrc files and ambient npm credentials or +configuration overrides are refused; npm runs with empty, isolated config. + +The run then anonymously reads the new version, checks tarball integrity, +metadata, GitHub OIDC configuration ID, signature/provenance presence, `next` +and unchanged other tags, and verifies registry signatures/attestations through +`npm audit signatures` in a clean consumer. Its receipt records only `next`. +No automatic dist-tag promotion, access grant, deprecation or rollback occurs. + +The npm release proof deliberately fixes `channel` to `next`; changing that +channel is a release-policy change and requires updating the proof validator, +workflow assertions, rollback analysis and independent review together. The +MCP Registry workflow likewise pins `mcp-publisher` to an explicit upstream +version and per-platform SHA-256 digest. Upgrade both values from the same +upstream release asset set; never switch the publish job back to `latest`. + +If publish fails or subsequent readback/audit fails, **stop**. The version may +already exist even if the run is red. Inspect the exact package/version, +preflight integrity and run before deciding recovery; never rerun publish as +an authentication test. Registry propagation delays also leave the run failed +pending read-only verification. Never overwrite/unpublish or move the tag. + +## Separate latest and migration gates (release owner only) + +Before promotion, attach all of the following to #153/#122: successful exact +tag OIDC run and receipt; current public access and Trusted Publisher readback; +verified npm signatures and provenance with expected repository/commit/workflow; +six binary checksums; and clean Linux, macOS and Windows install **and launch** +evidence. Install with lifecycle scripts disabled, invoke `mem-mcp`, and verify +its matching GitHub binary download. Include the existing-cache compatibility +case. Managed macOS machines must not execute newly built temporary Go binaries; +use approved isolated platform runners for those acceptance checks. + +After the release owner explicitly accepts that evidence, use interactive 2FA: + +```sh +npm dist-tag add @bytefolk/mem-mcp@0.1.2 latest --registry=https://registry.npmjs.org +npm view @bytefolk/mem-mcp dist-tags --json --registry=https://registry.npmjs.org +``` + +Require `latest=0.1.2` and repeat a clean default-channel install. Only after +OIDC is proven should the owner grant the reviewed `bytefolk:developers` +package access and enable npm's require-2FA/disallow-tokens setting, then read +those settings back. Complete consumer, directory/MCP, documentation and +lockfile migrations. Only after those consumers work may the owner authorize +deprecation of `@fullstack-ai-infra/mem-mcp` with an explicit replacement +message. Keep old `0.1.1` installable; **never unpublish** it or delete its cache. + +Before cutover, rollback means stop and keep the old package untouched. After +cutover, restore consumers to the still-installable old coordinates if needed; +an owner may undo deprecation or restore a previously verified new-scope tag. +Correct bad releases with a higher patch. Recreate an incorrect publisher +binding through the owner process rather than adding a token fallback. + +## Local verification and evidence limits + +```sh +node --test scripts/npm-release.test.mjs +./scripts/test_release_guards.sh +./scripts/validate_release_version.sh 0.1.2 +git diff --check +``` + +The new Node tests use temporary fixtures and injected Git/GitHub/npm command +adapters; no test publishes, changes remote settings, or executes a Go binary. +They exercise refusal and partial-failure behavior, including unavailable org +proof/registry, stale source, unsafe tags/packages, bad assets and failed +readback. A fixture PASS is local E3 evidence only. GitHub Actions execution, +independent approval, authenticated npm ownership/binding, actual OIDC/provenance +publication, three-platform launches and `latest` remain **NOT VERIFIED** until +their separate real evidence is recorded. + +Technical assumptions checked against official documentation on 2026-09-10: +[trusted publishers](https://docs.npmjs.com/trusted-publishers/), +[npm trust prerequisites](https://docs.npmjs.com/cli/v11/commands/npm-trust/), +[publish options](https://docs.npmjs.com/cli/v11/commands/npm-publish/), +[dist-tags](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag/), and +[provenance verification](https://docs.npmjs.com/generating-provenance-statements/). +GitHub's [workflow trigger rules](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow) +explain the explicit dispatch after a Release published using `GITHUB_TOKEN`. +The trust command requires npm >=11.15.0 and an existing package; GitHub OIDC +requires the configured workflow/environment and hosted runner. Direct-publish +permission must be checked explicitly. `next` must be specified because npm's +default publication channel is `latest`. ## Deferred channels @@ -133,7 +333,8 @@ The following are out of scope for the initial baseline: - PyPI publication - Container registry publication - Homebrew or other operating-system package managers -- Signed multi-platform binaries and provenance attestations +- Signed multi-platform binaries and binary provenance attestations (npm + package provenance is required by the OIDC gate above) Each new channel requires its own issue, threat and rollback analysis, credential design, and independently reviewable workflow. GitHub Release diff --git a/npm/README.md b/npm/README.md index 7d6665c..50b6267 100644 --- a/npm/README.md +++ b/npm/README.md @@ -7,7 +7,7 @@ This package distributes the `mem-mcp` stdio MCP server binary so it can be inst ## Install ```bash -npm install @fullstack-ai-infra/mem-mcp +npm install @bytefolk/mem-mcp@0.1.2 ``` ## Usage @@ -39,12 +39,34 @@ a later diagnostic sink fails. The executable cache is outside the installed npm package and is isolated by package version and platform. Defaults are `$XDG_CACHE_HOME` (or `~/.cache`) on Linux, `~/Library/Caches` on macOS, and `%LOCALAPPDATA%` on Windows, below -`fullstack-ai-infra/mem-mcp`. Set `MEM_MCP_CACHE_DIR` to an absolute path to use +`bytefolk/mem-mcp`. Set `MEM_MCP_CACHE_DIR` to an absolute path to use a different writable cache root. A per-asset cross-process lock serializes verification and atomic replacement, so concurrent hosts cannot expose or delete each other's downloads. Stale-lock recovery removes only artifacts named by that lock owner's nonce and leaves foreign temporary files untouched. +With the default cache root, the wrapper also checks the old +`fullstack-ai-infra/mem-mcp/v/-` location for the +exact requested version and platform. A regular file matching the current +Release checksum is copied into the new cache, verified again, and atomically +installed under the new cache lock. The old file and its permissions are left +untouched, even on failure. Missing, corrupt, unreadable, or symlinked legacy +entries fall back to the normal verified download. A 0.1.1 binary is never +substituted for 0.1.2. Setting `MEM_MCP_CACHE_DIR` disables this default legacy +binary lookup; its selected cache retains the verification and cleanup rules +above. Before any directory, lock, permission or cleanup change, the installer +resolves the destination and legacy cache paths, including existing ancestors +of directories not yet created. If the trees overlap in either direction +(including namespace, root, version or platform symlink aliases), startup fails +without changing either cache or downloading a manifest. Choose a separate +`MEM_MCP_CACHE_DIR` to recover; do not remove the old cache to resolve the error. +Explicit `MEM_MCP_CACHE_DIR` and programmatic `cacheDir` selections also reject +overlap with the default legacy tree. They disable reuse, not data protection. +Existing destination and same-version legacy entries are also compared by +device and inode: a hardlink is rejected before mutation even when the resolved +paths differ. These checks inspect only the selected paths, without scanning +other cached versions or platforms. + Bootstrap and verification diagnostics use stderr. Stdout is inherited by the verified binary and remains clean for the MCP stdio protocol. This first-run bootstrap works with npm 12 without approving dependency install scripts or @@ -59,7 +81,7 @@ bounded shutdown grace period. "mcpServers": { "mem": { "command": "npx", - "args": ["-y", "@fullstack-ai-infra/mem-mcp"], + "args": ["-y", "@bytefolk/mem-mcp@0.1.2"], "env": { "MEM_SERVER": "http://localhost:8787", "MEM_TOKEN": "mem_..." @@ -75,9 +97,29 @@ bounded shutdown grace period. claude mcp add --scope project --transport stdio \ --env MEM_SERVER=http://localhost:8787 \ --env MEM_TOKEN=mem_... \ - mem -- npx -y @fullstack-ai-infra/mem-mcp + mem -- npx -y @bytefolk/mem-mcp@0.1.2 ``` +## Migrating from 0.1.1 + +The npm package is now `@bytefolk/mem-mcp`; the MCP registry identifier is +`io.github.bytefolk/mem-mcp`. The executable and MCP handshake name remain +`mem-mcp`. Replace the old dependency key in your project's `package.json` +with `"@bytefolk/mem-mcp": "0.1.2"` and run `npm install`, or update your host's +`npx` argument to `@bytefolk/mem-mcp@0.1.2` as shown above. Keep the existing +server, token and workspace settings. + +The wrapper requires the matching `v0.1.2` GitHub Release binaries and checksum +manifest. Source/package metadata alone does not establish that a clean +installation can launch; those assets and the npm publication must be verified +before rolling out the new coordinates. + +Migration does not move stored memories or delete old caches. Keep +`@fullstack-ai-infra/mem-mcp@0.1.1` available until the new package has passed +installation and launch checks. To roll back, restore that exact dependency or +host argument and retain the same connection settings. Do not unpublish the +old package; deprecation follows verified consumer and directory migration. + ## Configuration | Environment variable | Flag equivalent | Default | Description | diff --git a/npm/clean-tarball.test.js b/npm/clean-tarball.test.js index ae2632a..f763970 100644 --- a/npm/clean-tarball.test.js +++ b/npm/clean-tarball.test.js @@ -134,12 +134,14 @@ test( const packageRoot = join( consumer, "node_modules", - "@fullstack-ai-infra", + "@bytefolk", "mem-mcp", ); const packageJson = JSON.parse( readFileSync(join(packageRoot, "package.json"), "utf8"), ); + assert.equal(packageJson.name, "@bytefolk/mem-mcp"); + assert.equal(packageJson.mcpName, "io.github.bytefolk/mem-mcp"); assert.equal(packageJson.version, PACKAGE_VERSION); assert.equal(packageJson.scripts.postinstall, undefined); diff --git a/npm/install.js b/npm/install.js index 4215cc7..ac8cc03 100644 --- a/npm/install.js +++ b/npm/install.js @@ -13,13 +13,17 @@ const { createHash, randomBytes, timingSafeEqual } = require("crypto"); const { chmodSync, + constants: { COPYFILE_EXCL }, + copyFileSync, createReadStream, createWriteStream, lstatSync, mkdirSync, readFileSync, + realpathSync, renameSync, rmSync, + statSync, unlinkSync, writeFileSync, } = require("fs"); @@ -30,7 +34,7 @@ const { pipeline } = require("stream/promises"); const { TextDecoder } = require("util"); const { assetFor } = require("./platforms"); -const PACKAGE = "@fullstack-ai-infra/mem-mcp"; +const PACKAGE = "@bytefolk/mem-mcp"; const REPO = "bytefolk/mem"; const CHECKSUM_ASSET = "mem-mcp-checksums.txt"; const MAX_CHECKSUM_BYTES = 64 * 1024; @@ -40,6 +44,7 @@ const LOCK_WAIT_TIMEOUT_MS = 120 * 1000; const LOCK_STALE_MS = 10 * 60 * 1000; const LOCK_ORPHAN_GRACE_MS = 5 * 1000; const LOCK_POLL_MS = 50; +const WINDOWS_MISSING_LOCK_GRACE_MS = 250; const guardedResponses = new WeakSet(); // Version from package.json — single source of truth for both release URLs. @@ -82,7 +87,12 @@ function removeOwnedPath(target) { try { const info = lstatSync(target); if (info.isDirectory() && !info.isSymbolicLink()) { - rmSync(target, { recursive: true, force: true }); + rmSync(target, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); } else { unlinkSync(target); } @@ -105,7 +115,7 @@ function absolutePath(value, osPlatform, label) { return value; } -function cacheRootFor(options = {}) { +function namespacedCacheRootFor(options, namespace) { const osPlatform = options.osPlatform || platform(); const environment = options.environment || process.env; const homeDirectory = options.homeDirectory || homedir(); @@ -120,7 +130,7 @@ function cacheRootFor(options = {}) { if (environment.LOCALAPPDATA) { return pathApi.join( absolutePath(environment.LOCALAPPDATA, osPlatform, "LOCALAPPDATA"), - "fullstack-ai-infra", + namespace, "mem-mcp", ); } @@ -128,7 +138,7 @@ function cacheRootFor(options = {}) { absolutePath(homeDirectory, osPlatform, "home directory"), "AppData", "Local", - "fullstack-ai-infra", + namespace, "mem-mcp", ); } @@ -138,7 +148,7 @@ function cacheRootFor(options = {}) { absolutePath(homeDirectory, osPlatform, "home directory"), "Library", "Caches", - "fullstack-ai-infra", + namespace, "mem-mcp", ); } @@ -146,18 +156,22 @@ function cacheRootFor(options = {}) { if (environment.XDG_CACHE_HOME) { return pathApi.join( absolutePath(environment.XDG_CACHE_HOME, osPlatform, "XDG_CACHE_HOME"), - "fullstack-ai-infra", + namespace, "mem-mcp", ); } return pathApi.join( absolutePath(homeDirectory, osPlatform, "home directory"), ".cache", - "fullstack-ai-infra", + namespace, "mem-mcp", ); } +function cacheRootFor(options = {}) { + return namespacedCacheRootFor(options, "bytefolk"); +} + function safeVersion(version) { if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { throw new Error(`Unsafe package version for cache path: ${version}`); @@ -178,6 +192,80 @@ function cacheDirectory(options = {}) { return pathApi.join(root, `v${version}`, `${osPlatform}-${osArch}`); } +// Resolve existing ancestors without creating the missing suffix. In particular, +// a namespace/version symlink must be followed before comparing cache trees. +// Dangling links and unresolvable paths fail closed instead of being treated as +// a fresh directory that recursive mkdir could create in the legacy cache. +function resolvedCachePath(target) { + absolutePath(target, platform(), "mem-mcp cache directory"); + const missing = []; + let current = target; + let concurrentCreateRetries = 0; + for (;;) { + try { + return path.join(realpathSync.native(current), ...missing); + } catch (err) { + if (err.code !== "ENOENT") throw err; + try { + const info = lstatSync(current); + if (info.isSymbolicLink() || concurrentCreateRetries >= 3) { + throw new Error(`Cannot safely resolve mem-mcp cache path: ${current}`); + } + // Another installer created this non-link ancestor after realpath + // returned ENOENT. Retry the same path instead of mistaking that safe + // creation race for a dangling symlink. + concurrentCreateRetries += 1; + continue; + } catch (inspectionError) { + if (inspectionError.code !== "ENOENT") throw inspectionError; + } + const parent = path.dirname(current); + if (parent === current) throw err; + missing.unshift(path.basename(current)); + current = parent; + } + } +} + +function pathContains(parent, child) { + const relative = path.relative(parent, child); + return relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +function assertSeparateLegacyCache(destinationPaths, legacyPaths) { + const destinations = destinationPaths.map(resolvedCachePath); + const legacy = legacyPaths.map(resolvedCachePath); + for (const destination of destinations) { + for (const source of legacy) { + if (pathContains(destination, source) || pathContains(source, destination)) { + throw new Error( + `Refusing mem-mcp destination overlapping legacy cache: ${destination} and ${source}. ` + + "Set MEM_MCP_CACHE_DIR to a separate directory.", + ); + } + } + } +} + +function assertSeparateLegacyEntry(destination, source) { + let destinationInfo; + let sourceInfo; + try { + destinationInfo = statSync(destination, { bigint: true }); + sourceInfo = statSync(source, { bigint: true }); + } catch (err) { + if (err.code === "ENOENT" || err.code === "ENOTDIR") return; + throw err; + } + if (destinationInfo.dev === sourceInfo.dev && destinationInfo.ino === sourceInfo.ino) { + throw new Error( + `Refusing mem-mcp destination sharing an inode with legacy cache: ${destination}. ` + + "Set MEM_MCP_CACHE_DIR to a separate directory.", + ); + } +} + function ensureCacheDirectory(cacheDir) { const hostPlatform = platform(); absolutePath(cacheDir, hostPlatform, "mem-mcp cache directory"); @@ -254,11 +342,12 @@ function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, mkd try { info = lstatSync(lockPath); } catch (err) { - // An EEXIST result followed by ENOENT means a competing owner released - // the lock before inspection. It is safe to retry, but it must take the - // normal deadline/delay path rather than spin synchronously. A Windows - // EPERM/EACCES without a lock to inspect remains a real permission failure. - if (err.code === "ENOENT" && mkdirError.code === "EEXIST") return; + // The owner may release the lock between our failed mkdir and inspection. + // Windows can report that mkdir race as EPERM/EACCES rather than EEXIST. + // Return "not observed" so the caller can retry those ambiguous Windows + // results for a short, bounded grace period without hiding a persistent + // permission failure. + if (err.code === "ENOENT" && isLockContention(mkdirError, "win32")) return false; if (err.code === "ENOENT") throw mkdirError; throw err; } @@ -274,7 +363,7 @@ function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, mkd : alive === true ? false : age >= staleMs; - if (!reclaimable) return; + if (!reclaimable) return true; const quarantine = `${lockPath}.stale.${process.pid}.${randomBytes(12).toString("hex")}`; try { @@ -282,7 +371,7 @@ function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, mkd } catch (err) { // Another contender changed the lock after we inspected it. Retrying is // safe, but uses the normal poll path so repeated races cannot busy-loop. - if (err.code === "ENOENT" || err.code === "EEXIST") return; + if (err.code === "ENOENT" || err.code === "EEXIST") return true; throw err; } @@ -292,6 +381,7 @@ function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, mkd } } removeOwnedPath(quarantine); + return true; } async function acquireAssetLock(cacheDir, asset, options = {}) { @@ -303,6 +393,7 @@ async function acquireAssetLock(cacheDir, asset, options = {}) { const signal = options.signal; const lockPath = path.join(cacheDir, `.${asset}.lock`); const deadline = Date.now() + waitTimeoutMs; + let missingWindowsLockSince = null; for (;;) { throwIfAborted(signal); @@ -330,8 +421,30 @@ async function acquireAssetLock(cacheDir, asset, options = {}) { // pass through the same deadline and abort-aware poll. This prevents a // repeated create/release race from bypassing the wait budget in a tight // synchronous loop. - reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, mkdirError); - if (Date.now() >= deadline) { + const lockObserved = reclaimStaleLock( + lockPath, + cacheDir, + asset, + staleMs, + orphanGraceMs, + mkdirError, + ); + const now = Date.now(); + const ambiguousWindowsRace = osPlatform === "win32" + && mkdirError.code !== "EEXIST" + && lockObserved === false; + if (ambiguousWindowsRace) { + missingWindowsLockSince ??= now; + if ( + now >= deadline + || now - missingWindowsLockSince >= WINDOWS_MISSING_LOCK_GRACE_MS + ) { + throw mkdirError; + } + } else { + missingWindowsLockSince = null; + } + if (now >= deadline) { throw new Error(`Timed out waiting for mem-mcp cache lock: ${lockPath}`); } await delay(Math.max(1, pollMs), signal); @@ -605,6 +718,24 @@ function quarantineCacheEntry(binPath, cacheDir, asset, nonce, suffix) { return quarantinePath; } +// Legacy caches are read-only inputs. Stage a separate copy under the new +// cache's lock, then verify that copy too: an old installer may change its +// source while we read. Never execute, chmod, rename or remove the old entry. +async function copyVerifiedLegacyBinary(source, destination, expected, signal) { + try { + await verifyFile(source, expected, signal); + copyFileSync(source, destination, COPYFILE_EXCL); + await verifyFile(destination, expected, signal); + return true; + } catch (err) { + removeIfPresent(destination); + if ((signal && signal.aborted) || err.name === "AbortError") throw err; + // Missing, unreadable, changed, or invalid legacy entries are optional; + // the normal verified Release download remains authoritative. + return false; + } +} + async function install(options = {}) { const osPlatform = options.osPlatform || platform(); const osArch = options.osArch || arch(); @@ -630,6 +761,20 @@ async function install(options = {}) { }; const asset = assetFor(osPlatform, osArch); + // Overrides disable legacy binary reuse, but cannot opt out of protecting the + // default legacy tree from destination writes through aliases or overlap. + // This is a filesystem path on the running host. osPlatform may select a + // foreign binary when a native explicit cacheDir is supplied (including CI). + const legacyRoot = namespacedCacheRootFor({ + osPlatform: platform(), + environment: { ...environment, MEM_MCP_CACHE_DIR: undefined }, + homeDirectory: options.homeDirectory, + }, "fullstack-ai-infra"); + const legacyVersion = path.join(legacyRoot, `v${version}`); + const legacyDirectory = path.join(legacyVersion, `${osPlatform}-${osArch}`); + const legacyPath = options.cacheDir === undefined && environment.MEM_MCP_CACHE_DIR === undefined + ? path.join(legacyDirectory, asset) + : null; const binPath = path.join(cacheDir, asset); const releaseBase = `https://github.com/${repository}/releases/download/v${version}`; const checksumUrl = `${releaseBase}/${CHECKSUM_ASSET}`; @@ -642,6 +787,16 @@ async function install(options = {}) { let operationError = null; throwIfAborted(signal); + // This must stay outside the mutation/cleanup try block and before mkdir, + // chmod or lock acquisition: even those operations can alter legacy data. + assertSeparateLegacyCache( + options.cacheDir !== undefined ? [cacheDir] : [ + cacheRootFor({ osPlatform, environment, homeDirectory: options.homeDirectory }), + path.dirname(cacheDir), cacheDir, + ], + [legacyRoot, legacyVersion, legacyDirectory], + ); + assertSeparateLegacyEntry(binPath, path.join(legacyDirectory, asset)); ensureCacheDirectory(cacheDir); lock = await acquireAssetLock(cacheDir, asset, { osPlatform, @@ -688,8 +843,14 @@ async function install(options = {}) { } tempPath = path.join(cacheDir, `.${asset}.${lock.nonce}.tmp`); - logger.log(`${PACKAGE}: downloading ${binaryUrl}...`); - await fetchFile(binaryUrl, tempPath, requestOptions); + const copiedLegacy = legacyPath !== null && + await copyVerifiedLegacyBinary(legacyPath, tempPath, expected, signal); + if (copiedLegacy) { + logger.log(`${PACKAGE}: copied verified legacy binary from ${legacyPath}`); + } else { + logger.log(`${PACKAGE}: downloading ${binaryUrl}...`); + await fetchFile(binaryUrl, tempPath, requestOptions); + } throwIfAborted(signal); await verifyFile(tempPath, expected, signal); throwIfAborted(signal); diff --git a/npm/install.test.js b/npm/install.test.js index 49aa4e5..6d4743b 100644 --- a/npm/install.test.js +++ b/npm/install.test.js @@ -226,7 +226,7 @@ test("cache paths are user-scoped, versioned, and require absolute overrides", ( environment: { XDG_CACHE_HOME: "/var/cache/example" }, homeDirectory: "/home/example", }), - "/var/cache/example/fullstack-ai-infra/mem-mcp", + "/var/cache/example/bytefolk/mem-mcp", ); assert.equal( cacheRootFor({ @@ -234,7 +234,7 @@ test("cache paths are user-scoped, versioned, and require absolute overrides", ( environment: {}, homeDirectory: "/Users/example", }), - "/Users/example/Library/Caches/fullstack-ai-infra/mem-mcp", + "/Users/example/Library/Caches/bytefolk/mem-mcp", ); assert.equal( cacheRootFor({ @@ -242,17 +242,17 @@ test("cache paths are user-scoped, versioned, and require absolute overrides", ( environment: { LOCALAPPDATA: "C:\\Users\\example\\AppData\\Local" }, homeDirectory: "C:\\Users\\example", }), - "C:\\Users\\example\\AppData\\Local\\fullstack-ai-infra\\mem-mcp", + "C:\\Users\\example\\AppData\\Local\\bytefolk\\mem-mcp", ); assert.equal( cacheDirectory({ osPlatform: "linux", osArch: "arm64", - version: "0.1.1", + version: "0.1.2", environment: { MEM_MCP_CACHE_DIR: "/var/cache/mem-mcp-test" }, homeDirectory: "/home/example", }), - "/var/cache/mem-mcp-test/v0.1.1/linux-arm64", + "/var/cache/mem-mcp-test/v0.1.2/linux-arm64", ); assert.throws( () => cacheRootFor({ @@ -278,14 +278,14 @@ test("install verifies a temporary download before exposing it", async (t) => { const osPlatform = platform(); const osArch = arch(); const asset = assetFor(osPlatform, osArch); - const cacheDir = join(cacheRoot, "v0.1.1", `${osPlatform}-${osArch}`); + const cacheDir = join(cacheRoot, "v0.1.2", `${osPlatform}-${osArch}`); const bytes = Buffer.from("trusted mem-mcp binary"); const requested = []; const installed = await install({ osPlatform, osArch, - version: "0.1.1", + version: "0.1.2", environment: { MEM_MCP_CACHE_DIR: cacheRoot }, homeDirectory: join(root, "read-only-package-home-must-not-be-used"), logger: QUIET_LOGGER, @@ -306,8 +306,8 @@ test("install verifies a temporary download before exposing it", async (t) => { } assert.deepEqual(readdirSync(cacheDir), [asset]); assert.deepEqual(requested, [ - "https://github.com/bytefolk/mem/releases/download/v0.1.1/mem-mcp-checksums.txt", - `https://github.com/bytefolk/mem/releases/download/v0.1.1/${asset}`, + "https://github.com/bytefolk/mem/releases/download/v0.1.2/mem-mcp-checksums.txt", + `https://github.com/bytefolk/mem/releases/download/v0.1.2/${asset}`, ]); }); @@ -341,6 +341,28 @@ test("install verifies and reuses a cached binary", async (t) => { } }); +test("explicit cacheDir keeps host paths when selecting a foreign platform binary", async (t) => { + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + const osPlatform = platform() === "win32" ? "linux" : "win32"; + const asset = assetFor(osPlatform, "x64"); + const bytes = Buffer.from("verified foreign platform fixture, never executed"); + const installed = await install({ + osPlatform, + osArch: "x64", + cacheDir, + homeDirectory: join(root, "host-home"), + environment: {}, + logger: QUIET_LOGGER, + downloadText: async () => manifestFor(bytes, asset), + downloadFile: async (_url, destination) => { + writeFileSync(destination, bytes, { flag: "wx", mode: 0o600 }); + }, + }); + assert.equal(installed, join(cacheDir, asset)); + assert.deepEqual(readFileSync(installed), bytes); +}); + test("concurrent installers serialize and publish one verified binary", async (t) => { const root = testDirectory(t); const cacheDir = join(root, "cache"); @@ -830,6 +852,43 @@ test("a contended Windows lock reported as EPERM waits for a proven lock", async assert.equal(existsSync(join(cacheDir, `.${ASSET}.lock`)), false); }); +test("a Windows EPERM lock released before inspection is retried", async (t) => { + // This is the race observed in the real node24-windows job: mkdir reports + // EPERM for a competing lock, but that owner removes it before lstat. + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + mkdirSync(cacheDir, { recursive: true }); + await runWorker(t, ` + const fs = require("node:fs"); + const realMkdirSync = fs.mkdirSync; + let attempts = 0; + fs.mkdirSync = function (target, ...rest) { + if (String(target).endsWith(".lock") && attempts++ === 0) { + throw Object.assign(new Error("simulated released Windows lock"), { + code: "EPERM", + syscall: "mkdir", + }); + } + return realMkdirSync.call(this, target, ...rest); + }; + const { acquireAssetLock, releaseAssetLock } = require(${JSON.stringify(require.resolve("./install"))}); + acquireAssetLock(${JSON.stringify(cacheDir)}, ${JSON.stringify(ASSET)}, { + osPlatform: "win32", + pollMs: 1, + waitTimeoutMs: 5000, + }) + .then((lock) => { + if (attempts !== 2) throw new Error("expected exactly one retry, saw " + attempts); + releaseAssetLock(lock); + }) + .catch((error) => { + console.error(error.stack || String(error)); + process.exitCode = 1; + }); + `); + assert.equal(existsSync(join(cacheDir, `.${ASSET}.lock`)), false); +}); + test("an EEXIST lock that disappears before inspection observes the timeout without spinning", async (t) => { const root = testDirectory(t); const cacheDir = join(root, "cache"); @@ -945,7 +1004,7 @@ test("a stale-lock rename race observes the timeout without spinning", async (t) `); }); -test("a persistent Windows EPERM without a lock fails promptly instead of retrying", async (t) => { +test("a persistent Windows EPERM without a lock fails after a bounded grace", async (t) => { const root = testDirectory(t); const cacheDir = join(root, "cache"); mkdirSync(cacheDir, { recursive: true }); @@ -973,7 +1032,10 @@ test("a persistent Windows EPERM without a lock fails promptly instead of retryi }) .catch((error) => { if (error.code !== "EPERM") throw error; - if (Date.now() - startedAt >= 1000) throw new Error("permission error entered the retry loop"); + const elapsed = Date.now() - startedAt; + if (elapsed < 200 || elapsed >= 1000) { + throw new Error("permission ambiguity grace was not bounded: " + elapsed + "ms"); + } }); `); }); @@ -1021,6 +1083,81 @@ test("a Windows lock inspection permission error fails promptly", async (t) => { `); }); +test("lock release retries a transient non-empty directory race", async (t) => { + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + mkdirSync(cacheDir, { recursive: true }); + await runWorker(t, ` + const fs = require("node:fs"); + const realRmSync = fs.rmSync; + fs.rmSync = function (target, options) { + if ( + String(target).endsWith(".lock") + && ( + !options + || (options.maxRetries ?? 0) < 3 + || (options.retryDelay ?? 0) < 10 + ) + ) { + throw Object.assign(new Error("simulated transient non-empty lock"), { + code: "ENOTEMPTY", + syscall: "rmdir", + }); + } + return realRmSync.call(this, target, options); + }; + const { acquireAssetLock, releaseAssetLock } = require(${JSON.stringify(require.resolve("./install"))}); + acquireAssetLock(${JSON.stringify(cacheDir)}, ${JSON.stringify(ASSET)}) + .then((lock) => releaseAssetLock(lock)) + .catch((error) => { + console.error(error.stack || String(error)); + process.exitCode = 1; + }); + `); + assert.equal(existsSync(join(cacheDir, `.${ASSET}.lock`)), false); +}); + +test("cache resolution retries a directory created between realpath and lstat", async (t) => { + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + await runWorker(t, ` + const crypto = require("node:crypto"); + const fs = require("node:fs"); + const realMkdirSync = fs.mkdirSync; + const realRealpathNative = fs.realpathSync.native; + const cacheDir = ${JSON.stringify(cacheDir)}; + let armed = true; + fs.realpathSync.native = function (target, ...rest) { + if (armed && target === cacheDir) { + armed = false; + realMkdirSync(cacheDir, { recursive: true }); + throw Object.assign(new Error("simulated concurrent cache creation"), { + code: "ENOENT", + syscall: "realpath", + }); + } + return realRealpathNative.call(this, target, ...rest); + }; + const { install } = require(${JSON.stringify(require.resolve("./install"))}); + const bytes = Buffer.from("verified concurrent cache fixture"); + const digest = crypto.createHash("sha256").update(bytes).digest("hex"); + install({ + osPlatform: "linux", + osArch: "x64", + cacheDir, + logger: { log() {}, warn() {} }, + downloadText: async () => digest + " ${ASSET}\\n", + downloadFile: async (_url, destination) => { + fs.writeFileSync(destination, bytes, { flag: "wx", mode: 0o600 }); + }, + }).catch((error) => { + console.error(error.stack || String(error)); + process.exitCode = 1; + }); + `); + assert.deepEqual(readFileSync(join(cacheDir, ASSET)), Buffer.from("verified concurrent cache fixture")); +}); + test("a non-contention error propagates immediately instead of entering the wait loop", async (t) => { const root = testDirectory(t); const missing = join(root, "no-such-parent", "cache"); diff --git a/npm/mcp-registry.server.json b/npm/mcp-registry.server.json new file mode 100644 index 0000000..e1f5de5 --- /dev/null +++ b/npm/mcp-registry.server.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.bytefolk/mem-mcp", + "description": "MCP server for mem — a portable, self-hosted memory plane for AI agents", + "repository": { + "url": "https://github.com/bytefolk/mem", + "source": "github" + }, + "version": "0.1.2", + "packages": [ + { + "registryType": "npm", + "identifier": "@bytefolk/mem-mcp", + "version": "0.1.2", + "transport": { + "type": "stdio" + } + } + ] +} diff --git a/npm/migration.test.js b/npm/migration.test.js new file mode 100644 index 0000000..6d41e80 --- /dev/null +++ b/npm/migration.test.js @@ -0,0 +1,291 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { createHash } = require("node:crypto"); +const { EventEmitter } = require("node:events"); +const { + closeSync, existsSync, fstatSync, linkSync, lstatSync, mkdirSync, mkdtempSync, + openSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, + writeFileSync, +} = require("node:fs"); +const { arch, platform, tmpdir } = require("node:os"); +const { dirname, join } = require("node:path"); +const { PassThrough } = require("node:stream"); +const test = require("node:test"); +const { cacheRootFor, install, openResponse } = require("./install"); +const { assetFor } = require("./platforms"); +const pkg = require("./package.json"); +const server = require("./server.json"); + +test("the npm package and MCP metadata identify the ByteFolk 0.1.2 release", () => { + assert.equal(pkg.name, "@bytefolk/mem-mcp"); + assert.equal(pkg.version, "0.1.2"); + assert.equal(pkg.mcpName, "io.github.bytefolk/mem-mcp"); + assert.equal(server.mcpName, pkg.mcpName); + assert.equal(server.version, pkg.version); + assert.equal(server.name, "mem-mcp"); + assert.equal(server.command, "mem-mcp"); + assert.equal(pkg.repository.url, `git+${server.repo}.git`); + assert.deepEqual(pkg.bin, { "mem-mcp": "./mem-mcp" }); + assert.equal(pkg.scripts.postinstall, undefined); + assert.throws(() => assetFor("freebsd", "x64"), /@bytefolk\/mem-mcp/); +}); + +test("installer HTTPS requests identify the renamed package", async () => { + const response = await openResponse("https://github.com/bytefolk/mem", 0, + (_url, options, callback) => { + assert.equal(options.headers["User-Agent"], "@bytefolk/mem-mcp/0.1.2"); + const request = new EventEmitter(); + queueMicrotask(() => { + const incoming = new PassThrough(); + incoming.statusCode = 200; + incoming.headers = {}; + callback(incoming); + incoming.end("fixture"); + }); + return request; + }); + for await (const _chunk of response) { /* consume response and close timeout */ } +}); + +test("default cache roots use ByteFolk on every supported OS", () => { + for (const [osPlatform, environment, homeDirectory, expected] of [ + ["linux", {}, "/home/example", "/home/example/.cache/bytefolk/mem-mcp"], + ["linux", { XDG_CACHE_HOME: "/cache" }, "/home/example", "/cache/bytefolk/mem-mcp"], + ["darwin", {}, "/Users/example", "/Users/example/Library/Caches/bytefolk/mem-mcp"], + ["win32", {}, "C:\\Users\\example", "C:\\Users\\example\\AppData\\Local\\bytefolk\\mem-mcp"], + ["win32", { LOCALAPPDATA: "C:\\Cache" }, "C:\\Users\\example", "C:\\Cache\\bytefolk\\mem-mcp"], + ]) { + assert.equal(cacheRootFor({ osPlatform, environment, homeDirectory }), expected); + } +}); + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "mem-mcp-migration-test-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const base = platform() === "darwin" ? join(root, "Library", "Caches") + : platform() === "win32" ? join(root, "AppData", "Local") : join(root, ".cache"); + const suffix = join("v0.1.2", `${platform()}-${arch()}`); + const asset = assetFor(platform(), arch()); + const legacyRoot = join(base, "fullstack-ai-infra", "mem-mcp"); + const legacyDir = join(legacyRoot, suffix); + const legacy = join(legacyDir, asset); + const destination = join(base, "bytefolk", "mem-mcp", suffix, asset); + const bytes = Buffer.from("verified release 0.1.2 fixture"); + const digest = createHash("sha256").update(bytes).digest("hex"); + const requests = []; + mkdirSync(legacyDir, { recursive: true }); + const options = { + environment: {}, homeDirectory: root, version: "0.1.2", + logger: { log() {}, warn() {} }, + downloadText: async (url) => { + requests.push(url); + return `${digest} ${asset}\n`; + }, + downloadFile: async (url, target) => { + requests.push(url); + writeFileSync(target, bytes, { flag: "wx" }); + }, + }; + return { root, asset, legacyRoot, legacyDir, legacy, destination, bytes, requests, options }; +} + +function snapshotFile(path) { + const fd = openSync(path, "r"); + try { + return { info: fstatSync(fd), bytes: readFileSync(fd) }; + } finally { + closeSync(fd); + } +} + +function snapshotTree(root) { + const info = lstatSync(root); + if (info.isSymbolicLink()) return { mode: info.mode, link: readlinkSync(root) }; + if (info.isDirectory()) { + return { mode: info.mode, entries: Object.fromEntries( + readdirSync(root).sort().map((name) => [name, snapshotTree(join(root, name))]), + ) }; + } + const file = snapshotFile(root); + return { mode: file.info.mode, bytes: file.bytes.toString("hex") }; +} + +function directoryAlias(target, link) { + mkdirSync(dirname(link), { recursive: true }); + symlinkSync(target, link, platform() === "win32" ? "junction" : "dir"); +} + +for (const level of ["namespace", "root", "version", "platform", "ancestor"]) { + for (const entry of ["readonly-file", "failed-manifest-file", "failed-manifest-directory"]) { + test(`legacy alias at ${level} preserves ${entry} before any cache mutation`, async (t) => { + const f = fixture(t); + if (entry === "failed-manifest-directory") { + mkdirSync(f.legacy); + writeFileSync(join(f.legacy, "user-data"), "must survive"); + } else { + writeFileSync(f.legacy, f.bytes, { mode: 0o400 }); + } + const newRoot = dirname(dirname(dirname(f.destination))); + const hops = { namespace: 3, root: 2, version: 1, platform: 0 }; + if (level === "ancestor") { + // The writable root's ancestor resolves inside the protected old root; + // the remaining destination suffix does not exist yet. + directoryAlias(f.legacyRoot, dirname(newRoot)); + } else { + let target = f.legacyDir; + let link = dirname(f.destination); + for (let i = 0; i < hops[level]; i++) { + target = dirname(target); + link = dirname(link); + } + directoryAlias(target, link); + } + const before = snapshotTree(f.root); + let requests = 0; + const downloadText = f.options.downloadText; + f.options.downloadText = async (...args) => { + requests++; + if (entry !== "readonly-file") throw new Error("fixture manifest failure"); + return downloadText(...args); + }; + const result = await install(f.options).catch((error) => error); + assert.deepEqual(snapshotTree(f.root), before, "legacy bytes, modes, and all directory entries must survive"); + assert.equal(requests, 0, "reject aliasing before downloading or creating a cache lock"); + assert.ok(result instanceof Error, "aliased caches must fail closed"); + assert.match(result.message, /legacy cache/); + }); + } +} + +for (const override of ["environment", "cacheDir"]) { + test(`explicit ${override} rejects overlap with the default legacy cache`, async (t) => { + const f = fixture(t); + writeFileSync(f.legacy, f.bytes, { mode: 0o400 }); + const alias = join(f.root, "selected-cache"); + directoryAlias(override === "environment" ? f.legacyRoot : f.legacyDir, alias); + if (override === "environment") f.options.environment.MEM_MCP_CACHE_DIR = alias; + else f.options.cacheDir = alias; + const before = snapshotTree(f.root); + const result = await install(f.options).catch((error) => error); + assert.deepEqual(snapshotTree(f.root), before); + assert.equal(f.requests.length, 0); + assert.ok(result instanceof Error); + assert.match(result.message, /legacy cache/); + }); +} + +test("a legacy version alias into the new tree is rejected before creating the destination", async (t) => { + const f = fixture(t); + // Use a second version so no pre-existing fixture directory is removed. + f.options.version = "0.1.3"; + const newRoot = dirname(dirname(dirname(f.destination))); + mkdirSync(join(newRoot, "v0.1.3"), { recursive: true }); + directoryAlias(join(newRoot, "v0.1.3"), join(f.legacyRoot, "v0.1.3")); + const before = snapshotTree(f.root); + await assert.rejects(install(f.options), /legacy cache/); + assert.deepEqual(snapshotTree(f.root), before); + assert.equal(f.requests.length, 0); +}); + +for (const failure of [false, true]) { + test(`hardlinked destination preserves legacy readonly mode with manifest failure=${failure}`, async (t) => { + const f = fixture(t); + writeFileSync(f.legacy, f.bytes, { mode: 0o400 }); + mkdirSync(dirname(f.destination), { recursive: true }); + linkSync(f.legacy, f.destination); + const original = statSync(f.legacy); + assert.equal(statSync(f.destination).ino, original.ino); + const before = snapshotTree(f.root); + let requests = 0; + const downloadText = f.options.downloadText; + f.options.downloadText = async (...args) => { + requests++; + if (failure) throw new Error("fixture manifest failure"); + return downloadText(...args); + }; + const result = await install(f.options).catch((error) => error); + assert.deepEqual(snapshotTree(f.root), before); + assert.equal(statSync(f.legacy).nlink, original.nlink); + assert.equal(requests, 0); + assert.ok(result instanceof Error); + assert.match(result.message, /legacy cache/); + }); +} + +test("concurrent migration copies a verified legacy cache without changing its bytes or mode", async (t) => { + const f = fixture(t); + writeFileSync(f.legacy, f.bytes, { mode: 0o400 }); + writeFileSync(join(f.legacyDir, "user-data"), "keep me"); + const before = snapshotFile(f.legacy); + assert.deepEqual(await Promise.all([install(f.options), install(f.options)]), + [f.destination, f.destination]); + assert.deepEqual(readFileSync(f.destination), f.bytes); + const after = snapshotFile(f.legacy); + assert.deepEqual(after.bytes, f.bytes); + assert.deepEqual(after.bytes, before.bytes); + assert.equal(after.info.mode, before.info.mode); + assert.equal(after.info.mtimeMs, before.info.mtimeMs); + assert.equal(readFileSync(join(f.legacyDir, "user-data"), "utf8"), "keep me"); + assert.deepEqual(readdirSync(f.legacyDir).sort(), [f.asset, "user-data"].sort()); + assert.deepEqual(f.requests, Array(2).fill( + "https://github.com/bytefolk/mem/releases/download/v0.1.2/mem-mcp-checksums.txt")); +}); + +for (const kind of ["corrupt", "directory", "symlink", "old-version", "other-platform"]) { + test(`migration ignores ${kind} legacy entries and preserves them`, + { skip: kind === "symlink" && platform() === "win32" }, async (t) => { + const f = fixture(t); + let preserved = f.legacy; + if (kind === "directory") { + mkdirSync(f.legacy); + preserved = join(f.legacy, "user-data"); + } else if (kind === "symlink") { + preserved = join(f.root, "symlink-target"); + symlinkSync(preserved, f.legacy); + } else if (kind === "old-version" || kind === "other-platform") { + const directory = join(f.legacyRoot, + kind === "old-version" ? "v0.1.1" : "v0.1.2", + kind === "other-platform" ? "unsupported-arch" : `${platform()}-${arch()}`); + mkdirSync(directory, { recursive: true }); + preserved = join(directory, f.asset); + } + const original = kind === "corrupt" ? Buffer.from("untrusted bytes") : f.bytes; + writeFileSync(preserved, original); + assert.equal(await install(f.options), f.destination); + assert.deepEqual(readFileSync(f.destination), f.bytes); + assert.deepEqual(readFileSync(preserved), original); + if (kind === "symlink") assert.ok(lstatSync(f.legacy).isSymbolicLink()); + assert.equal(f.requests.length, 2); + assert.equal(f.requests[1], `https://github.com/bytefolk/mem/releases/download/v0.1.2/${f.asset}`); + }); +} + +for (const failure of ["manifest", "download"]) { + test(`${failure} failure never deletes a legacy entry`, async (t) => { + const f = fixture(t); + writeFileSync(f.legacy, "legacy bytes to preserve"); + f.options[failure === "manifest" ? "downloadText" : "downloadFile"] = async () => { + throw new Error("fixture failure"); + }; + await assert.rejects(install(f.options), /fixture failure/); + assert.equal(readFileSync(f.legacy, "utf8"), "legacy bytes to preserve"); + assert.equal(existsSync(f.destination), false); + assert.deepEqual(readdirSync(f.legacyDir), [f.asset]); + }); +} + +for (const override of ["environment", "cacheDir"]) { + test(`explicit ${override} cache selection disables default legacy lookup`, async (t) => { + const f = fixture(t); + writeFileSync(f.legacy, f.bytes); + const custom = join(f.root, "custom"); + if (override === "environment") f.options.environment.MEM_MCP_CACHE_DIR = custom; + else f.options.cacheDir = custom; + const result = await install(f.options); + assert.ok(result.startsWith(custom)); + assert.equal(f.requests.length, 2); + assert.deepEqual(readFileSync(f.legacy), f.bytes); + assert.equal(existsSync(f.destination), false); + }); +} diff --git a/npm/package.json b/npm/package.json index 235e2ee..8ea8b93 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { - "name": "@fullstack-ai-infra/mem-mcp", - "version": "0.1.1", + "name": "@bytefolk/mem-mcp", + "version": "0.1.2", "description": "MCP server for mem — a portable, self-hosted memory plane for AI agents", "mcpName": "io.github.bytefolk/mem-mcp", "keywords": [ @@ -27,7 +27,7 @@ "os": ["linux", "darwin", "win32"], "cpu": ["x64", "arm64"], "scripts": { - "test": "node --test install.test.js mem-mcp.test.js registry-identity.test.js windows-shim.test.js", + "test": "node --test install.test.js mem-mcp.test.js migration.test.js registry-identity.test.js windows-shim.test.js", "test:tarball": "node --test clean-tarball.test.js" }, "files": [ diff --git a/npm/platforms.js b/npm/platforms.js index 2dcb510..df049b2 100644 --- a/npm/platforms.js +++ b/npm/platforms.js @@ -35,7 +35,7 @@ function assetFor(osPlatform, osArch) { if (!asset) { throw new Error( `Unsupported platform: ${key}. ${ - "@fullstack-ai-infra/mem-mcp" + "@bytefolk/mem-mcp" } publishes: ${Object.keys(ASSETS).join(", ")}.` ); } diff --git a/npm/registry-identity.test.js b/npm/registry-identity.test.js index 298e161..eb0b253 100644 --- a/npm/registry-identity.test.js +++ b/npm/registry-identity.test.js @@ -41,10 +41,9 @@ test("both manifests name the same server", () => { assert.equal(serverManifest.version, packageManifest.version); }); -// A registry identifier is a primary key, so the npm scope is allowed to differ -// from it while the package name is not allowed to drift from it: `mcpName` -// ends in the unscoped package name by construction, and moving the package -// without moving the identifier would silently fork the registry record. +// A registry identifier is a primary key. After the ByteFolk cutover the npm +// scope matches the GitHub owner; the trailing mcpName segment must still be +// the unscoped package name. test("the registry name is the unscoped package name", () => { const [, packageName] = packageManifest.name.split("/"); assert.equal( diff --git a/npm/server.json b/npm/server.json index aa3fd6e..f924368 100644 --- a/npm/server.json +++ b/npm/server.json @@ -1,7 +1,7 @@ { "mcpName": "io.github.bytefolk/mem-mcp", "name": "mem-mcp", - "version": "0.1.1", + "version": "0.1.2", "description": "MCP server for mem — a portable, self-hosted memory plane for AI agents", "protocol": "2024-11-05", "transport": "stdio", diff --git a/scripts/npm-release.mjs b/scripts/npm-release.mjs new file mode 100644 index 0000000..c0bec8d --- /dev/null +++ b/scripts/npm-release.mjs @@ -0,0 +1,303 @@ +// Repository-owned stable npm release gate. Importing this module has no effects. +// Test adapters never invoke a real publisher; the CLI requires hosted OIDC and +// a fresh, exact-release human attestation from the protected npm-release environment. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const PACKAGE = '@bytefolk/mem-mcp'; +export const REGISTRY = 'https://registry.npmjs.org'; +export const ASSETS = [ + 'mem-mcp-darwin-amd64', 'mem-mcp-darwin-arm64', + 'mem-mcp-linux-amd64', 'mem-mcp-linux-arm64', + 'mem-mcp-windows-amd64.exe', 'mem-mcp-windows-arm64.exe', +]; +const FILES = ['LICENSE', 'README.md', 'install.js', 'mem-mcp', 'package.json', 'platforms.js']; +const MANIFEST = 'mem-mcp-checksums.txt'; +const stable = /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; +const canonicalRepository = 'git+https://github.com/bytefolk/mem.git'; +const readJSON = path => JSON.parse(readFileSync(path, 'utf8')); +const sha = (algorithm, value) => createHash(algorithm).update(value).digest(algorithm === 'sha512' ? 'base64' : 'hex'); +const requireValue = (condition, message) => assert.ok(condition, message); +// Downloads change counters; compare the publication identity and asset bytes, +// not incidental API statistics, when checking for a race before publishing. +const releaseIdentity = release => ({ + id: release.id, tag: release.tag_name, draft: release.draft, + prerelease: release.prerelease, publishedAt: release.published_at, + assets: release.assets.map(({ id, name, size, state, digest, updated_at }) => + ({ id, name, size, state, digest, updated_at })).sort((a, b) => a.name.localeCompare(b.name)), +}); + +export const commandStdio = descriptor => descriptor === undefined + ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe', descriptor]; + +export function checkContext(tag, env, nodeVersion, npmVersion) { + requireValue(typeof tag === 'string' && stable.test(tag) && !tag.includes('\n'), 'exact stable vX.Y.Z tag required'); + requireValue(env.GITHUB_ACTIONS === 'true' && env.GITHUB_REPOSITORY === 'bytefolk/mem', 'canonical GitHub Actions repository required'); + requireValue(['release', 'workflow_dispatch'].includes(env.GITHUB_EVENT_NAME), 'unsupported release event'); + requireValue(env.GITHUB_REF === `refs/tags/${tag}`, 'dispatch from the exact tag, so provenance identifies the packaged source'); + requireValue(env.GITHUB_WORKFLOW_REF === `bytefolk/mem/.github/workflows/npm-publish.yml@refs/tags/${tag}`, 'unexpected workflow identity'); + requireValue(/^[a-f0-9]{40}$/.test(env.GITHUB_SHA || ''), 'exact GitHub event commit required'); + requireValue(env.RUNNER_ENVIRONMENT === 'github-hosted' && env.RUNNER_OS === 'Linux', 'GitHub-hosted Linux runner required'); + requireValue(env.ACTIONS_ID_TOKEN_REQUEST_URL && env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, 'OIDC id-token permission unavailable'); + requireValue(/^24\.[0-9]+\.[0-9]+$/.test(nodeVersion), 'Node 24 required'); + const npm = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(npmVersion); + requireValue(npm && (+npm[1] > 11 || (+npm[1] === 11 && +npm[2] >= 15)), 'stable npm >=11.15.0 required'); + for (const [key, value] of Object.entries(env)) { + if (!value) continue; + requireValue(!/^(NPM_TOKEN|NODE_AUTH_TOKEN|NPM_AUTH_TOKEN|NPM_ID_TOKEN)$/i.test(key), 'npm token fallback is forbidden'); + requireValue(!/^npm_config_/i.test(key), 'ambient npm configuration is forbidden; the release runner isolates it'); + } +} + +export function checkProof(proof, tag, commit, now = Date.now()) { + requireValue(proof && typeof proof === 'object', 'HOLD: owner/org/Trusted Publisher proof unavailable'); + const exact = { schema: 1, package: PACKAGE, tag, commit, channel: 'next', organization: 'bytefolk', + repository: 'bytefolk/mem', workflow: 'npm-publish.yml', environment: 'npm-release', + organizationControlVerified: true, packageAccessVerified: true, twoFactorVerified: true, + publisherVerified: true, allowPublish: true }; + for (const [key, value] of Object.entries(exact)) assert.equal(proof[key], value, `HOLD: owner proof ${key} mismatch`); + requireValue(/^oidc:[A-Za-z0-9-]+$/.test(proof.publisherId || ''), 'HOLD: exact npm publisher configuration id required'); + requireValue(/^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(proof.approvedBy || ''), 'HOLD: responsible human approver required'); + requireValue(/^https:\/\/github\.com\/bytefolk\/(mem|\.github)\/issues\/(153|22)#issuecomment-[0-9]+$/.test(proof.evidence || ''), 'HOLD: sanitized owner evidence comment required'); + const verified = Date.parse(proof.verifiedAt); + const expires = Date.parse(proof.expiresAt); + requireValue(Number.isFinite(verified) && Number.isFinite(expires) && verified <= now && now < expires && + expires - verified <= 24 * 60 * 60 * 1000, 'HOLD: proof must be current and valid for at most 24 hours'); +} + +export function checkPackage(pkg, server, tag) { + assert.equal(pkg.name, PACKAGE, 'wrong npm scope/package'); + assert.equal(pkg.version, tag.slice(1), 'npm/tag version mismatch'); + assert.equal(pkg.mcpName, 'io.github.bytefolk/mem-mcp', 'wrong MCP identity'); + assert.equal(pkg.repository?.url, canonicalRepository, 'wrong provenance repository'); + assert.equal(pkg.repository?.type, 'git', 'git repository metadata required'); + requireValue(pkg.private !== true, 'private package must never publish'); + assert.deepEqual(Object.keys(pkg.bin || {}), ['mem-mcp'], 'unexpected CLI mapping'); + // npm normalizes the equivalent ./mem-mcp path to mem-mcp in the registry. + requireValue(['./mem-mcp', 'mem-mcp'].includes(pkg.bin['mem-mcp']), 'unexpected CLI path'); + assert.equal(server.version, pkg.version, 'MCP metadata version mismatch'); + assert.equal(server.mcpName, pkg.mcpName, 'MCP metadata identity mismatch'); + for (const key of ['dependencies', 'optionalDependencies', 'peerDependencies', 'bundledDependencies', 'bundleDependencies']) { + requireValue(!pkg[key] || Object.keys(pkg[key]).length === 0, 'wrapper dependency changes require release guard review'); + } + for (const key of Object.keys(pkg.scripts || {})) { + requireValue(['test', 'test:tarball'].includes(key), 'unexpected lifecycle script in release package'); + } + const allowed = { access: 'public', tag: 'next', registry: REGISTRY, provenance: true }; + for (const [key, value] of Object.entries(pkg.publishConfig || {})) { + requireValue(Object.hasOwn(allowed, key) && allowed[key] === value, 'unsafe publishConfig override'); + } +} + +export function checkRelease(release, tag) { + requireValue(release && release.tag_name === tag && release.draft === false && release.prerelease === false && + Number.isSafeInteger(release.id) && release.id > 0 && Number.isFinite(Date.parse(release.published_at)), 'published stable GitHub Release required'); + assert.equal(release.html_url, `https://github.com/bytefolk/mem/releases/tag/${tag}`, 'wrong release repository'); + assert.deepEqual(release.assets?.map(a => a.name).sort(), [...ASSETS, MANIFEST].sort(), 'exact seven release assets required'); + requireValue(release.assets.every(a => Number.isSafeInteger(a.size) && a.size > 0 && a.state === 'uploaded'), 'all release assets must be uploaded and nonempty'); +} + +export function readReleaseFile(file, expectedSize, read = readFileSync, inspect = () => {}) { + // Inspect and read the opened object, never check a pathname and reopen it. + // NONBLOCK lets us reject a substituted FIFO without waiting for a writer. + const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const info = fstatSync(fd); + requireValue(info.isFile(), 'asset must be a regular file'); + assert.equal(info.size, expectedSize, 'downloaded asset size mismatch'); + const bytes = read(fd); + assert.equal(bytes.length, expectedSize, 'downloaded asset changed during read'); + inspect(fd); + const after = fstatSync(fd); + requireValue(after.size === info.size && after.mtimeMs === info.mtimeMs, 'asset changed during read'); + return bytes; + } finally { + closeSync(fd); + } +} + +export function checkAssets(directory, release, commit, run) { + assert.deepEqual(readdirSync(directory).sort(), [...ASSETS, MANIFEST].sort(), 'downloaded asset set mismatch'); + const verified = new Map(); + const buildMetadata = new Map(); + for (const asset of release.assets) { + const file = join(directory, asset.name); + const bytes = readReleaseFile(file, asset.size, readFileSync, fd => { + if (asset.name !== MANIFEST) { + // The hosted Linux child receives this already-open object at fd 3. + // Never reopen the mutable asset pathname for build metadata. + buildMetadata.set(asset.name, run('go', ['version', '-m', '/proc/self/fd/3'], undefined, fd)); + } + }); + verified.set(asset.name, bytes); + if (asset.digest != null) assert.equal(asset.digest, `sha256:${sha('sha256', bytes)}`, 'GitHub asset digest mismatch'); + } + const manifest = verified.get(MANIFEST).toString('utf8'); + requireValue(manifest.endsWith('\n'), 'checksum manifest must end in newline'); + const rows = manifest.slice(0, -1).split('\n').map(line => { + const row = /^([a-f0-9]{64}) (mem-mcp-[a-z0-9.-]+)$/.exec(line); + requireValue(row, 'malformed checksum row'); + return { digest: row[1], name: row[2] }; + }); + assert.deepEqual(rows.map(row => row.name).sort(), [...ASSETS].sort(), 'exactly one checksum per expected binary required'); + for (const { name, digest } of rows) { + assert.equal(sha('sha256', verified.get(name)), digest, 'binary checksum mismatch'); + // go version -m reads metadata; it never executes the downloaded binary. + const metadata = buildMetadata.get(name); + const [, , os, arch] = name.replace('.exe', '').split('-'); + for (const field of [`GOOS=${os}`, `GOARCH=${arch}`, `vcs.revision=${commit}`, 'vcs.modified=false']) { + requireValue(metadata.split('\n').some(line => line.trim() === `build\t${field}`), `binary build metadata mismatch: ${field}`); + } + requireValue(/(?:^|\n)\s*path\s+[^\s]+\/server\/cmd\/mem-mcp\s*\n/.test(metadata), 'wrong binary command'); + } +} + +export function checkRegistryBefore(data, tag) { + requireValue(data && data.name === PACKAGE && data.versions && data['dist-tags'], 'HOLD: public package/bootstrap unavailable; 404 is not org proof'); + const bootstrap = data.versions['0.1.2-rc.0']; + requireValue(bootstrap?.name === PACKAGE && bootstrap.version === '0.1.2-rc.0', 'HOLD: reviewed 0.1.2-rc.0 bootstrap required'); + requireValue(!Object.hasOwn(data.versions, tag.slice(1)), 'npm version already exists; never republish, including after partial failure'); + requireValue(!Object.values(data['dist-tags']).includes(tag.slice(1)), 'registry dist-tag references candidate before publish'); +} + +export function checkRegistryAfter(data, before, tag, integrity, proof) { + assert.equal(data?.name, PACKAGE, 'registry package mismatch'); + const version = tag.slice(1); + const published = data.versions?.[version]; + requireValue(published, 'published version missing from registry'); + checkPackage(published, { version, mcpName: 'io.github.bytefolk/mem-mcp' }, tag); + assert.equal(data['dist-tags']?.next, version, 'next readback mismatch'); + const tagsWithoutNext = tags => Object.fromEntries(Object.entries(tags).filter(([name]) => name !== 'next')); + assert.deepEqual(tagsWithoutNext(data['dist-tags']), tagsWithoutNext(before['dist-tags']), 'non-next dist-tags changed; owner investigation required'); + assert.equal(published.dist?.integrity, integrity, 'registry/tarball integrity mismatch'); + assert.equal(published._npmUser?.trustedPublisher?.id, 'github', 'publication was not GitHub OIDC'); + assert.equal(published._npmUser?.trustedPublisher?.oidcConfigId, proof.publisherId, 'unexpected Trusted Publisher'); + requireValue(Array.isArray(published.dist.signatures) && published.dist.signatures.length > 0 && + published.dist.signatures.every(s => s.keyid && s.sig), 'registry signatures unavailable'); + assert.equal(published.dist.attestations?.provenance?.predicateType, 'https://slsa.dev/provenance/v1', 'provenance unavailable'); + requireValue(published.dist.attestations?.url?.startsWith(`${REGISTRY}/-/npm/v1/attestations/`), 'unexpected attestation URL'); + assert.equal(published.dist.tarball, `${REGISTRY}/@bytefolk/mem-mcp/-/mem-mcp-${version}.tgz`, 'unexpected registry tarball URL'); + return published; +} + +async function registryJSON(url) { + const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(30000), + headers: { accept: 'application/json', 'cache-control': 'no-cache' } }); + requireValue(response.status === 200, `registry read failed (${response.status}); no write permitted`); + return response.json(); +} + +export async function runRelease(tag, options = {}) { + const repo = options.repo || resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const env = options.env || process.env; + const now = options.now || Date.now; + const proof = JSON.parse(env.NPM_RELEASE_PROOF || 'null'); + checkProof(proof, tag, env.GITHUB_SHA, now()); + const directory = resolve(options.directory || join(env.RUNNER_TEMP || '', 'mem-npm-release')); + requireValue(!existsSync(directory), 'release output directory must be fresh'); + // Refuse ambient npmrc files, and bypass user/global config in all npm calls. + requireValue(!existsSync(join(repo, '.npmrc')) && !existsSync(join(repo, 'npm/.npmrc')), 'repository npmrc requires explicit security review'); + mkdirSync(directory, { recursive: true }); + const npmEnv = { ...env, NPM_CONFIG_USERCONFIG: join(directory, 'user.npmrc'), + NPM_CONFIG_GLOBALCONFIG: join(directory, 'global.npmrc'), NPM_CONFIG_CACHE: join(directory, 'cache') }; + delete npmEnv.GH_TOKEN; + delete npmEnv.GITHUB_TOKEN; + for (const name of ['user.npmrc', 'global.npmrc']) writeFileSync(join(directory, name), '', { flag: 'wx', mode: 0o600 }); + const run = options.run || ((command, args, cwd = repo, descriptor) => { + try { + return execFileSync(command, args, { cwd, encoding: 'utf8', env: command === 'npm' ? npmEnv : env, + stdio: commandStdio(descriptor), + timeout: 120000, maxBuffer: 16 * 1024 * 1024 }).trim(); + } catch { + // Never echo raw auth errors, subprocess output or environment values. + throw new Error(`${command} ${args[0]} failed; stop and inspect the private run. Do not retry publication automatically.`); + } + }); + const getJSON = options.getJSON || registryJSON; + checkContext(tag, env, options.nodeVersion || process.versions.node, run('npm', ['--version'])); + const source = () => { + run('git', ['fetch', '--no-tags', 'origin', 'refs/heads/main:refs/remotes/origin/main', `refs/tags/${tag}:refs/tags/${tag}`]); + assert.equal(run('git', ['cat-file', '-t', `refs/tags/${tag}`]), 'tag', 'annotated tag required'); + const commit = run('git', ['rev-parse', `refs/tags/${tag}^{commit}`]); + assert.equal(commit, env.GITHUB_SHA, 'tag/event commit mismatch'); + assert.equal(run('git', ['rev-parse', 'HEAD']), commit, 'checkout/tag mismatch'); + run('git', ['merge-base', '--is-ancestor', commit, 'refs/remotes/origin/main']); + assert.equal(run('git', ['status', '--porcelain', '--untracked-files=all']), '', 'release checkout must be clean'); + run('bash', [join(repo, 'scripts/validate_release_version.sh'), tag.slice(1)]); + checkPackage(readJSON(join(repo, 'npm/package.json')), readJSON(join(repo, 'npm/server.json')), tag); + return commit; + }; + const commit = source(); + const releaseJSON = () => JSON.parse(run('gh', ['api', `repos/bytefolk/mem/releases/tags/${tag}`])); + let release = releaseJSON(); + checkRelease(release, tag); + const releaseId = release.id; + if (env.GITHUB_EVENT_NAME === 'release') { + const event = readJSON(env.GITHUB_EVENT_PATH); + requireValue(event.action === 'published' && event.release?.id === releaseId && event.release?.tag_name === tag, 'Release event mismatch'); + } + const url = `${REGISTRY}/@bytefolk%2fmem-mcp`; + const before = await getJSON(url); + checkRegistryBefore(before, tag); + const assets = join(directory, 'assets'); + mkdirSync(assets); + run('gh', ['release', 'download', tag, '--repo', 'bytefolk/mem', '--dir', assets, + ...[...ASSETS, MANIFEST].flatMap(name => ['--pattern', name])]); + release = releaseJSON(); + checkRelease(release, tag); + assert.equal(release.id, releaseId, 'Release replaced while downloading'); + checkAssets(assets, release, commit, run); + const packed = JSON.parse(run('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', directory], join(repo, 'npm'))); + requireValue(Array.isArray(packed) && packed.length === 1, 'exactly one packed tarball required'); + const pack = packed[0]; + assert.equal(pack.name, PACKAGE, 'packed package name mismatch'); + assert.equal(pack.version, tag.slice(1), 'packed package version mismatch'); + assert.equal(pack.filename, `bytefolk-mem-mcp-${tag.slice(1)}.tgz`, 'unexpected tarball filename'); + assert.deepEqual(pack.files?.map(f => f.path).sort(), FILES, 'unexpected packed file inventory'); + const tarball = join(directory, pack.filename); + const integrity = `sha512-${sha('sha512', readFileSync(tarball))}`; + assert.equal(pack.integrity, integrity, 'packed tarball integrity mismatch'); + // Final checks immediately before the only registry write. Missing reads, + // races and moved tags stop; no exception is interpreted as version absence. + checkProof(proof, tag, source(), now()); + const currentRelease = releaseJSON(); + checkRelease(currentRelease, tag); + assert.deepEqual(releaseIdentity(currentRelease), releaseIdentity(release), 'GitHub Release changed after verification'); + const current = await getJSON(url); + checkRegistryBefore(current, tag); + assert.deepEqual(current['dist-tags'], before['dist-tags'], 'registry channels changed during preflight'); + assert.equal(`sha512-${sha('sha512', readFileSync(tarball))}`, integrity, 'tarball changed after packing'); + writeFileSync(join(directory, 'preflight.json'), JSON.stringify({ package: PACKAGE, tag, commit, integrity, releaseId, channel: 'next' }, null, 2)); + run('npm', ['publish', tarball, '--tag', 'next', '--access', 'public', '--provenance', '--ignore-scripts', `--registry=${REGISTRY}`], directory); + // A failure here may mean publish succeeded. Never retry npm publish, move a + // dist-tag, delete a version, or mark the run successful on that basis. + checkRegistryAfter(await getJSON(url), before, tag, integrity, proof); + const consumer = join(directory, 'consumer'); + mkdirSync(consumer); + writeFileSync(join(consumer, 'package.json'), '{"private":true}\n'); + run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--save-exact', `${PACKAGE}@${tag.slice(1)}`, `--registry=${REGISTRY}`], consumer); + run('npm', ['audit', 'signatures', `--registry=${REGISTRY}`], consumer); + checkRegistryAfter(await getJSON(url), before, tag, integrity, proof); + const receipt = { package: PACKAGE, tag, commit, integrity, releaseId, channel: 'next', + publisherId: proof.publisherId, registryMetadata: url, + attestations: 'verified by npm audit signatures', + signatures: 'verified by npm audit signatures', latestPromotion: 'NOT PERFORMED: separate release-owner gate', + platformLaunch: 'NOT VERIFIED: release owner must record Linux/macOS/Windows clean launches' }; + writeFileSync(join(directory, 'receipt.json'), JSON.stringify(receipt, null, 2)); + return receipt; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + requireValue(process.argv.length === 3, 'usage: node scripts/npm-release.mjs vX.Y.Z'); + const receipt = await runRelease(process.argv[2]); + process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`); + } catch (error) { + process.stderr.write(`HOLD: ${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/npm-release.test.mjs b/scripts/npm-release.test.mjs new file mode 100644 index 0000000..d39fcdb --- /dev/null +++ b/scripts/npm-release.test.mjs @@ -0,0 +1,355 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { fstatSync, mkdtempSync, mkdirSync, readFileSync, readSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + ASSETS, PACKAGE, REGISTRY, checkContext, checkProof, checkPackage, + checkRelease, checkAssets, checkRegistryBefore, checkRegistryAfter, commandStdio, readReleaseFile, runRelease, +} from './npm-release.mjs'; + +const tag = 'v0.1.2'; +const commit = 'a'.repeat(40); +const now = Date.parse('2026-09-10T00:00:00Z'); +const env = () => ({ + GITHUB_ACTIONS: 'true', GITHUB_REPOSITORY: 'bytefolk/mem', + GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REF: `refs/tags/${tag}`, + GITHUB_SHA: commit, GITHUB_WORKFLOW_REF: `bytefolk/mem/.github/workflows/npm-publish.yml@refs/tags/${tag}`, + RUNNER_ENVIRONMENT: 'github-hosted', RUNNER_OS: 'Linux', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://example.invalid/oidc', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'fixture-only', +}); +const proof = () => ({ + schema: 1, package: PACKAGE, tag, commit, channel: 'next', + organization: 'bytefolk', organizationControlVerified: true, + packageAccessVerified: true, twoFactorVerified: true, + publisherVerified: true, allowPublish: true, + repository: 'bytefolk/mem', workflow: 'npm-publish.yml', environment: 'npm-release', + publisherId: 'oidc:fixture', approvedBy: 'release-owner', + evidence: 'https://github.com/bytefolk/mem/issues/153#issuecomment-123', + verifiedAt: '2026-09-09T23:00:00Z', expiresAt: '2026-09-10T23:00:00Z', +}); +const pkg = () => ({ + name: PACKAGE, version: '0.1.2', mcpName: 'io.github.bytefolk/mem-mcp', + repository: { type: 'git', url: 'git+https://github.com/bytefolk/mem.git' }, + bin: { 'mem-mcp': './mem-mcp' }, +}); +const server = () => ({ version: '0.1.2', name: 'mem-mcp', mcpName: 'io.github.bytefolk/mem-mcp' }); +const release = () => ({ id: 123, tag_name: tag, draft: false, prerelease: false, + published_at: '2026-09-09T23:00:00Z', html_url: `https://github.com/bytefolk/mem/releases/tag/${tag}`, + assets: [...ASSETS, 'mem-mcp-checksums.txt'].map(name => ({ name, size: 1, state: 'uploaded' })) }); +const before = () => ({ name: PACKAGE, versions: { '0.1.2-rc.0': { name: PACKAGE, version: '0.1.2-rc.0' } }, + 'dist-tags': { next: '0.1.2-rc.0' } }); +const integrity = 'sha512-' + Buffer.alloc(64, 1).toString('base64'); +const after = () => ({ ...before(), 'dist-tags': { next: '0.1.2' }, versions: { + ...before().versions, '0.1.2': { ...pkg(), bin: { 'mem-mcp': 'mem-mcp' }, + _npmUser: { trustedPublisher: { id: 'github', oidcConfigId: 'oidc:fixture' } }, + dist: { integrity, tarball: `${REGISTRY}/@bytefolk/mem-mcp/-/mem-mcp-0.1.2.tgz`, + signatures: [{ keyid: 'SHA256:fixture', sig: 'fixture' }], + attestations: { url: `${REGISTRY}/-/npm/v1/attestations/@bytefolk%2fmem-mcp@0.1.2`, + provenance: { predicateType: 'https://slsa.dev/provenance/v1' } } } } } }); + +test('accepts exact hosted tag context, current human proof, package, assets and registry readback', () => { + checkContext(tag, env(), '24.13.0', '11.15.0'); + checkProof(proof(), tag, commit, now); + checkPackage(pkg(), server(), tag); + checkRelease(release(), tag); + checkRegistryBefore(before(), tag); + checkRegistryAfter(after(), before(), tag, integrity, proof()); +}); + +for (const bad of ['', 'v01.2.3', '0.1.2', 'v0.1.2-rc.0', 'v0.1.2+build', 'v0.1.2\n', 'v0.1.2;id', '--help']) { + test(`rejects unsafe/nonstable tag ${JSON.stringify(bad)}`, () => { + assert.throws(() => checkContext(bad, env(), '24.13.0', '11.15.0')); + }); +} +for (const [field, value] of [ + ['GITHUB_REPOSITORY', 'attacker/mem'], ['GITHUB_EVENT_NAME', 'pull_request'], + ['GITHUB_REF', 'refs/heads/main'], ['GITHUB_SHA', ''], ['GITHUB_ACTIONS', 'false'], + ['RUNNER_ENVIRONMENT', 'self-hosted'], ['ACTIONS_ID_TOKEN_REQUEST_TOKEN', ''], + ['GITHUB_WORKFLOW_REF', 'bytefolk/mem/.github/workflows/other.yml@refs/tags/v0.1.2'], + ['NPM_TOKEN', 'fixture'], ['NODE_AUTH_TOKEN', 'fixture'], ['npm_config_provenance', 'false'], +]) { + test(`rejects wrong context or credential/config injection: ${field}`, () => { + assert.throws(() => checkContext(tag, { ...env(), [field]: value }, '24.13.0', '11.15.0')); + }); +} +for (const [node, npm] of [['22.0.0', '11.15.0'], ['24.13.0', '11.6.2'], ['24.13.0', '11.15.0-rc.1']]) { + test(`rejects unsupported tools ${node}/${npm}`, () => assert.throws(() => checkContext(tag, env(), node, npm))); +} +for (const [field, value] of [ + ['organizationControlVerified', false], ['packageAccessVerified', false], ['twoFactorVerified', false], + ['publisherVerified', false], ['allowPublish', false], ['publisherId', ''], + ['environment', 'production'], ['workflow', 'release.yml'], ['repository', 'someone/mem'], + ['package', '@fullstack-ai-infra/mem-mcp'], ['channel', 'latest'], ['tag', 'v0.1.3'], + ['commit', 'b'.repeat(40)], ['approvedBy', ''], ['evidence', ''], + ['expiresAt', '2026-09-09T23:59:00Z'], ['verifiedAt', '2026-09-11T00:00:00Z'], + ['expiresAt', '2027-01-01T00:00:00Z'], +]) { + test(`fails closed on missing/wrong/stale owner proof: ${field}`, () => { + assert.throws(() => checkProof({ ...proof(), [field]: value }, tag, commit, now)); + }); +} +test('no owner proof is a blocker', () => assert.throws(() => checkProof(null, tag, commit, now))); +for (const mutate of [ + p => { p.name = '@fullstack-ai-infra/mem-mcp'; }, p => { p.version = '0.1.1'; }, + p => { p.private = true; }, p => { p.repository.url = 'git+https://github.com/attacker/mem.git'; }, + p => { p.publishConfig = { tag: 'latest' }; }, p => { p.publishConfig = { registry: 'https://example.invalid' }; }, + p => { p.scripts = { prepublishOnly: 'dangerous' }; }, p => { p.dependencies = { surprise: '*' }; }, +]) { + test(`rejects unsafe package: ${mutate}`, () => { const p = pkg(); mutate(p); assert.throws(() => checkPackage(p, server(), tag)); }); +} +for (const mutate of [ + r => { r.draft = true; }, r => { r.prerelease = true; }, r => { r.tag_name = 'v0.1.1'; }, + r => { r.assets.pop(); }, r => { r.assets.push(r.assets[0]); }, r => { r.assets[0].size = 0; }, + r => { r.assets[0].name = '../outside'; }, r => { r.assets[0].state = 'new'; }, +]) { + test(`rejects incomplete or mismatched Release: ${mutate}`, () => { const r = release(); mutate(r); assert.throws(() => checkRelease(r, tag)); }); +} +test('registry 404/error-shaped or duplicate stable version is never publish permission', () => { + for (const data of [null, {}, { error: 'Not found' }, after()]) assert.throws(() => checkRegistryBefore(data, tag)); +}); +for (const mutate of [ + r => { r['dist-tags'].latest = '0.1.2'; }, r => { r['dist-tags'].next = '0.1.2-rc.0'; }, + r => { r.versions['0.1.2'].dist.integrity = 'bad'; }, + r => { delete r.versions['0.1.2'].dist.attestations; }, + r => { delete r.versions['0.1.2'].dist.signatures; }, + r => { r.versions['0.1.2']._npmUser.trustedPublisher.oidcConfigId = 'oidc:other'; }, + r => { r.versions['0.1.2'].repository.url = 'https://github.com/attacker/mem'; }, +]) { + test(`readback failure blocks success: ${mutate}`, () => { const r = after(); mutate(r); assert.throws(() => checkRegistryAfter(r, before(), tag, integrity, proof())); }); +} + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'mem-npm-release-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const repo = join(root, 'repo'); + mkdirSync(join(repo, 'npm'), { recursive: true }); + writeFileSync(join(repo, 'npm/package.json'), JSON.stringify(pkg())); + writeFileSync(join(repo, 'npm/server.json'), JSON.stringify(server())); + const directory = join(root, 'out'); + const calls = []; + let registryReads = 0; + let published = false; + const r = release(); + const run = (command, args, cwd, descriptor) => { + calls.push([command, ...args]); + if (command === 'git') { + if (args[0] === 'cat-file') return 'tag'; + if (args[0] === 'rev-parse') return commit; + return ''; + } + if (command === 'bash') return ''; + if (command === 'gh' && args[0] === 'api') return JSON.stringify(r); + if (command === 'gh' && args[0] === 'release') { + const assetDir = args[args.indexOf('--dir') + 1]; + let manifest = ''; + for (const name of ASSETS) { + const body = Buffer.from(`fixture ${name}`); + writeFileSync(join(assetDir, name), body); + r.assets.find(a => a.name === name).size = body.length; + manifest += `${createHash('sha256').update(body).digest('hex')} ${name}\n`; + } + writeFileSync(join(assetDir, 'mem-mcp-checksums.txt'), manifest); + r.assets.at(-1).size = Buffer.byteLength(manifest); + return ''; + } + if (command === 'go') { + let bytes; + if (descriptor === undefined) bytes = readFileSync(args.at(-1)); + else { + assert.equal(args.at(-1), '/proc/self/fd/3'); + bytes = Buffer.alloc(fstatSync(descriptor).size); + assert.equal(readSync(descriptor, bytes, 0, bytes.length, 0), bytes.length); + } + const [, name, revision = commit] = bytes.toString().split(' '); + const [, , os, arch] = name.replace('.exe', '').split('-'); + return `\tpath\tgithub.com/PeterGuy326/mem/server/cmd/mem-mcp\n\tbuild\tGOOS=${os}\n\tbuild\tGOARCH=${arch}\n\tbuild\tvcs.revision=${revision}\n\tbuild\tvcs.modified=false\n`; + } + if (command === 'npm') { + if (args[0] === '--version') return '11.15.0'; + if (args[0] === 'pack') { + const body = Buffer.from('test tarball'); + writeFileSync(join(directory, 'bytefolk-mem-mcp-0.1.2.tgz'), body); + return JSON.stringify([{ name: PACKAGE, version: '0.1.2', filename: 'bytefolk-mem-mcp-0.1.2.tgz', + integrity: 'sha512-' + createHash('sha512').update(body).digest('base64'), + files: ['LICENSE', 'README.md', 'install.js', 'mem-mcp', 'package.json', 'platforms.js'].map(path => ({ path })) }]); + } + if (args[0] === 'publish') { published = true; return ''; } + if (args[0] === 'install' || args[0] === 'audit') return ''; + } + throw Error(`unexpected command: ${command} ${args.join(' ')}, cwd=${cwd}`); + }; + const getJSON = async () => { + registryReads++; + if (!published) return before(); + const data = after(); + data.versions['0.1.2'].dist.integrity = 'sha512-' + createHash('sha512').update('test tarball').digest('base64'); + return data; + }; + return { repo, directory, env: { ...env(), NPM_RELEASE_PROOF: JSON.stringify(proof()) }, + now: () => now, nodeVersion: '24.13.0', run, getJSON, calls, r, + readCount: () => registryReads }; +} + +test('asset read holds one descriptor across path replacement and always closes it', t => { + const f = fixture(t); + const file = join(f.repo, 'asset'); + writeFileSync(file, 'original'); + let descriptor; + const bytes = readReleaseFile(file, 8, fd => { + descriptor = fd; + renameSync(file, file + '.saved'); + writeFileSync(file, 'replaced'); + return readFileSync(fd); + }); + assert.equal(bytes.toString(), 'original'); + assert.equal(readFileSync(file, 'utf8'), 'replaced'); + assert.throws(() => fstatSync(descriptor), /EBADF/); +}); + +test('asset descriptor rejects symlinks, directories and wrong sizes', t => { + const f = fixture(t); + const file = join(f.repo, 'asset'); + writeFileSync(file, 'original'); + symlinkSync(file, file + '.link'); + assert.throws(() => readReleaseFile(file + '.link', 8)); + assert.throws(() => readReleaseFile(f.repo, 8), /regular file/); + assert.throws(() => readReleaseFile(file, 7), /size mismatch/); + let descriptor; + assert.throws(() => readReleaseFile(file, 8, fd => { descriptor = fd; throw Error('read failed'); }), /read failed/); + assert.throws(() => fstatSync(descriptor), /EBADF/); +}); + +test('real child metadata transport keeps the verified descriptor across path replacement', t => { + const f = fixture(t); + const file = join(f.repo, 'asset'); + writeFileSync(file, 'original'); + readReleaseFile(file, 8, readFileSync, fd => { + renameSync(file, file + '.saved'); + writeFileSync(file, 'replaced'); + const path = process.platform === 'linux' ? '/proc/self/fd/3' : '/dev/fd/3'; + const child = `const fs = require('node:fs'); const fd = fs.openSync(process.argv[1], 'r'); + const bytes = Buffer.alloc(fs.fstatSync(fd).size); fs.readSync(fd, bytes, 0, bytes.length, 0); + fs.closeSync(fd); process.stdout.write(bytes);`; + assert.equal(execFileSync(process.execPath, ['-e', child, path], { stdio: commandStdio(fd) }).toString(), 'original'); + }); +}); + +for (const replacedIndex of [0, 1]) { + test(`metadata and checksum cannot validate different objects: asset ${replacedIndex}`, t => { + const f = fixture(t); + const assets = join(f.directory, 'assets'); + mkdirSync(assets, { recursive: true }); + f.run('gh', ['release', 'download', tag, '--dir', assets]); + const name = ASSETS[replacedIndex]; + const path = join(assets, name); + const wrong = Buffer.from(`fixture ${name} ${'b'.repeat(40)}`); + writeFileSync(path, wrong); + f.r.assets.find(a => a.name === name).size = wrong.length; + const manifestPath = join(assets, 'mem-mcp-checksums.txt'); + const manifest = readFileSync(manifestPath, 'utf8').split('\n').map(line => + line.endsWith(` ${name}`) ? `${createHash('sha256').update(wrong).digest('hex')} ${name}` : line).join('\n'); + writeFileSync(manifestPath, manifest); + let replaced = false; + const run = (command, args, cwd, descriptor) => { + if (command === 'go' && !replaced) { + replaced = true; + renameSync(path, path + '.saved'); + writeFileSync(path, `fixture ${name} ${commit}`); + } + return f.run(command, args, cwd, descriptor); + }; + assert.throws(() => checkAssets(assets, f.r, commit, run), /checksum mismatch|build metadata mismatch/); + assert.equal(replaced, true); + }); +} + +test('receipt records verified local facts, not a registry-supplied payload', async t => { + const f = fixture(t); + const original = f.getJSON; + f.getJSON = async () => { + const data = await original(); + if (data.versions['0.1.2']) data.versions['0.1.2'].dist.attestations.url = `${REGISTRY}/-/npm/v1/attestations/server-controlled-marker`; + return data; + }; + const receipt = await runRelease(tag, f); + assert.equal(receipt.registryMetadata, `${REGISTRY}/@bytefolk%2fmem-mcp`); + assert.equal(receipt.attestations, 'verified by npm audit signatures'); + assert.ok(!readFileSync(join(f.directory, 'receipt.json'), 'utf8').includes('server-controlled-marker')); +}); + +test('full fixture publishes the checked tarball once to next then verifies signatures; no latest mutation', async t => { + const f = fixture(t); + await runRelease(tag, f); + const writes = f.calls.filter(c => c[0] === 'npm' && c[1] === 'publish'); + assert.equal(writes.length, 1); + assert.deepEqual(writes[0].slice(3), ['--tag', 'next', '--access', 'public', '--provenance', '--ignore-scripts', `--registry=${REGISTRY}`]); + assert.ok(f.calls.some(c => c.join(' ') === `npm audit signatures --registry=${REGISTRY}`)); + assert.ok(f.readCount() >= 3); + assert.ok(!f.calls.some(c => c.includes('dist-tag') || c.includes('deprecate'))); + assert.ok(readFileSync(join(f.directory, 'receipt.json'), 'utf8').includes('next')); +}); + +for (const failure of ['proof', 'source', 'registry', 'assets', 'pack', 'recheck', 'publish', 'readback', 'audit']) { + test(`full fixture stops at ${failure}; no publish retry or promotion`, async t => { + const f = fixture(t); + const original = f.run; + let sourceChecks = 0; + if (failure === 'proof') delete f.env.NPM_RELEASE_PROOF; + if (failure === 'registry' || failure === 'readback') { + const get = f.getJSON; + f.getJSON = async () => { if (failure === 'registry' || f.calls.some(c => c[1] === 'publish')) throw Error('fixture unavailable'); return get(); }; + } + f.run = (command, args, cwd, descriptor) => { + if (command === 'git' && args[0] === 'fetch') { + sourceChecks++; + if (failure === 'source' || (failure === 'recheck' && sourceChecks > 1)) throw Error('source moved'); + } + if ((failure === 'pack' && args[0] === 'pack') || (failure === 'publish' && args[0] === 'publish') || + (failure === 'audit' && args[0] === 'audit')) { f.calls.push([command, ...args]); throw Error('fixture failure'); } + const result = original(command, args, cwd, descriptor); + if (failure === 'assets' && command === 'gh' && args[0] === 'release') writeFileSync(join(f.directory, 'assets', ASSETS[0]), 'tampered'); + return result; + }; + await assert.rejects(runRelease(tag, f)); + const count = f.calls.filter(c => c[0] === 'npm' && c[1] === 'publish').length; + assert.equal(count, ['publish', 'readback', 'audit'].includes(failure) ? 1 : 0); + assert.ok(!f.calls.some(c => c.includes('dist-tag'))); + }); +} + +test('checksum parser rejects duplicate, traversal, missing and mismatched rows', t => { + const f = fixture(t); + mkdirSync(join(f.directory, 'assets'), { recursive: true }); + f.run('gh', ['release', 'download', tag, '--dir', join(f.directory, 'assets')]); + const path = join(f.directory, 'assets/mem-mcp-checksums.txt'); + const good = readFileSync(path, 'utf8'); + checkAssets(join(f.directory, 'assets'), f.r, commit, f.run); + for (const bad of [good + good.split('\n')[0] + '\n', good.replace(ASSETS[0], '../outside'), good.split('\n').slice(1).join('\n'), good.replace(/^[a-f0-9]/, 'z')]) { + writeFileSync(path, bad); + assert.throws(() => checkAssets(join(f.directory, 'assets'), f.r, commit, f.run)); + } +}); + +for (const changed of ['download_count', 'digest']) { + test(`final Release readback handles changing ${changed}`, async t => { + const f = fixture(t); + const original = f.run; + let releaseReads = 0; + f.run = (command, args, cwd, descriptor) => { + if (command === 'gh' && args[0] === 'api' && ++releaseReads === 3) { + f.r.assets[0][changed] = changed === 'digest' ? 'sha256:' + 'b'.repeat(64) : 17; + } + return original(command, args, cwd, descriptor); + }; + if (changed === 'digest') { + await assert.rejects(runRelease(tag, f), /Release changed/); + assert.ok(!f.calls.some(c => c[0] === 'npm' && c[1] === 'publish')); + } else { + await runRelease(tag, f); + } + }); +} diff --git a/scripts/test_release_guards.sh b/scripts/test_release_guards.sh index 1447bcd..236cdd2 100755 --- a/scripts/test_release_guards.sh +++ b/scripts/test_release_guards.sh @@ -273,6 +273,14 @@ assets=( mem-mcp-windows-amd64.exe mem-mcp-windows-arm64.exe ) +empty_asset_dir="${tmp_dir}/empty assets" +mkdir -p -- "${empty_asset_dir}" +if empty_error="$("${repo_root}/scripts/generate_release_checksums.sh" \ + "${current_tag}" "${same_commit}" "${empty_asset_dir}" 2>&1)"; then + die "empty asset directory unexpectedly succeeded" +fi +[[ "${empty_error}" == *'release assets differ from the exact expected set'* ]] || + die "empty assets must fail explicitly, not with a Bash 3.2 unbound array error" for asset in "${assets[@]}"; do printf 'test payload for %s\n' "${asset}" > "${asset_dir}/${asset}" done @@ -286,6 +294,13 @@ server_manifest="${asset_dir}/mem-checksums.txt" # BSD wc pads its count with blanks, so a line count must not come from wc -l. [[ "$(grep -c '' "${mcp_manifest}")" == 6 ]] || die "mcp checksum manifest must have six rows" [[ "$(grep -c '' "${server_manifest}")" == 16 ]] || die "server checksum manifest must have 16 rows" +actual_manifest_names="$( + sed -E 's/^[0-9a-f]{64} //' "${mcp_manifest}" "${server_manifest}" | + LC_ALL=C sort +)" +expected_manifest_names="$(printf '%s\n' "${assets[@]}" | LC_ALL=C sort)" +[[ "${actual_manifest_names}" == "${expected_manifest_names}" ]] || + die "portable asset enumeration lost or combined a basename" ( cd -- "${asset_dir}" sha256sum --check --strict "$(basename -- "${mcp_manifest}")" >/dev/null @@ -320,3 +335,5 @@ expect_failure "symlink asset" \ bash "${repo_root}/scripts/test_release_checksum_output_safety.sh" printf 'PASS: release source, notes, asset-set and checksum guards fail closed\n' + +node --test "${repo_root}/scripts/npm-release.test.mjs" diff --git a/scripts/test_release_helpers_compat.sh b/scripts/test_release_helpers_compat.sh index b6a8f0a..0cb8c80 100755 --- a/scripts/test_release_helpers_compat.sh +++ b/scripts/test_release_helpers_compat.sh @@ -27,7 +27,7 @@ if ! ( exit 1 fi -asset_dir="${tmp_dir}/assets" +asset_dir="${tmp_dir}/assets with spaces" mkdir -p -- "${asset_dir}" assets=( memd-darwin-amd64 diff --git a/scripts/validate_release_version.sh b/scripts/validate_release_version.sh index e063ac3..766664a 100755 --- a/scripts/validate_release_version.sh +++ b/scripts/validate_release_version.sh @@ -38,7 +38,7 @@ fi require_exact_line npm/package.json " \"version\": \"${version}\"," require_exact_line npm/server.json " \"version\": \"${version}\"," require_exact_line server/cmd/mem-mcp/main.go \ - $'\t\"version\": \"'"${version}"$'\", // synced with npm/@fullstack-ai-infra/mem-mcp version' + $'\t\"version\": \"'"${version}"$'\", // synced with npm/@bytefolk/mem-mcp version' require_exact_line worker/pyproject.toml "version = \"${version}\"" require_exact_line worker/mem_worker/__init__.py "__version__ = \"${version}\"" diff --git a/server/cmd/mem-mcp/main.go b/server/cmd/mem-mcp/main.go index e976d30..0d7d886 100644 --- a/server/cmd/mem-mcp/main.go +++ b/server/cmd/mem-mcp/main.go @@ -50,7 +50,7 @@ const protocolVersion = "2024-11-05" // serverInfo is what we report back in the `initialize` handshake. var serverInfo = map[string]any{ "name": "mem-mcp", - "version": "0.1.1", // synced with npm/@fullstack-ai-infra/mem-mcp version + "version": "0.1.2", // synced with npm/@bytefolk/mem-mcp version } func main() { diff --git a/web/package-lock.json b/web/package-lock.json index 28a95f0..ce35cc0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "mem-web", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mem-web", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", diff --git a/web/package.json b/web/package.json index e8a3535..2988971 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "mem-web", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "description": "mem · Agent-Native AI 网盘 · Web UI (Phase 1)", "scripts": { diff --git a/worker/README.md b/worker/README.md index 7cd5e8c..a1feb17 100644 --- a/worker/README.md +++ b/worker/README.md @@ -293,7 +293,7 @@ print(stub.HealthCheck(pb.HealthCheckRequest())) PY ``` -Expected: `status: SERVING\nversion: "0.1.1"`. +Expected: `status: SERVING\nversion: "0.1.2"`. --- diff --git a/worker/mem_worker/__init__.py b/worker/mem_worker/__init__.py index 7e1ada1..052bb7a 100644 --- a/worker/mem_worker/__init__.py +++ b/worker/mem_worker/__init__.py @@ -13,4 +13,4 @@ from __future__ import annotations -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/worker/pyproject.toml b/worker/pyproject.toml index 9fead71..b965758 100644 --- a/worker/pyproject.toml +++ b/worker/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mem-worker" -version = "0.1.1" +version = "0.1.2" description = "mem AI Worker — Processor + Provider service (gRPC, Python 3.11+)" readme = "README.md" requires-python = ">=3.11" diff --git a/worker/uv.lock b/worker/uv.lock index 6ded14e..482ddc8 100644 --- a/worker/uv.lock +++ b/worker/uv.lock @@ -925,7 +925,7 @@ wheels = [ [[package]] name = "mem-worker" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "boto3" },