From 9de11f098e65ffe3bbe610f34f3e87fd82b2774f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 05:41:24 -0700 Subject: [PATCH] fix(release): forward-port July guards and restore main validation (#107462) * fix(installer): validate upgraded Windows SQLite runtime * fix(release): lock packed AI runtime dependency * test(mattermost): tolerate additional fallback diagnostics * test(live): make Claude resume proof explicit * test(plugins): repair exact-main prerelease coverage * test(release): restore root test type coverage --------- Co-authored-by: Vincent Koc Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> --- .github/workflows/openclaw-npm-release.yml | 7 + extensions/acpx/index.test.ts | 24 +- extensions/acpx/src/config.test.ts | 10 + .../src/mattermost/slash-http.test.ts | 9 +- extensions/qa-channel/src/inbound.test.ts | 2 +- .../telegram/src/bot-message-dispatch.test.ts | 6 +- scripts/install.ps1 | 48 ++- scripts/openclaw-npm-prepublish-verify.ts | 20 +- scripts/prepare-openclaw-npm-shrinkwrap.ts | 274 ++++++++++++++++++ .../gateway-cli-backend.live-helpers.test.ts | 8 +- .../gateway-cli-backend.live-helpers.ts | 8 +- test/scripts/install-ps1.test.ts | 30 +- .../prepare-openclaw-npm-shrinkwrap.test.ts | 264 +++++++++++++++++ 13 files changed, 663 insertions(+), 47 deletions(-) create mode 100644 scripts/prepare-openclaw-npm-shrinkwrap.ts create mode 100644 test/scripts/prepare-openclaw-npm-shrinkwrap.test.ts diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index 331bc4a69a8e..ebca75a34d83 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -343,6 +343,13 @@ jobs: AI_RUNTIME_TARBALL_DIR: ${{ steps.ai_runtime_tarballs.outputs.dir }} run: | set -euo pipefail + mapfile -t AI_TARBALLS < <(find "$AI_RUNTIME_TARBALL_DIR" -maxdepth 1 -type f -name 'openclaw-ai-*.tgz' -print | sort) + if [[ "${#AI_TARBALLS[@]}" -ne 1 ]]; then + echo "Expected exactly one prepared @openclaw/ai tarball, found ${#AI_TARBALLS[@]}." >&2 + exit 1 + fi + AI_TARBALL_PATH="${AI_TARBALLS[0]}" + node --import tsx scripts/prepare-openclaw-npm-shrinkwrap.ts "$AI_TARBALL_PATH" PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt" pnpm pack --json 2>&1 | tee "$PACK_OUTPUT" PACK_NAME="$(node - "$PACK_OUTPUT" <<'NODE' diff --git a/extensions/acpx/index.test.ts b/extensions/acpx/index.test.ts index 13f3b0b892d7..283454cfaad3 100644 --- a/extensions/acpx/index.test.ts +++ b/extensions/acpx/index.test.ts @@ -47,14 +47,14 @@ describe("acpx plugin", () => { createAcpxRuntimeServiceMock.mockReturnValue(service); const openKeyedStore = vi.fn(); - const api = { + const api = createTestPluginApi({ pluginConfig: { stateDir: "/tmp/acpx" }, - runtime: { state: { openKeyedStore } }, + runtime: { state: { openKeyedStore } } as never, registerService: vi.fn(), on: vi.fn(), - }; + }); - plugin.register(api as never); + plugin.register(api); expect(createAcpxRuntimeServiceMock).toHaveBeenCalledWith({ pluginConfig: api.pluginConfig, @@ -75,14 +75,14 @@ describe("acpx plugin", () => { const service = { id: "acpx-service", start: vi.fn() }; createAcpxRuntimeServiceMock.mockReturnValue(service); - const api = { + const api = createTestPluginApi({ pluginConfig: { timeoutSeconds: 180 }, - runtime: { state: { openKeyedStore: vi.fn() } }, + runtime: { state: { openKeyedStore: vi.fn() } } as never, registerService: vi.fn(), on: vi.fn(), - }; + }); - plugin.register(api as never); + plugin.register(api); expect(api.on).toHaveBeenCalledWith("reply_dispatch", expect.any(Function), { timeoutMs: 180_000, @@ -93,14 +93,14 @@ describe("acpx plugin", () => { const service = { id: "acpx-service", start: vi.fn() }; createAcpxRuntimeServiceMock.mockReturnValue(service); - const api = { + const api = createTestPluginApi({ pluginConfig: {}, - runtime: {}, + runtime: {} as never, registerService: vi.fn(), on: vi.fn(), - }; + }); - expect(() => plugin.register(api as never)).not.toThrow(); + expect(() => plugin.register(api)).not.toThrow(); expect(api.registerService).toHaveBeenCalledWith(service); }); diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts index c282d3cae7d0..b19cf8ce68cc 100644 --- a/extensions/acpx/src/config.test.ts +++ b/extensions/acpx/src/config.test.ts @@ -226,6 +226,16 @@ describe("embedded acpx plugin config", () => { type: "number", minimum: 0, }, + piSessionCatalog: { + type: "object", + additionalProperties: false, + properties: { + enabled: { + type: "boolean", + default: true, + }, + }, + }, probeAgent: { type: "string", minLength: 1, diff --git a/extensions/mattermost/src/mattermost/slash-http.test.ts b/extensions/mattermost/src/mattermost/slash-http.test.ts index 08c88bda1cf2..803b1dd8a48e 100644 --- a/extensions/mattermost/src/mattermost/slash-http.test.ts +++ b/extensions/mattermost/src/mattermost/slash-http.test.ts @@ -928,8 +928,13 @@ describe("slash-http", () => { }), ).resolves.toBe(true); - expect(log).toHaveBeenCalledTimes(1); - const message = firstLogMessage(log); + const message = log.mock.calls + .map(([entry]) => (typeof entry === "string" ? entry : "")) + .find((entry) => entry.includes("command lookup by id failed")); + expect(message).toBeTruthy(); + if (!message) { + throw new Error("expected sanitized Mattermost command lookup failure log"); + } expect(message).not.toMatch(/[\r\n\t]/u); expect(message).toContain("/oc_status"); expect(message).toContain("primary token=[redacted]"); diff --git a/extensions/qa-channel/src/inbound.test.ts b/extensions/qa-channel/src/inbound.test.ts index 1ce3a0abfab4..f428ddb35577 100644 --- a/extensions/qa-channel/src/inbound.test.ts +++ b/extensions/qa-channel/src/inbound.test.ts @@ -246,7 +246,7 @@ describe("handleQaInbound", () => { expect(output).not.toContain(paragraphSeparator); expect(output).toContain("dispatch\\u000d\\u000aforged\\u2029next"); expect(output).toContain("cleanup\\u000aforged\\u001b[31m\\u009b32m\\u2028next"); - expect(output).toContain("[object Undefined]"); + expect(output).toContain("reply dispatch failed: undefined"); } finally { warn.mockRestore(); } diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 53a5e1765e29..fadafe0bdf2d 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -4080,8 +4080,8 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(answerDraftStream.updatePreview).toHaveBeenCalledWith( telegramProgressPreview( - "Working\n\n🛠️ Exec\n🛠️ git rev-parse --abbrev-ref HEAD", - "Working\n🛠️ Exec\n🛠️ Exec git rev-parse --abbrev-ref HEAD", + "Cracking\n\n🛠️ Exec\n🛠️ git rev-parse --abbrev-ref HEAD", + "Cracking\n🛠️ Exec\n🛠️ Exec git rev-parse --abbrev-ref HEAD", ), ); expect(answerDraftStream.update).not.toHaveBeenCalledWith("Branch is up to date"); @@ -4806,7 +4806,7 @@ describe("dispatchTelegramMessage draft streaming", () => { }); expect(answerDraftStream.updatePreview).toHaveBeenCalledWith( - telegramProgressPreview("Working\n\n🛠️ Exec", "Working\n🛠️ Exec"), + telegramProgressPreview("Cracking\n\n🛠️ Exec", "Cracking\n🛠️ Exec"), ); expect(answerDraftStream.update).toHaveBeenCalledTimes(1); expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, trailingFinalStatusText); diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 3abc311fb3d6..e213a3facb4b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -172,24 +172,56 @@ function Test-NodeVersionSupported { } function Test-NodeSqliteSupported { - try { - $probe = 'const { DatabaseSync } = require("node:sqlite"); const db = new DatabaseSync(":memory:"); try { const value = db.prepare("SELECT sqlite_version() AS version").get()?.version; const match = typeof value === "string" ? /^(\d+)\.(\d+)\.(\d+)$/.exec(value) : null; const major = Number(match?.[1]); const minor = Number(match?.[2]); const patch = Number(match?.[3]); const safe = major > 3 || (major === 3 && (minor > 51 || (minor === 51 && patch >= 3) || (minor === 50 && patch >= 7) || (minor === 44 && patch >= 6))); if (!safe) process.exitCode = 1; } finally { db.close(); }' - $probe | & node - 2>$null - return ($LASTEXITCODE -eq 0) - } catch { + param([string]$Version) + + if ([string]::IsNullOrWhiteSpace($Version)) { return $false } + $versionMatch = [regex]::Match($Version, '^(?\d+)\.(?\d+)\.(?\d+)$') + if (-not $versionMatch.Success) { + return $false + } + $major = [int]$versionMatch.Groups["major"].Value + $minor = [int]$versionMatch.Groups["minor"].Value + $patch = [int]$versionMatch.Groups["patch"].Value + return ( + $major -gt 3 -or + ( + $major -eq 3 -and + ( + $minor -gt 51 -or + ($minor -eq 51 -and $patch -ge 3) -or + ($minor -eq 50 -and $patch -ge 7) -or + ($minor -eq 44 -and $patch -ge 6) + ) + ) + ) } function Check-Node { try { - $nodeVersion = (node -v 2>$null) + $nodeCommand = Get-Command node -CommandType Application -ErrorAction Stop | Select-Object -First 1 + $nodePath = $nodeCommand.Source + $nodeVersion = (& $nodePath -v 2>$null) + $sqliteProbe = 'const { DatabaseSync } = require("node:sqlite"); const db = new DatabaseSync(":memory:"); try { process.stdout.write(String(db.prepare("SELECT sqlite_version() AS version").get().version)); } finally { db.close(); }' + $sqliteVersion = ($sqliteProbe | & $nodePath - 2>$null) + if ($LASTEXITCODE -ne 0) { + $sqliteVersion = $null + } if ($nodeVersion) { - if ((Test-NodeVersionSupported -Version $nodeVersion) -and (Test-NodeSqliteSupported)) { + if ( + (Test-NodeVersionSupported -Version $nodeVersion) -and + (Test-NodeSqliteSupported -Version $sqliteVersion) + ) { Write-Host "[OK] Node.js $nodeVersion found" -ForegroundColor Green return $true } elseif (Test-NodeVersionSupported -Version $nodeVersion) { - Write-Host "[!] Node.js $nodeVersion uses an unsafe SQLite build; SQLite 3.51.3+ (or patched 3.50.7+/3.44.6+) is required" -ForegroundColor Yellow + $sqliteVersionLabel = if ([string]::IsNullOrWhiteSpace($sqliteVersion)) { + "unavailable" + } else { + $sqliteVersion + } + Write-Host "[!] Node.js $nodeVersion uses SQLite $sqliteVersionLabel; SQLite 3.51.3+ (or patched 3.50.7+/3.44.6+) is required" -ForegroundColor Yellow return $false } else { Write-Host "[!] Node.js $nodeVersion found, but Node 22.22.3+, Node 24.15.0+, or Node 25.9.0+ is required" -ForegroundColor Yellow diff --git a/scripts/openclaw-npm-prepublish-verify.ts b/scripts/openclaw-npm-prepublish-verify.ts index 9ef88ae83831..e52c20055fc1 100644 --- a/scripts/openclaw-npm-prepublish-verify.ts +++ b/scripts/openclaw-npm-prepublish-verify.ts @@ -15,6 +15,11 @@ import { resolveInstalledBinaryCommandInvocation, } from "./openclaw-npm-postpublish-verify.ts"; import { resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts"; +import { + assertPreparedOpenClawNpmShrinkwrap, + npmTarballIntegrity, + readTarballJson, +} from "./prepare-openclaw-npm-shrinkwrap.ts"; import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; type InstalledPackageJson = { @@ -99,6 +104,15 @@ function main(argv = process.argv.slice(2)): void { let binaryInvocation: NpmVerifyCommandInvocation; let packageRoot: string; if (usesPreparedLocalDependencyInstall(args.dependencyTarballPaths.length)) { + const aiTarballPath = realpathSync( + expectDefined(args.dependencyTarballPaths[0], "prepared dependency tarball"), + ); + assertPreparedOpenClawNpmShrinkwrap({ + aiIntegrity: npmTarballIntegrity(aiTarballPath), + aiManifest: readTarballJson(aiTarballPath, "package/package.json"), + rootManifest: readTarballJson(args.tarballPath, "package/package.json"), + shrinkwrap: readTarballJson(args.tarballPath, "package/npm-shrinkwrap.json"), + }); mkdirSync(prefixDir, { recursive: true }); writeFileSync( join(prefixDir, "package.json"), @@ -106,11 +120,7 @@ function main(argv = process.argv.slice(2)): void { { private: true, dependencies: { - "@openclaw/ai": pathToFileURL( - realpathSync( - expectDefined(args.dependencyTarballPaths[0], "prepared dependency tarball"), - ), - ).href, + "@openclaw/ai": pathToFileURL(aiTarballPath).href, openclaw: pathToFileURL(realpathSync(args.tarballPath)).href, }, }, diff --git a/scripts/prepare-openclaw-npm-shrinkwrap.ts b/scripts/prepare-openclaw-npm-shrinkwrap.ts new file mode 100644 index 000000000000..03b55e01834d --- /dev/null +++ b/scripts/prepare-openclaw-npm-shrinkwrap.ts @@ -0,0 +1,274 @@ +#!/usr/bin/env -S node --import tsx + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { satisfies as satisfiesSemver, validRange } from "semver"; + +const AI_PACKAGE_NAME = "@openclaw/ai"; +const AI_LOCK_PATH = "node_modules/@openclaw/ai"; + +type JsonObject = Record; + +type PackageManifest = JsonObject & { + dependencies?: Record; + engines?: Record; + license?: string; + name?: string; + version?: string; +}; + +type NpmShrinkwrap = JsonObject & { + lockfileVersion?: number; + packages?: Record; +}; + +function requireObject(value: unknown, label: string): JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as JsonObject; +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${label} must be a nonempty string`); + } + return value.trim(); +} + +function requireOptionalString(value: unknown, label: string): string | undefined { + return value === undefined ? undefined : requireString(value, label); +} + +function requireOptionalStringMap( + value: unknown, + label: string, +): Record | undefined { + if (value === undefined) { + return undefined; + } + const object = requireObject(value, label); + return Object.fromEntries( + Object.entries(object).map(([key, entry]) => [key, requireString(entry, `${label}.${key}`)]), + ); +} + +function requirePackageManifest(value: JsonObject, label: string): PackageManifest { + return { + ...value, + dependencies: requireOptionalStringMap(value.dependencies, `${label} dependencies`), + engines: requireOptionalStringMap(value.engines, `${label} engines`), + license: requireOptionalString(value.license, `${label} license`), + name: requireOptionalString(value.name, `${label} name`), + version: requireOptionalString(value.version, `${label} version`), + }; +} + +function requireNpmShrinkwrap(value: JsonObject, label: string): NpmShrinkwrap { + const lockfileVersion = value.lockfileVersion; + if (lockfileVersion !== undefined && typeof lockfileVersion !== "number") { + throw new Error(`${label} lockfileVersion must be a number`); + } + const rawPackages = value.packages; + const packages = + rawPackages === undefined + ? undefined + : Object.fromEntries( + Object.entries(requireObject(rawPackages, `${label} packages`)).map(([key, entry]) => [ + key, + requireObject(entry, `${label} packages.${key}`), + ]), + ); + return { ...value, lockfileVersion, packages }; +} + +function registryTarballUrl(packageName: string, version: string): string { + return `https://registry.npmjs.org/${packageName}/-/${basename(packageName)}-${version}.tgz`; +} + +function dependencySemverRange(dependencyName: string, rawSpec: string): string { + const spec = requireString(rawSpec, `${dependencyName} dependency spec`); + const range = validRange(spec); + if (!range) { + throw new Error(`${dependencyName} dependency must use a registry semver spec`); + } + return range; +} + +function expectedAiLockEntry(params: { + aiIntegrity: string; + aiManifest: PackageManifest; + aiVersion: string; +}): JsonObject { + const aiDependencies = params.aiManifest.dependencies ?? {}; + return { + version: params.aiVersion, + resolved: registryTarballUrl(AI_PACKAGE_NAME, params.aiVersion), + integrity: params.aiIntegrity, + ...(params.aiManifest.license ? { license: params.aiManifest.license } : {}), + ...(Object.keys(aiDependencies).length > 0 ? { dependencies: aiDependencies } : {}), + ...(params.aiManifest.engines ? { engines: params.aiManifest.engines } : {}), + }; +} + +export function prepareOpenClawNpmShrinkwrap(params: { + aiIntegrity: string; + aiManifest: PackageManifest; + rootManifest: PackageManifest; + shrinkwrap: NpmShrinkwrap; +}): NpmShrinkwrap { + const rootVersion = requireString(params.rootManifest.version, "root package version"); + const aiName = requireString(params.aiManifest.name, "AI package name"); + const aiVersion = requireString(params.aiManifest.version, "AI package version"); + if (aiName !== AI_PACKAGE_NAME) { + throw new Error(`AI package name must be ${AI_PACKAGE_NAME}, found ${aiName}`); + } + if (aiVersion !== rootVersion) { + throw new Error(`AI package version ${aiVersion} does not match OpenClaw ${rootVersion}`); + } + if (!params.aiIntegrity.startsWith("sha512-")) { + throw new Error("AI package integrity must use sha512"); + } + if (params.shrinkwrap.lockfileVersion !== 3) { + throw new Error(`npm shrinkwrap lockfileVersion must be 3`); + } + + const packages = requireObject(params.shrinkwrap.packages, "npm shrinkwrap packages") as Record< + string, + JsonObject + >; + const rootPackage = requireObject(packages[""], "npm shrinkwrap root package"); + const rootLockVersion = requireString(rootPackage.version, "npm shrinkwrap root version"); + if (rootLockVersion !== rootVersion) { + throw new Error( + `npm shrinkwrap root version ${rootLockVersion} does not match OpenClaw ${rootVersion}`, + ); + } + + const rootDependencies = requireObject( + rootPackage.dependencies, + "npm shrinkwrap root dependencies", + ) as Record; + const aiDependencies = params.aiManifest.dependencies ?? {}; + for (const [dependencyName, dependencySpec] of Object.entries(aiDependencies)) { + const lockEntry = packages[`node_modules/${dependencyName}`]; + if (!lockEntry) { + throw new Error(`npm shrinkwrap is missing AI runtime dependency ${dependencyName}`); + } + const lockedVersion = requireString( + lockEntry.version, + `npm shrinkwrap AI runtime dependency ${dependencyName} version`, + ); + const versionRange = dependencySemverRange(dependencyName, dependencySpec); + if (!satisfiesSemver(lockedVersion, versionRange)) { + throw new Error( + `npm shrinkwrap AI runtime dependency ${dependencyName}@${lockedVersion} does not satisfy ${dependencySpec}`, + ); + } + } + + rootDependencies[AI_PACKAGE_NAME] = aiVersion; + packages[AI_LOCK_PATH] = expectedAiLockEntry({ + aiIntegrity: params.aiIntegrity, + aiManifest: params.aiManifest, + aiVersion, + }); + return params.shrinkwrap; +} + +export function assertPreparedOpenClawNpmShrinkwrap(params: { + aiIntegrity: string; + aiManifest: JsonObject; + rootManifest: JsonObject; + shrinkwrap: JsonObject; +}): void { + const aiManifest = requirePackageManifest(params.aiManifest, "AI package manifest"); + const rootManifest = requirePackageManifest(params.rootManifest, "root package manifest"); + const shrinkwrap = requireNpmShrinkwrap(params.shrinkwrap, "npm shrinkwrap"); + const aiVersion = requireString(aiManifest.version, "AI package version"); + // npm 12 ignores dependency shrinkwraps. The packed manifest is the canonical runtime edge; + // the shrinkwrap additionally pins registry integrity for npm clients that still honor it. + if (rootManifest.dependencies?.[AI_PACKAGE_NAME] !== aiVersion) { + throw new Error( + `packed OpenClaw manifest must depend on exact ${AI_PACKAGE_NAME}@${aiVersion}`, + ); + } + const expected = prepareOpenClawNpmShrinkwrap({ + aiIntegrity: params.aiIntegrity, + aiManifest, + rootManifest, + shrinkwrap: structuredClone(shrinkwrap), + }); + const actualPackages = requireObject(shrinkwrap.packages, "npm shrinkwrap packages") as Record< + string, + JsonObject + >; + const expectedPackages = requireObject( + expected.packages, + "expected npm shrinkwrap packages", + ) as Record; + const actualRoot = requireObject(actualPackages[""], "npm shrinkwrap root package"); + const expectedRoot = requireObject(expectedPackages[""], "expected npm shrinkwrap root package"); + const actualRootDependencies = requireObject( + actualRoot.dependencies, + "npm shrinkwrap root dependencies", + ); + const expectedRootDependencies = requireObject( + expectedRoot.dependencies, + "expected npm shrinkwrap root dependencies", + ); + if ( + actualRootDependencies[AI_PACKAGE_NAME] !== expectedRootDependencies[AI_PACKAGE_NAME] || + JSON.stringify(actualPackages[AI_LOCK_PATH]) !== JSON.stringify(expectedPackages[AI_LOCK_PATH]) + ) { + throw new Error( + `prepared OpenClaw npm shrinkwrap does not lock the exact ${AI_PACKAGE_NAME} tarball`, + ); + } +} + +export function readTarballJson(tarballPath: string, entry: string): JsonObject { + const raw = execFileSync("tar", ["-xOf", tarballPath, entry], { + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + return requireObject(JSON.parse(raw), `${entry} in ${tarballPath}`); +} + +export function npmTarballIntegrity(tarballPath: string): string { + return `sha512-${createHash("sha512").update(readFileSync(tarballPath)).digest("base64")}`; +} + +function main(argv = process.argv.slice(2)): void { + const aiTarballPath = argv[0]?.trim(); + const shrinkwrapPath = resolve(argv[1]?.trim() || "npm-shrinkwrap.json"); + const rootManifestPath = resolve(argv[2]?.trim() || "package.json"); + if (!aiTarballPath || argv.length > 3) { + throw new Error( + "Usage: node --import tsx scripts/prepare-openclaw-npm-shrinkwrap.ts [npm-shrinkwrap.json] [package.json]", + ); + } + + const prepared = prepareOpenClawNpmShrinkwrap({ + aiIntegrity: npmTarballIntegrity(aiTarballPath), + aiManifest: readTarballJson(aiTarballPath, "package/package.json") as PackageManifest, + rootManifest: requireObject( + JSON.parse(readFileSync(rootManifestPath, "utf8")), + "root package manifest", + ) as PackageManifest, + shrinkwrap: requireObject( + JSON.parse(readFileSync(shrinkwrapPath, "utf8")), + "npm shrinkwrap", + ) as NpmShrinkwrap, + }); + writeFileSync(shrinkwrapPath, `${JSON.stringify(prepared, null, 2)}\n`); + console.log(`Prepared ${shrinkwrapPath} for ${AI_PACKAGE_NAME}.`); +} + +const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : null; +if (entrypoint !== null && import.meta.url === entrypoint) { + main(); +} diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 984c8ec499f1..50b5c0cbe2cf 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -198,9 +198,9 @@ describe("gateway cli backend live helpers", () => { }); }); - it("builds Claude continuity prompts without revealing the hidden note", () => { + it("builds Claude continuity prompts without revealing the hidden token", () => { const { buildClaudeCliResumeContinuityProbe } = liveHelpers; - const memoryToken = "CLI-MEM-A1B2C3D4E5F6"; + const memoryToken = "test-memory-token"; const probe = buildClaudeCliResumeContinuityProbe({ firstTurnNonce: "112233", @@ -213,8 +213,8 @@ describe("gateway cli backend live helpers", () => { ); expect(probe.resumePrompt).toBe( "Do not inspect files or run tools. " + - "What private session note were you asked to remember earlier? " + - "Reply with CLI-RESUME-445566 and the remembered note.", + "Return exactly two whitespace-separated tokens: CLI-RESUME-445566 followed by " + + "the exact opaque session token from the earlier turn. Do not add prose.", ); expect(probe.firstTurnPrompt).not.toContain(memoryToken); expect(probe.resumePrompt).not.toContain(memoryToken); diff --git a/src/gateway/gateway-cli-backend.live-helpers.ts b/src/gateway/gateway-cli-backend.live-helpers.ts index b663d5ef0468..5f5e4a76c8ad 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.ts @@ -291,12 +291,12 @@ export function buildClaudeCliResumeContinuityProbe(params: { firstTurnMarker, firstTurnPrompt: `Do not inspect files or run tools. Reply with exactly: ${firstTurnMarker}.`, injectedContext: - `For this turn only, remember the private session note ${params.memoryToken} for a later turn. ` + - "Do not include that note in this turn's reply.", + `Remember this exact opaque session token for a later turn: ${params.memoryToken}. ` + + "Do not include the token in this turn's reply.", resumePrompt: "Do not inspect files or run tools. " + - "What private session note were you asked to remember earlier? " + - `Reply with CLI-RESUME-${params.resumeNonce} and the remembered note.`, + `Return exactly two whitespace-separated tokens: CLI-RESUME-${params.resumeNonce} followed by ` + + "the exact opaque session token from the earlier turn. Do not add prose.", expectedFirstReply: `${firstTurnMarker}.`, expectedResumeMarker: `CLI-RESUME-${params.resumeNonce}`, }; diff --git a/test/scripts/install-ps1.test.ts b/test/scripts/install-ps1.test.ts index b2ef428f6b6f..a8e7bbb03c3b 100644 --- a/test/scripts/install-ps1.test.ts +++ b/test/scripts/install-ps1.test.ts @@ -127,11 +127,24 @@ describe("install.ps1 failure handling", () => { ].join("\n"), }, { - name: "node-sqlite-runtime", + name: "sqlite-versions", source: [ scriptWithoutEntryPoint, "", - "if (-not (Test-NodeSqliteSupported)) { throw 'Bundled SQLite runtime was rejected' }", + "$cases = @{", + " '3.44.5' = $false", + " '3.44.6' = $true", + " '3.50.6' = $false", + " '3.50.7' = $true", + " '3.51.2' = $false", + " '3.51.3' = $true", + " '3.53.1' = $true", + " 'unavailable' = $false", + "}", + "foreach ($entry in $cases.GetEnumerator()) {", + " $actual = Test-NodeSqliteSupported -Version $entry.Key", + ' if ($actual -ne $entry.Value) { throw "Version=$($entry.Key) Actual=$actual" }', + "}", "", ].join("\n"), }, @@ -480,18 +493,19 @@ describe("install.ps1 failure handling", () => { expect(versionBody).toContain("$major -eq 25"); expect(versionBody).toContain("$minor -ge 9"); expect(versionBody).toContain("$major -gt 25"); - expect(sqliteBody).toContain("SELECT sqlite_version() AS version"); - expect(sqliteBody).toContain("$probe | & node -"); - expect(sqliteBody).not.toContain("& node -e"); - expect(sqliteBody).toContain("patch >= 3"); + expect(sqliteBody).toContain("$minor -eq 51 -and $patch -ge 3"); expect(checkNodeBody).toContain("Test-NodeVersionSupported -Version $nodeVersion"); - expect(checkNodeBody).toContain("Test-NodeSqliteSupported"); + expect(checkNodeBody).toContain("Get-Command node -CommandType Application"); + expect(checkNodeBody).toContain("SELECT sqlite_version() AS version"); + expect(checkNodeBody).toContain("$sqliteProbe | & $nodePath -"); + expect(checkNodeBody).not.toContain("& $nodePath -e"); + expect(checkNodeBody).toContain("Test-NodeSqliteSupported -Version $sqliteVersion"); expect(source).toContain("Please install Node.js 24.15+ manually:"); }); runIfPowerShell("accepts only supported Node versions", () => { expectBatchedPowerShellCase("node-versions"); - expectBatchedPowerShellCase("node-sqlite-runtime"); + expectBatchedPowerShellCase("sqlite-versions"); }); runIfPowerShell("upgrades and validates Node installed by Windows package managers", () => { diff --git a/test/scripts/prepare-openclaw-npm-shrinkwrap.test.ts b/test/scripts/prepare-openclaw-npm-shrinkwrap.test.ts new file mode 100644 index 000000000000..d77a68fc80e4 --- /dev/null +++ b/test/scripts/prepare-openclaw-npm-shrinkwrap.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "vitest"; +import { + assertPreparedOpenClawNpmShrinkwrap, + prepareOpenClawNpmShrinkwrap, +} from "../../scripts/prepare-openclaw-npm-shrinkwrap.ts"; + +const AI_DEPENDENCIES = { + "@anthropic-ai/sdk": "0.109.1", + openai: "6.45.0", +}; + +type ShrinkwrapPackage = Record & { + dependencies?: Record; + name?: string; + version?: string; +}; + +function createShrinkwrap(): { + lockfileVersion: number; + packages: Record; +} { + return { + lockfileVersion: 3, + packages: { + "": { + name: "openclaw", + version: "2026.7.1-beta.5", + dependencies: { + openai: "6.45.0", + }, + }, + "node_modules/@anthropic-ai/sdk": { + version: "0.109.1", + }, + "node_modules/openai": { + version: "6.45.0", + }, + }, + }; +} + +describe("prepareOpenClawNpmShrinkwrap", () => { + it("adds the exact registry AI runtime dependency to the root shrinkwrap", () => { + const prepared = prepareOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + license: "MIT", + engines: { node: ">=22.19.0" }, + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap: createShrinkwrap(), + }); + + expect(prepared.packages?.[""]?.dependencies).toEqual({ + "@openclaw/ai": "2026.7.1-beta.5", + openai: "6.45.0", + }); + expect(prepared.packages?.["node_modules/@openclaw/ai"]).toEqual({ + version: "2026.7.1-beta.5", + resolved: "https://registry.npmjs.org/@openclaw/ai/-/ai-2026.7.1-beta.5.tgz", + integrity: "sha512-test", + license: "MIT", + dependencies: AI_DEPENDENCIES, + engines: { node: ">=22.19.0" }, + }); + expect(() => + assertPreparedOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + license: "MIT", + engines: { node: ">=22.19.0" }, + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + dependencies: { "@openclaw/ai": "2026.7.1-beta.5" }, + }, + shrinkwrap: prepared, + }), + ).not.toThrow(); + }); + + it("rejects mismatched versions and incomplete dependency graphs", () => { + expect(() => + prepareOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.4", + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap: createShrinkwrap(), + }), + ).toThrow("does not match OpenClaw"); + + const incomplete = createShrinkwrap(); + Reflect.deleteProperty(incomplete.packages, "node_modules/openai"); + expect(() => + prepareOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap: incomplete, + }), + ).toThrow("missing AI runtime dependency openai"); + + const stale = createShrinkwrap(); + stale.packages["node_modules/openai"] = { version: "6.44.0" }; + expect(() => + prepareOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap: stale, + }), + ).toThrow("openai@6.44.0 does not satisfy 6.45.0"); + + expect(() => + assertPreparedOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + dependencies: { "@openclaw/ai": "2026.7.1-beta.5" }, + }, + shrinkwrap: createShrinkwrap(), + }), + ).toThrow("does not lock the exact @openclaw/ai tarball"); + + const prepared = prepareOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap: createShrinkwrap(), + }); + expect(() => + assertPreparedOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + dependencies: AI_DEPENDENCIES, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap: prepared, + }), + ).toThrow("packed OpenClaw manifest must depend on exact @openclaw/ai"); + }); + + it("validates semver ranges and rejects unsupported dependency specs", () => { + const shrinkwrap = createShrinkwrap(); + shrinkwrap.packages["node_modules/ranged"] = { version: "2.4.1" }; + shrinkwrap.packages["node_modules/aliased"] = { version: "3.2.0" }; + + expect(() => + prepareOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + dependencies: { + ranged: "^2.4.0", + }, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap, + }), + ).not.toThrow(); + + expect(() => + prepareOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + dependencies: { aliased: "npm:@scope/real-package@~3.2.0" }, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap, + }), + ).toThrow("aliased dependency must use a registry semver spec"); + + shrinkwrap.packages["node_modules/ranged"].version = "2.5.0-beta.1"; + expect(() => + prepareOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + dependencies: { ranged: "^2.4.0" }, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap, + }), + ).toThrow("ranged@2.5.0-beta.1 does not satisfy ^2.4.0"); + + expect(() => + prepareOpenClawNpmShrinkwrap({ + aiIntegrity: "sha512-test", + aiManifest: { + name: "@openclaw/ai", + version: "2026.7.1-beta.5", + dependencies: { ranged: "workspace:*" }, + }, + rootManifest: { + name: "openclaw", + version: "2026.7.1-beta.5", + }, + shrinkwrap, + }), + ).toThrow("ranged dependency must use a registry semver spec"); + }); +});