From 467dae53cf8d281c46b76aab37c13a5c30c2deac Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:31:44 +0000 Subject: [PATCH 01/10] Secrets: honor caller env during runtime validation --- src/agents/agent-paths.ts | 11 +++++------ src/agents/agent-scope.ts | 10 +++++++--- src/secrets/apply.test.ts | 3 +++ src/secrets/runtime-web-tools.test.ts | 27 +++++++++++++++++++++++++++ src/secrets/runtime-web-tools.ts | 12 +++++++----- src/secrets/runtime.ts | 19 +++++++++++++------ 6 files changed, 62 insertions(+), 20 deletions(-) diff --git a/src/agents/agent-paths.ts b/src/agents/agent-paths.ts index cfb874d3112..15861d145a1 100644 --- a/src/agents/agent-paths.ts +++ b/src/agents/agent-paths.ts @@ -3,14 +3,13 @@ import { resolveStateDir } from "../config/paths.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; -export function resolveOpenClawAgentDir(): string { - const override = - process.env.OPENCLAW_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim(); +export function resolveOpenClawAgentDir(env: NodeJS.ProcessEnv = process.env): string { + const override = env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim(); if (override) { - return resolveUserPath(override); + return resolveUserPath(override, env); } - const defaultAgentDir = path.join(resolveStateDir(), "agents", DEFAULT_AGENT_ID, "agent"); - return resolveUserPath(defaultAgentDir); + const defaultAgentDir = path.join(resolveStateDir(env), "agents", DEFAULT_AGENT_ID, "agent"); + return resolveUserPath(defaultAgentDir, env); } export function ensureOpenClawAgentEnv(): string { diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 5d190ce1eae..5425b033dca 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -327,12 +327,16 @@ export function resolveAgentIdByWorkspacePath( return resolveAgentIdsByWorkspacePath(cfg, workspacePath)[0]; } -export function resolveAgentDir(cfg: OpenClawConfig, agentId: string) { +export function resolveAgentDir( + cfg: OpenClawConfig, + agentId: string, + env: NodeJS.ProcessEnv = process.env, +) { const id = normalizeAgentId(agentId); const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim(); if (configured) { - return resolveUserPath(configured); + return resolveUserPath(configured, env); } - const root = resolveStateDir(process.env); + const root = resolveStateDir(env); return path.join(root, "agents", id, "agent"); } diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index 55d14c7e6d0..d71c98ac389 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { runSecretsApply } from "./apply.js"; import type { SecretsApplyPlan } from "./plan.js"; +import { clearSecretsRuntimeSnapshot } from "./runtime.js"; const OPENAI_API_KEY_ENV_REF = { source: "env", @@ -173,11 +174,13 @@ describe("secrets apply", () => { let fixture: ApplyFixture; beforeEach(async () => { + clearSecretsRuntimeSnapshot(); fixture = await createApplyFixture(); await seedDefaultApplyFixture(fixture); }); afterEach(async () => { + clearSecretsRuntimeSnapshot(); await fs.rm(fixture.rootDir, { recursive: true, force: true }); }); diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index 57e3e955066..c67f6af6573 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import * as webSearchProviders from "../plugins/web-search-providers.js"; import * as secretResolve from "./resolve.js"; import { createResolverContext } from "./runtime-shared.js"; import { resolveRuntimeWebTools } from "./runtime-web-tools.js"; @@ -88,6 +89,32 @@ describe("runtime web tools resolution", () => { vi.restoreAllMocks(); }); + it("skips loading web search providers when search config is absent", async () => { + const providerSpy = vi.spyOn(webSearchProviders, "resolvePluginWebSearchProviders"); + + const { metadata } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + fetch: { + firecrawl: { + apiKey: { source: "env", provider: "default", id: "FIRECRAWL_API_KEY_REF" }, + }, + }, + }, + }, + }), + env: { + FIRECRAWL_API_KEY: "firecrawl-runtime-key", // pragma: allowlist secret + }, + }); + + expect(providerSpy).not.toHaveBeenCalled(); + expect(metadata.search.providerSource).toBe("none"); + expect(metadata.fetch.firecrawl.active).toBe(true); + expect(metadata.fetch.firecrawl.apiKeySource).toBe("env"); + }); + it.each([ { provider: "brave" as const, diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 71b346cc462..fd32ecedf93 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -315,11 +315,13 @@ export async function resolveRuntimeWebTools(params: { const tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined; const web = isRecord(tools?.web) ? tools.web : undefined; const search = isRecord(web?.search) ? web.search : undefined; - const providers = resolvePluginWebSearchProviders({ - config: params.sourceConfig, - env: params.context.env, - bundledAllowlistCompat: true, - }); + const providers = search + ? resolvePluginWebSearchProviders({ + config: params.sourceConfig, + env: params.context.env, + bundledAllowlistCompat: true, + }) + : []; const searchMetadata: RuntimeWebSearchMetadata = { providerSource: "none", diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index 903fe5a6d24..ed85cde5a8d 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -79,11 +79,14 @@ function clearActiveSecretsRuntimeState(): void { clearRuntimeAuthProfileStoreSnapshots(); } -function collectCandidateAgentDirs(config: OpenClawConfig): string[] { +function collectCandidateAgentDirs( + config: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): string[] { const dirs = new Set(); - dirs.add(resolveUserPath(resolveOpenClawAgentDir())); + dirs.add(resolveUserPath(resolveOpenClawAgentDir(env), env)); for (const agentId of listAgentIds(config)) { - dirs.add(resolveUserPath(resolveAgentDir(config, agentId))); + dirs.add(resolveUserPath(resolveAgentDir(config, agentId, env), env)); } return [...dirs]; } @@ -92,7 +95,7 @@ function resolveRefreshAgentDirs( config: OpenClawConfig, context: SecretsRuntimeRefreshContext, ): string[] { - const configDerived = collectCandidateAgentDirs(config); + const configDerived = collectCandidateAgentDirs(config, context.env); if (!context.explicitAgentDirs || context.explicitAgentDirs.length === 0) { return configDerived; } @@ -119,8 +122,12 @@ export async function prepareSecretsRuntimeSnapshot(params: { const loadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreForSecretsRuntime; const candidateDirs = params.agentDirs?.length - ? [...new Set(params.agentDirs.map((entry) => resolveUserPath(entry)))] - : collectCandidateAgentDirs(resolvedConfig); + ? [ + ...new Set( + params.agentDirs.map((entry) => resolveUserPath(entry, params.env ?? process.env)), + ), + ] + : collectCandidateAgentDirs(resolvedConfig, params.env ?? process.env); const authStores: Array<{ agentDir: string; store: AuthProfileStore }> = []; for (const agentDir of candidateDirs) { From ad18866bcc616d132a1cec81e274824a5bce15ab Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:31:51 +0000 Subject: [PATCH 02/10] Tests: align Docker cache checks with non-root images --- src/docker-build-cache.test.ts | 74 +++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 19 deletions(-) diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts index 6f56ef4f5c7..31b2cd07717 100644 --- a/src/docker-build-cache.test.ts +++ b/src/docker-build-cache.test.ts @@ -9,6 +9,10 @@ async function readRepoFile(path: string): Promise { return readFile(resolve(repoRoot, path), "utf8"); } +function indexOfPattern(source: string, pattern: RegExp): number { + return source.search(pattern); +} + describe("docker build cache layout", () => { it("keeps the root dependency layer independent from scripts changes", async () => { const dockerfile = await readRepoFile("Dockerfile"); @@ -29,8 +33,11 @@ describe("docker build cache layout", () => { "scripts/docker/cleanup-smoke/Dockerfile", ]) { const dockerfile = await readRepoFile(path); - expect(dockerfile, `${path} should use a shared pnpm store cache`).toContain( - "--mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked", + expect( + dockerfile, + `${path} should use a shared pnpm store cache under the active user's home`, + ).toMatch( + /--mount=type=cache,id=openclaw-pnpm-store,target=\/(?:root|home\/appuser)\/\.local\/share\/pnpm\/store,sharing=locked/, ); } }); @@ -87,23 +94,41 @@ describe("docker build cache layout", () => { const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); expect( - dockerfile.indexOf("COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./"), - ).toBeLessThan(installIndex); - expect(dockerfile.indexOf("COPY ui/package.json ./ui/package.json")).toBeLessThan(installIndex); - expect( - dockerfile.indexOf( - "COPY extensions/memory-core/package.json ./extensions/memory-core/package.json", + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.\/$/m, ), ).toBeLessThan(installIndex); expect( - dockerfile.indexOf( - "COPY tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./", + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+ui\/package\.json \.\/ui\/package\.json$/m, + ), + ).toBeLessThan(installIndex); + expect( + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+extensions\/memory-core\/package\.json \.\/extensions\/memory-core\/package\.json$/m, + ), + ).toBeLessThan(installIndex); + expect( + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsdown\.config\.ts vitest\.config\.ts vitest\.e2e\.config\.ts openclaw\.mjs \.\/$/m, ), ).toBeGreaterThan(installIndex); - expect(dockerfile.indexOf("COPY src ./src")).toBeGreaterThan(installIndex); - expect(dockerfile.indexOf("COPY test ./test")).toBeGreaterThan(installIndex); - expect(dockerfile.indexOf("COPY scripts ./scripts")).toBeGreaterThan(installIndex); - expect(dockerfile.indexOf("COPY ui ./ui")).toBeGreaterThan(installIndex); + expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+src \.\/src$/m)).toBeGreaterThan( + installIndex, + ); + expect( + indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+test \.\/test$/m), + ).toBeGreaterThan(installIndex); + expect( + indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+scripts \.\/scripts$/m), + ).toBeGreaterThan(installIndex); + expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+ui \.\/ui$/m)).toBeGreaterThan( + installIndex, + ); }); it("copies manifests before install in the qr-import image", async () => { @@ -111,17 +136,28 @@ describe("docker build cache layout", () => { const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); expect( - dockerfile.indexOf("COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./"), + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.\/$/m, + ), + ).toBeLessThan(installIndex); + expect( + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+ui\/package\.json \.\/ui\/package\.json$/m, + ), ).toBeLessThan(installIndex); - expect(dockerfile.indexOf("COPY ui/package.json ./ui/package.json")).toBeLessThan(installIndex); expect(dockerfile).toContain( "This image only exercises the root qrcode-terminal dependency path.", ); expect( - dockerfile.indexOf( - "COPY extensions/memory-core/package.json ./extensions/memory-core/package.json", + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+extensions\/memory-core\/package\.json \.\/extensions\/memory-core\/package\.json$/m, ), ).toBe(-1); - expect(dockerfile.indexOf("COPY . .")).toBeGreaterThan(installIndex); + expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+\.\s+\.$/m)).toBeGreaterThan( + installIndex, + ); }); }); From c186176ca3ad3fd5567b29fd2f6a3e0bf280470b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:35:13 +0000 Subject: [PATCH 03/10] Plugin SDK: keep root alias reflection lazy --- src/plugin-sdk/root-alias.cjs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 12d98caf8a8..9f3ab45379f 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -169,9 +169,8 @@ rootExports = new Proxy(target, { }, ownKeys() { const keys = new Set(Reflect.ownKeys(target)); - const monolithic = getMonolithicSdk(); - if (monolithic) { - for (const key of Reflect.ownKeys(monolithic)) { + if (monolithicSdk && typeof monolithicSdk === "object") { + for (const key of Reflect.ownKeys(monolithicSdk)) { if (!keys.has(key)) { keys.add(key); } From 77566a14483cf38b359bc24166fd4e9b9131494c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:45:56 +0000 Subject: [PATCH 04/10] Providers: scope compat resolution to owning plugins --- .../auth-choice.plugin-providers.runtime.ts | 5 +++- .../auth-choice.plugin-providers.test.ts | 14 ++++++++++ .../local/auth-choice.plugin-providers.ts | 11 +++++++- src/plugins/providers.test.ts | 28 +++++++++++++++++++ src/plugins/providers.ts | 10 ++++++- 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts index a19d1861c7e..a02dd2f2ee2 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts @@ -1,2 +1,5 @@ export { resolveProviderPluginChoice } from "../../../plugins/provider-wizard.js"; -export { resolvePluginProviders } from "../../../plugins/providers.js"; +export { + resolveOwningPluginIdsForProvider, + resolvePluginProviders, +} from "../../../plugins/providers.js"; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts index 4e0f37e2882..f993091dd49 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts @@ -7,9 +7,11 @@ vi.mock("../../auth-choice.preferred-provider.js", () => ({ resolvePreferredProviderForAuthChoice, })); +const resolveOwningPluginIdsForProvider = vi.hoisted(() => vi.fn(() => undefined)); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn()); const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); vi.mock("./auth-choice.plugin-providers.runtime.js", () => ({ + resolveOwningPluginIdsForProvider, resolveProviderPluginChoice, resolvePluginProviders, PROVIDER_PLUGIN_CHOICE_PREFIX: "provider-plugin:", @@ -30,6 +32,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { it("loads plugin providers for provider-plugin auth choices", async () => { const runtime = createRuntime(); const runNonInteractive = vi.fn(async () => ({ plugins: { allow: ["vllm"] } })); + resolveOwningPluginIdsForProvider.mockReturnValue(["vllm"] as never); resolvePluginProviders.mockReturnValue([{ id: "vllm", pluginId: "vllm" }] as never); resolveProviderPluginChoice.mockReturnValue({ provider: { id: "vllm", pluginId: "vllm", label: "vLLM" }, @@ -46,7 +49,18 @@ describe("applyNonInteractivePluginProviderChoice", () => { toApiKeyCredential: vi.fn(), }); + expect(resolveOwningPluginIdsForProvider).toHaveBeenCalledOnce(); + expect(resolveOwningPluginIdsForProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "vllm", + }), + ); expect(resolvePluginProviders).toHaveBeenCalledOnce(); + expect(resolvePluginProviders).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["vllm"], + }), + ); expect(resolveProviderPluginChoice).toHaveBeenCalledOnce(); expect(runNonInteractive).toHaveBeenCalledOnce(); expect(result).toEqual({ plugins: { allow: ["vllm"] } }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index 8d9b820fc52..3f11a7367a9 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -79,11 +79,20 @@ export async function applyNonInteractivePluginProviderChoice(params: { params.nextConfig, preferredProviderId, ); - const { resolveProviderPluginChoice, resolvePluginProviders } = await loadPluginProviderRuntime(); + const { resolveOwningPluginIdsForProvider, resolveProviderPluginChoice, resolvePluginProviders } = + await loadPluginProviderRuntime(); + const owningPluginIds = preferredProviderId + ? resolveOwningPluginIdsForProvider({ + provider: preferredProviderId, + config: resolutionConfig, + workspaceDir, + }) + : undefined; const providerChoice = resolveProviderPluginChoice({ providers: resolvePluginProviders({ config: resolutionConfig, workspaceDir, + onlyPluginIds: owningPluginIds, bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }), diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 50530a3c051..bfc976a7abf 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -125,6 +125,34 @@ describe("resolvePluginProviders", () => { expect(allow).not.toContain("workspace-provider"); }); + it("scopes bundled provider compat expansion to the requested plugin ids", () => { + resolvePluginProviders({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + bundledProviderAllowlistCompat: true, + onlyPluginIds: ["moonshot"], + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["moonshot"], + config: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: expect.arrayContaining(["openrouter", "moonshot"]), + }), + }), + }), + ); + + const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0]; + const allow = call?.config?.plugins?.allow; + expect(allow).not.toContain("google"); + expect(allow).not.toContain("kilocode"); + }); + it("maps provider ids to owning plugin ids via manifests", () => { loadPluginManifestRegistryMock.mockReturnValue({ plugins: [ diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index f2a2b4497c9..90a2acedcad 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -62,14 +62,21 @@ function resolveBundledProviderCompatPluginIds(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; + onlyPluginIds?: string[]; }): string[] { + const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null; const registry = loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, }); return registry.plugins - .filter((plugin) => plugin.origin === "bundled" && plugin.providers.length > 0) + .filter( + (plugin) => + plugin.origin === "bundled" && + plugin.providers.length > 0 && + (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)), + ) .map((plugin) => plugin.id) .toSorted((left, right) => left.localeCompare(right)); } @@ -116,6 +123,7 @@ export function resolvePluginProviders(params: { config: params.config, workspaceDir: params.workspaceDir, env: params.env, + onlyPluginIds: params.onlyPluginIds, }) : []; const maybeAllowlistCompat = params.bundledProviderAllowlistCompat From 841025da66e00e9f4d881e32cfebd5764fb4f8ea Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:46:04 +0000 Subject: [PATCH 05/10] Plugin SDK: add narrow setup subpaths --- extensions/ollama/index.ts | 2 +- extensions/sglang/index.ts | 2 +- extensions/vllm/index.ts | 2 +- package.json | 8 +++++++ src/plugin-sdk/ollama-setup.ts | 17 ++++++++++++++ src/plugin-sdk/self-hosted-provider-setup.ts | 23 +++++++++++++++++++ src/plugin-sdk/subpaths.test.ts | 19 +++++++++++++++ .../contracts/discovery.contract.test.ts | 17 ++++++++++++++ 8 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 src/plugin-sdk/ollama-setup.ts create mode 100644 src/plugin-sdk/self-hosted-provider-setup.ts diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index f19c4cfbc0a..9f4e7eef1ea 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -13,7 +13,7 @@ const PROVIDER_ID = "ollama"; const DEFAULT_API_KEY = "ollama-local"; async function loadProviderSetup() { - return await import("openclaw/plugin-sdk/provider-setup"); + return await import("openclaw/plugin-sdk/ollama-setup"); } const ollamaPlugin = { diff --git a/extensions/sglang/index.ts b/extensions/sglang/index.ts index 38ecf508b07..fc7522ef15b 100644 --- a/extensions/sglang/index.ts +++ b/extensions/sglang/index.ts @@ -13,7 +13,7 @@ import { const PROVIDER_ID = "sglang"; async function loadProviderSetup() { - return await import("openclaw/plugin-sdk/provider-setup"); + return await import("openclaw/plugin-sdk/self-hosted-provider-setup"); } const sglangPlugin = { diff --git a/extensions/vllm/index.ts b/extensions/vllm/index.ts index 8ab11b9f1bc..938fb78c9bd 100644 --- a/extensions/vllm/index.ts +++ b/extensions/vllm/index.ts @@ -13,7 +13,7 @@ import { const PROVIDER_ID = "vllm"; async function loadProviderSetup() { - return await import("openclaw/plugin-sdk/provider-setup"); + return await import("openclaw/plugin-sdk/self-hosted-provider-setup"); } const vllmPlugin = { diff --git a/package.json b/package.json index 4f1c502b586..a0b5e9581df 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,10 @@ "types": "./dist/plugin-sdk/compat.d.ts", "default": "./dist/plugin-sdk/compat.js" }, + "./plugin-sdk/ollama-setup": { + "types": "./dist/plugin-sdk/ollama-setup.d.ts", + "default": "./dist/plugin-sdk/ollama-setup.js" + }, "./plugin-sdk/provider-setup": { "types": "./dist/plugin-sdk/provider-setup.d.ts", "default": "./dist/plugin-sdk/provider-setup.js" @@ -58,6 +62,10 @@ "types": "./dist/plugin-sdk/sandbox.d.ts", "default": "./dist/plugin-sdk/sandbox.js" }, + "./plugin-sdk/self-hosted-provider-setup": { + "types": "./dist/plugin-sdk/self-hosted-provider-setup.d.ts", + "default": "./dist/plugin-sdk/self-hosted-provider-setup.js" + }, "./plugin-sdk/routing": { "types": "./dist/plugin-sdk/routing.d.ts", "default": "./dist/plugin-sdk/routing.js" diff --git a/src/plugin-sdk/ollama-setup.ts b/src/plugin-sdk/ollama-setup.ts new file mode 100644 index 00000000000..5b6fd732774 --- /dev/null +++ b/src/plugin-sdk/ollama-setup.ts @@ -0,0 +1,17 @@ +export type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderAuthMethodNonInteractiveContext, + ProviderAuthResult, + ProviderDiscoveryContext, +} from "../plugins/types.js"; + +export { + OLLAMA_DEFAULT_BASE_URL, + OLLAMA_DEFAULT_MODEL, + configureOllamaNonInteractive, + ensureOllamaModelPulled, + promptAndConfigureOllama, +} from "../commands/ollama-setup.js"; + +export { buildOllamaProvider } from "../agents/models-config.providers.discovery.js"; diff --git a/src/plugin-sdk/self-hosted-provider-setup.ts b/src/plugin-sdk/self-hosted-provider-setup.ts new file mode 100644 index 00000000000..950bbbb953e --- /dev/null +++ b/src/plugin-sdk/self-hosted-provider-setup.ts @@ -0,0 +1,23 @@ +export type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderAuthMethodNonInteractiveContext, + ProviderAuthResult, + ProviderDiscoveryContext, +} from "../plugins/types.js"; + +export { + applyProviderDefaultModel, + configureOpenAICompatibleSelfHostedProviderNonInteractive, + discoverOpenAICompatibleSelfHostedProvider, + promptAndConfigureOpenAICompatibleSelfHostedProvider, + promptAndConfigureOpenAICompatibleSelfHostedProviderAuth, + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "../commands/self-hosted-provider-setup.js"; + +export { + buildSglangProvider, + buildVllmProvider, +} from "../agents/models-config.providers.discovery.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 813d3e28e1e..eff2820af79 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -11,8 +11,10 @@ import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as lineSdk from "openclaw/plugin-sdk/line"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; import * as nostrSdk from "openclaw/plugin-sdk/nostr"; +import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup"; import * as sandboxSdk from "openclaw/plugin-sdk/sandbox"; +import * as selfHostedProviderSetupSdk from "openclaw/plugin-sdk/self-hosted-provider-setup"; import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; @@ -62,6 +64,23 @@ describe("plugin-sdk subpath exports", () => { ); }); + it("exports narrow self-hosted provider setup helpers", () => { + expect(typeof selfHostedProviderSetupSdk.buildVllmProvider).toBe("function"); + expect(typeof selfHostedProviderSetupSdk.buildSglangProvider).toBe("function"); + expect(typeof selfHostedProviderSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe( + "function", + ); + expect( + typeof selfHostedProviderSetupSdk.configureOpenAICompatibleSelfHostedProviderNonInteractive, + ).toBe("function"); + }); + + it("exports narrow Ollama setup helpers", () => { + expect(typeof ollamaSetupSdk.buildOllamaProvider).toBe("function"); + expect(typeof ollamaSetupSdk.configureOllamaNonInteractive).toBe("function"); + expect(typeof ollamaSetupSdk.ensureOllamaModelPulled).toBe("function"); + }); + it("exports sandbox helpers from the dedicated subpath", () => { expect(typeof sandboxSdk.registerSandboxBackend).toBe("function"); expect(typeof sandboxSdk.runPluginCommandWithTimeout).toBe("function"); diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index a7576acdd5c..072e657616e 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -32,6 +32,23 @@ vi.mock("openclaw/plugin-sdk/provider-setup", async () => { }; }); +vi.mock("openclaw/plugin-sdk/self-hosted-provider-setup", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/self-hosted-provider-setup"); + return { + ...actual, + buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args), + buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args), + }; +}); + +vi.mock("openclaw/plugin-sdk/ollama-setup", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/ollama-setup"); + return { + ...actual, + buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args), + }; +}); + const qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default; const githubCopilotPlugin = (await import("../../../extensions/github-copilot/index.js")).default; const ollamaPlugin = (await import("../../../extensions/ollama/index.js")).default; From 7d4ccee7170ff8916d37389f7c3715ffc448d112 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:46:23 +0000 Subject: [PATCH 06/10] Plugin SDK: update entrypoint metadata --- scripts/lib/plugin-sdk-entrypoints.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 0bfef390abd..a6de3f4e24e 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -2,8 +2,10 @@ "index", "core", "compat", + "ollama-setup", "provider-setup", "sandbox", + "self-hosted-provider-setup", "routing", "telegram", "discord", From 80bef826f863668c9465f49d757295f0d7302536 Mon Sep 17 00:00:00 2001 From: Yauheni Shauchenka Date: Mon, 16 Mar 2026 15:49:24 +0300 Subject: [PATCH 07/10] fix(slack): harden bolt import interop (#45953) * fix(slack): harden bolt import interop * fix(slack): simplify bolt interop resolver * fix(slack): harden startup bolt interop * fix(slack): place changelog entry at section end --------- Co-authored-by: Ubuntu Co-authored-by: Altay --- CHANGELOG.md | 1 + .../src/monitor/provider.interop.test.ts | 94 +++++++++++++++++++ extensions/slack/src/monitor/provider.ts | 80 ++++++++++++++-- 3 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 extensions/slack/src/monitor/provider.interop.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a2873ccd64..2c2f0cc487a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai - Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc. - Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411) - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. +- Slack/startup: harden `@slack/bolt` import interop across current bundled runtime shapes so Slack monitors no longer crash with `App is not a constructor` after plugin-sdk bundling changes. (#45953) thanks @merc1305. ## 2026.3.13 diff --git a/extensions/slack/src/monitor/provider.interop.test.ts b/extensions/slack/src/monitor/provider.interop.test.ts new file mode 100644 index 00000000000..3e761cb45f1 --- /dev/null +++ b/extensions/slack/src/monitor/provider.interop.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./provider.js"; + +describe("resolveSlackBoltInterop", () => { + class FakeApp {} + class FakeHTTPReceiver {} + + it("uses the default import when it already exposes named exports", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: { + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }, + namespaceImport: {}, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("uses nested default export when the default import is a wrapper object", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: { + default: { + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }, + }, + namespaceImport: {}, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("uses the namespace receiver when the default import is the App constructor itself", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: FakeApp, + namespaceImport: { + HTTPReceiver: FakeHTTPReceiver, + }, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("uses namespace.default when it exposes named exports", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: undefined, + namespaceImport: { + default: { + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }, + }, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("falls back to the namespace import when it exposes named exports", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: undefined, + namespaceImport: { + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("throws when the module cannot be resolved", () => { + expect(() => + __testing.resolveSlackBoltInterop({ + defaultImport: null, + namespaceImport: {}, + }), + ).toThrow("Unable to resolve @slack/bolt App/HTTPReceiver exports"); + }); +}); diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 149d33bbf15..2104a5355cf 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import SlackBolt from "@slack/bolt"; +import SlackBolt, * as SlackBoltNamespace from "@slack/bolt"; import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../../../src/auto-reply/reply/history.js"; import { @@ -46,14 +46,77 @@ import { import { registerSlackMonitorSlashCommands } from "./slash.js"; import type { MonitorSlackOpts } from "./types.js"; -const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & { - default?: typeof import("@slack/bolt"); +type SlackAppConstructor = typeof import("@slack/bolt").App; +type SlackHttpReceiverConstructor = typeof import("@slack/bolt").HTTPReceiver; +type SlackBoltResolvedExports = { + App: SlackAppConstructor; + HTTPReceiver: SlackHttpReceiverConstructor; }; -// Bun allows named imports from CJS; Node ESM doesn't. Use default+fallback for compatibility. -// Fix: Check if module has App property directly (Node 25.x ESM/CJS compat issue) -const slackBolt = - (slackBoltModule.App ? slackBoltModule : slackBoltModule.default) ?? slackBoltModule; -const { App, HTTPReceiver } = slackBolt; +type Constructor = abstract new (...args: never[]) => unknown; + +function isConstructorFunction(value: unknown): value is T { + return typeof value === "function"; +} + +function resolveSlackBoltModule(value: unknown): SlackBoltResolvedExports | null { + if (!value || typeof value !== "object") { + return null; + } + const app = Reflect.get(value, "App"); + const httpReceiver = Reflect.get(value, "HTTPReceiver"); + if ( + !isConstructorFunction(app) || + !isConstructorFunction(httpReceiver) + ) { + return null; + } + return { + App: app, + HTTPReceiver: httpReceiver, + }; +} + +function resolveSlackBoltInterop(params: { + defaultImport: unknown; + namespaceImport: unknown; +}): SlackBoltResolvedExports { + const { defaultImport, namespaceImport } = params; + const nestedDefault = + defaultImport && typeof defaultImport === "object" + ? Reflect.get(defaultImport, "default") + : undefined; + const namespaceDefault = + namespaceImport && typeof namespaceImport === "object" + ? Reflect.get(namespaceImport, "default") + : undefined; + const namespaceReceiver = + namespaceImport && typeof namespaceImport === "object" + ? Reflect.get(namespaceImport, "HTTPReceiver") + : undefined; + const directModule = + resolveSlackBoltModule(defaultImport) ?? + resolveSlackBoltModule(nestedDefault) ?? + resolveSlackBoltModule(namespaceDefault) ?? + resolveSlackBoltModule(namespaceImport); + if (directModule) { + return directModule; + } + if ( + isConstructorFunction(defaultImport) && + isConstructorFunction(namespaceReceiver) + ) { + return { + App: defaultImport, + HTTPReceiver: namespaceReceiver, + }; + } + throw new TypeError("Unable to resolve @slack/bolt App/HTTPReceiver exports"); +} + +const { App, HTTPReceiver } = resolveSlackBoltInterop({ + defaultImport: SlackBolt, + namespaceImport: SlackBoltNamespace, +}); const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000; @@ -515,6 +578,7 @@ export const __testing = { publishSlackDisconnectedStatus, resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, + resolveSlackBoltInterop, getSocketEmitter, waitForSlackSocketDisconnect, }; From 8ad8069854de963cbd86635ee9c0d1550ccdb0cb Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:50:59 +0000 Subject: [PATCH 08/10] Tests: fix green check typing regressions --- ...rs-response-has-heartbeat-ok-but-includes.test.ts | 2 +- src/cron/isolated-agent.model-formatting.test.ts | 2 +- ...whatsapp-recipient-besteffortdeliver-true.test.ts | 2 +- ...d-agent.uses-last-non-empty-agent-text-as.test.ts | 10 ++-------- src/cron/isolated-agent/run.test-harness.ts | 2 +- src/media-understanding/apply.test.ts | 4 ++-- src/plugin-sdk/sandbox.ts | 1 + src/plugins/provider-runtime.test.ts | 12 ++++++++---- src/plugins/types.ts | 4 ++++ src/plugins/web-search-providers.ts | 4 ++-- 10 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts index 6316f394b9c..58450d3a650 100644 --- a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts +++ b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts @@ -73,7 +73,7 @@ async function runTelegramAnnounceTurn(params: { describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { - vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue(undefined); + vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue("off"); setupIsolatedAgentTurnMocks({ fast: true }); }); diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index c783247e6f3..5232d1349a6 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -126,7 +126,7 @@ async function expectInvalidModel(home: string, model: string) { describe("cron model formatting and precedence edge cases", () => { beforeEach(() => { - vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue(undefined); + vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue("off"); vi.mocked(runEmbeddedPiAgent).mockClear(); vi.mocked(loadModelCatalog).mockResolvedValue([]); }); diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 639c207459e..9a5adcc2627 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -262,7 +262,7 @@ async function assertExplicitTelegramTargetDelivery(params: { describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { - vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue(undefined); + vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue("off"); setupIsolatedAgentTurnMocks(); }); diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index 5830029d9e7..e7804835054 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -16,10 +16,6 @@ import { } from "./isolated-agent.test-harness.js"; import type { CronJob } from "./types.js"; -let resolveThinkingDefaultSpy: ReturnType< - typeof vi.spyOn ->; - function makeDeps(): CliDeps { return { sendMessageSlack: vi.fn(), @@ -168,9 +164,7 @@ async function runStoredOverrideAndExpectModel(params: { describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { - resolveThinkingDefaultSpy = vi - .spyOn(modelSelection, "resolveThinkingDefault") - .mockReturnValue(undefined); + vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue("off"); vi.mocked(runEmbeddedPiAgent).mockClear(); vi.mocked(loadModelCatalog).mockResolvedValue([]); }); @@ -513,7 +507,7 @@ describe("runCronIsolatedAgentTurn", () => { it("passes through the resolved default thinking level", async () => { await withTempHome(async (home) => { - resolveThinkingDefaultSpy.mockReturnValueOnce("low"); + vi.mocked(modelSelection.resolveThinkingDefault).mockReturnValueOnce("low"); await runCronTurn(home, { jobPayload: DEFAULT_AGENT_TURN_PAYLOAD, diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 81e4c8b902b..0e9ac3c6069 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -363,7 +363,7 @@ export function resetRunCronIsolatedAgentTurnHarness(): void { resolveConfiguredModelRefMock.mockReturnValue({ provider: "openai", model: "gpt-4" }); resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }); resolveHooksGmailModelMock.mockReturnValue(null); - resolveThinkingDefaultMock.mockReturnValue(undefined); + resolveThinkingDefaultMock.mockReturnValue("off"); getModelRefStatusMock.mockReturnValue({ allowed: false }); isCliProviderMock.mockReturnValue(false); diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index b979a2ac4ad..7058cef6bb1 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -13,14 +13,14 @@ import { clearMediaUnderstandingBinaryCacheForTests } from "./runner.js"; import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js"; const resolveApiKeyForProviderMock = vi.hoisted(() => - vi.fn(async () => ({ + vi.fn(async () => ({ apiKey: "test-key", // pragma: allowlist secret source: "test", mode: "api-key", })), ); const hasAvailableAuthForProviderMock = vi.hoisted(() => - vi.fn(async (...args: unknown[]) => { + vi.fn(async (...args: Parameters) => { const resolved = await resolveApiKeyForProviderMock(...args); return Boolean(resolved?.apiKey); }), diff --git a/src/plugin-sdk/sandbox.ts b/src/plugin-sdk/sandbox.ts index 245fa9f6b83..ce349fb9de5 100644 --- a/src/plugin-sdk/sandbox.ts +++ b/src/plugin-sdk/sandbox.ts @@ -19,6 +19,7 @@ export type { SshSandboxSession, SshSandboxSettings, } from "../agents/sandbox.js"; +export type { OpenClawConfig } from "../config/config.js"; export { buildExecRemoteCommand, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index dec7be0b53d..a41cb52727f 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -1,9 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin, ProviderRuntimeModel } from "./types.js"; -const resolvePluginProvidersMock = vi.fn((_: unknown) => [] as ProviderPlugin[]); -const resolveOwningPluginIdsForProviderMock = vi.fn( - (_: unknown) => undefined as string[] | undefined, +type ResolvePluginProviders = typeof import("./providers.js").resolvePluginProviders; +type ResolveOwningPluginIdsForProvider = + typeof import("./providers.js").resolveOwningPluginIdsForProvider; + +const resolvePluginProvidersMock = vi.fn((_) => [] as ProviderPlugin[]); +const resolveOwningPluginIdsForProviderMock = vi.fn( + (_) => undefined as string[] | undefined, ); vi.mock("./providers.js", () => ({ @@ -98,7 +102,7 @@ describe("provider-runtime", () => { }); it("dispatches runtime hooks for the matched provider", async () => { - resolveOwningPluginIdsForProviderMock.mockImplementation((params: { provider?: string }) => { + resolveOwningPluginIdsForProviderMock.mockImplementation((params) => { if (params.provider === "demo") { return ["demo"]; } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index b9b6e801214..0c817a99cf8 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -849,6 +849,10 @@ export type WebSearchProviderPlugin = { createTool: (ctx: WebSearchProviderContext) => WebSearchProviderToolDefinition | null; }; +export type PluginWebSearchProviderEntry = WebSearchProviderPlugin & { + pluginId: string; +}; + export type OpenClawPluginGatewayMethod = { method: string; handler: GatewayRequestHandler; diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 8ff3c90c932..97b6d9ee022 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -12,7 +12,7 @@ import { } from "./bundled-compat.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; -import type { WebSearchProviderPlugin } from "./types.js"; +import type { PluginWebSearchProviderEntry } from "./types.js"; const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "brave", @@ -114,7 +114,7 @@ export function resolvePluginWebSearchProviders(params: { workspaceDir?: string; env?: PluginLoadOptions["env"]; bundledAllowlistCompat?: boolean; -}): WebSearchProviderPlugin[] { +}): PluginWebSearchProviderEntry[] { const allowlistCompat = params.bundledAllowlistCompat ? withBundledPluginAllowlistCompat({ config: params.config, From 55253e2a9d12bdce0b14c1b9aa0e9e400799c128 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:56:09 +0000 Subject: [PATCH 09/10] Plugins: avoid booting bundled providers for catalog hooks --- extensions/openai/openai-codex-provider.ts | 2 +- extensions/openai/openai-provider.ts | 2 +- extensions/xai/index.ts | 2 +- src/agents/model-catalog.test-harness.ts | 3 + src/agents/model-suppression.ts | 2 +- src/plugins/provider-catalog-metadata.ts | 97 ++++++++++++++++++ src/plugins/provider-runtime.test.ts | 51 ++++++++++ src/plugins/provider-runtime.ts | 111 +++++++++++++++++++-- src/plugins/providers.ts | 30 +++++- 9 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 src/plugins/provider-catalog-metadata.ts diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index c0ae2c12210..999c37c6204 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -10,8 +10,8 @@ import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js" import type { OAuthCredential } from "../../src/agents/auth-profiles/types.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { normalizeProviderId } from "../../src/agents/model-selection.js"; import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js"; +import { normalizeProviderId } from "../../src/agents/provider-id.js"; import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js"; import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js"; import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 9155fb3cd30..9c93ec1bd27 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -3,7 +3,7 @@ import { type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { normalizeProviderId } from "../../src/agents/model-selection.js"; +import { normalizeProviderId } from "../../src/agents/provider-id.js"; import { applyOpenAIConfig, OPENAI_DEFAULT_MODEL, diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 98731023653..c9f3bcdf4de 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -1,4 +1,4 @@ -import { normalizeProviderId } from "../../src/agents/model-selection.js"; +import { normalizeProviderId } from "../../src/agents/provider-id.js"; import { createPluginBackedWebSearchProvider, getScopedCredentialValue, diff --git a/src/agents/model-catalog.test-harness.ts b/src/agents/model-catalog.test-harness.ts index 0c4633d6748..4343cfc40e6 100644 --- a/src/agents/model-catalog.test-harness.ts +++ b/src/agents/model-catalog.test-harness.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, vi } from "vitest"; +import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js"; import { __setModelCatalogImportForTest, resetModelCatalogCacheForTest } from "./model-catalog.js"; export type PiSdkModule = typeof import("./pi-model-discovery.js"); @@ -14,11 +15,13 @@ vi.mock("./agent-paths.js", () => ({ export function installModelCatalogTestHooks() { beforeEach(() => { resetModelCatalogCacheForTest(); + resetProviderRuntimeHookCacheForTest(); }); afterEach(() => { __setModelCatalogImportForTest(); resetModelCatalogCacheForTest(); + resetProviderRuntimeHookCacheForTest(); vi.restoreAllMocks(); }); } diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index ac1dcccdb74..48927e6d5f3 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -1,5 +1,5 @@ import { resolveProviderBuiltInModelSuppression } from "../plugins/provider-runtime.js"; -import { normalizeProviderId } from "./model-selection.js"; +import { normalizeProviderId } from "./provider-id.js"; function resolveBuiltInModelSuppression(params: { provider?: string | null; id?: string | null }) { const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? ""); diff --git a/src/plugins/provider-catalog-metadata.ts b/src/plugins/provider-catalog-metadata.ts new file mode 100644 index 00000000000..123fef24289 --- /dev/null +++ b/src/plugins/provider-catalog-metadata.ts @@ -0,0 +1,97 @@ +import { normalizeProviderId } from "../agents/provider-id.js"; +import type { + ProviderAugmentModelCatalogContext, + ProviderBuiltInModelSuppressionContext, +} from "./types.js"; + +const OPENAI_PROVIDER_ID = "openai"; +const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; +const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; +const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]); + +function findCatalogTemplate(params: { + entries: ReadonlyArray<{ provider: string; id: string }>; + providerId: string; + templateIds: readonly string[]; +}) { + return params.templateIds + .map((templateId) => + params.entries.find( + (entry) => + entry.provider.toLowerCase() === params.providerId.toLowerCase() && + entry.id.toLowerCase() === templateId.toLowerCase(), + ), + ) + .find((entry) => entry !== undefined); +} + +export function resolveBundledProviderBuiltInModelSuppression( + context: ProviderBuiltInModelSuppressionContext, +) { + if ( + !SUPPRESSED_SPARK_PROVIDERS.has(normalizeProviderId(context.provider)) || + context.modelId.toLowerCase() !== OPENAI_DIRECT_SPARK_MODEL_ID + ) { + return undefined; + } + return { + suppress: true, + errorMessage: `Unknown model: ${context.provider}/${OPENAI_DIRECT_SPARK_MODEL_ID}. ${OPENAI_DIRECT_SPARK_MODEL_ID} is only supported via openai-codex OAuth. Use openai-codex/${OPENAI_DIRECT_SPARK_MODEL_ID}.`, + }; +} + +export function augmentBundledProviderCatalog( + context: ProviderAugmentModelCatalogContext, +): ProviderAugmentModelCatalogContext["entries"] { + const openAiGpt54Template = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_PROVIDER_ID, + templateIds: ["gpt-5.2"], + }); + const openAiGpt54ProTemplate = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_PROVIDER_ID, + templateIds: ["gpt-5.2-pro", "gpt-5.2"], + }); + const openAiCodexGpt54Template = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_CODEX_PROVIDER_ID, + templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"], + }); + const openAiCodexSparkTemplate = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_CODEX_PROVIDER_ID, + templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"], + }); + + return [ + openAiGpt54Template + ? { + ...openAiGpt54Template, + id: "gpt-5.4", + name: "gpt-5.4", + } + : undefined, + openAiGpt54ProTemplate + ? { + ...openAiGpt54ProTemplate, + id: "gpt-5.4-pro", + name: "gpt-5.4-pro", + } + : undefined, + openAiCodexGpt54Template + ? { + ...openAiCodexGpt54Template, + id: "gpt-5.4", + name: "gpt-5.4", + } + : undefined, + openAiCodexSparkTemplate + ? { + ...openAiCodexSparkTemplate, + id: OPENAI_DIRECT_SPARK_MODEL_ID, + name: OPENAI_DIRECT_SPARK_MODEL_ID, + } + : undefined, + ].filter((entry): entry is NonNullable => entry !== undefined); +} diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index a41cb52727f..07ee1794562 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -2,16 +2,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin, ProviderRuntimeModel } from "./types.js"; type ResolvePluginProviders = typeof import("./providers.js").resolvePluginProviders; +type ResolveNonBundledProviderPluginIds = + typeof import("./providers.js").resolveNonBundledProviderPluginIds; type ResolveOwningPluginIdsForProvider = typeof import("./providers.js").resolveOwningPluginIdsForProvider; const resolvePluginProvidersMock = vi.fn((_) => [] as ProviderPlugin[]); +const resolveNonBundledProviderPluginIdsMock = vi.fn( + (_) => [] as string[], +); const resolveOwningPluginIdsForProviderMock = vi.fn( (_) => undefined as string[] | undefined, ); vi.mock("./providers.js", () => ({ resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), + resolveNonBundledProviderPluginIds: (params: unknown) => + resolveNonBundledProviderPluginIdsMock(params as never), resolveOwningPluginIdsForProvider: (params: unknown) => resolveOwningPluginIdsForProviderMock(params as never), })); @@ -34,6 +41,7 @@ import { normalizeProviderResolvedModelWithPlugin, prepareProviderDynamicModel, prepareProviderRuntimeAuth, + resetProviderRuntimeHookCacheForTest, refreshProviderOAuthCredentialWithPlugin, resolveProviderRuntimePlugin, runProviderDynamicModel, @@ -55,8 +63,11 @@ const MODEL: ProviderRuntimeModel = { describe("provider-runtime", () => { beforeEach(() => { + resetProviderRuntimeHookCacheForTest(); resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); + resolveNonBundledProviderPluginIdsMock.mockReset(); + resolveNonBundledProviderPluginIdsMock.mockReturnValue([]); resolveOwningPluginIdsForProviderMock.mockReset(); resolveOwningPluginIdsForProviderMock.mockReturnValue(undefined); }); @@ -454,4 +465,44 @@ describe("provider-runtime", () => { expect(resolveUsageAuth).toHaveBeenCalledTimes(1); expect(fetchUsageSnapshot).toHaveBeenCalledTimes(1); }); + + it("resolves bundled catalog hooks without loading provider plugins", async () => { + expect( + resolveProviderBuiltInModelSuppression({ + env: process.env, + context: { + env: process.env, + provider: "openai", + modelId: "gpt-5.3-codex-spark", + }, + }), + ).toMatchObject({ + suppress: true, + }); + + await expect( + augmentModelCatalogWithProviderPlugins({ + env: process.env, + context: { + env: process.env, + entries: [ + { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, + { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, + { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + ], + }, + }), + ).resolves.toEqual([ + { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, + { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, + { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, + { + provider: "openai-codex", + id: "gpt-5.3-codex-spark", + name: "gpt-5.3-codex-spark", + }, + ]); + + expect(resolvePluginProvidersMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 3d1bd77f6d9..61a2a0c5792 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -1,7 +1,15 @@ import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-profiles/types.js"; -import { normalizeProviderId } from "../agents/model-selection.js"; +import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js"; +import { + augmentBundledProviderCatalog, + resolveBundledProviderBuiltInModelSuppression, +} from "./provider-catalog-metadata.js"; +import { + resolveNonBundledProviderPluginIds, + resolveOwningPluginIdsForProvider, + resolvePluginProviders, +} from "./providers.js"; import type { ProviderAuthDoctorHintContext, ProviderAugmentModelCatalogContext, @@ -33,19 +41,104 @@ function matchesProviderId(provider: ProviderPlugin, providerId: string): boolea return (provider.aliases ?? []).some((alias) => normalizeProviderId(alias) === normalized); } +let cachedHookProvidersWithoutConfig = new WeakMap< + NodeJS.ProcessEnv, + Map +>(); +let cachedHookProvidersByConfig = new WeakMap< + OpenClawConfig, + WeakMap> +>(); + +function resolveHookProviderCacheBucket(params: { + config?: OpenClawConfig; + env: NodeJS.ProcessEnv; +}) { + if (!params.config) { + let bucket = cachedHookProvidersWithoutConfig.get(params.env); + if (!bucket) { + bucket = new Map(); + cachedHookProvidersWithoutConfig.set(params.env, bucket); + } + return bucket; + } + + let envBuckets = cachedHookProvidersByConfig.get(params.config); + if (!envBuckets) { + envBuckets = new WeakMap>(); + cachedHookProvidersByConfig.set(params.config, envBuckets); + } + let bucket = envBuckets.get(params.env); + if (!bucket) { + bucket = new Map(); + envBuckets.set(params.env, bucket); + } + return bucket; +} + +function buildHookProviderCacheKey(params: { workspaceDir?: string; onlyPluginIds?: string[] }) { + return `${params.workspaceDir ?? ""}::${JSON.stringify(params.onlyPluginIds ?? [])}`; +} + +export function resetProviderRuntimeHookCacheForTest(): void { + cachedHookProvidersWithoutConfig = new WeakMap< + NodeJS.ProcessEnv, + Map + >(); + cachedHookProvidersByConfig = new WeakMap< + OpenClawConfig, + WeakMap> + >(); +} + function resolveProviderPluginsForHooks(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; onlyPluginIds?: string[]; }): ProviderPlugin[] { - return resolvePluginProviders({ + const env = params.env ?? process.env; + const cacheBucket = resolveHookProviderCacheBucket({ + config: params.config, + env, + }); + const cacheKey = buildHookProviderCacheKey({ + workspaceDir: params.workspaceDir, + onlyPluginIds: params.onlyPluginIds, + }); + const cached = cacheBucket.get(cacheKey); + if (cached) { + return cached; + } + const resolved = resolvePluginProviders({ ...params, + env, activate: false, cache: false, bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }); + cacheBucket.set(cacheKey, resolved); + return resolved; +} + +function resolveProviderPluginsForCatalogHooks(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderPlugin[] { + const onlyPluginIds = resolveNonBundledProviderPluginIds({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + if (onlyPluginIds.length === 0) { + return []; + } + return resolveProviderPluginsForHooks({ + ...params, + onlyPluginIds, + }); } export function resolveProviderRuntimePlugin(params: { @@ -265,7 +358,11 @@ export function resolveProviderBuiltInModelSuppression(params: { env?: NodeJS.ProcessEnv; context: ProviderBuiltInModelSuppressionContext; }) { - for (const plugin of resolveProviderPluginsForHooks(params)) { + const bundledResult = resolveBundledProviderBuiltInModelSuppression(params.context); + if (bundledResult?.suppress) { + return bundledResult; + } + for (const plugin of resolveProviderPluginsForCatalogHooks(params)) { const result = plugin.suppressBuiltInModel?.(params.context); if (result?.suppress) { return result; @@ -280,8 +377,10 @@ export async function augmentModelCatalogWithProviderPlugins(params: { env?: NodeJS.ProcessEnv; context: ProviderAugmentModelCatalogContext; }) { - const supplemental = [] as ProviderAugmentModelCatalogContext["entries"]; - for (const plugin of resolveProviderPluginsForHooks(params)) { + const supplemental = [ + ...augmentBundledProviderCatalog(params.context), + ] as ProviderAugmentModelCatalogContext["entries"]; + for (const plugin of resolveProviderPluginsForCatalogHooks(params)) { const next = await plugin.augmentModelCatalog?.(params.context); if (!next || next.length === 0) { continue; diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 90a2acedcad..35ef2703553 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -1,6 +1,7 @@ -import { normalizeProviderId } from "../agents/model-selection.js"; +import { normalizeProviderId } from "../agents/provider-id.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { withBundledPluginAllowlistCompat } from "./bundled-compat.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; @@ -106,6 +107,33 @@ export function resolveOwningPluginIdsForProvider(params: { return pluginIds.length > 0 ? pluginIds : undefined; } +export function resolveNonBundledProviderPluginIds(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): string[] { + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const normalizedConfig = normalizePluginsConfig(params.config?.plugins); + return registry.plugins + .filter( + (plugin) => + plugin.origin !== "bundled" && + plugin.providers.length > 0 && + resolveEffectiveEnableState({ + id: plugin.id, + origin: plugin.origin, + config: normalizedConfig, + rootConfig: params.config, + }).enabled, + ) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); +} + export function resolvePluginProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; From 1b31ede435bb5f07d87a5570d07e5d0d2dd5cccf Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Mar 2026 18:26:33 +0530 Subject: [PATCH 10/10] fix: bypass telegram runtime proxy during health checks --- extensions/telegram/src/channel.test.ts | 61 ++++++++++++++----------- extensions/telegram/src/channel.ts | 14 +++--- 2 files changed, 43 insertions(+), 32 deletions(-) diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 476260f2969..7c810cd2226 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -4,10 +4,13 @@ import type { OpenClawConfig, PluginRuntime, } from "openclaw/plugin-sdk/telegram"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import type { ResolvedTelegramAccount } from "./accounts.js"; +import * as auditModule from "./audit.js"; import { telegramPlugin } from "./channel.js"; +import * as monitorModule from "./monitor.js"; +import * as probeModule from "./probe.js"; import { setTelegramRuntime } from "./runtime.js"; function createCfg(): OpenClawConfig { @@ -53,32 +56,34 @@ function createStartAccountCtx(params: { } function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: string }) { - const monitorTelegramProvider = vi.fn(async () => undefined); - const probeTelegram = vi.fn(async () => - params?.probeOk ? { ok: true, bot: { username: params.botUsername ?? "bot" } } : { ok: false }, - ); - const collectUnmentionedGroupIds = vi.fn(() => ({ - groupIds: [] as string[], - unresolvedGroups: 0, - hasWildcardUnmentionedGroups: false, - })); - const auditGroupMembership = vi.fn(async () => ({ - ok: true, - checkedGroups: 0, - unresolvedGroups: 0, - hasWildcardUnmentionedGroups: false, - groups: [], - elapsedMs: 0, - })); + const monitorTelegramProvider = vi + .spyOn(monitorModule, "monitorTelegramProvider") + .mockImplementation(async () => undefined); + const probeTelegram = vi + .spyOn(probeModule, "probeTelegram") + .mockImplementation(async () => + params?.probeOk + ? { ok: true, bot: { username: params.botUsername ?? "bot" } } + : { ok: false }, + ); + const collectUnmentionedGroupIds = vi + .spyOn(auditModule, "collectTelegramUnmentionedGroupIds") + .mockImplementation(() => ({ + groupIds: [] as string[], + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + })); + const auditGroupMembership = vi + .spyOn(auditModule, "auditTelegramGroupMembership") + .mockImplementation(async () => ({ + ok: true, + checkedGroups: 0, + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + groups: [], + elapsedMs: 0, + })); setTelegramRuntime({ - channel: { - telegram: { - monitorTelegramProvider, - probeTelegram, - collectUnmentionedGroupIds, - auditGroupMembership, - }, - }, logging: { shouldLogVerbose: () => false, }, @@ -115,6 +120,10 @@ function installSendMessageRuntime( return sendMessageTelegram; } +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("telegramPlugin duplicate token guard", () => { it("marks secondary account as not configured when token is shared", async () => { const cfg = createCfg(); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index cfb5e8a5f8d..5b3ce7279c6 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -47,15 +47,17 @@ import { type ResolvedTelegramAccount, } from "./accounts.js"; import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; +import { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./audit.js"; import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import { isTelegramExecApprovalClientEnabled, resolveTelegramExecApprovalTarget, } from "./exec-approvals.js"; +import { monitorTelegramProvider } from "./monitor.js"; import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js"; import { sendTelegramPayloadMessages } from "./outbound-adapter.js"; import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; -import type { TelegramProbe } from "./probe.js"; +import { probeTelegram, type TelegramProbe } from "./probe.js"; import { getTelegramRuntime } from "./runtime.js"; import { sendTypingTelegram } from "./send.js"; import { telegramSetupAdapter } from "./setup-core.js"; @@ -697,7 +699,7 @@ export const telegramPlugin: ChannelPlugin buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => - getTelegramRuntime().channel.telegram.probeTelegram(account.token, timeoutMs, { + probeTelegram(account.token, timeoutMs, { accountId: account.accountId, proxyUrl: account.config.proxy, network: account.config.network, @@ -731,7 +733,7 @@ export const telegramPlugin: ChannelPlugin