diff --git a/src/agents/cli-session.ts b/src/agents/cli-session.ts index 0456c54059f..1cf35fdfcc8 100644 --- a/src/agents/cli-session.ts +++ b/src/agents/cli-session.ts @@ -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; diff --git a/src/agents/model-auth-env.ts b/src/agents/model-auth-env.ts index 45c7ec59527..ce121631d99 100644 --- a/src/agents/model-auth-env.ts +++ b/src/agents/model-auth-env.ts @@ -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"; diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 49e27f9de6e..ae2f56d07e6 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -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, diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index d0b716b15a6..d134099d782 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -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(); + 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, }), diff --git a/src/commands/channel-setup/plugin-install.ts b/src/commands/channel-setup/plugin-install.ts index e11daa9270e..d4321c22ec4 100644 --- a/src/commands/channel-setup/plugin-install.ts +++ b/src/commands/channel-setup/plugin-install.ts @@ -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, }); } diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index d9ad9123e06..4852009f2ef 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -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 { diff --git a/src/commands/opencode-go-model-default.ts b/src/commands/opencode-go-model-default.ts index b203a4f8873..c87816456c3 100644 --- a/src/commands/opencode-go-model-default.ts +++ b/src/commands/opencode-go-model-default.ts @@ -1,4 +1,4 @@ export { applyOpencodeGoModelDefault, OPENCODE_GO_DEFAULT_MODEL_REF, -} from "../plugin-sdk/opencode-go.js"; +} from "../plugins/provider-model-defaults.js"; diff --git a/src/commands/opencode-zen-model-default.ts b/src/commands/opencode-zen-model-default.ts index 9979894ee1b..0d874241076 100644 --- a/src/commands/opencode-zen-model-default.ts +++ b/src/commands/opencode-zen-model-default.ts @@ -1,4 +1,4 @@ export { applyOpencodeZenModelDefault, OPENCODE_ZEN_DEFAULT_MODEL, -} from "../plugin-sdk/opencode.js"; +} from "../plugins/provider-model-defaults.js"; diff --git a/src/config/channel-configured.ts b/src/config/channel-configured.ts index 0d07e5b261f..6d2e6c8a29b 100644 --- a/src/config/channel-configured.ts +++ b/src/config/channel-configured.ts @@ -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) { diff --git a/src/plugin-activation-boundary.test.ts b/src/plugin-activation-boundary.test.ts new file mode 100644 index 00000000000..a41f6051a1a --- /dev/null +++ b/src/plugin-activation-boundary.test.ts @@ -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(); + return { + ...actual, + loadBundledPluginPublicSurfaceModuleSync, + }; +}); + +describe("plugin activation boundary", () => { + beforeEach(() => { + loadBundledPluginPublicSurfaceModuleSync.mockReset(); + }); + + let ambientImportsPromise: Promise | 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(); + }); +}); diff --git a/src/plugin-sdk/anthropic-vertex-auth-presence.test.ts b/src/plugin-sdk/anthropic-vertex-auth-presence.test.ts new file mode 100644 index 00000000000..3b04f5004c4 --- /dev/null +++ b/src/plugin-sdk/anthropic-vertex-auth-presence.test.ts @@ -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); + }); +}); diff --git a/src/plugin-sdk/anthropic-vertex-auth-presence.ts b/src/plugin-sdk/anthropic-vertex-auth-presence.ts new file mode 100644 index 00000000000..436950da262 --- /dev/null +++ b/src/plugin-sdk/anthropic-vertex-auth-presence.ts @@ -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 + ); +} diff --git a/src/plugin-sdk/google-model-id.ts b/src/plugin-sdk/google-model-id.ts index 8d67c75b968..076fa1c9059 100644 --- a/src/plugin-sdk/google-model-id.ts +++ b/src/plugin-sdk/google-model-id.ts @@ -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; +} diff --git a/src/plugin-sdk/whatsapp-auth-presence.test.ts b/src/plugin-sdk/whatsapp-auth-presence.test.ts new file mode 100644 index 00000000000..9e4da9f8a9d --- /dev/null +++ b/src/plugin-sdk/whatsapp-auth-presence.test.ts @@ -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); + }); +}); diff --git a/src/plugin-sdk/whatsapp-auth-presence.ts b/src/plugin-sdk/whatsapp-auth-presence.ts index 66d3ccf3c36..08bb0a4f586 100644 --- a/src/plugin-sdk/whatsapp-auth-presence.ts +++ b/src/plugin-sdk/whatsapp-auth-presence.ts @@ -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, + 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([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)); +} diff --git a/src/plugin-sdk/xai-model-id.ts b/src/plugin-sdk/xai-model-id.ts index f637271fe90..9bf95e28a32 100644 --- a/src/plugin-sdk/xai-model-id.ts +++ b/src/plugin-sdk/xai-model-id.ts @@ -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({ - 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"]; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index dfb1098ca68..0bc69cd68a9 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 7b6cebb7490..bdd60abb9cd 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -280,6 +280,17 @@ function normalizeScopedPluginIds(ids?: string[]): string[] | undefined { return normalized.length > 0 ? normalized : undefined; } +function matchesScopedPluginRequest(params: { + onlyPluginIdSet: ReadonlySet | 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); diff --git a/src/plugins/provider-model-defaults.ts b/src/plugins/provider-model-defaults.ts index fbc45edaf16..9ce577fce10 100644 --- a/src/plugins/provider-model-defaults.ts +++ b/src/plugins/provider-model-defaults.ts @@ -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"; diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts new file mode 100644 index 00000000000..96f5a3950d0 --- /dev/null +++ b/src/tts/tts.test.ts @@ -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(); + return { + ...actual, + loadBundledPluginPublicSurfaceModuleSync, + }; +}); + +describe("tts runtime facade", () => { + let ttsModulePromise: Promise | 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); + }); +}); diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 965a36dc611..bb24aa062f1 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -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";