mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
1d1c1211a5
commit
9de11f098e
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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
@@ -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,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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user