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");
+ });
+});