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 <vincentkoc@ieee.org>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-07-14 05:41:24 -07:00
committed by GitHub
parent 1d1c1211a5
commit 9de11f098e
13 changed files with 663 additions and 47 deletions
@@ -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'
+12 -12
View File
@@ -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);
});
+10
View File
@@ -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,
@@ -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]");
+1 -1
View File
@@ -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();
}
@@ -4080,8 +4080,8 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramProgressPreview(
"Working\n\n🛠️ Exec\n🛠️ git rev-parse --abbrev-ref HEAD",
"<b>Working</b>\n<b>🛠️ Exec</b>\n<b>🛠️ Exec</b> <code>git rev-parse --abbrev-ref HEAD</code>",
"Cracking\n\n🛠️ Exec\n🛠️ git rev-parse --abbrev-ref HEAD",
"<b>Cracking</b>\n<b>🛠️ Exec</b>\n<b>🛠️ Exec</b> <code>git rev-parse --abbrev-ref HEAD</code>",
),
);
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", "<b>Working</b>\n<b>🛠️ Exec</b>"),
telegramProgressPreview("Cracking\n\n🛠️ Exec", "<b>Cracking</b>\n<b>🛠️ Exec</b>"),
);
expect(answerDraftStream.update).toHaveBeenCalledTimes(1);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, trailingFinalStatusText);
+40 -8
View File
@@ -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, '^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\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
+15 -5
View File
@@ -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,
},
},
+274
View File
@@ -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<string, unknown>;
type PackageManifest = JsonObject & {
dependencies?: Record<string, string>;
engines?: Record<string, string>;
license?: string;
name?: string;
version?: string;
};
type NpmShrinkwrap = JsonObject & {
lockfileVersion?: number;
packages?: Record<string, JsonObject>;
};
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<string, string> | 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<string, unknown>;
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<string, JsonObject>;
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 <openclaw-ai.tgz> [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();
}
@@ -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);
@@ -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}`,
};
+22 -8
View File
@@ -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", () => {
@@ -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<string, unknown> & {
dependencies?: Record<string, string>;
name?: string;
version?: string;
};
function createShrinkwrap(): {
lockfileVersion: number;
packages: Record<string, ShrinkwrapPackage>;
} {
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");
});
});