From 38ae93049ae4552e325f85f1a2a07dbf4ee84dd6 Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:49:41 +0700 Subject: [PATCH] fix(mcp): prepare 0.0.18 and unblock aborted SSE reconnects --- .claude-plugin/marketplace.json | 2 +- .cursor-plugin/marketplace.json | 2 +- .github/workflows/test.yml | 8 +++ docs/mcp/changelog.mdx | 13 +++-- docs/relayer/self-hosting.md | 19 +++++++ packages/mcp/CHANGELOG.md | 11 ++-- packages/mcp/package.json | 2 +- .../mcp/plugin/.claude-plugin/plugin.json | 2 +- packages/mcp/plugin/.codex-plugin/plugin.json | 2 +- .../mcp/plugin/.cursor-plugin/plugin.json | 2 +- packages/mcp/plugin/plugin.json | 5 +- .../mcp/plugin/scripts/lib/mcp-launch.mjs | 52 ++++++++++++++++--- packages/mcp/src/bridge.ts | 15 +++++- packages/mcp/test/trusted-launcher.test.mjs | 22 ++++++++ scripts/check-mcp-pin.mjs | 16 +++++- .../tests/verify-manual-sdk-release.test.mjs | 29 +++++++++++ .../verify-package-version-bumps.test.mjs | 45 ++++++++++++++++ scripts/verify-manual-sdk-release.mjs | 12 +++++ scripts/verify-package-version-bumps.mjs | 26 ++++++++++ 19 files changed, 259 insertions(+), 26 deletions(-) create mode 100644 scripts/tests/verify-package-version-bumps.test.mjs create mode 100644 scripts/verify-package-version-bumps.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e8706d74f..2ae569b95 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "name": "memwal", "source": "./packages/mcp/plugin", "description": "Automatic Walrus Memory — proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.", - "version": "0.0.17" + "version": "0.0.18" } ] } diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 88d429857..f3a454ef7 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -11,7 +11,7 @@ "name": "memwal", "source": "./packages/mcp/plugin", "description": "Automatic Walrus Memory — proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.", - "version": "0.0.17" + "version": "0.0.18" } ] } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8aa1861ea..9c5e7aac6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,6 +48,14 @@ jobs: with: node-version-file: .nvmrc + - name: Verify changed package versions + if: matrix.script == 'scripts/verify-manual-sdk-release.mjs' && github.event_name == 'pull_request' && github.base_ref == 'main' + env: + RELEASE_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git fetch --no-tags --depth=1 origin "$RELEASE_BASE_SHA" + node scripts/verify-package-version-bumps.mjs --base "$RELEASE_BASE_SHA" + - name: Run check run: node ${{ matrix.script }} diff --git a/docs/mcp/changelog.mdx b/docs/mcp/changelog.mdx index b8bc3afcf..9a984fc77 100644 --- a/docs/mcp/changelog.mdx +++ b/docs/mcp/changelog.mdx @@ -28,16 +28,21 @@ questions: - What changed in the MemWal MCP changelog? - When was the automatic memory plugin added to MemWal MCP? answer: >- - The upcoming 0.0.17 release stops a bridge reconnect from replaying `memwal_remember_bulk` as a second paid write (#1045) and pins the plugin launcher to `@mysten-incubation/memwal-mcp@0.0.16`. It is not published yet. The latest MCP package on npm `latest` is 0.0.16, which updates the transport SDK and refuses authenticated SSE redirects and endpoints on another origin. 0.0.15 keeps a project-local credentials file inert until `memwal-mcp approve-project`, refuses a `MEMWAL_CREDS_DIR` inside the project, stores the login write-ahead record outside the repository, and closes the previous listener when `memwal_login` uses `freshKey: true`. 0.0.13 stopped reporting a timed-out write as safe to retry, answers a rejected delegate key with `memwal_login` instead of a dropped-connection story, backs off on handshake 429, and pinned plugin launchers to `@0.0.13` so npx cannot keep a cached 0.0.5. + The prepared 0.0.18 release fixes environment-based auto-save consent overrides, wakes aborted SSE readers for reliable reconnects, and prepares a matching stable launcher pin with a published fixed bootstrap. The published 0.0.17 release prevents duplicate bulk writes during bridge replay. Version 0.0.16 hardened transport dependencies and authenticated redirects. --- -## 0.0.17 - -Unreleased. Package and plugin manifests are bumped manually to 0.0.17. The launcher installs the published 0.0.16 through `mcpPackageVersion` until 0.0.17 is available on npm; retarget that pin after publishing. +## 0.0.18 ### Fixed - `MEMWAL_AUTO_SAVE` in `process.env` can no longer turn automatic memory on or clear `pendingConsent`. An MCP client populates `env` from `.cursor/mcp.json`, `.vscode/mcp.json`, or `.claude/settings.json` inside the checkout, so a truthy `MEMWAL_AUTO_SAVE` in `env` could flip automatic saving on over a stored `autoSave: false` (reporting `enabled: true` alongside `state: "off"`) or bypass the consent prompt on an unanswered install. Both the MCP server and the plugin hooks now honour only a disabling override (`MEMWAL_AUTO_SAVE=0`) from `env`, keep `state` and `enabled` consistent, and preserve `pendingConsent: true` until a stored answer exists in `~/.memwal/settings.json`. (#995) +- Wake pending SSE readers immediately when the connection is aborted, so an idle session reconnects without depending on the HTTP reader rejecting first. +- Pin the launcher to 0.0.18 with published 0.0.17-dev.3 (which contains the consent fix) as an explicit bootstrap while the stable release is unavailable. Bootstrap is permitted only when npm reports the exact preferred package version missing; cached stable installs keep their existing offline fast path. + +## 0.0.17 + +### Fixed + - `memwal_remember_bulk` no longer registers a second paid Walrus blob when the bridge reconnects and replays the call. The bridge stamps each call with a stable idempotency key before tracking it, and the sidecar sends that key to the relayer, which answers a replayed key with the jobs it already queued. A bulk call the agent retries itself is a new call and gets a new key. (#1045) - The plugin launcher installs `@mysten-incubation/memwal-mcp@0.0.16` instead of `0.0.15`. diff --git a/docs/relayer/self-hosting.md b/docs/relayer/self-hosting.md index 130a46a5a..675ef62be 100644 --- a/docs/relayer/self-hosting.md +++ b/docs/relayer/self-hosting.md @@ -289,3 +289,22 @@ To exercise the policy database and HTTP tests locally, set `OWNER_RATE_LIMIT_TEST_DATABASE_URL` to a disposable local PostgreSQL database and run `cargo test --manifest-path services/server/Cargo.toml --bin memwal-server owner_`. The tests use isolated temporary schemas and reject non-local database hosts. + +### Uploader gas failures in live validation + +A successful recall or `write_ready=true` does not prove a durable upload can +finish. If a remember job reports infrastructure capacity failure, inspect the +relayer upload error and the uploader balances in the admin dashboard. The +specific message `Unable to perform gas selection due to insufficient SUI balance` +requires operator maintenance of the configured uploader pool. It is not an +account rate-limit error and must not be addressed by increasing API limits or by +asking the end user to fund their wallet. + +For dev, confirm the configured Sui network and replenish the affected uploader +wallets through the approved testnet funding procedure. Address-balance-only +configurations must have spendable address balance, not just owned coin objects. +Do not transfer production funds, change keys or lower E2E assertions as a +workaround. After maintenance, rerun `SDK / E2E (live relayer)` and confirm each +remember job reaches `done`, then recall from the test namespace. A failed or +pending job is not a stored memory. This release does not perform wallet funding +or add balance RPCs to the remember/recall request path. diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 2bc6d6420..5590cd1a1 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,12 +1,17 @@ # @mysten-incubation/memwal-mcp -## 0.0.17 - -Unreleased. Package and plugin manifests are bumped manually to 0.0.17. The launcher installs the published 0.0.16 through `mcpPackageVersion` until 0.0.17 is available on npm; retarget that pin after publishing. +## 0.0.18 ### Fixed - `MEMWAL_AUTO_SAVE` in `process.env` can no longer turn automatic memory on or clear `pendingConsent`. An MCP client populates `env` from `.cursor/mcp.json`, `.vscode/mcp.json`, or `.claude/settings.json` inside the checkout, so a truthy `MEMWAL_AUTO_SAVE` in `env` could flip automatic saving on over a stored `autoSave: false` (reporting `enabled: true` alongside `state: "off"`) or bypass the consent prompt on an unanswered install. Both the MCP server and the plugin hooks now honour only a disabling override (`MEMWAL_AUTO_SAVE=0`) from `env`, keep `state` and `enabled` consistent, and preserve `pendingConsent: true` until a stored answer exists in `~/.memwal/settings.json`. (#995) +- Wake pending SSE readers immediately when the connection is aborted, so an idle session reconnects without depending on the HTTP reader rejecting first. +- Pin the launcher to 0.0.18 with published 0.0.17-dev.3 (which contains the consent fix) as an explicit bootstrap while the stable release is unavailable. Bootstrap is permitted only when npm reports the exact preferred package version missing; cached stable installs keep their existing offline fast path. + +## 0.0.17 + +### Fixed + - `memwal_remember_bulk` no longer registers a second paid Walrus blob when the bridge reconnects and replays the call. The bridge stamps each call with a stable idempotency key before tracking it, and the sidecar sends that key to the relayer, which answers a replayed key with the jobs it already queued. A bulk call the agent retries itself is a new call and gets a new key. (#1045) - The plugin launcher installs `@mysten-incubation/memwal-mcp@0.0.16` instead of `0.0.15`. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index fb1326b4e..7259db86b 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@mysten-incubation/memwal-mcp", - "version": "0.0.17", + "version": "0.0.18", "description": "Walrus Memory MCP client \u2014 single-binary stdio MCP server that bridges Cursor / Claude Desktop / Antigravity / Claude Code to the Walrus Memory relayer. Handles browser-based wallet login on first run.", "type": "module", "engines": { diff --git a/packages/mcp/plugin/.claude-plugin/plugin.json b/packages/mcp/plugin/.claude-plugin/plugin.json index 2eb14e03b..d5b3acead 100644 --- a/packages/mcp/plugin/.claude-plugin/plugin.json +++ b/packages/mcp/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "memwal", - "version": "0.0.17", + "version": "0.0.18", "description": "Automatic Walrus Memory for Claude Code \u2014 proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.", "author": { "name": "Mysten Labs" diff --git a/packages/mcp/plugin/.codex-plugin/plugin.json b/packages/mcp/plugin/.codex-plugin/plugin.json index f1bd03687..25c06c69e 100644 --- a/packages/mcp/plugin/.codex-plugin/plugin.json +++ b/packages/mcp/plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "memwal", - "version": "0.0.17", + "version": "0.0.18", "description": "Persistent Walrus Memory for Codex. Remembers decisions, preferences, and project context across sessions.", "author": { "name": "Mysten Labs", diff --git a/packages/mcp/plugin/.cursor-plugin/plugin.json b/packages/mcp/plugin/.cursor-plugin/plugin.json index 716c26fa7..0f6d2a6e0 100644 --- a/packages/mcp/plugin/.cursor-plugin/plugin.json +++ b/packages/mcp/plugin/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "memwal", - "version": "0.0.17", + "version": "0.0.18", "description": "Automatic Walrus Memory for Cursor \u2014 proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.", "author": { "name": "Mysten Labs" diff --git a/packages/mcp/plugin/plugin.json b/packages/mcp/plugin/plugin.json index b4f71987b..67ea4d399 100644 --- a/packages/mcp/plugin/plugin.json +++ b/packages/mcp/plugin/plugin.json @@ -1,8 +1,9 @@ { "id": "memwal", "name": "memwal", - "version": "0.0.17", - "mcpPackageVersion": "0.0.16", + "version": "0.0.18", + "mcpPackageVersion": "0.0.18", + "mcpBootstrapVersion": "0.0.17-dev.3", "description": "Automatic Walrus Memory for Antigravity \u2014 proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.", "author": { "name": "Mysten Labs" diff --git a/packages/mcp/plugin/scripts/lib/mcp-launch.mjs b/packages/mcp/plugin/scripts/lib/mcp-launch.mjs index e31bfc7a1..1bc152ef9 100644 --- a/packages/mcp/plugin/scripts/lib/mcp-launch.mjs +++ b/packages/mcp/plugin/scripts/lib/mcp-launch.mjs @@ -455,6 +455,7 @@ export function installArguments(spec, staging) { // otherwise run preinstall/install/postinstall of the whole dependency tree // as the user, before any of our verification runs. "--ignore-scripts", + "--json", `--registry=${MCP_REGISTRY}`, "--no-audit", "--no-fund", @@ -463,6 +464,25 @@ export function installArguments(spec, staging) { ]; } +/** Only an exact missing-version npm error can use the reviewed bootstrap. */ +export function isMissingPinnedVersion(result, version) { + try { + const error = JSON.parse(result.stdout).error; + return error?.code === "ETARGET" + && error.summary === `No matching version found for ${MCP_PACKAGE_NAME}@${version}.`; + } catch { return false; } +} + +/** No fallback on network, integrity, permissions or dependency failures. */ +export function resolvePinnedEntry(preferred, bootstrap, load) { + try { return load(preferred); } + catch (error) { + if (!bootstrap || bootstrap === preferred + || error?.code !== "MCP_VERSION_UNAVAILABLE" || error.version !== preferred) throw error; + return load(bootstrap); + } +} + /** * Install the pinned version into the trusted area and return its absolute entry * point. Staged in a sibling temp directory and renamed into place, so a second @@ -503,6 +523,12 @@ function install(version, root) { throw new Error(`could not run npm (${npm.path}): ${result.error.message}`); } if (result.status !== 0) { + if (isMissingPinnedVersion(result, version)) { + const error = new Error(`The pinned ${MCP_PACKAGE_NAME}@${version} is not published`); + error.code = "MCP_VERSION_UNAVAILABLE"; + error.version = version; + throw error; + } throw new Error( `npm install ${spec} failed (exit ${result.status}): ` + `${(result.stderr || result.stdout || "").trim()}`, @@ -542,17 +568,27 @@ function install(version, root) { * The absolute path the plugin should launch. Installs the pinned version into the * trusted area on first use; later calls are a stat of a known path. * - * There is deliberately no fallback: if the trusted install cannot be produced, the - * launcher fails instead of reaching for a package name that a project could answer. + * A reviewed bootstrap is permitted only for npm's exact missing-version error. + * Every candidate uses the same trusted install path and verification; the launcher + * never resolves a package from the user's project. */ -export function ensureTrustedEntry({ version = pinnedVersion(), root = runtimeRoot() } = {}) { +export function ensureTrustedEntry(options = {}) { + const { version = pinnedVersion(), root = runtimeRoot() } = options; const trustedRoot = prepareRuntimeRoot(assertRuntimeRootLocation(root)); - const dir = installDir(version, trustedRoot); - if (verifyTrustedDirectory(dir)) { - const existing = resolveInstalledEntry(dir, version); - if (existing) return existing; + const manifest = options.version === undefined + ? JSON.parse(readFileSync(join(PLUGIN_ROOT, "plugin.json"), "utf8")) : {}; + const bootstrap = manifest.mcpBootstrapVersion; + if (bootstrap !== undefined && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(bootstrap)) { + throw new Error("Invalid MCP bootstrap version"); } - return install(version, trustedRoot); + return resolvePinnedEntry(version, bootstrap, candidate => { + const dir = installDir(candidate, trustedRoot); + if (verifyTrustedDirectory(dir)) { + const existing = resolveInstalledEntry(dir, candidate); + if (existing) return existing; + } + return install(candidate, trustedRoot); + }); } /** Exported for the regression test: the path we expect, without installing anything. */ diff --git a/packages/mcp/src/bridge.ts b/packages/mcp/src/bridge.ts index 3f6394446..d075dfa7d 100644 --- a/packages/mcp/src/bridge.ts +++ b/packages/mcp/src/bridge.ts @@ -774,7 +774,7 @@ async function openSseStream( // be open (observed in the wild: relayer session state silently dropped // while the bridge waited forever for a response that never arrived, // because the next POST landed in the void). Abort the controller — the - // catch block sets streamEnded=true and runBridge's serverPump triggers + // abort listener sets streamEnded=true and runBridge's serverPump triggers // reconnect("server-pump-eof"), which replays any in-flight requests on // the fresh session. const idleTimeoutMs = resolveSseIdleMs(); @@ -790,6 +790,18 @@ async function openSseStream( // unref so the watchdog never holds the event loop open during shutdown. watchdog.unref?.(); + // Some supported fetch implementations leave reader.read() pending after + // abort. Wake our iterator directly; cancellation is cleanup, not the signal + // on which reconnect progress depends. + const onStreamAbort = () => { + clearInterval(watchdog); + streamEnded = true; + wake(); + void reader.cancel().catch(() => {}); + }; + controller.signal.addEventListener("abort", onStreamAbort, { once: true }); + if (controller.signal.aborted) onStreamAbort(); + // Pump the SSE stream in the background. const pump = (async () => { try { @@ -842,6 +854,7 @@ async function openSseStream( } } } finally { + controller.signal.removeEventListener("abort", onStreamAbort); clearInterval(watchdog); streamEnded = true; // Wake any waiter so they see EOF. diff --git a/packages/mcp/test/trusted-launcher.test.mjs b/packages/mcp/test/trusted-launcher.test.mjs index a4c0a10f6..59b62d20e 100644 --- a/packages/mcp/test/trusted-launcher.test.mjs +++ b/packages/mcp/test/trusted-launcher.test.mjs @@ -821,3 +821,25 @@ test("a subdirectory of the home directory is a project again", (t) => { assert.match(result.stderr, /inside the project tree/); assert.doesNotMatch(result.stdout, new RegExp(LOCAL_MARKER)); }); + +test("stable pin bootstrap is used only for the exact unpublished version", async () => { + const { resolvePinnedEntry, isMissingPinnedVersion } = await import("../plugin/scripts/lib/mcp-launch.mjs"); + const preferred = "0.0.18", bootstrap = "0.0.17-dev.3"; + const missing = Object.assign(new Error("missing"), { code: "MCP_VERSION_UNAVAILABLE", version: preferred }); + const calls = []; + assert.equal(resolvePinnedEntry(preferred, bootstrap, version => { + calls.push(version); + if (version === preferred) throw missing; + return "trusted-bootstrap"; + }), "trusted-bootstrap"); + assert.deepEqual(calls, [preferred, bootstrap]); + assert.equal(resolvePinnedEntry(preferred, bootstrap, () => "cached-stable"), "cached-stable"); + for (const error of [new Error("network"), Object.assign(new Error("dependency"), { code: "MCP_VERSION_UNAVAILABLE", version: "other" })]) { + assert.throws(() => resolvePinnedEntry(preferred, bootstrap, () => { throw error; }), value => value === error); + } + const result = error => ({ stdout: JSON.stringify({ error }) }); + assert.equal(isMissingPinnedVersion(result({ code: "ETARGET", summary: `No matching version found for ${MCP_PACKAGE_NAME}@${preferred}.` }), preferred), true); + assert.equal(isMissingPinnedVersion(result({ code: "ETARGET", summary: "No matching version found for dependency@99.0.0." }), preferred), false); + assert.equal(isMissingPinnedVersion(result({ code: "ECONNRESET", summary: "network" }), preferred), false); + assert.equal(isMissingPinnedVersion({ stdout: "not json" }, preferred), false); +}); diff --git a/scripts/check-mcp-pin.mjs b/scripts/check-mcp-pin.mjs index b1ab623b4..512288ad6 100755 --- a/scripts/check-mcp-pin.mjs +++ b/scripts/check-mcp-pin.mjs @@ -14,15 +14,20 @@ * A unit test cannot catch this — whether a version exists is a fact about npm, * not about the repository — so it lives here. * + * An explicit published bootstrap can serve dev installs while their exact + * preferred stable version is pending. Runtime fallback is allowed only for + * npm ETARGET on that exact preferred version, never for network/install errors. + * * Two findings, deliberately unequal: * * ERROR the pin is not published. The launcher cannot install it. Blocking. - * One exception: on a ref headed for `main`, a pin equal to + * An explicit published bootstrap permits a pending preferred pin. + * Otherwise, on a ref headed for `main`, a pin equal to * packages/mcp/package.json's version is the release being cut, and * release-mcp.yml publishes exactly that from this tree moments later. * Erroring there would redden every release. The exception is scoped to * `main` on purpose: `dev` pinning an unpublished release version is - * precisely the 2026-09-17 bug, and must stay an error. + * precisely the 2026-09-17 bug without an explicit published bootstrap. * * WARNING the pin is published but is not what the dist-tag for this branch * points at. Not blocking, because it cannot be: pushing to `dev` @@ -120,6 +125,13 @@ async function main() { } if (!versions.includes(pin)) { + const bootstrap = manifest.mcpBootstrapVersion; + if (pin === sourceVersion && typeof bootstrap === "string" && versions.includes(bootstrap)) { + annotate("warning", `Preferred ${pin} is not published yet; the launcher can bootstrap from published ${bootstrap}. Stable installs switch to ${pin} when npm serves it.`); + summary.push(`Preferred release pending; explicit published bootstrap: \`${bootstrap}\`.`); + summarise(summary); + return; + } // The release being cut is allowed to pin ahead of npm: release-mcp.yml // publishes packages/mcp/package.json's version from this very tree a // moment after the push. Only on a ref headed for `main`, though — diff --git a/scripts/tests/verify-manual-sdk-release.test.mjs b/scripts/tests/verify-manual-sdk-release.test.mjs index 99fb4a4b4..10101acad 100644 --- a/scripts/tests/verify-manual-sdk-release.test.mjs +++ b/scripts/tests/verify-manual-sdk-release.test.mjs @@ -1,3 +1,4 @@ +import "./verify-package-version-bumps.test.mjs"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; @@ -101,3 +102,31 @@ test("publication ends the old-pin window and requires retargeting the launcher" assert.notEqual(result.status, 0); assert.match(result.stdout, /pending-release window has ended/); }); + +test("an invalid or future bootstrap cannot bypass release validation", (t) => { + for (const bootstrap of ["latest", "99.0.0"]) { + const result = check(t, directory => edit(directory, "packages/mcp/plugin/plugin.json", text => { + const manifest = JSON.parse(text); manifest.mcpBootstrapVersion = bootstrap; return JSON.stringify(manifest); + })); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /bootstrap/i); + } +}); + +test("the pending exact stable release can use its explicit published bootstrap", (t) => { + const directory = mkdtempSync(join(tmpdir(), "memwal-bootstrap-check-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const version = "0.0.18", bootstrap = "0.0.17-dev.3"; + for (const [file, value] of [["packages/mcp/package.json", { version }], ["packages/mcp/plugin/plugin.json", { version, mcpPackageVersion: version, mcpBootstrapVersion: bootstrap }]]) { + mkdirSync(dirname(join(directory, file)), { recursive: true }); + writeFileSync(join(directory, file), JSON.stringify(value)); + } + for (const stablePublished of [false, true]) { + const versions = { [bootstrap]: {} }; + if (stablePublished) versions[version] = {}; + const packument = { versions, "dist-tags": { latest: stablePublished ? version : "0.0.17" } }; + const code = `globalThis.fetch = async () => ({ok:true,json:async()=>(${JSON.stringify(packument)})});await import(${JSON.stringify(resolve("scripts/check-mcp-pin.mjs"))});`; + const result = spawnSync(process.execPath, ["--input-type=module", "-e", code], { cwd: directory, encoding: "utf8", env: { ...process.env, GITHUB_BASE_REF: "dev", GITHUB_STEP_SUMMARY: "" } }); + assert.equal(result.status, 0, result.stdout + result.stderr); + } +}); diff --git a/scripts/tests/verify-package-version-bumps.test.mjs b/scripts/tests/verify-package-version-bumps.test.mjs new file mode 100644 index 000000000..8cf248879 --- /dev/null +++ b/scripts/tests/verify-package-version-bumps.test.mjs @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +const checker = resolve('scripts/verify-package-version-bumps.mjs'); +function fixture(t) { + const cwd = mkdtempSync(join(tmpdir(), 'memwal-version-guard-')); + t.after(() => rmSync(cwd, { recursive: true, force: true })); + const git = (...args) => execFileSync('git', ['-c', 'user.name=Release Test', '-c', 'user.email=release-test@example.invalid', '-c', 'commit.gpgsign=false', '-c', `core.hooksPath=${join(cwd, 'no-hooks')}`, ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + git('init'); + for (const pkg of ['sdk', 'mcp', 'openclaw-memory-memwal']) { + mkdirSync(join(cwd, 'packages', pkg, 'src'), { recursive: true }); + writeFileSync(join(cwd, 'packages', pkg, 'package.json'), JSON.stringify({ version: '1.0.0' })); + writeFileSync(join(cwd, 'packages', pkg, 'src', 'index.js'), 'export const value = 1;'); + } + git('add', '.'); git('commit', '-m', 'base'); + const base = git('rev-parse', 'HEAD'); + return { cwd, git, base, run: () => spawnSync(process.execPath, [checker, '--base', base], { cwd, encoding: 'utf8' }) }; +} +test('runtime changes require a version bump', t => { + const f = fixture(t); + writeFileSync(join(f.cwd, 'packages/mcp/src/index.js'), 'export const value = 2;'); + f.git('add', '.'); f.git('commit', '-m', 'runtime'); + const result = f.run(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /runtime payload changed but version/); +}); +test('runtime changes with an increased patch version pass', t => { + const f = fixture(t); + writeFileSync(join(f.cwd, 'packages/mcp/src/index.js'), 'export const value = 2;'); + writeFileSync(join(f.cwd, 'packages/mcp/package.json'), JSON.stringify({ version: '1.0.1' })); + f.git('add', '.'); f.git('commit', '-m', 'release'); + const result = f.run(); + assert.equal(result.status, 0, result.stderr); +}); +test('test-only changes do not require a package release', t => { + const f = fixture(t); + mkdirSync(join(f.cwd, 'packages/mcp/test')); + writeFileSync(join(f.cwd, 'packages/mcp/test/example.test.mjs'), '// regression'); + f.git('add', '.'); f.git('commit', '-m', 'test'); + const result = f.run(); + assert.equal(result.status, 0, result.stderr); +}); diff --git a/scripts/verify-manual-sdk-release.mjs b/scripts/verify-manual-sdk-release.mjs index ad9af6064..b6cf947cb 100644 --- a/scripts/verify-manual-sdk-release.mjs +++ b/scripts/verify-manual-sdk-release.mjs @@ -96,6 +96,18 @@ if (pinOverride !== undefined) { ); } } +const bootstrap = JSON.parse(readFileSync("packages/mcp/plugin/plugin.json", "utf8")).mcpBootstrapVersion; +if (bootstrap !== undefined) { + if (pinOverride !== mcpVersion || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(bootstrap)) { + throw new Error("MCP bootstrap requires an exact preferred release pin and a semver bootstrap"); + } + const parts = bootstrap.split("-")[0].split(".").map(Number); + const release = mcpVersion.split(".").map(Number); + const difference = parts.findIndex((value, i) => value !== release[i]); + if (difference !== -1 && parts[difference] > release[difference]) { + throw new Error("MCP bootstrap must not be newer than the preferred release"); + } +} const LAUNCHER = "scripts/launch_mcp.mjs"; for (const [pluginPath, rootPlaceholder] of [ ["packages/mcp/plugin/.mcp.json", "${CLAUDE_PLUGIN_ROOT}"], diff --git a/scripts/verify-package-version-bumps.mjs b/scripts/verify-package-version-bumps.mjs new file mode 100644 index 000000000..5f6ce7130 --- /dev/null +++ b/scripts/verify-package-version-bumps.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +// Compare runtime payloads against the stable main baseline at promotion time. +// Dev PRs can continue working on a not-yet-stable release without bumping each commit. +import { execFileSync } from 'node:child_process'; +const baseIndex = process.argv.indexOf('--base'); +const base = process.argv[baseIndex + 1]; +if (baseIndex < 0 || !base || base.startsWith('-')) throw new Error('--base is required'); +const headIndex = process.argv.indexOf('--head'); +const head = headIndex < 0 ? 'HEAD' : process.argv[headIndex + 1]; +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); +for (const directory of ['packages/sdk', 'packages/mcp', 'packages/openclaw-memory-memwal']) { + const changed = git('diff', '--name-only', base, head, '--', directory).split('\n'); + const runtime = changed.some(file => file.startsWith(`${directory}/src/`) + || file.startsWith(`${directory}/plugin/scripts/`) + || file === `${directory}/package.json`); + if (!runtime) continue; + const previous = JSON.parse(git('show', `${base}:${directory}/package.json`)).version; + const next = JSON.parse(git('show', `${head}:${directory}/package.json`)).version; + if (!/^\d+\.\d+\.\d+$/.test(previous) || !/^\d+\.\d+\.\d+$/.test(next)) throw new Error(`${directory}: stable source versions required`); + const a = previous.split('.').map(Number), b = next.split('.').map(Number); + const difference = b.findIndex((part, i) => part !== a[i]); + if (difference < 0 || b[difference] < a[difference]) { + throw new Error(`${directory}: runtime payload changed but version ${previous} was not increased (received ${next})`); + } + console.log(`${directory}: ${previous} -> ${next}`); +}