From d52d5ad6ff9f6bc56cd1c88f347c78ed46e6e986 Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Wed, 8 Apr 2026 04:00:17 -0700 Subject: [PATCH] release: mirror bundled channel deps at root (#63065) Merged via squash. Prepared head SHA: ac26799a54088b811d3dae926f5c47b4dcf10f9a Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Reviewed-by: @scoootscooob --- CHANGELOG.md | 1 + extensions/discord/package.json | 8 + extensions/feishu/package.json | 5 + extensions/slack/package.json | 7 + extensions/telegram/package.json | 7 + package.json | 10 + pnpm-lock.yaml | 32 ++- scripts/openclaw-npm-postpublish-verify.ts | 182 ++++++++++++++++- .../package-manifest.contract.test.ts | 23 ++- .../plugins/package-manifest-contract.ts | 40 +++- test/openclaw-npm-postpublish-verify.test.ts | 187 ++++++++++++++++++ 11 files changed, 488 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d26714fc75..5e6be413994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Slack/media: preserve bearer auth across same-origin `files.slack.com` redirects while still stripping it on cross-origin Slack CDN hops, so `url_private_download` image attachments load again. (#62960) Thanks @vincentkoc. - Control UI: guard stale session-history reloads during fast session switches so the selected session and rendered transcript stay in sync. (#62975) Thanks @scoootscooob. - Agents/failover: classify Z.ai vendor code `1311` as billing and `1113` as auth, including long wrapped `1311` payloads, so these errors stop falling through to generic failover handling. (#49552) Thanks @1bcMax. +- npm packaging: mirror bundled Slack, Telegram, Discord, and Feishu channel runtime deps at the root and harden published-install verification so fresh installs fail fast on manifest drift instead of missing-module crashes. (#63065) Thanks @scoootscooob. ## 2026.4.8 diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 17988e78403..4eecc8b4539 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -60,6 +60,14 @@ "bundle": { "stageRuntimeDependencies": true }, + "releaseChecks": { + "rootDependencyMirrorAllowlist": [ + "@buape/carbon", + "@discordjs/opus", + "https-proxy-agent", + "opusscript" + ] + }, "release": { "publishToClawHub": true, "publishToNpm": true diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 979cbf7e219..eaf61700dda 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -51,6 +51,11 @@ "bundle": { "stageRuntimeDependencies": true }, + "releaseChecks": { + "rootDependencyMirrorAllowlist": [ + "@larksuiteoapi/node-sdk" + ] + }, "release": { "publishToClawHub": true, "publishToNpm": true diff --git a/extensions/slack/package.json b/extensions/slack/package.json index c6e1e8faf01..76813f08d6b 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -34,6 +34,13 @@ }, "bundle": { "stageRuntimeDependencies": true + }, + "releaseChecks": { + "rootDependencyMirrorAllowlist": [ + "@slack/bolt", + "@slack/web-api", + "https-proxy-agent" + ] } } } diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 5213f16b3c3..1af28ab7512 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -39,6 +39,13 @@ }, "bundle": { "stageRuntimeDependencies": true + }, + "releaseChecks": { + "rootDependencyMirrorAllowlist": [ + "@grammyjs/runner", + "@grammyjs/transformer-throttler", + "grammy" + ] } } } diff --git a/package.json b/package.json index 34a20c3c5bc..3d859f3619a 100644 --- a/package.json +++ b/package.json @@ -1311,8 +1311,12 @@ "@aws-sdk/client-bedrock": "3.1024.0", "@aws-sdk/client-bedrock-runtime": "3.1024.0", "@aws-sdk/credential-provider-node": "3.972.29", + "@buape/carbon": "0.14.0", "@clack/prompts": "^1.2.0", + "@grammyjs/runner": "^2.0.3", + "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.6", + "@larksuiteoapi/node-sdk": "^1.60.0", "@line/bot-sdk": "^11.0.0", "@lydell/node-pty": "1.2.0-beta.10", "@mariozechner/pi-agent-core": "0.65.2", @@ -1323,6 +1327,8 @@ "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "0.34.49", + "@slack/bolt": "^4.6.0", + "@slack/web-api": "^7.15.0", "ajv": "^8.18.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", @@ -1333,7 +1339,9 @@ "express": "^5.2.1", "file-type": "22.0.0", "gaxios": "7.1.4", + "grammy": "^1.42.0", "hono": "4.12.10", + "https-proxy-agent": "^9.0.0", "ipaddr.js": "^2.3.0", "jiti": "^2.6.1", "json5": "^2.2.3", @@ -1343,6 +1351,7 @@ "markdown-it": "^14.1.1", "matrix-js-sdk": "41.3.0-rc.0", "node-edge-tts": "^1.2.10", + "opusscript": "^0.1.1", "osc-progress": "^0.3.0", "pdfjs-dist": "^5.6.205", "playwright-core": "1.59.1", @@ -1392,6 +1401,7 @@ } }, "optionalDependencies": { + "@discordjs/opus": "^0.10.0", "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", "openshell": "0.1.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81dfb23a088..61619019905 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,12 +45,24 @@ importers: '@aws-sdk/credential-provider-node': specifier: 3.972.29 version: 3.972.29 + '@buape/carbon': + specifier: 0.14.0 + version: 0.14.0(@discordjs/opus@0.10.0)(hono@4.12.10)(opusscript@0.1.1) '@clack/prompts': specifier: ^1.2.0 version: 1.2.0 + '@grammyjs/runner': + specifier: ^2.0.3 + version: 2.0.3(grammy@1.42.0) + '@grammyjs/transformer-throttler': + specifier: ^1.2.1 + version: 1.2.1(grammy@1.42.0) '@homebridge/ciao': specifier: ^1.3.6 version: 1.3.6 + '@larksuiteoapi/node-sdk': + specifier: ^1.60.0 + version: 1.60.0 '@line/bot-sdk': specifier: ^11.0.0 version: 11.0.0 @@ -84,6 +96,12 @@ importers: '@sinclair/typebox': specifier: 0.34.49 version: 0.34.49 + '@slack/bolt': + specifier: ^4.6.0 + version: 4.6.0(@types/express@5.0.6) + '@slack/web-api': + specifier: ^7.15.0 + version: 7.15.0 ajv: specifier: ^8.18.0 version: 8.18.0 @@ -114,9 +132,15 @@ importers: gaxios: specifier: 7.1.4 version: 7.1.4 + grammy: + specifier: ^1.42.0 + version: 1.42.0 hono: specifier: 4.12.10 version: 4.12.10 + https-proxy-agent: + specifier: ^9.0.0 + version: 9.0.0 ipaddr.js: specifier: ^2.3.0 version: 2.3.0 @@ -147,6 +171,9 @@ importers: node-llama-cpp: specifier: 3.18.1 version: 3.18.1(typescript@6.0.2) + opusscript: + specifier: ^0.1.1 + version: 0.1.1 osc-progress: specifier: ^0.3.0 version: 0.3.0 @@ -257,6 +284,9 @@ importers: specifier: ^4.1.2 version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/browser-playwright@4.1.2)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) optionalDependencies: + '@discordjs/opus': + specifier: ^0.10.0 + version: 0.10.0 '@matrix-org/matrix-sdk-crypto-nodejs': specifier: ^0.4.0 version: 0.4.0 @@ -9807,7 +9837,7 @@ snapshots: dependencies: '@slack/logger': 4.0.1 '@slack/types': 2.20.1 - '@types/node': 25.5.0 + '@types/node': 25.5.2 '@types/retry': 0.12.0 axios: 1.13.6 eventemitter3: 5.0.4 diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts index 08fcee08343..552b89d4a73 100644 --- a/scripts/openclaw-npm-postpublish-verify.ts +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -1,9 +1,17 @@ #!/usr/bin/env -S node --import tsx import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join, relative } from "node:path"; import { pathToFileURL } from "node:url"; import { formatErrorMessage } from "../src/infra/errors.ts"; import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../src/plugins/runtime-sidecar-paths.ts"; @@ -11,8 +19,27 @@ import { parseReleaseVersion, resolveNpmCommandInvocation } from "./openclaw-npm type InstalledPackageJson = { version?: string; + dependencies?: Record; + optionalDependencies?: Record; }; +type InstalledBundledExtensionPackageJson = { + dependencies?: Record; + optionalDependencies?: Record; + openclaw?: { + releaseChecks?: { + rootDependencyMirrorAllowlist?: unknown; + }; + }; +}; + +type InstalledBundledExtensionManifestRecord = { + manifest: InstalledBundledExtensionPackageJson; + path: string; +}; + +const MAX_BUNDLED_EXTENSION_MANIFEST_BYTES = 1024 * 1024; + export type PublishedInstallScenario = { name: string; installSpecs: string[]; @@ -64,6 +91,141 @@ export function collectInstalledPackageErrors(params: { } } + errors.push(...collectInstalledMirroredRootDependencyManifestErrors(params.packageRoot)); + + return errors; +} + +export function resolveInstalledBinaryPath(prefixDir: string, platform = process.platform): string { + return platform === "win32" + ? join(prefixDir, "openclaw.cmd") + : join(prefixDir, "bin", "openclaw"); +} + +function collectRuntimeDependencySpecs(packageJson: InstalledPackageJson): Map { + return new Map([ + ...Object.entries(packageJson.dependencies ?? {}), + ...Object.entries(packageJson.optionalDependencies ?? {}), + ]); +} + +function readBundledExtensionPackageJsons(packageRoot: string): { + manifests: InstalledBundledExtensionManifestRecord[]; + errors: string[]; +} { + const extensionsDir = join(packageRoot, "dist", "extensions"); + if (!existsSync(extensionsDir)) { + return { manifests: [], errors: [] }; + } + + const manifests: InstalledBundledExtensionManifestRecord[] = []; + const errors: string[] = []; + + for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const extensionDirPath = join(extensionsDir, entry.name); + const packageJsonPath = join(extensionsDir, entry.name, "package.json"); + if (!existsSync(packageJsonPath)) { + errors.push(`installed bundled extension manifest missing: ${packageJsonPath}.`); + continue; + } + + try { + const packageJsonStats = lstatSync(packageJsonPath); + if (!packageJsonStats.isFile()) { + throw new Error("manifest must be a regular file"); + } + if (packageJsonStats.size > MAX_BUNDLED_EXTENSION_MANIFEST_BYTES) { + throw new Error(`manifest exceeds ${MAX_BUNDLED_EXTENSION_MANIFEST_BYTES} bytes`); + } + + const realExtensionDirPath = realpathSync(extensionDirPath); + const realPackageJsonPath = realpathSync(packageJsonPath); + const relativeManifestPath = relative(realExtensionDirPath, realPackageJsonPath); + if ( + relativeManifestPath.length === 0 || + relativeManifestPath.startsWith("..") || + isAbsolute(relativeManifestPath) + ) { + throw new Error("manifest resolves outside the bundled extension directory"); + } + + manifests.push({ + manifest: JSON.parse( + readFileSync(realPackageJsonPath, "utf8"), + ) as InstalledBundledExtensionPackageJson, + path: realPackageJsonPath, + }); + } catch (error) { + errors.push( + `installed bundled extension manifest invalid: failed to parse ${packageJsonPath}: ${formatErrorMessage(error)}.`, + ); + } + } + + return { manifests, errors }; +} + +export function collectInstalledMirroredRootDependencyManifestErrors( + packageRoot: string, +): string[] { + const packageJsonPath = join(packageRoot, "package.json"); + if (!existsSync(packageJsonPath)) { + return ["installed package is missing package.json."]; + } + + const rootPackageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as InstalledPackageJson; + const rootRuntimeDeps = collectRuntimeDependencySpecs(rootPackageJson); + const { manifests, errors } = readBundledExtensionPackageJsons(packageRoot); + + for (const { manifest: extensionPackageJson } of manifests) { + const allowlist = extensionPackageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist; + if (allowlist === undefined) { + continue; + } + if (!Array.isArray(allowlist)) { + errors.push( + "installed bundled extension manifest invalid: openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array.", + ); + continue; + } + + const extensionRuntimeDeps = collectRuntimeDependencySpecs(extensionPackageJson); + for (const entry of allowlist) { + if (typeof entry !== "string" || entry.trim().length === 0) { + errors.push( + "installed bundled extension manifest invalid: openclaw.releaseChecks.rootDependencyMirrorAllowlist entries must be non-empty strings.", + ); + continue; + } + + const extensionSpec = extensionRuntimeDeps.get(entry); + if (!extensionSpec) { + errors.push( + `installed bundled extension manifest invalid: mirrored dependency '${entry}' must be declared in the extension runtime dependencies.`, + ); + continue; + } + + const rootSpec = rootRuntimeDeps.get(entry); + if (!rootSpec) { + errors.push( + `installed package is missing mirrored root runtime dependency '${entry}' required by a bundled extension.`, + ); + continue; + } + + if (rootSpec !== extensionSpec) { + errors.push( + `installed package mirrored dependency '${entry}' version mismatch: root '${rootSpec}', extension '${extensionSpec}'.`, + ); + } + } + } + return errors; } @@ -89,6 +251,15 @@ function installSpec(prefixDir: string, spec: string, cwd: string): void { npmExec(["install", "-g", "--prefix", prefixDir, spec, "--no-fund", "--no-audit"], cwd); } +function readInstalledBinaryVersion(prefixDir: string, cwd: string): string { + return execFileSync(resolveInstalledBinaryPath(prefixDir), ["--version"], { + cwd, + encoding: "utf8", + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + function verifyScenario(version: string, scenario: PublishedInstallScenario): void { const workingDir = mkdtempSync(join(tmpdir(), `openclaw-postpublish-${scenario.name}.`)); const prefixDir = join(workingDir, "prefix"); @@ -108,6 +279,13 @@ function verifyScenario(version: string, scenario: PublishedInstallScenario): vo installedVersion: pkg.version?.trim() ?? "", packageRoot, }); + const installedBinaryVersion = readInstalledBinaryVersion(prefixDir, workingDir); + + if (installedBinaryVersion !== scenario.expectedVersion) { + errors.push( + `installed openclaw binary version mismatch: expected ${scenario.expectedVersion}, found ${installedBinaryVersion || ""}.`, + ); + } if (errors.length > 0) { throw new Error(`${scenario.name} failed:\n- ${errors.join("\n- ")}`); diff --git a/src/plugins/contracts/package-manifest.contract.test.ts b/src/plugins/contracts/package-manifest.contract.test.ts index cfb237d2be9..5b20c3ec4c3 100644 --- a/src/plugins/contracts/package-manifest.contract.test.ts +++ b/src/plugins/contracts/package-manifest.contract.test.ts @@ -6,12 +6,17 @@ const packageManifestContractTests: PackageManifestContractParams[] = [ { pluginId: "bluebubbles", minHostVersionBaseline: "2026.3.22" }, { pluginId: "discord", - runtimeDeps: ["@buape/carbon", "https-proxy-agent"], + mirroredRootRuntimeDeps: [ + "@buape/carbon", + "@discordjs/opus", + "https-proxy-agent", + "opusscript", + ], minHostVersionBaseline: "2026.3.22", }, { pluginId: "feishu", - runtimeDeps: ["@larksuiteoapi/node-sdk"], + mirroredRootRuntimeDeps: ["@larksuiteoapi/node-sdk"], minHostVersionBaseline: "2026.3.22", }, { pluginId: "googlechat", minHostVersionBaseline: "2026.3.22" }, @@ -21,21 +26,27 @@ const packageManifestContractTests: PackageManifestContractParams[] = [ { pluginId: "mattermost", minHostVersionBaseline: "2026.3.22" }, { pluginId: "memory-lancedb", - runtimeDeps: ["@lancedb/lancedb"], + pluginLocalRuntimeDeps: ["@lancedb/lancedb"], minHostVersionBaseline: "2026.3.22", }, { pluginId: "msteams", minHostVersionBaseline: "2026.3.22" }, { pluginId: "nextcloud-talk", minHostVersionBaseline: "2026.3.22" }, { pluginId: "nostr", minHostVersionBaseline: "2026.3.22" }, - { pluginId: "slack", runtimeDeps: ["@slack/bolt"] }, + { + pluginId: "slack", + mirroredRootRuntimeDeps: ["@slack/bolt", "@slack/web-api", "https-proxy-agent"], + }, { pluginId: "synology-chat", minHostVersionBaseline: "2026.3.22" }, - { pluginId: "telegram", runtimeDeps: ["grammy"] }, + { + pluginId: "telegram", + mirroredRootRuntimeDeps: ["@grammyjs/runner", "@grammyjs/transformer-throttler", "grammy"], + }, { pluginId: "tlon", minHostVersionBaseline: "2026.3.22" }, { pluginId: "twitch", minHostVersionBaseline: "2026.3.22" }, { pluginId: "voice-call", minHostVersionBaseline: "2026.3.22" }, { pluginId: "whatsapp", - runtimeDeps: ["@whiskeysockets/baileys", "jimp"], + pluginLocalRuntimeDeps: ["@whiskeysockets/baileys", "jimp"], minHostVersionBaseline: "2026.3.22", }, { pluginId: "zalo", minHostVersionBaseline: "2026.3.22" }, diff --git a/test/helpers/plugins/package-manifest-contract.ts b/test/helpers/plugins/package-manifest-contract.ts index 41e6c20aa61..b6e705f4804 100644 --- a/test/helpers/plugins/package-manifest-contract.ts +++ b/test/helpers/plugins/package-manifest-contract.ts @@ -7,16 +7,21 @@ import { bundledPluginFile } from "../bundled-plugin-paths.js"; type PackageManifest = { dependencies?: Record; + optionalDependencies?: Record; openclaw?: { install?: { minHostVersion?: string; }; + releaseChecks?: { + rootDependencyMirrorAllowlist?: unknown; + }; }; }; type PackageManifestContractParams = { pluginId: string; - runtimeDeps?: string[]; + pluginLocalRuntimeDeps?: string[]; + mirroredRootRuntimeDeps?: string[]; minHostVersionBaseline?: string; }; @@ -29,13 +34,17 @@ export function describePackageManifestContract(params: PackageManifestContractP const packagePath = bundledPluginFile(params.pluginId, "package.json"); describe(`${params.pluginId} package manifest contract`, () => { - if (params.runtimeDeps?.length) { - for (const dependencyName of params.runtimeDeps) { + if (params.pluginLocalRuntimeDeps?.length) { + for (const dependencyName of params.pluginLocalRuntimeDeps) { it(`keeps ${dependencyName} plugin-local`, () => { const rootManifest = readJson("package.json"); const pluginManifest = readJson(packagePath); - const pluginSpec = pluginManifest.dependencies?.[dependencyName]; - const rootSpec = rootManifest.dependencies?.[dependencyName]; + const pluginSpec = + pluginManifest.dependencies?.[dependencyName] ?? + pluginManifest.optionalDependencies?.[dependencyName]; + const rootSpec = + rootManifest.dependencies?.[dependencyName] ?? + rootManifest.optionalDependencies?.[dependencyName]; expect(pluginSpec).toBeTruthy(); expect(rootSpec).toBeUndefined(); @@ -43,6 +52,27 @@ export function describePackageManifestContract(params: PackageManifestContractP } } + if (params.mirroredRootRuntimeDeps?.length) { + for (const dependencyName of params.mirroredRootRuntimeDeps) { + it(`mirrors ${dependencyName} at the root package`, () => { + const rootManifest = readJson("package.json"); + const pluginManifest = readJson(packagePath); + const pluginSpec = + pluginManifest.dependencies?.[dependencyName] ?? + pluginManifest.optionalDependencies?.[dependencyName]; + const rootSpec = + rootManifest.dependencies?.[dependencyName] ?? + rootManifest.optionalDependencies?.[dependencyName]; + const allowlist = pluginManifest.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist; + + expect(pluginSpec).toBeTruthy(); + expect(rootSpec).toBe(pluginSpec); + expect(Array.isArray(allowlist)).toBe(true); + expect(allowlist).toContain(dependencyName); + }); + } + } + const minHostVersionBaseline = params.minHostVersionBaseline; if (minHostVersionBaseline) { it("declares a parseable minHostVersion floor at or above the baseline", () => { diff --git a/test/openclaw-npm-postpublish-verify.test.ts b/test/openclaw-npm-postpublish-verify.test.ts index 54d6f2d9e0c..eef7083b1b1 100644 --- a/test/openclaw-npm-postpublish-verify.test.ts +++ b/test/openclaw-npm-postpublish-verify.test.ts @@ -1,7 +1,12 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import { buildPublishedInstallScenarios, + collectInstalledMirroredRootDependencyManifestErrors, collectInstalledPackageErrors, + resolveInstalledBinaryPath, } from "../scripts/openclaw-npm-postpublish-verify.ts"; import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../src/plugins/runtime-sidecar-paths.ts"; @@ -54,3 +59,185 @@ describe("collectInstalledPackageErrors", () => { expect(errors.length).toBeGreaterThanOrEqual(1 + BUNDLED_RUNTIME_SIDECAR_PATHS.length); }); }); + +describe("resolveInstalledBinaryPath", () => { + it("uses the Unix global bin path on non-Windows platforms", () => { + expect(resolveInstalledBinaryPath("/tmp/openclaw-prefix", "darwin")).toBe( + "/tmp/openclaw-prefix/bin/openclaw", + ); + }); + + it("uses the Windows npm shim path on win32", () => { + expect(resolveInstalledBinaryPath("C:/openclaw-prefix", "win32")).toBe( + "C:/openclaw-prefix/openclaw.cmd", + ); + }); +}); + +describe("collectInstalledMirroredRootDependencyManifestErrors", () => { + function makeInstalledPackageRoot(): string { + return mkdtempSync(join(tmpdir(), "openclaw-postpublish-installed-")); + } + + function writePackageFile(root: string, relativePath: string, value: unknown): void { + const fullPath = join(root, relativePath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + } + + it("flags missing mirrored root dependencies required by bundled extensions", () => { + const packageRoot = makeInstalledPackageRoot(); + + try { + writePackageFile(packageRoot, "package.json", { + version: "2026.4.9", + dependencies: {}, + }); + writePackageFile(packageRoot, "dist/extensions/slack/package.json", { + dependencies: { + "@slack/web-api": "^7.15.0", + }, + openclaw: { + releaseChecks: { + rootDependencyMirrorAllowlist: ["@slack/web-api"], + }, + }, + }); + + expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ + "installed package is missing mirrored root runtime dependency '@slack/web-api' required by a bundled extension.", + ]); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + }); + + it("accepts mirrored root dependencies declared in package optionalDependencies", () => { + const packageRoot = makeInstalledPackageRoot(); + + try { + writePackageFile(packageRoot, "package.json", { + version: "2026.4.9", + optionalDependencies: { + "@discordjs/opus": "^0.10.0", + }, + }); + writePackageFile(packageRoot, "dist/extensions/discord/package.json", { + optionalDependencies: { + "@discordjs/opus": "^0.10.0", + }, + openclaw: { + releaseChecks: { + rootDependencyMirrorAllowlist: ["@discordjs/opus"], + }, + }, + }); + + expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + }); + + it("flags mirrored root dependency version mismatches", () => { + const packageRoot = makeInstalledPackageRoot(); + + try { + writePackageFile(packageRoot, "package.json", { + version: "2026.4.9", + dependencies: { + "@slack/web-api": "^7.16.0", + }, + }); + writePackageFile(packageRoot, "dist/extensions/slack/package.json", { + dependencies: { + "@slack/web-api": "^7.15.0", + }, + openclaw: { + releaseChecks: { + rootDependencyMirrorAllowlist: ["@slack/web-api"], + }, + }, + }); + + expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ + "installed package mirrored dependency '@slack/web-api' version mismatch: root '^7.16.0', extension '^7.15.0'.", + ]); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + }); + + it("flags malformed bundled extension manifests instead of silently skipping them", () => { + const packageRoot = makeInstalledPackageRoot(); + + try { + writePackageFile(packageRoot, "package.json", { + version: "2026.4.9", + dependencies: {}, + }); + mkdirSync(join(packageRoot, "dist/extensions/slack"), { recursive: true }); + writeFileSync( + join(packageRoot, "dist/extensions/slack/package.json"), + '{\n "openclaw": { invalid json\n', + "utf8", + ); + + expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ + expect.stringContaining("installed bundled extension manifest invalid: failed to parse"), + ]); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + }); + + it("flags bundled extension directories that are missing package.json", () => { + const packageRoot = makeInstalledPackageRoot(); + + try { + writePackageFile(packageRoot, "package.json", { + version: "2026.4.9", + dependencies: {}, + }); + mkdirSync(join(packageRoot, "dist/extensions/slack"), { recursive: true }); + + expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ + `installed bundled extension manifest missing: ${join(packageRoot, "dist/extensions/slack/package.json")}.`, + ]); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + }); + + it("rejects bundled extension manifests that are not regular files", () => { + const packageRoot = makeInstalledPackageRoot(); + const outsideManifestRoot = makeInstalledPackageRoot(); + + try { + writePackageFile(packageRoot, "package.json", { + version: "2026.4.9", + dependencies: {}, + }); + writePackageFile(outsideManifestRoot, "package.json", { + dependencies: { + "@slack/web-api": "^7.15.0", + }, + }); + mkdirSync(join(packageRoot, "dist/extensions/slack"), { recursive: true }); + symlinkSync( + join(outsideManifestRoot, "package.json"), + join(packageRoot, "dist/extensions/slack/package.json"), + ); + + expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ + expect.stringContaining("installed bundled extension manifest invalid: failed to parse"), + ]); + expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)[0]).toContain( + "manifest must be a regular file", + ); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + rmSync(outsideManifestRoot, { recursive: true, force: true }); + } + }); +});