From de152b1f65a6c266c5ba445d30735d91c2a11310 Mon Sep 17 00:00:00 2001 From: xingzhou Date: Tue, 7 Jul 2026 14:38:41 +0800 Subject: [PATCH] fix(plugin-sdk): align speech runtime packaging (#89899) Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../qa-lab/src/bundled-plugin-staging.ts | 1 - extensions/qa-lab/src/gateway-child.test.ts | 14 ++++--- scripts/openclaw-npm-postpublish-verify.ts | 21 ++++++++++ .../facade-activation-check.runtime.ts | 9 ++--- src/plugin-sdk/facade-activation-contract.ts | 6 +++ src/plugin-sdk/facade-runtime.test.ts | 21 +++++++++- test/openclaw-npm-postpublish-verify.test.ts | 38 +++++++++++++++++++ test/release-check.test.ts | 12 ++++++ 9 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 src/plugin-sdk/facade-activation-contract.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 44273ca7ada..486db04c51c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Packaged speech runtime:** stop treating package-backed `speech-core` as a bundled plugin sidecar, restoring TTS startup in npm installs while release checks keep true activation-bypassing facades package-complete. (#89899, #89425) Thanks @zhangguiping-xydt. - **Codex app-server protocol:** require app-server 0.142 or newer, remove pre-0.142 wire-shape compatibility, and teach Codex to retrieve deferred native `spawn_agent` through `tool_search` so native subagent task mirroring works on search-capable models. (#101221) - **Android hardware keyboard chat:** send with unmodified Enter on physical keyboards while preserving Shift+Enter and other modified Enter combinations for multiline input. (#101239) Thanks @3ninyt3nin-creator. - **CJK Markdown emphasis:** render adjacent Chinese, Japanese, and Korean emphasis punctuation through the shared Markdown pipeline instead of leaking literal markers across channels. (#101230, #101120) Thanks @nicknmorty. diff --git a/extensions/qa-lab/src/bundled-plugin-staging.ts b/extensions/qa-lab/src/bundled-plugin-staging.ts index 92daebeb141..6511be91fbd 100644 --- a/extensions/qa-lab/src/bundled-plugin-staging.ts +++ b/extensions/qa-lab/src/bundled-plugin-staging.ts @@ -8,7 +8,6 @@ import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/strin const QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS = Object.freeze([ "image-generation-core", "media-understanding-core", - "speech-core", ]); const QA_OPENAI_PLUGIN_ID = "openai"; const QA_BUNDLED_PLUGIN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index ae68ec53b0e..ffe6d899e89 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -1529,7 +1529,9 @@ describe("qa bundled plugin dir", () => { ); await mkdir(path.join(repoRoot, "dist", "extensions", "qa-channel"), { recursive: true }); await mkdir(path.join(repoRoot, "dist", "extensions", "memory-core"), { recursive: true }); - await mkdir(path.join(repoRoot, "dist", "extensions", "speech-core"), { recursive: true }); + await mkdir(path.join(repoRoot, "dist", "extensions", "image-generation-core"), { + recursive: true, + }); await mkdir(path.join(repoRoot, "dist", "extensions", "unused-plugin"), { recursive: true }); await mkdir(path.join(repoRoot, "dist", "plugin-sdk"), { recursive: true }); await writeFile( @@ -1564,9 +1566,9 @@ describe("qa bundled plugin dir", () => { }); expect((await readdir(bundledPluginsDir)).toSorted()).toEqual([ + "image-generation-core", "memory-core", "qa-channel", - "speech-core", ]); expect(bundledPluginsDir).toBe( path.join( @@ -1590,7 +1592,9 @@ describe("qa bundled plugin dir", () => { expect(qaChannel.accountId).toBe("qa"); expect((await lstat(path.join(bundledPluginsDir, "qa-channel"))).isDirectory()).toBe(true); expect((await lstat(path.join(bundledPluginsDir, "memory-core"))).isDirectory()).toBe(true); - expect((await lstat(path.join(bundledPluginsDir, "speech-core"))).isDirectory()).toBe(true); + expect((await lstat(path.join(bundledPluginsDir, "image-generation-core"))).isDirectory()).toBe( + true, + ); const sharedChunkStat = await lstat( path.join( repoRoot, @@ -2064,9 +2068,9 @@ describe("qa bundled plugin dir", () => { JSON.stringify({ openclaw: { install: { minHostVersion: ">=2026.4.8" } } }), "utf8", ); - await mkdir(path.join(bundledRoot, "speech-core"), { recursive: true }); + await mkdir(path.join(bundledRoot, "image-generation-core"), { recursive: true }); await writeFile( - path.join(bundledRoot, "speech-core", "package.json"), + path.join(bundledRoot, "image-generation-core", "package.json"), JSON.stringify({ openclaw: { install: { minHostVersion: ">=2026.4.9" } } }), "utf8", ); diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts index 74c85e19e7f..26d33d381ce 100644 --- a/scripts/openclaw-npm-postpublish-verify.ts +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -25,6 +25,7 @@ import { } from "node:path"; import { pathToFileURL } from "node:url"; import { formatErrorMessage } from "../src/infra/errors.ts"; +import { ALWAYS_ALLOWED_RUNTIME_DIR_NAMES } from "../src/plugin-sdk/facade-activation-contract.ts"; import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../src/plugins/runtime-sidecar-paths.ts"; import { readBoundedResponseText } from "./lib/bounded-response.ts"; import { listBundledPluginPackArtifacts } from "./lib/bundled-plugin-build-entries.mjs"; @@ -409,6 +410,7 @@ export function collectInstalledPackageErrors(params: { } errors.push(...collectInstalledBundledExtensionManifestErrors(params.packageRoot)); + errors.push(...collectInstalledAlwaysAllowedRuntimeFacadeErrors(params.packageRoot)); errors.push(...collectInstalledContextEngineRuntimeErrors(params.packageRoot)); errors.push(...collectInstalledPluginSdkZodArtifactErrors(params.packageRoot)); errors.push(...collectInstalledPluginSdkDeclarationErrors(params.packageRoot)); @@ -417,6 +419,25 @@ export function collectInstalledPackageErrors(params: { return errors; } +export function collectInstalledAlwaysAllowedRuntimeFacadeErrors(packageRoot: string): string[] { + const errors: string[] = []; + const activationRuntimePath = "dist/facade-activation-check.runtime.js"; + if (!existsSync(join(packageRoot, activationRuntimePath))) { + errors.push( + `installed package is missing required facade activation runtime: ${activationRuntimePath}`, + ); + } + for (const dirName of ALWAYS_ALLOWED_RUNTIME_DIR_NAMES) { + const relativePath = `dist/extensions/${dirName}/runtime-api.js`; + if (!existsSync(join(packageRoot, relativePath))) { + errors.push( + `installed package allows bundled runtime facade ${dirName}/runtime-api.js but is missing required runtime sidecar: ${relativePath}.`, + ); + } + } + return errors; +} + function collectInstalledBundledExtensionIds(packageRoot: string): Set { const extensionsDir = join(packageRoot, "dist", "extensions"); if (!existsSync(extensionsDir)) { diff --git a/src/plugin-sdk/facade-activation-check.runtime.ts b/src/plugin-sdk/facade-activation-check.runtime.ts index 89fef908e2a..fd8387788d1 100644 --- a/src/plugin-sdk/facade-activation-check.runtime.ts +++ b/src/plugin-sdk/facade-activation-check.runtime.ts @@ -25,13 +25,10 @@ import { type PluginManifestRecord, } from "../plugins/manifest-registry.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; +import { ALWAYS_ALLOWED_RUNTIME_DIR_NAMES } from "./facade-activation-contract.js"; import { resolveRegistryPluginModuleLocationFromRecords } from "./facade-resolution-shared.js"; -const ALWAYS_ALLOWED_RUNTIME_DIR_NAMES = new Set([ - "image-generation-core", - "media-understanding-core", - "speech-core", -]); +const ALWAYS_ALLOWED_RUNTIME_DIR_NAME_SET = new Set(ALWAYS_ALLOWED_RUNTIME_DIR_NAMES); const EMPTY_FACADE_BOUNDARY_CONFIG: OpenClawConfig = {}; /** Minimal manifest shape needed to decide whether a bundled facade may load. */ @@ -265,7 +262,7 @@ export function resolveBundledPluginPublicSurfaceAccess(params: { }): { allowed: boolean; pluginId?: string; reason?: string } { if ( params.artifactBasename === "runtime-api.js" && - ALWAYS_ALLOWED_RUNTIME_DIR_NAMES.has(params.dirName) + ALWAYS_ALLOWED_RUNTIME_DIR_NAME_SET.has(params.dirName) ) { return { allowed: true, diff --git a/src/plugin-sdk/facade-activation-contract.ts b/src/plugin-sdk/facade-activation-contract.ts new file mode 100644 index 00000000000..801a284a571 --- /dev/null +++ b/src/plugin-sdk/facade-activation-contract.ts @@ -0,0 +1,6 @@ +// Runtime facades with no activation-owned plugin manifest. Every entry must +// ship a matching dist/extensions//runtime-api.js sidecar. +export const ALWAYS_ALLOWED_RUNTIME_DIR_NAMES = [ + "image-generation-core", + "media-understanding-core", +] as const; diff --git a/src/plugin-sdk/facade-runtime.test.ts b/src/plugin-sdk/facade-runtime.test.ts index 0cc35460454..9c2d3f67137 100644 --- a/src/plugin-sdk/facade-runtime.test.ts +++ b/src/plugin-sdk/facade-runtime.test.ts @@ -547,10 +547,10 @@ describe("plugin-sdk facade runtime", () => { }); }); - it("keeps shared runtime-core facades available without plugin activation", () => { + it("keeps bundled extension runtime-core facades available without plugin activation", () => { setRuntimeConfigSnapshot({}); - for (const dirName of ["speech-core", "image-generation-core", "media-understanding-core"]) { + for (const dirName of ["image-generation-core", "media-understanding-core"]) { expect( resolveActivationCheckBundledPluginPublicSurfaceAccess({ dirName, @@ -566,6 +566,23 @@ describe("plugin-sdk facade runtime", () => { } }); + it("does not treat package-backed speech-core as a bundled extension facade", () => { + setRuntimeConfigSnapshot({}); + + expect( + resolveActivationCheckBundledPluginPublicSurfaceAccess({ + dirName: "speech-core", + artifactBasename: "runtime-api.js", + location: null, + sourceExtensionsRoot: "", + resolutionKey: "runtime-core:speech-core", + }), + ).toEqual({ + allowed: false, + reason: "no bundled plugin manifest found for speech-core", + }); + }); + it("prefers the source runtime snapshot for facade activation checks", () => { const dir = createTempDirSync("openclaw-facade-source-snapshot-"); fs.mkdirSync(path.join(dir, "demo"), { recursive: true }); diff --git a/test/openclaw-npm-postpublish-verify.test.ts b/test/openclaw-npm-postpublish-verify.test.ts index 0ac5dd0ce7a..7f0d90d6fa4 100644 --- a/test/openclaw-npm-postpublish-verify.test.ts +++ b/test/openclaw-npm-postpublish-verify.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest"; import { buildPublishedInstallCommandArgs, buildPublishedInstallScenarios, + collectInstalledAlwaysAllowedRuntimeFacadeErrors, collectInstalledBundledExtensionManifestErrors, collectInstalledBundledRuntimeSidecarPaths, collectInstalledContextEngineRuntimeErrors, @@ -447,6 +448,43 @@ describe("collectInstalledPackageErrors", () => { }); }); +describe("collectInstalledAlwaysAllowedRuntimeFacadeErrors", () => { + function withInstalledPackageRoot(run: (packageRoot: string) => void): void { + const packageRoot = mkdtempSync(join(tmpdir(), "openclaw-postpublish-facade-runtime-")); + try { + run(packageRoot); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + } + + function writeInstalledFile(packageRoot: string, relativePath: string): void { + const filePath = join(packageRoot, relativePath); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, "export {};\n", "utf8"); + } + + it("reports the activation runtime and every missing allowlisted sidecar", () => { + withInstalledPackageRoot((packageRoot) => { + expect(collectInstalledAlwaysAllowedRuntimeFacadeErrors(packageRoot)).toEqual([ + "installed package is missing required facade activation runtime: dist/facade-activation-check.runtime.js", + "installed package allows bundled runtime facade image-generation-core/runtime-api.js but is missing required runtime sidecar: dist/extensions/image-generation-core/runtime-api.js.", + "installed package allows bundled runtime facade media-understanding-core/runtime-api.js but is missing required runtime sidecar: dist/extensions/media-understanding-core/runtime-api.js.", + ]); + }); + }); + + it("accepts a package with the activation runtime and allowlisted sidecars", () => { + withInstalledPackageRoot((packageRoot) => { + writeInstalledFile(packageRoot, "dist/facade-activation-check.runtime.js"); + writeInstalledFile(packageRoot, "dist/extensions/image-generation-core/runtime-api.js"); + writeInstalledFile(packageRoot, "dist/extensions/media-understanding-core/runtime-api.js"); + + expect(collectInstalledAlwaysAllowedRuntimeFacadeErrors(packageRoot)).toStrictEqual([]); + }); + }); +}); + describe("collectInstalledContextEngineRuntimeErrors", () => { function makeInstalledPackageRoot(): string { return mkdtempSync(join(tmpdir(), "openclaw-postpublish-context-engine-")); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index d0668ea6b8b..c4b54384ab3 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -729,6 +729,18 @@ describe("collectMissingPackPaths", () => { const packageRoot = join(root, "openclaw"); const distDir = join(packageRoot, "dist"); mkdirSync(distDir, { recursive: true }); + for (const relativePath of [ + "facade-activation-check.runtime.js", + "extensions/image-generation-core/runtime-api.js", + "extensions/media-understanding-core/runtime-api.js", + ]) { + const filePath = join(distDir, relativePath); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, "export {};\n"); + } + for (const pluginId of ["image-generation-core", "media-understanding-core"]) { + writeFileSync(join(distDir, "extensions", pluginId, "package.json"), "{}\n"); + } writeFileSync( join(packageRoot, "package.json"), `${JSON.stringify({ name: "openclaw", version: "2026.5.14-beta.3", dependencies: {} })}\n`,