fix(plugins): enforce activation before shipped imports (#59136)

* fix(plugins): enforce activation before shipped imports

* fix(plugins): remove more ambient bundled loads

* fix(plugins): tighten scoped loader matching

* fix(plugins): remove channel-id scoped loader matches

* refactor(plugin-sdk): relocate ambient provider helpers

* fix(plugin-sdk): preserve unicode ADC credential paths

* fix(plugins): restore safe setup fallback
This commit is contained in:
Vincent Koc
2026-04-02 11:18:49 +09:00
committed by GitHub
parent 765e8fb713
commit 7771c69caf
21 changed files with 643 additions and 87 deletions

View File

@@ -1,8 +1,9 @@
import crypto from "node:crypto";
import type { CliSessionBinding, SessionEntry } from "../config/sessions.js";
import { CLAUDE_CLI_BACKEND_ID } from "../plugin-sdk/anthropic-cli.js";
import { normalizeProviderId } from "./model-selection.js";
const CLAUDE_CLI_BACKEND_ID = "claude-cli";
function trimOptional(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;

View File

@@ -1,6 +1,6 @@
import { getEnvApiKey } from "@mariozechner/pi-ai";
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
import { hasAnthropicVertexAvailableAuth } from "../plugin-sdk/anthropic-vertex.js";
import { hasAnthropicVertexAvailableAuth } from "../plugin-sdk/anthropic-vertex-auth-presence.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js";
import { GCP_VERTEX_CREDENTIALS_MARKER } from "./model-auth-markers.js";

View File

@@ -6,8 +6,8 @@ import {
toAgentModelListLike,
} from "../config/model-input.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeGoogleModelId } from "../plugin-sdk/google.js";
import { normalizeXaiModelId } from "../plugin-sdk/xai.js";
import { normalizeGoogleModelId } from "../plugin-sdk/google-model-id.js";
import { normalizeXaiModelId } from "../plugin-sdk/xai-model-id.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import {
resolveAgentConfig,

View File

@@ -29,6 +29,20 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({
}));
const resolveBundledPluginSources = vi.fn();
const getChannelPluginCatalogEntry = vi.fn();
vi.mock("../../channels/plugins/catalog.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../channels/plugins/catalog.js")>();
return {
...actual,
getChannelPluginCatalogEntry: (...args: unknown[]) => getChannelPluginCatalogEntry(...args),
};
});
const loadPluginManifestRegistry = vi.fn();
vi.mock("../../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: (...args: unknown[]) => loadPluginManifestRegistry(...args),
}));
vi.mock("../../plugins/bundled-sources.js", () => ({
findBundledPluginSourceInMap: ({
bundled,
@@ -107,6 +121,8 @@ beforeEach(() => {
changes: [],
}));
resolveBundledPluginSources.mockReturnValue(new Map());
getChannelPluginCatalogEntry.mockReturnValue(undefined);
loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] });
setActivePluginRegistry(createEmptyPluginRegistry());
});
@@ -408,6 +424,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
it("scopes channel reloads when setup starts from an empty registry", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@openclaw/telegram-plugin" });
reloadChannelSetupPluginRegistryForChannel({
cfg,
@@ -421,7 +438,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
config: cfg,
workspaceDir: "/tmp/openclaw-workspace",
cache: false,
onlyPluginIds: ["telegram"],
onlyPluginIds: ["@openclaw/telegram-plugin"],
includeSetupOnlyChannelPlugins: true,
}),
);
@@ -459,6 +476,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
it("scopes channel reloads when the global registry is populated but the pinned channel registry is empty", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@openclaw/telegram-plugin" });
const activeRegistry = createEmptyPluginRegistry();
activeRegistry.plugins.push(
createPluginRecord({
@@ -485,7 +503,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["telegram"],
onlyPluginIds: ["@openclaw/telegram-plugin"],
}),
);
});
@@ -493,6 +511,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
it("can load a channel-scoped snapshot without activating the global registry", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@openclaw/telegram-plugin" });
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
@@ -506,7 +525,52 @@ describe("ensureChannelSetupPluginInstalled", () => {
config: cfg,
workspaceDir: "/tmp/openclaw-workspace",
cache: false,
onlyPluginIds: ["telegram"],
onlyPluginIds: ["@openclaw/telegram-plugin"],
includeSetupOnlyChannelPlugins: true,
activate: false,
}),
);
});
it("does not scope by raw channel id when no trusted plugin mapping exists", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: expect.anything(),
}),
);
});
it("scopes snapshots by a unique discovered manifest match when catalog mapping is missing", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "custom-telegram-plugin", channels: ["telegram"] }],
diagnostics: [],
});
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
config: cfg,
workspaceDir: "/tmp/openclaw-workspace",
cache: false,
onlyPluginIds: ["custom-telegram-plugin"],
includeSetupOnlyChannelPlugins: true,
activate: false,
}),

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { getChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
import { resolveBundledInstallPlanForCatalogEntry } from "../../cli/plugin-install-plan.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -16,6 +17,7 @@ import { installPluginFromNpmSpec } from "../../plugins/install.js";
import { buildNpmResolutionInstallFields, recordPluginInstall } from "../../plugins/installs.js";
import { loadOpenClawPlugins } from "../../plugins/loader.js";
import { createPluginLoaderLogger } from "../../plugins/logger.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { getActivePluginChannelRegistry } from "../../plugins/runtime.js";
import type { RuntimeEnv } from "../../runtime.js";
@@ -258,6 +260,35 @@ function loadChannelSetupPluginRegistry(params: {
});
}
function resolveScopedChannelPluginId(params: {
cfg: OpenClawConfig;
channel: string;
pluginId?: string;
workspaceDir?: string;
}): string | undefined {
const explicitPluginId = params.pluginId?.trim();
if (explicitPluginId) {
return explicitPluginId;
}
return getChannelPluginCatalogEntry(params.channel, {
workspaceDir: params.workspaceDir,
})?.pluginId ?? resolveUniqueManifestScopedChannelPluginId(params);
}
function resolveUniqueManifestScopedChannelPluginId(params: {
cfg: OpenClawConfig;
channel: string;
workspaceDir?: string;
}): string | undefined {
const matches = loadPluginManifestRegistry({
config: params.cfg,
workspaceDir: params.workspaceDir,
cache: false,
env: process.env,
}).plugins.filter((plugin) => plugin.channels.includes(params.channel));
return matches.length === 1 ? matches[0]?.id : undefined;
}
export function reloadChannelSetupPluginRegistryForChannel(params: {
cfg: OpenClawConfig;
runtime: RuntimeEnv;
@@ -266,11 +297,17 @@ export function reloadChannelSetupPluginRegistryForChannel(params: {
workspaceDir?: string;
}): void {
const activeRegistry = getActivePluginChannelRegistry();
const scopedPluginId = resolveScopedChannelPluginId({
cfg: params.cfg,
channel: params.channel,
pluginId: params.pluginId,
workspaceDir: params.workspaceDir,
});
// On low-memory hosts, the empty-registry fallback should only recover the selected
// plugin instead of importing every bundled extension during setup.
const onlyPluginIds = activeRegistry?.plugins.length
? undefined
: [params.pluginId ?? params.channel];
// plugin when we have a trusted channel -> plugin mapping. Otherwise fall back
// to an unscoped reload instead of trusting manifest-declared channel ids.
const onlyPluginIds =
activeRegistry?.plugins.length || !scopedPluginId ? undefined : [scopedPluginId];
loadChannelSetupPluginRegistry({
...params,
onlyPluginIds,
@@ -284,9 +321,15 @@ export function loadChannelSetupPluginRegistrySnapshotForChannel(params: {
pluginId?: string;
workspaceDir?: string;
}): PluginRegistry {
const scopedPluginId = resolveScopedChannelPluginId({
cfg: params.cfg,
channel: params.channel,
pluginId: params.pluginId,
workspaceDir: params.workspaceDir,
});
return loadChannelSetupPluginRegistry({
...params,
onlyPluginIds: [params.pluginId ?? params.channel],
...(scopedPluginId ? { onlyPluginIds: [scopedPluginId] } : {}),
activate: false,
});
}

View File

@@ -4,7 +4,7 @@ import { buildModelAliasIndex, modelKey } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ModelProviderConfig } from "../config/types.models.js";
import { isSecretRef, type SecretInput } from "../config/types.secrets.js";
import { OLLAMA_DEFAULT_BASE_URL } from "../plugin-sdk/ollama-surface.js";
import { OLLAMA_DEFAULT_BASE_URL } from "../plugins/provider-model-defaults.js";
import type { RuntimeEnv } from "../runtime.js";
import { fetchWithTimeout } from "../utils/fetch-timeout.js";
import {

View File

@@ -1,4 +1,4 @@
export {
applyOpencodeGoModelDefault,
OPENCODE_GO_DEFAULT_MODEL_REF,
} from "../plugin-sdk/opencode-go.js";
} from "../plugins/provider-model-defaults.js";

View File

@@ -1,4 +1,4 @@
export {
applyOpencodeZenModelDefault,
OPENCODE_ZEN_DEFAULT_MODEL,
} from "../plugin-sdk/opencode.js";
} from "../plugins/provider-model-defaults.js";

View File

@@ -1,5 +1,5 @@
import { hasMeaningfulChannelConfig } from "../channels/config-presence.js";
import { hasAnyWhatsAppAuth } from "../plugin-sdk/whatsapp.js";
import { hasAnyWhatsAppAuth } from "../plugin-sdk/whatsapp-auth-presence.js";
import { isRecord } from "../utils.js";
import type { OpenClawConfig } from "./config.js";
@@ -127,8 +127,8 @@ function isStructuredChannelConfigured(
return hasMeaningfulChannelConfig(entry);
}
function isWhatsAppConfigured(cfg: OpenClawConfig): boolean {
if (hasAnyWhatsAppAuth(cfg)) {
function isWhatsAppConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
if (hasAnyWhatsAppAuth(cfg, env)) {
return true;
}
const entry = resolveChannelConfig(cfg, "whatsapp");
@@ -149,7 +149,7 @@ export function isChannelConfigured(
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (channelId === "whatsapp") {
return isWhatsAppConfigured(cfg);
return isWhatsAppConfigured(cfg, env);
}
const spec = STRUCTURED_CHANNEL_CONFIG_SPECS[channelId];
if (spec) {

View File

@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn());
vi.mock("./plugin-sdk/facade-runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./plugin-sdk/facade-runtime.js")>();
return {
...actual,
loadBundledPluginPublicSurfaceModuleSync,
};
});
describe("plugin activation boundary", () => {
beforeEach(() => {
loadBundledPluginPublicSurfaceModuleSync.mockReset();
});
let ambientImportsPromise: Promise<void> | undefined;
let configHelpersPromise:
| Promise<{
isChannelConfigured: typeof import("./config/channel-configured.js").isChannelConfigured;
resolveEnvApiKey: typeof import("./agents/model-auth-env.js").resolveEnvApiKey;
}>
| undefined;
let modelSelectionPromise:
| Promise<{
normalizeModelRef: typeof import("./agents/model-selection.js").normalizeModelRef;
}>
| undefined;
function importAmbientModules() {
ambientImportsPromise ??= Promise.all([
import("./agents/cli-session.js"),
import("./commands/onboard-custom.js"),
import("./commands/opencode-go-model-default.js"),
import("./commands/opencode-zen-model-default.js"),
]).then(() => undefined);
return ambientImportsPromise;
}
function importConfigHelpers() {
configHelpersPromise ??= Promise.all([
import("./config/channel-configured.js"),
import("./agents/model-auth-env.js"),
]).then(([channelConfigured, modelAuthEnv]) => ({
isChannelConfigured: channelConfigured.isChannelConfigured,
resolveEnvApiKey: modelAuthEnv.resolveEnvApiKey,
}));
return configHelpersPromise;
}
function importModelSelection() {
modelSelectionPromise ??= import("./agents/model-selection.js").then((module) => ({
normalizeModelRef: module.normalizeModelRef,
}));
return modelSelectionPromise;
}
it("does not load bundled provider plugins on ambient command imports", async () => {
await importAmbientModules();
expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled();
});
it("does not load bundled plugins for config and env detection helpers", async () => {
const { isChannelConfigured, resolveEnvApiKey } = await importConfigHelpers();
expect(isChannelConfigured({}, "whatsapp", {})).toBe(false);
expect(resolveEnvApiKey("anthropic-vertex", {})).toBeNull();
expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled();
});
it("does not load provider plugins for static model id normalization", async () => {
const { normalizeModelRef } = await importModelSelection();
expect(normalizeModelRef("google", "gemini-3.1-pro")).toEqual({
provider: "google",
model: "gemini-3.1-pro-preview",
});
expect(normalizeModelRef("xai", "grok-4-fast-reasoning")).toEqual({
provider: "xai",
model: "grok-4-fast",
});
expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,30 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-auth-presence.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
describe("hasAnthropicVertexAvailableAuth", () => {
it("preserves unicode GOOGLE_APPLICATION_CREDENTIALS paths", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-vertex-auth-"));
tempDirs.push(root);
const unicodeDir = path.join(root, "認証情報");
await fs.mkdir(unicodeDir, { recursive: true });
const credentialsPath = path.join(unicodeDir, "application_default_credentials.json");
await fs.writeFile(credentialsPath, "{}\n", "utf8");
expect(
hasAnthropicVertexAvailableAuth({
GOOGLE_APPLICATION_CREDENTIALS: ` ${credentialsPath} `,
} as NodeJS.ProcessEnv),
).toBe(true);
});
});

View File

@@ -0,0 +1,47 @@
import { existsSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
const GCLOUD_DEFAULT_ADC_PATH = join(
homedir(),
".config",
"gcloud",
"application_default_credentials.json",
);
function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.env): boolean {
const explicitMetadataOptIn = normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_USE_GCP_METADATA);
return explicitMetadataOptIn === "1" || explicitMetadataOptIn?.toLowerCase() === "true";
}
function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.env): string {
return platform() === "win32"
? join(
env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
"gcloud",
"application_default_credentials.json",
)
: GCLOUD_DEFAULT_ADC_PATH;
}
function resolveAnthropicVertexAdcCredentialsPath(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const explicitCredentialsPath = env.GOOGLE_APPLICATION_CREDENTIALS?.trim();
if (explicitCredentialsPath) {
return existsSync(explicitCredentialsPath) ? explicitCredentialsPath : undefined;
}
const defaultAdcPath = resolveAnthropicVertexDefaultAdcPath(env);
return existsSync(defaultAdcPath) ? defaultAdcPath : undefined;
}
export function hasAnthropicVertexAvailableAuth(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return (
hasAnthropicVertexMetadataServerAdc(env) ||
resolveAnthropicVertexAdcCredentialsPath(env) !== undefined
);
}

View File

@@ -1 +1,27 @@
export { normalizeAntigravityModelId, normalizeGoogleModelId } from "./google.js";
const ANTIGRAVITY_BARE_PRO_IDS = new Set(["gemini-3-pro", "gemini-3.1-pro", "gemini-3-1-pro"]);
export function normalizeGoogleModelId(id: string): string {
if (id === "gemini-3-pro") {
return "gemini-3-pro-preview";
}
if (id === "gemini-3-flash") {
return "gemini-3-flash-preview";
}
if (id === "gemini-3.1-pro") {
return "gemini-3.1-pro-preview";
}
if (id === "gemini-3.1-flash-lite") {
return "gemini-3.1-flash-lite-preview";
}
if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") {
return "gemini-3-flash-preview";
}
return id;
}
export function normalizeAntigravityModelId(id: string): string {
if (ANTIGRAVITY_BARE_PRO_IDS.has(id)) {
return `${id}-low`;
}
return id;
}

View File

@@ -0,0 +1,40 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { hasAnyWhatsAppAuth } from "./whatsapp-auth-presence.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
describe("hasAnyWhatsAppAuth", () => {
it("resolves account authDir against the provided environment", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-wa-auth-"));
tempDirs.push(homeDir);
const authDir = path.join(homeDir, "wa-auth");
await fs.mkdir(authDir, { recursive: true });
await fs.writeFile(path.join(authDir, "creds.json"), "{}\n", "utf8");
expect(
hasAnyWhatsAppAuth(
{
channels: {
whatsapp: {
accounts: {
custom: {
authDir: "~/wa-auth",
},
},
},
},
},
{ HOME: homeDir } as NodeJS.ProcessEnv,
),
).toBe(true);
});
});

View File

@@ -1 +1,88 @@
export { hasAnyWhatsAppAuth } from "./whatsapp.js";
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import type { WhatsAppAccountConfig, WhatsAppConfig } from "../config/types.whatsapp.js";
import { resolveOAuthDir } from "../config/paths.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { isRecord, resolveUserPath } from "../utils.js";
function hasWebCredsSync(authDir: string): boolean {
try {
return fs.existsSync(path.join(authDir, "creds.json"));
} catch {
return false;
}
}
function resolveWhatsAppChannelConfig(cfg: OpenClawConfig): WhatsAppConfig | undefined {
return cfg.channels?.whatsapp;
}
function addAccountAuthDirs(
authDirs: Set<string>,
accountId: string,
account: WhatsAppAccountConfig | undefined,
accountsRoot: string,
env: NodeJS.ProcessEnv,
): void {
authDirs.add(path.join(accountsRoot, normalizeAccountId(accountId)));
const configuredAuthDir = account?.authDir?.trim();
if (configuredAuthDir) {
authDirs.add(resolveUserPath(configuredAuthDir, env));
}
}
function listWhatsAppAuthDirs(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): readonly string[] {
const oauthDir = resolveOAuthDir(env);
const accountsRoot = path.join(oauthDir, "whatsapp");
const channel = resolveWhatsAppChannelConfig(cfg);
const authDirs = new Set<string>([oauthDir, path.join(accountsRoot, DEFAULT_ACCOUNT_ID)]);
addAccountAuthDirs(authDirs, DEFAULT_ACCOUNT_ID, undefined, accountsRoot, env);
if (channel?.defaultAccount?.trim()) {
addAccountAuthDirs(
authDirs,
channel.defaultAccount,
channel.accounts?.[channel.defaultAccount],
accountsRoot,
env,
);
}
const accounts = channel?.accounts;
if (isRecord(accounts)) {
for (const [accountId, value] of Object.entries(accounts)) {
addAccountAuthDirs(
authDirs,
accountId,
isRecord(value) ? value : undefined,
accountsRoot,
env,
);
}
}
try {
const entries = fs.readdirSync(accountsRoot, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
authDirs.add(path.join(accountsRoot, entry.name));
}
}
} catch {
// Missing directories are equivalent to no auth state.
}
return [...authDirs];
}
export function hasAnyWhatsAppAuth(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): boolean {
return listWhatsAppAuthDirs(cfg, env).some((authDir) => hasWebCredsSync(authDir));
}

View File

@@ -1,15 +1,21 @@
import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type-map.generated.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
type FacadeEntry = PluginSdkFacadeTypeMap["xai"];
type FacadeModule = FacadeEntry["module"];
function loadFacadeModule(): FacadeModule {
return loadBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "xai",
artifactBasename: "api.js",
});
export function normalizeXaiModelId(id: string): string {
if (id === "grok-4-fast-reasoning") {
return "grok-4-fast";
}
if (id === "grok-4-1-fast-reasoning") {
return "grok-4-1-fast";
}
if (id === "grok-4.20-experimental-beta-0304-reasoning") {
return "grok-4.20-beta-latest-reasoning";
}
if (id === "grok-4.20-experimental-beta-0304-non-reasoning") {
return "grok-4.20-beta-latest-non-reasoning";
}
if (id === "grok-4.20-reasoning") {
return "grok-4.20-beta-latest-reasoning";
}
if (id === "grok-4.20-non-reasoning") {
return "grok-4.20-beta-latest-non-reasoning";
}
return id;
}
export const normalizeXaiModelId: FacadeModule["normalizeXaiModelId"] = ((...args) =>
loadFacadeModule()["normalizeXaiModelId"](...args)) as FacadeModule["normalizeXaiModelId"];

View File

@@ -2448,15 +2448,59 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
expect(disabled?.status).toBe("disabled");
});
it("skips disabled channel imports unless setup-only loading is explicitly enabled", () => {
it("does not treat manifest channel ids as scoped plugin id matches", () => {
useNoBundledPlugins();
const target = writePlugin({
id: "target-plugin",
filename: "target-plugin.cjs",
body: `module.exports = { id: "target-plugin", register() {} };`,
});
const unrelated = writePlugin({
id: "unrelated-plugin",
filename: "unrelated-plugin.cjs",
body: `module.exports = { id: "unrelated-plugin", register() { throw new Error("unrelated plugin should not load"); } };`,
});
fs.writeFileSync(
path.join(unrelated.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "unrelated-plugin",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["target-plugin"],
},
null,
2,
),
"utf-8",
);
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [target.file, unrelated.file] },
allow: ["target-plugin", "unrelated-plugin"],
entries: {
"target-plugin": { enabled: true },
"unrelated-plugin": { enabled: true },
},
},
},
onlyPluginIds: ["target-plugin"],
});
expect(registry.plugins.map((entry) => entry.id)).toEqual(["target-plugin"]);
});
it("only setup-loads a disabled channel plugin when the caller scopes to the selected plugin", () => {
useNoBundledPlugins();
const marker = path.join(makeTempDir(), "lazy-channel-imported.txt");
const plugin = writePlugin({
id: "lazy-channel",
id: "lazy-channel-plugin",
filename: "lazy-channel.cjs",
body: `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "loaded", "utf-8");
module.exports = {
id: "lazy-channel",
id: "lazy-channel-plugin",
register(api) {
api.registerChannel({
plugin: {
@@ -2483,7 +2527,7 @@ module.exports = {
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "lazy-channel",
id: "lazy-channel-plugin",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["lazy-channel"],
},
@@ -2495,9 +2539,9 @@ module.exports = {
const config = {
plugins: {
load: { paths: [plugin.file] },
allow: ["lazy-channel"],
allow: ["lazy-channel-plugin"],
entries: {
"lazy-channel": { enabled: false },
"lazy-channel-plugin": { enabled: false },
},
},
};
@@ -2509,25 +2553,41 @@ module.exports = {
expect(fs.existsSync(marker)).toBe(false);
expect(registry.channelSetups).toHaveLength(0);
expect(registry.plugins.find((entry) => entry.id === "lazy-channel")?.status).toBe("disabled");
expect(registry.plugins.find((entry) => entry.id === "lazy-channel-plugin")?.status).toBe(
"disabled",
);
const setupRegistry = loadOpenClawPlugins({
const broadSetupRegistry = loadOpenClawPlugins({
cache: false,
config,
includeSetupOnlyChannelPlugins: true,
});
expect(fs.existsSync(marker)).toBe(false);
expect(broadSetupRegistry.channelSetups).toHaveLength(0);
expect(broadSetupRegistry.channels).toHaveLength(0);
expect(
broadSetupRegistry.plugins.find((entry) => entry.id === "lazy-channel-plugin")?.status,
).toBe("disabled");
const scopedSetupRegistry = loadOpenClawPlugins({
cache: false,
config,
includeSetupOnlyChannelPlugins: true,
onlyPluginIds: ["lazy-channel-plugin"],
});
expect(fs.existsSync(marker)).toBe(true);
expect(setupRegistry.channelSetups).toHaveLength(1);
expect(setupRegistry.channels).toHaveLength(0);
expect(setupRegistry.plugins.find((entry) => entry.id === "lazy-channel")?.status).toBe(
"disabled",
);
expect(scopedSetupRegistry.channelSetups).toHaveLength(1);
expect(scopedSetupRegistry.channels).toHaveLength(0);
expect(
scopedSetupRegistry.plugins.find((entry) => entry.id === "lazy-channel-plugin")?.status,
).toBe("disabled");
});
it.each([
{
name: "uses package setupEntry for setup-only channel loads",
name: "uses package setupEntry for selected setup-only channel loads",
fixture: {
id: "setup-entry-test",
label: "Setup Entry Test",
@@ -2549,6 +2609,7 @@ module.exports = {
},
},
includeSetupOnlyChannelPlugins: true,
onlyPluginIds: ["setup-entry-test"],
}),
expectFullLoaded: false,
expectSetupLoaded: true,

View File

@@ -280,6 +280,17 @@ function normalizeScopedPluginIds(ids?: string[]): string[] | undefined {
return normalized.length > 0 ? normalized : undefined;
}
function matchesScopedPluginRequest(params: {
onlyPluginIdSet: ReadonlySet<string> | null;
pluginId: string;
}): boolean {
const scopedIds = params.onlyPluginIdSet;
if (!scopedIds) {
return true;
}
return scopedIds.has(params.pluginId);
}
function resolveRuntimeSubagentMode(
runtimeOptions: PluginLoadOptions["runtimeOptions"],
): "default" | "explicit" | "gateway-bindable" {
@@ -1007,9 +1018,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
const pluginId = manifestRecord.id;
const matchesRequestedScope = matchesScopedPluginRequest({
onlyPluginIdSet,
pluginId,
});
// Filter again at import time as a final guard. The earlier manifest filter keeps
// warnings scoped; this one prevents loading/registering anything outside the scope.
if (onlyPluginIdSet && !onlyPluginIdSet.has(pluginId)) {
if (!matchesRequestedScope) {
continue;
}
const existingOrigin = seenIds.get(pluginId);
@@ -1087,7 +1102,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
})
? "setup-runtime"
: "full"
: includeSetupOnlyChannelPlugins && !validateOnly && manifestRecord.channels.length > 0
: includeSetupOnlyChannelPlugins &&
!validateOnly &&
onlyPluginIdSet &&
manifestRecord.channels.length > 0
? "setup-only"
: null;
@@ -1492,7 +1510,12 @@ export async function loadOpenClawPluginCliRegistry(
continue;
}
const pluginId = manifestRecord.id;
if (onlyPluginIdSet && !onlyPluginIdSet.has(pluginId)) {
if (
!matchesScopedPluginRequest({
onlyPluginIdSet,
pluginId,
})
) {
continue;
}
const existingOrigin = seenIds.get(pluginId);

View File

@@ -10,6 +10,7 @@ export const OPENAI_DEFAULT_TTS_VOICE = "alloy";
export const OPENAI_DEFAULT_AUDIO_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe";
export const OPENAI_DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
export const OPENCODE_GO_DEFAULT_MODEL_REF = "opencode-go/kimi-k2.5";
export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode/claude-opus-4-6";

44
src/tts/tts.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn());
vi.mock("../plugin-sdk/facade-runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugin-sdk/facade-runtime.js")>();
return {
...actual,
loadBundledPluginPublicSurfaceModuleSync,
};
});
describe("tts runtime facade", () => {
let ttsModulePromise: Promise<typeof import("./tts.js")> | undefined;
beforeEach(() => {
loadBundledPluginPublicSurfaceModuleSync.mockReset();
});
function importTtsModule() {
ttsModulePromise ??= import("./tts.js");
return ttsModulePromise;
}
it("does not load speech-core on module import", async () => {
await importTtsModule();
expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled();
});
it("loads speech-core lazily on first runtime access", async () => {
const buildTtsSystemPromptHint = vi.fn().mockReturnValue("hint");
loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({
buildTtsSystemPromptHint,
});
const tts = await importTtsModule();
expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled();
expect(tts.buildTtsSystemPromptHint({} as never)).toBe("hint");
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledTimes(1);
expect(buildTtsSystemPromptHint).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,36 +1,33 @@
import * as speechRuntime from "../../extensions/speech-core/runtime-api.js";
export const buildTtsSystemPromptHint = speechRuntime.buildTtsSystemPromptHint;
export const getLastTtsAttempt = speechRuntime.getLastTtsAttempt;
export const getResolvedSpeechProviderConfig = speechRuntime.getResolvedSpeechProviderConfig;
export const getTtsMaxLength = speechRuntime.getTtsMaxLength;
export const getTtsProvider = speechRuntime.getTtsProvider;
export const isSummarizationEnabled = speechRuntime.isSummarizationEnabled;
export const isTtsEnabled = speechRuntime.isTtsEnabled;
export const isTtsProviderConfigured = speechRuntime.isTtsProviderConfigured;
export const listSpeechVoices = speechRuntime.listSpeechVoices;
export const maybeApplyTtsToPayload = speechRuntime.maybeApplyTtsToPayload;
export const resolveTtsAutoMode = speechRuntime.resolveTtsAutoMode;
export const resolveTtsConfig = speechRuntime.resolveTtsConfig;
export const resolveTtsPrefsPath = speechRuntime.resolveTtsPrefsPath;
export const resolveTtsProviderOrder = speechRuntime.resolveTtsProviderOrder;
export const setLastTtsAttempt = speechRuntime.setLastTtsAttempt;
export const setSummarizationEnabled = speechRuntime.setSummarizationEnabled;
export const setTtsAutoMode = speechRuntime.setTtsAutoMode;
export const setTtsEnabled = speechRuntime.setTtsEnabled;
export const setTtsMaxLength = speechRuntime.setTtsMaxLength;
export const setTtsProvider = speechRuntime.setTtsProvider;
export const synthesizeSpeech = speechRuntime.synthesizeSpeech;
export const textToSpeech = speechRuntime.textToSpeech;
export const textToSpeechTelephony = speechRuntime.textToSpeechTelephony;
export const _test = speechRuntime._test;
export type {
ResolvedTtsConfig,
ResolvedTtsModelOverrides,
TtsDirectiveOverrides,
TtsDirectiveParseResult,
TtsResult,
TtsSynthesisResult,
TtsTelephonyResult,
export {
_test,
buildTtsSystemPromptHint,
getLastTtsAttempt,
getResolvedSpeechProviderConfig,
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
isTtsEnabled,
isTtsProviderConfigured,
listSpeechVoices,
maybeApplyTtsToPayload,
resolveTtsAutoMode,
resolveTtsConfig,
resolveTtsPrefsPath,
resolveTtsProviderOrder,
setLastTtsAttempt,
setSummarizationEnabled,
setTtsAutoMode,
setTtsEnabled,
setTtsMaxLength,
setTtsProvider,
synthesizeSpeech,
textToSpeech,
textToSpeechTelephony,
type ResolvedTtsConfig,
type ResolvedTtsModelOverrides,
type TtsDirectiveOverrides,
type TtsDirectiveParseResult,
type TtsResult,
type TtsSynthesisResult,
type TtsTelephonyResult,
} from "../plugin-sdk/speech-runtime.js";