diff --git a/CHANGELOG.md b/CHANGELOG.md index 026eef4f593..01db2591aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Release tooling: align the published launcher Node floor, `npm start`, package script checks, sharded lint locking, Vitest root project coverage, and plugin-SDK declaration build cache metadata so release/package validation does not silently skip or ship stale surfaces. - Codex app-server/MCP: scope user MCP servers to specific OpenClaw agent ids through an optional `mcp.servers..codex.agents` list and accept `codex.defaultToolsApprovalMode` (`auto`/`prompt`/`approve`) for native Codex approval defaults; OpenClaw strips the `codex` block before handing `mcp_servers` config to Codex. (#82180) Thanks @sercada. - Agents/OpenAI Responses: clamp `input_tokens - cached_tokens` at zero and reconstruct `totalTokens` from input + output + cached components so Responses-API streams report consistent usage when providers under-report `input_tokens` relative to `cached_tokens`. - Plugins: reject malformed `package.json` `openclaw.extensions` metadata during install, discovery, and post-update payload smoke instead of silently dropping invalid entries. diff --git a/openclaw.mjs b/openclaw.mjs index 7a35f199b2a..69a84e48f73 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -9,7 +9,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const MIN_NODE_MAJOR = 22; -const MIN_NODE_MINOR = 12; +const MIN_NODE_MINOR = 16; const MIN_NODE_VERSION = `${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}`; const parseNodeVersion = (rawVersion) => { diff --git a/package.json b/package.json index fc131e5cdd1..52f2160ee82 100644 --- a/package.json +++ b/package.json @@ -1360,9 +1360,6 @@ "build:plugin-sdk:dts": "node scripts/run-tsgo.mjs -p tsconfig.plugin-sdk.dts.json --declaration true", "build:plugin-sdk:strict-smoke": "pnpm build:plugin-sdk:dts && node --experimental-strip-types scripts/write-plugin-sdk-entry-dts.ts", "build:strict-smoke": "pnpm plugins:assets:build && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm build:plugin-sdk:dts && node --experimental-strip-types scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs", - "canon:check": "node scripts/canon.mjs check", - "canon:check:json": "node scripts/canon.mjs check --json", - "canon:enforce": "node scripts/canon.mjs enforce --json", "canvas:a2ui:bundle": "node scripts/bundle-a2ui.mjs", "changed:lanes": "node scripts/changed-lanes.mjs", "check": "node scripts/check.mjs", @@ -1572,7 +1569,7 @@ "rtt": "node --import tsx scripts/rtt.ts", "runtime-sidecars:check": "node --import tsx scripts/generate-runtime-sidecar-paths-baseline.ts --check", "runtime-sidecars:gen": "node --import tsx scripts/generate-runtime-sidecar-paths-baseline.ts --write", - "start": "node scripts/run-node.mjs", + "start": "node openclaw.mjs", "test": "node scripts/test-projects.mjs", "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", "test:auth:compat": "node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts", diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index 81af362637c..633a81ee1ce 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -35,11 +35,12 @@ export const BUILD_ALL_STEPS = [ "tsconfig.json", "tsconfig.plugin-sdk.dts.json", "src/plugin-sdk", + "packages/memory-host-sdk/src", "src/types", "src/video-generation/dashscope-compatible.ts", "src/video-generation/types.ts", ], - outputs: ["dist/plugin-sdk/.tsbuildinfo", "dist/plugin-sdk/src"], + outputs: ["dist/plugin-sdk/.tsbuildinfo", "dist/plugin-sdk/packages", "dist/plugin-sdk/src"], }, }, { diff --git a/scripts/run-oxlint-shards.mjs b/scripts/run-oxlint-shards.mjs index 39a3e21658c..3f1e7e87d2b 100644 --- a/scripts/run-oxlint-shards.mjs +++ b/scripts/run-oxlint-shards.mjs @@ -1,24 +1,33 @@ import { spawn, spawnSync } from "node:child_process"; import path from "node:path"; +import { + acquireLocalHeavyCheckLockSync, + resolveLocalHeavyCheckEnv, + shouldAcquireLocalHeavyCheckLockForOxlint, +} from "./lib/local-heavy-check-runtime.mjs"; const extraArgs = process.argv.slice(2); const runner = path.resolve("scripts", "run-oxlint.mjs"); - -const prepareResult = spawnSync( - process.execPath, - [path.resolve("scripts", "prepare-extension-package-boundary-artifacts.mjs")], - { - stdio: "inherit", - env: process.env, - }, +const env = resolveLocalHeavyCheckEnv(process.env); +const hasMetadataOnlyFlag = extraArgs.some((arg) => + ["--help", "-h", "--version", "-V", "--rules", "--print-config", "--init"].includes(arg), ); - -if (prepareResult.error) { - throw prepareResult.error; -} -if ((prepareResult.status ?? 1) !== 0) { - process.exit(prepareResult.status ?? 1); -} +const shouldAcquireParentLock = + !hasMetadataOnlyFlag || + shouldAcquireLocalHeavyCheckLockForOxlint(extraArgs, { + cwd: process.cwd(), + env, + }); +const releaseLock = + env.OPENCLAW_OXLINT_SKIP_LOCK === "1" + ? () => {} + : shouldAcquireParentLock + ? acquireLocalHeavyCheckLockSync({ + cwd: process.cwd(), + env, + toolName: "oxlint shards", + }) + : () => {}; const shards = [ { @@ -35,11 +44,31 @@ const shards = [ }, ]; -const runSerial = process.env.OPENCLAW_OXLINT_SHARDS_SERIAL === "1"; -const results = runSerial - ? await runShardsSerial(shards) - : await Promise.all(shards.map((shard) => runShard(shard))); -process.exitCode = results.find((status) => status !== 0) ?? 0; +try { + const prepareResult = spawnSync( + process.execPath, + [path.resolve("scripts", "prepare-extension-package-boundary-artifacts.mjs")], + { + stdio: "inherit", + env, + }, + ); + + if (prepareResult.error) { + throw prepareResult.error; + } + if ((prepareResult.status ?? 1) !== 0) { + process.exitCode = prepareResult.status ?? 1; + } else { + const runSerial = env.OPENCLAW_OXLINT_SHARDS_SERIAL === "1"; + const results = runSerial + ? await runShardsSerial(shards) + : await Promise.all(shards.map((shard) => runShard(shard))); + process.exitCode = results.find((status) => status !== 0) ?? 0; + } +} finally { + releaseLock(); +} async function runShardsSerial(entries) { const results = []; @@ -54,7 +83,7 @@ async function runShard(shard) { const child = spawn(process.execPath, [runner, ...shard.args, ...extraArgs], { stdio: "inherit", env: { - ...process.env, + ...env, OPENCLAW_OXLINT_SKIP_LOCK: "1", OPENCLAW_OXLINT_SKIP_PREPARE: "1", }, diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 489028521ff..e90b4198b93 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -139,6 +139,30 @@ describe("openclaw launcher", () => { cleanupTempDirs(fixtureRoots); }); + it("keeps the bootstrap Node floor aligned with package and runtime guards", async () => { + const [launcher, runtimeGuard, packageJsonRaw] = await Promise.all([ + fs.readFile(path.resolve(process.cwd(), "openclaw.mjs"), "utf8"), + fs.readFile(path.resolve(process.cwd(), "src/infra/runtime-guard.ts"), "utf8"), + fs.readFile(path.resolve(process.cwd(), "package.json"), "utf8"), + ]); + const packageJson = JSON.parse(packageJsonRaw) as { engines?: { node?: string } }; + const launcherMatch = launcher.match( + /const MIN_NODE_MAJOR = (\d+);\s+const MIN_NODE_MINOR = (\d+);/u, + ); + const runtimeMatch = runtimeGuard.match( + /const MIN_NODE: Semver = \{ major: (\d+), minor: (\d+), patch: (\d+) \};/u, + ); + const engineMatch = packageJson.engines?.node?.match(/^>=(\d+)\.(\d+)\.(\d+)$/u); + + expect(launcherMatch).not.toBeNull(); + expect(runtimeMatch).not.toBeNull(); + expect(engineMatch).not.toBeNull(); + expect(`${launcherMatch?.[1]}.${launcherMatch?.[2]}.0`).toBe( + `${engineMatch?.[1]}.${engineMatch?.[2]}.${engineMatch?.[3]}`, + ); + expect(runtimeMatch?.slice(1, 4)).toEqual(engineMatch?.slice(1, 4)); + }); + it("surfaces transitive entry import failures instead of masking them as missing dist", async () => { const fixtureRoot = await makeLauncherFixture(fixtureRoots); await fs.writeFile( diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts new file mode 100644 index 00000000000..87056a8b91f --- /dev/null +++ b/test/package-scripts.test.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; + +type RootPackageJson = { + scripts: Record; +}; + +const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/u; +const NODE_OPTIONS_WITH_VALUE = new Set([ + "--conditions", + "--env-file", + "--env-file-if-exists", + "--import", + "--loader", + "--max-old-space-size", + "--require", + "--test-name-pattern", + "--test-reporter", + "-C", + "-r", +]); + +function readPackageJson(): RootPackageJson { + return JSON.parse(fs.readFileSync("package.json", "utf8")) as RootPackageJson; +} + +function tokenizeCommand(command: string): string[] { + return ( + command + .match(/"[^"]*"|'[^']*'|[^\s]+/gu) + ?.map((token) => token.replace(/^(['"])(.*)\1$/u, "$2")) ?? [] + ); +} + +function extractNodeScriptTargets(script: string): string[] { + return script.split(/\s*(?:&&|\|\||;)\s*/u).flatMap((command) => { + const tokens = tokenizeCommand(command); + let index = tokens[0] === "env" ? 1 : 0; + + while (ENV_ASSIGNMENT_RE.test(tokens[index] ?? "")) { + index += 1; + } + + if (tokens[index] !== "node") { + return []; + } + + for (let tokenIndex = index + 1; tokenIndex < tokens.length; tokenIndex += 1) { + const token = tokens[tokenIndex]; + if (!token) { + continue; + } + if (token.startsWith("scripts/")) { + return [token]; + } + if (token === "--") { + continue; + } + if (token.startsWith("--") && token.includes("=")) { + continue; + } + if (NODE_OPTIONS_WITH_VALUE.has(token)) { + tokenIndex += 1; + continue; + } + if (token.startsWith("-")) { + continue; + } + + return []; + } + + return []; + }); +} + +describe("package scripts", () => { + it("finds node script targets after env assignments and valued node options", () => { + expect( + extractNodeScriptTargets( + "FOO=1 node --import tsx scripts/release-check.ts && node --max-old-space-size=8192 scripts/plugin-sdk-surface-report.mjs && env BAR=1 node -r tsx scripts/check.ts", + ), + ).toEqual([ + "scripts/release-check.ts", + "scripts/plugin-sdk-surface-report.mjs", + "scripts/check.ts", + ]); + }); + + it("keeps direct node script targets present in the source checkout", () => { + const packageJson = readPackageJson(); + const missingTargets = Object.entries(packageJson.scripts).flatMap(([name, script]) => + extractNodeScriptTargets(script) + .filter((target) => !fs.existsSync(target)) + .map((target) => `${name}: ${target}`), + ); + + expect(missingTargets).toEqual([]); + }); + + it("uses the shipped package launcher for npm start", () => { + expect(readPackageJson().scripts.start).toBe("node openclaw.mjs"); + }); +}); diff --git a/test/scripts/build-all.test.ts b/test/scripts/build-all.test.ts index 3dac0fe2225..a20a52d3a70 100644 --- a/test/scripts/build-all.test.ts +++ b/test/scripts/build-all.test.ts @@ -124,6 +124,13 @@ describe("resolveBuildAllStep", () => { }, }); }); + + it("keeps plugin-sdk dts cache metadata aligned with declaration inputs", () => { + const step = getBuildAllStep("build:plugin-sdk:dts"); + + expect(step.cache?.inputs).toEqual(expect.arrayContaining(["packages/memory-host-sdk/src"])); + expect(step.cache?.outputs).toEqual(expect.arrayContaining(["dist/plugin-sdk/packages"])); + }); }); describe("resolveBuildAllSteps", () => { diff --git a/test/scripts/run-oxlint.test.ts b/test/scripts/run-oxlint.test.ts index 90980ebdb21..54e3f014cf0 100644 --- a/test/scripts/run-oxlint.test.ts +++ b/test/scripts/run-oxlint.test.ts @@ -34,6 +34,20 @@ describe("run-oxlint", () => { expect(shardedLintRunner).toContain('OPENCLAW_OXLINT_SKIP_PREPARE: "1"'); }); + it("holds one parent heavy-check lock for sharded lint runs", () => { + const shardedLintRunner = readFileSync("scripts/run-oxlint-shards.mjs", "utf8"); + const skipLockIndex = shardedLintRunner.indexOf('env.OPENCLAW_OXLINT_SKIP_LOCK === "1"'); + const lockIndex = shardedLintRunner.indexOf("acquireLocalHeavyCheckLockSync({"); + const childSkipIndex = shardedLintRunner.indexOf('OPENCLAW_OXLINT_SKIP_LOCK: "1"'); + + expect(shardedLintRunner).toContain("resolveLocalHeavyCheckEnv"); + expect(shardedLintRunner).toContain("shouldAcquireLocalHeavyCheckLockForOxlint"); + expect(skipLockIndex).toBeGreaterThan(-1); + expect(lockIndex).toBeGreaterThan(-1); + expect(lockIndex).toBeGreaterThan(skipLockIndex); + expect(childSkipIndex).toBeGreaterThan(lockIndex); + }); + it("lets dev update preflight run oxlint shards serially", () => { const shardedLintRunner = readFileSync("scripts/run-oxlint-shards.mjs", "utf8"); diff --git a/test/vitest-projects-config.test.ts b/test/vitest-projects-config.test.ts index bac4905a902..010f1a01a2a 100644 --- a/test/vitest-projects-config.test.ts +++ b/test/vitest-projects-config.test.ts @@ -25,6 +25,7 @@ import { resolveSharedVitestWorkerConfig, sharedVitestConfig, } from "./vitest/vitest.shared.config.ts"; +import { fullSuiteVitestShards } from "./vitest/vitest.test-shards.mjs"; import { createUiVitestConfig, unitUiIncludePatterns } from "./vitest/vitest.ui.config.ts"; import { createUnitFastVitestConfig } from "./vitest/vitest.unit-fast.config.ts"; import unitUiConfig from "./vitest/vitest.unit-ui.config.ts"; @@ -58,6 +59,29 @@ describe("projects vitest config", () => { expect(requireTestConfig(baseConfig).projects).toEqual([...rootVitestProjects]); }); + it("keeps root watch projects aligned with dedicated extension shard lanes", () => { + const extensionShard = fullSuiteVitestShards.find( + (shard) => shard.config === "test/vitest/vitest.full-extensions.config.ts", + ); + + expect(extensionShard?.projects).toEqual( + expect.arrayContaining([ + "test/vitest/vitest.extension-browser.config.ts", + "test/vitest/vitest.extension-qa.config.ts", + "test/vitest/vitest.extension-media.config.ts", + "test/vitest/vitest.extension-misc.config.ts", + ]), + ); + expect(rootVitestProjects).toEqual( + expect.arrayContaining([ + "test/vitest/vitest.extension-browser.config.ts", + "test/vitest/vitest.extension-qa.config.ts", + "test/vitest/vitest.extension-media.config.ts", + "test/vitest/vitest.extension-misc.config.ts", + ]), + ); + }); + it("disables vite env-file loading for vitest lanes", () => { expect(baseConfig.envFile).toBe(false); expect(sharedVitestConfig.envFile).toBe(false); diff --git a/test/vitest/vitest.config.ts b/test/vitest/vitest.config.ts index 7344d33d038..9a587958042 100644 --- a/test/vitest/vitest.config.ts +++ b/test/vitest/vitest.config.ts @@ -74,6 +74,10 @@ export const rootVitestProjects = [ "test/vitest/vitest.extension-voice-call.config.ts", "test/vitest/vitest.extension-whatsapp.config.ts", "test/vitest/vitest.extension-zalo.config.ts", + "test/vitest/vitest.extension-browser.config.ts", + "test/vitest/vitest.extension-qa.config.ts", + "test/vitest/vitest.extension-media.config.ts", + "test/vitest/vitest.extension-misc.config.ts", "test/vitest/vitest.extensions.config.ts", ] as const;