diff --git a/Dockerfile b/Dockerfile index a8151663a9f..3de3e56af9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -130,7 +130,8 @@ RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \ cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \ CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod && \ node scripts/postinstall-bundled-plugins.mjs && \ - find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete + find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \ + node scripts/check-package-dist-imports.mjs /app # ── Runtime base image ────────────────────────────────────────── FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-runtime diff --git a/extensions/video-generation-providers.live.test.ts b/extensions/video-generation-providers.live.test.ts index 364b17bb4df..02fc7e6e0ae 100644 --- a/extensions/video-generation-providers.live.test.ts +++ b/extensions/video-generation-providers.live.test.ts @@ -18,13 +18,7 @@ import { getShellEnvAppliedKeys, isLiveProfileKeyModeEnabled, isLiveTestEnabled, - isModelNotFoundErrorMessage, isTruthyEnvValue, - isAuthErrorMessage, - isBillingErrorMessage, - isOverloadedErrorMessage, - isServerErrorMessage, - isTimeoutErrorMessage, normalizeVideoGenerationDuration, parseCsvFilter, parseProviderModelMap, @@ -42,6 +36,7 @@ import type { VideoGenerationRequest, } from "openclaw/plugin-sdk/test-env"; import { describe, expect, it } from "vitest"; +import { resolveLiveVideoSkipReason } from "../test/helpers/media-generation/live-video-skip-reason.js"; import alibabaPlugin from "./alibaba/index.js"; import byteplusPlugin from "./byteplus/index.js"; import deepinfraPlugin from "./deepinfra/index.js"; @@ -77,7 +72,7 @@ const LIVE_VIDEO_OPERATION_TIMEOUT_MS = readPositiveIntegerEnv( const LIVE_VIDEO_TEST_TIMEOUT_MS = (RUN_FULL_VIDEO_MODES ? 3 : 1) * LIVE_VIDEO_OPERATION_TIMEOUT_MS + 30_000; const LIVE_VIDEO_SMOKE_PROMPT = - "A one-second low-motion video of a lobster walking across wet sand, no text."; + "A one-second low-motion video of a blue cube sliding across a clean studio floor."; type LiveProviderCase = { plugin: Parameters[0]["plugin"]; @@ -230,39 +225,6 @@ function buildLiveCapabilityOverrides(params: { }; } -function resolveLiveVideoSkipReason(message: string): string | null { - if (isAuthErrorMessage(message)) { - return "auth drift"; - } - if (isModelNotFoundErrorMessage(message)) { - return "model drift"; - } - if (isBillingErrorMessage(message)) { - return "billing drift"; - } - if ( - isTimeoutErrorMessage(message) || - /did not finish in time/i.test(message) || - /last status:\s*in_progress/i.test(message) - ) { - return "provider timeout"; - } - if (isOverloadedErrorMessage(message) || isServerErrorMessage(message)) { - return "provider outage"; - } - if ( - /HTTP\s+404/i.test(message) && - /Invalid URL/i.test(message) && - /\/platform\/video_gen/i.test(message) - ) { - return "provider endpoint drift"; - } - if (/access denied|not authorized|not enabled|permission denied/i.test(message)) { - return "provider/model drift"; - } - return null; -} - async function runLiveVideoAttempt(params: { authLabel: string; attempted: string[]; diff --git a/scripts/check-openclaw-package-tarball.mjs b/scripts/check-openclaw-package-tarball.mjs index 9c5cb7e8d9d..b40c5e43864 100644 --- a/scripts/check-openclaw-package-tarball.mjs +++ b/scripts/check-openclaw-package-tarball.mjs @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import { LOCAL_BUILD_METADATA_DIST_PATHS } from "./lib/local-build-metadata-paths.mjs"; +import { collectPackageDistImportErrors } from "./lib/package-dist-imports.mjs"; function usage() { return "Usage: node scripts/check-openclaw-package-tarball.mjs "; @@ -195,6 +196,13 @@ if (entrySet.has("dist/postinstall-inventory.json")) { } } +errors.push( + ...collectPackageDistImportErrors({ + files: normalized, + readText: readTarEntry, + }), +); + if (errors.length > 0) { fail(`OpenClaw package tarball integrity failed:\n${errors.join("\n")}`); } diff --git a/scripts/check-package-dist-imports.mjs b/scripts/check-package-dist-imports.mjs new file mode 100644 index 00000000000..a2b4eeb0301 --- /dev/null +++ b/scripts/check-package-dist-imports.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { collectPackageDistImportErrors } from "./lib/package-dist-imports.mjs"; + +function usage() { + return "Usage: node scripts/check-package-dist-imports.mjs [package-root]"; +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +const packageRoot = path.resolve(process.argv[2] ?? process.cwd()); +if (process.argv.length > 3) { + fail(usage()); +} + +const distRoot = path.join(packageRoot, "dist"); +if (!fs.existsSync(distRoot)) { + fail(`missing dist directory: ${distRoot}`); +} + +function collectFiles(rootDir) { + const pending = [rootDir]; + const files = []; + while (pending.length > 0) { + const dir = pending.pop(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + continue; + } + if (entry.isFile()) { + files.push(path.relative(packageRoot, entryPath).replace(/\\/gu, "/")); + } + } + } + return files; +} + +const errors = collectPackageDistImportErrors({ + files: collectFiles(distRoot), + readText(relativePath) { + return fs.readFileSync(path.join(packageRoot, relativePath), "utf8"); + }, +}); + +if (errors.length > 0) { + fail(`OpenClaw package dist import closure failed:\n${errors.join("\n")}`); +} + +console.log("OpenClaw package dist import closure passed."); diff --git a/scripts/lib/package-dist-imports.mjs b/scripts/lib/package-dist-imports.mjs new file mode 100644 index 00000000000..4d8cffcc90f --- /dev/null +++ b/scripts/lib/package-dist-imports.mjs @@ -0,0 +1,131 @@ +import path from "node:path"; + +const JS_DIST_FILE_RE = /^dist\/.*\.(?:cjs|js|mjs)$/u; + +function normalizePackagePath(value) { + return value.replace(/\\/gu, "/").replace(/^package\//u, ""); +} + +function stripSpecifierSuffix(value) { + return value.replace(/[?#].*$/u, ""); +} + +function resolveDistImportPath(importerPath, specifier) { + if (!specifier.startsWith(".")) { + return null; + } + const stripped = stripSpecifierSuffix(specifier); + if (!stripped) { + return null; + } + return path.posix.normalize(path.posix.join(path.posix.dirname(importerPath), stripped)); +} + +function findStatementStart(source, index) { + return ( + Math.max( + source.lastIndexOf(";", index), + source.lastIndexOf("{", index), + source.lastIndexOf("}", index), + source.lastIndexOf("\n", index), + source.lastIndexOf("\r", index), + ) + 1 + ); +} + +function isImportSpecifierContext(source, index) { + const dynamicPrefix = source.slice(Math.max(0, index - 32), index); + if (/\bimport\s*\(\s*$/u.test(dynamicPrefix)) { + return true; + } + const statementPrefix = source.slice(findStatementStart(source, index), index).trimStart(); + return ( + /^(?:import|export)\b[\s\S]*\bfrom\s*$/u.test(statementPrefix) || + /^import\s*$/u.test(statementPrefix) + ); +} + +function collectImportSpecifiers(source) { + const specifiers = []; + let inBlockComment = false; + let inLineComment = false; + for (let index = 0; index < source.length; index += 1) { + if (inBlockComment) { + if (source[index] === "*" && source[index + 1] === "/") { + inBlockComment = false; + index += 1; + } + continue; + } + if (inLineComment) { + if (source[index] === "\n" || source[index] === "\r") { + inLineComment = false; + } + continue; + } + if (source[index] === "/" && source[index + 1] === "*") { + inBlockComment = true; + index += 1; + continue; + } + if (source[index] === "/" && source[index + 1] === "/") { + inLineComment = true; + index += 1; + continue; + } + + const quote = source[index]; + if (quote !== '"' && quote !== "'") { + continue; + } + + let cursor = index + 1; + let value = ""; + while (cursor < source.length) { + const char = source[cursor]; + if (char === "\\") { + value += source.slice(cursor, cursor + 2); + cursor += 2; + continue; + } + if (char === quote) { + break; + } + value += char; + cursor += 1; + } + if (cursor >= source.length) { + break; + } + + if (value.startsWith(".")) { + if (isImportSpecifierContext(source, index)) { + specifiers.push(value); + } + } + index = cursor; + } + return specifiers; +} + +export function collectPackageDistImportErrors(params) { + const files = [...new Set(params.files.map(normalizePackagePath))]; + const fileSet = new Set(files); + const errors = []; + + for (const importerPath of files.toSorted((left, right) => left.localeCompare(right))) { + if (!JS_DIST_FILE_RE.test(importerPath) || importerPath.includes("/node_modules/")) { + continue; + } + const source = params.readText(importerPath); + for (const specifier of collectImportSpecifiers(source)) { + const importedPath = resolveDistImportPath(importerPath, specifier); + if (!importedPath || fileSet.has(importedPath)) { + continue; + } + errors.push(`${importerPath} imports missing ${importedPath}`); + } + } + + return errors; +} diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh index 73e70f4fd8a..f82bb813594 100755 --- a/scripts/test-install-sh-docker.sh +++ b/scripts/test-install-sh-docker.sh @@ -226,6 +226,15 @@ restore_local_dist_from_image() { docker rm -f "$container_id" >/dev/null } +ensure_local_update_dist_import_closure() { + if node scripts/check-package-dist-imports.mjs "$ROOT_DIR"; then + return 0 + fi + echo "WARN: reused Docker image dist failed import-closure check; rebuilding local release artifacts" >&2 + pnpm build + pnpm ui:build +} + prepare_update_tarball() { local pack_json local baseline_pack_json @@ -241,6 +250,7 @@ prepare_update_tarball() { echo "==> Build local release artifacts for update smoke" if [[ -n "$UPDATE_DIST_IMAGE" ]]; then restore_local_dist_from_image "$UPDATE_DIST_IMAGE" + ensure_local_update_dist_import_closure elif [[ "$UPDATE_SKIP_LOCAL_BUILD" != "1" ]]; then pnpm build pnpm ui:build @@ -249,6 +259,7 @@ prepare_update_tarball() { node -p 'JSON.parse(require("node:fs").readFileSync("package.json", "utf8")).version' )" node --import tsx scripts/write-package-dist-inventory.ts + node scripts/check-package-dist-imports.mjs "$ROOT_DIR" quiet_npm pack --ignore-scripts --json --pack-destination "$UPDATE_DIR" >"$pack_json_file" fi UPDATE_TGZ_FILE="$( @@ -262,6 +273,9 @@ if (!last || typeof last.filename !== "string" || last.filename.length === 0) { process.stdout.write(last.filename); ' "$pack_json_file" )" + if [[ -z "$UPDATE_PACKAGE_SPEC" ]]; then + node scripts/check-openclaw-package-tarball.mjs "${UPDATE_DIR}/${UPDATE_TGZ_FILE}" + fi print_pack_audit "update" "$pack_json_file" assert_pack_unpacked_size_budget "update" "$pack_json_file" packed_update_version="$( diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index fc623776ea7..1d3aaf23221 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -354,11 +354,14 @@ describeLive("gateway live (cli backend)", () => { { sessionKey, idempotencyKey: `idem-${randomUUID()}`, - message: enableCliModelSwitchProbe - ? `Please include the token CLI-BACKEND-${nonce} in your reply.` + - ` Also remember this session note for later: ${memoryToken}.` + - " Do not include the note in your reply." - : `Please include the token CLI-BACKEND-${nonce} in your reply.`, + message: + providerId === "codex-cli" + ? `Do not inspect files or run tools. Reply with exactly: CLI-BACKEND-${nonce}.` + : enableCliModelSwitchProbe + ? `Please include the token CLI-BACKEND-${nonce} in your reply.` + + ` Also remember this session note for later: ${memoryToken}.` + + " Do not include the note in your reply." + : `Please include the token CLI-BACKEND-${nonce} in your reply.`, deliver: false, timeout: CLI_BACKEND_AGENT_TIMEOUT_SECONDS, }, @@ -457,7 +460,7 @@ describeLive("gateway live (cli backend)", () => { idempotencyKey: `idem-${randomUUID()}`, message: providerId === "codex-cli" - ? `Please include the token CLI-RESUME-${resumeNonce} in your reply.` + ? `Do not inspect files or run tools. Reply with exactly: CLI-RESUME-${resumeNonce}.` : `Reply with exactly: CLI backend RESUME OK ${resumeNonce}.`, deliver: false, timeout: CLI_BACKEND_AGENT_TIMEOUT_SECONDS, diff --git a/test/helpers/media-generation/live-video-skip-reason.test.ts b/test/helpers/media-generation/live-video-skip-reason.test.ts new file mode 100644 index 00000000000..96ec7646fa9 --- /dev/null +++ b/test/helpers/media-generation/live-video-skip-reason.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { resolveLiveVideoSkipReason } from "./live-video-skip-reason.js"; + +describe("resolveLiveVideoSkipReason", () => { + it("classifies provider policy moderation blocks as skip-worthy drift", () => { + expect(resolveLiveVideoSkipReason("Your request was blocked by our moderation system.")).toBe( + "provider policy drift", + ); + }); + + it("does not hide ordinary provider failures", () => { + expect(resolveLiveVideoSkipReason("video generation returned an empty asset")).toBeNull(); + }); +}); diff --git a/test/helpers/media-generation/live-video-skip-reason.ts b/test/helpers/media-generation/live-video-skip-reason.ts new file mode 100644 index 00000000000..5a8d74a956e --- /dev/null +++ b/test/helpers/media-generation/live-video-skip-reason.ts @@ -0,0 +1,44 @@ +import { + isAuthErrorMessage, + isBillingErrorMessage, + isModelNotFoundErrorMessage, + isOverloadedErrorMessage, + isServerErrorMessage, + isTimeoutErrorMessage, +} from "openclaw/plugin-sdk/test-env"; + +export function resolveLiveVideoSkipReason(message: string): string | null { + if (isAuthErrorMessage(message)) { + return "auth drift"; + } + if (isModelNotFoundErrorMessage(message)) { + return "model drift"; + } + if (isBillingErrorMessage(message)) { + return "billing drift"; + } + if ( + isTimeoutErrorMessage(message) || + /did not finish in time/i.test(message) || + /last status:\s*in_progress/i.test(message) + ) { + return "provider timeout"; + } + if (isOverloadedErrorMessage(message) || isServerErrorMessage(message)) { + return "provider outage"; + } + if ( + /HTTP\s+404/i.test(message) && + /Invalid URL/i.test(message) && + /\/platform\/video_gen/i.test(message) + ) { + return "provider endpoint drift"; + } + if (/access denied|not authorized|not enabled|permission denied/i.test(message)) { + return "provider/model drift"; + } + if (/blocked by (?:our )?moderation system|content policy|policy violation/i.test(message)) { + return "provider policy drift"; + } + return null; +} diff --git a/test/scripts/check-openclaw-package-tarball.test.ts b/test/scripts/check-openclaw-package-tarball.test.ts index b24936add7a..164edc40257 100644 --- a/test/scripts/check-openclaw-package-tarball.test.ts +++ b/test/scripts/check-openclaw-package-tarball.test.ts @@ -94,6 +94,39 @@ describe("check-openclaw-package-tarball", () => { ); }); + it("rejects dist files that import missing relative chunks", () => { + withTarball( + ["dist/cli/run-main.js"], + { "dist/cli/run-main.js": 'await import("../memory-state-old.js");\n' }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "dist/cli/run-main.js imports missing dist/memory-state-old.js", + ); + }, + "2026.4.27", + ); + }); + + it("accepts dist files whose relative chunks are present", () => { + withTarball( + ["dist/cli/run-main.js", "dist/memory-state-current.js"], + { + "dist/cli/run-main.js": 'await import("../memory-state-current.js");\n', + "dist/memory-state-current.js": "export {};\n", + }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("OpenClaw package tarball integrity passed."); + }, + "2026.4.27", + ); + }); + it("rejects missing Control UI assets", () => { withTarball( ["dist/index.js"], diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index d99e0a4af2d..be7533d76df 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -53,11 +53,18 @@ describe("test-install-sh-docker", () => { it("can reuse dist from the already-built root Docker smoke image", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); + const dockerfile = readFileSync("Dockerfile", "utf8"); expect(script).toContain('UPDATE_DIST_IMAGE="${OPENCLAW_INSTALL_SMOKE_UPDATE_DIST_IMAGE:-}"'); expect(script).toContain("restore_local_dist_from_image"); expect(script).toContain('docker cp "${container_id}:/app/dist" "$ROOT_DIR/dist"'); expect(script).toContain('echo "==> Reuse local dist/ from Docker image: $image"'); + expect(script).toContain("ensure_local_update_dist_import_closure"); + expect(script).toContain('node scripts/check-package-dist-imports.mjs "$ROOT_DIR"'); + expect(script).toContain("WARN: reused Docker image dist failed import-closure check"); + expect(script).toContain("pnpm build"); + expect(script).toContain("pnpm ui:build"); + expect(dockerfile).toContain("node scripts/check-package-dist-imports.mjs /app"); }); it("allows repository branch history and release tags for secret-backed Docker release checks", () => { @@ -92,7 +99,9 @@ describe("test-install-sh-docker", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); expect(script).toContain("node --import tsx scripts/write-package-dist-inventory.ts"); + expect(script).toContain('node scripts/check-package-dist-imports.mjs "$ROOT_DIR"'); expect(script).toContain("quiet_npm pack --ignore-scripts"); + expect(script).toContain("node scripts/check-openclaw-package-tarball.mjs"); }); });