diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index de72181e22e..5d3803384d7 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -44,6 +44,18 @@ async function expectVertexAdcEnvApiKey(params: { } } +function testModelDefinition(id: string): Model { + return { + id, + name: id, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8192, + }; +} + vi.mock("../plugins/setup-registry.js", async () => { const { readFileSync } = await import("node:fs"); return { @@ -627,6 +639,51 @@ describe("getApiKeyForModel", () => { } }); + it("reuses runtime auth availability for provider auth checks", () => { + const store = { version: 1 as const, profiles: {} }; + const localNoKeyConfig = { + models: { + providers: { + vllm: { + api: "openai-completions", + baseUrl: "http://127.0.0.1:8000/v1", + models: [testModelDefinition("meta-llama/Meta-Llama-3-8B-Instruct")], + }, + remote: { + api: "openai-completions", + baseUrl: "https://remote.example.com/v1", + models: [testModelDefinition("remote-model")], + }, + }, + }, + } as OpenClawConfig; + + expect( + hasAuthForModelProvider({ + provider: "amazon-bedrock", + cfg: {} as OpenClawConfig, + env: {}, + store, + }), + ).toBe(true); + expect( + hasAuthForModelProvider({ + provider: "vllm", + cfg: localNoKeyConfig, + env: {}, + store, + }), + ).toBe(true); + expect( + hasAuthForModelProvider({ + provider: "remote", + cfg: localNoKeyConfig, + env: {}, + store, + }), + ).toBe(false); + }); + it("hasAvailableAuthForProvider('google') accepts GOOGLE_API_KEY fallback", async () => { await withEnvAsync( { diff --git a/src/agents/model-provider-auth.test.ts b/src/agents/model-provider-auth.test.ts deleted file mode 100644 index 05c4aa05492..00000000000 --- a/src/agents/model-provider-auth.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { AuthProfileStore } from "./auth-profiles.js"; -import { hasAuthForModelProvider } from "./model-provider-auth.js"; - -const emptyStore: AuthProfileStore = { - version: 1, - profiles: {}, -}; - -function modelDefinition(id: string) { - return { - id, - name: id, - reasoning: false, - input: ["text" as const], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 8192, - }; -} - -describe("model provider auth availability", () => { - it("accepts implicit Bedrock AWS SDK auth without an API key", () => { - expect( - hasAuthForModelProvider({ - provider: "amazon-bedrock", - cfg: {} as OpenClawConfig, - env: {}, - store: emptyStore, - }), - ).toBe(true); - }); - - it("accepts local no-key custom providers", () => { - const cfg = { - models: { - providers: { - vllm: { - api: "openai-completions", - baseUrl: "http://127.0.0.1:8000/v1", - models: [modelDefinition("meta-llama/Meta-Llama-3-8B-Instruct")], - }, - }, - }, - } as OpenClawConfig; - - expect( - hasAuthForModelProvider({ - provider: "vllm", - cfg, - env: {}, - store: emptyStore, - }), - ).toBe(true); - }); - - it("keeps remote no-key custom providers unavailable", () => { - const cfg = { - models: { - providers: { - remote: { - api: "openai-completions", - baseUrl: "https://remote.example.com/v1", - models: [modelDefinition("remote-model")], - }, - }, - }, - } as OpenClawConfig; - - expect( - hasAuthForModelProvider({ - provider: "remote", - cfg, - env: {}, - store: emptyStore, - }), - ).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-runner/run/attempt-system-prompt.test.ts b/src/agents/pi-embedded-runner/run/attempt-system-prompt.test.ts index 889cc4ae206..c67ecd6783b 100644 --- a/src/agents/pi-embedded-runner/run/attempt-system-prompt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt-system-prompt.test.ts @@ -11,11 +11,16 @@ const baseProviderTransform = { }, }; +const transformProviderSystemPrompt: Parameters< + typeof buildAttemptSystemPrompt +>[0]["transformProviderSystemPrompt"] = ({ context }) => context.systemPrompt; + describe("buildAttemptSystemPrompt", () => { it("preserves bootstrap Project Context when a system prompt override is configured", () => { const result = buildAttemptSystemPrompt({ isRawModelRun: false, systemPromptOverrideText: "Custom override prompt.", + transformProviderSystemPrompt, embeddedSystemPrompt: { workspaceDir: "/tmp/openclaw", reasoningTagHint: false, @@ -59,6 +64,7 @@ describe("buildAttemptSystemPrompt", () => { it("omits system prompts for raw model probes", () => { const result = buildAttemptSystemPrompt({ isRawModelRun: true, + transformProviderSystemPrompt, embeddedSystemPrompt: { workspaceDir: "/tmp/openclaw", reasoningTagHint: false, diff --git a/src/agents/pi-embedded-runner/run/attempt-system-prompt.ts b/src/agents/pi-embedded-runner/run/attempt-system-prompt.ts index 85ffa422316..41c33f7b633 100644 --- a/src/agents/pi-embedded-runner/run/attempt-system-prompt.ts +++ b/src/agents/pi-embedded-runner/run/attempt-system-prompt.ts @@ -1,15 +1,21 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { transformProviderSystemPrompt } from "../../../plugins/provider-runtime.js"; import type { ProviderTransformSystemPromptContext } from "../../../plugins/types.js"; import { appendAgentBootstrapSystemPromptSupplement } from "../../system-prompt.js"; import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js"; type EmbeddedSystemPromptParams = Parameters[0]; +type ProviderSystemPromptTransform = (params: { + provider: string; + config?: OpenClawConfig; + workspaceDir: string; + context: ProviderTransformSystemPromptContext; +}) => string; export type BuildAttemptSystemPromptParams = { isRawModelRun: boolean; systemPromptOverrideText?: string; embeddedSystemPrompt: EmbeddedSystemPromptParams; + transformProviderSystemPrompt: ProviderSystemPromptTransform; providerTransform: { provider: string; config?: OpenClawConfig; @@ -38,7 +44,7 @@ export function buildAttemptSystemPrompt( const systemPrompt = params.isRawModelRun ? "" - : transformProviderSystemPrompt({ + : params.transformProviderSystemPrompt({ provider: params.providerTransform.provider, config: params.providerTransform.config, workspaceDir: params.providerTransform.workspaceDir, diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index eb98b3c36b2..3f6c64a2bb7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -22,6 +22,7 @@ import { resolveEmbeddedAgentStreamFn, resolveUnknownToolGuardThreshold, shouldCreateBundleMcpRuntimeForAttempt, + shouldBuildCoreCodingToolsForAllowlist, resolveAttemptToolPolicyMessageProvider, resolvePromptBuildHookResult, resolvePromptModeForSession, @@ -81,6 +82,15 @@ describe("applyEmbeddedAttemptToolsAllow", () => { applyEmbeddedAttemptToolsAllow(tools, [" cron ", "READ"]).map((tool) => tool.name), ).toEqual(["cron", "read"]); }); + + it("keeps plugin-only allowlists on the shared tool policy path", () => { + const tools = [{ name: "memory_search" }, { name: "plugin_extra" }]; + + expect(shouldBuildCoreCodingToolsForAllowlist(["memory_search"])).toBe(false); + expect( + applyEmbeddedAttemptToolsAllow(tools, ["memory_search"]).map((tool) => tool.name), + ).toEqual(["memory_search"]); + }); }); describe("buildEmbeddedAttemptToolRunContext", () => { diff --git a/src/agents/pi-embedded-runner/run/attempt.tools-allow-regression.test.ts b/src/agents/pi-embedded-runner/run/attempt.tools-allow-regression.test.ts deleted file mode 100644 index 0cd18a83fb8..00000000000 --- a/src/agents/pi-embedded-runner/run/attempt.tools-allow-regression.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - cleanupTempPaths, - createContextEngineAttemptRunner, - getHoisted, - resetEmbeddedAttemptHarness, -} from "./attempt.spawn-workspace.test-support.js"; - -describe("runEmbeddedAttempt toolsAllow startup cost", () => { - const tempPaths: string[] = []; - - beforeEach(() => { - resetEmbeddedAttemptHarness(); - }); - - afterEach(async () => { - await cleanupTempPaths(tempPaths); - }); - - it("keeps plugin-only allowlists on the shared tool policy path", async () => { - const hoisted = getHoisted(); - hoisted.createOpenClawCodingToolsMock.mockReturnValue([ - { - name: "memory_search", - description: "search memory", - parameters: { type: "object", properties: {} }, - execute: async () => "ok", - }, - { - name: "plugin_extra", - description: "extra plugin tool", - parameters: { type: "object", properties: {} }, - execute: async () => "ok", - }, - ]); - - await createContextEngineAttemptRunner({ - contextEngine: { - assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }), - }, - attemptOverrides: { - toolsAllow: ["memory_search"], - }, - sessionKey: "agent:main:main", - tempPaths, - }); - - expect(hoisted.createOpenClawCodingToolsMock).toHaveBeenCalledWith( - expect.objectContaining({ - includeCoreTools: false, - runtimeToolAllowlist: ["memory_search"], - }), - ); - const createSessionOptions = hoisted.createAgentSessionMock.mock.calls[0]?.[0] as - | { customTools?: { name: string }[] } - | undefined; - expect(createSessionOptions?.customTools?.map((tool) => tool.name)).toEqual(["memory_search"]); - }); -}); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 2b7f2322cf9..a8ab0ac9bc4 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -32,6 +32,7 @@ import { import { resolveProviderSystemPromptContribution, resolveProviderTextTransforms, + transformProviderSystemPrompt, } from "../../../plugins/provider-runtime.js"; import { getPluginToolMeta } from "../../../plugins/tools.js"; import { isAcpSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js"; @@ -525,7 +526,7 @@ const CORE_CODING_TOOL_ALLOWLIST_NAMES = new Set([ "write", ]); -function shouldBuildCoreCodingToolsForAllowlist(toolsAllow?: string[]): boolean { +export function shouldBuildCoreCodingToolsForAllowlist(toolsAllow?: string[]): boolean { if (!toolsAllow || toolsAllow.length === 0) { return true; } @@ -1291,6 +1292,7 @@ export async function runEmbeddedAttempt( const attemptSystemPrompt = buildAttemptSystemPrompt({ isRawModelRun, systemPromptOverrideText, + transformProviderSystemPrompt, embeddedSystemPrompt: { workspaceDir: effectiveWorkspace, defaultThinkLevel: params.thinkLevel, diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index f6a7e548bb7..8e422ce2647 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../../config/types.js"; -import { migrateLegacyConfig } from "./legacy-config-migrate.js"; import { LEGACY_CONFIG_MIGRATIONS } from "./legacy-config-migrations.js"; function migrateLegacyConfigForTest(raw: unknown): { @@ -211,38 +210,6 @@ describe("legacy migrate mention routing", () => { }); describe("legacy migrate sandbox scope aliases", () => { - it("returns migrated config when unrelated plugin validation issues remain (#76798)", () => { - const res = migrateLegacyConfig({ - agents: { - defaults: { - model: { primary: "openai/gpt-5.5" }, - llm: { idleTimeoutSeconds: 120 }, - }, - }, - plugins: { - entries: { - brave: { - enabled: true, - config: { webSearch: { mode: "definitely-invalid" } }, - }, - }, - }, - tools: { web: { search: { provider: "brave" } } }, - }); - - expect(res.partiallyValid).toBe(true); - expect(res.changes).toContain( - "Removed agents.defaults.llm; model idle timeout now follows models.providers..timeoutSeconds.", - ); - expect(res.changes).toContain( - "Migration applied; other validation issues remain — run doctor to review.", - ); - expect(res.config?.agents?.defaults).toEqual({ - model: { primary: "openai/gpt-5.5" }, - }); - expect(res.config?.tools?.web?.search?.provider).toBe("brave"); - }); - it("removes legacy agents.defaults.llm timeout config", () => { const res = migrateLegacyConfigForTest({ agents: { diff --git a/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts b/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts new file mode 100644 index 00000000000..fb470a1e94e --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { migrateLegacyConfig } from "./legacy-config-migrate.js"; + +describe("legacy config migrate validation", () => { + it("returns migrated config when unrelated plugin validation issues remain (#76798)", () => { + const res = migrateLegacyConfig({ + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + llm: { idleTimeoutSeconds: 120 }, + }, + }, + plugins: { + entries: { + brave: { + enabled: true, + config: { webSearch: { mode: "definitely-invalid" } }, + }, + }, + }, + tools: { web: { search: { provider: "brave" } } }, + }); + + expect(res.partiallyValid).toBe(true); + expect(res.changes).toContain( + "Removed agents.defaults.llm; model idle timeout now follows models.providers..timeoutSeconds.", + ); + expect(res.changes).toContain( + "Migration applied; other validation issues remain — run doctor to review.", + ); + expect(res.config?.agents?.defaults).toEqual({ + model: { primary: "openai/gpt-5.5" }, + }); + expect(res.config?.tools?.web?.search?.provider).toBe("brave"); + }); +}); diff --git a/src/commands/doctor/shared/legacy-web-search-migrate.test.ts b/src/commands/doctor/shared/legacy-web-search-migrate.test.ts index a28f51d22ee..ac3dfafe34c 100644 --- a/src/commands/doctor/shared/legacy-web-search-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-web-search-migrate.test.ts @@ -1,28 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; - -vi.mock("../../../plugins/plugin-metadata-snapshot.js", () => ({ - loadPluginMetadataSnapshot: () => ({ - plugins: [ - { - id: "brave", - origin: "bundled", - contracts: { webSearchProviders: ["brave"] }, - }, - { - id: "xai", - origin: "bundled", - contracts: { webSearchProviders: ["grok"] }, - }, - { - id: "moonshot", - origin: "bundled", - contracts: { webSearchProviders: ["kimi"] }, - }, - ], - }), -})); - import { listLegacyWebSearchConfigPaths, migrateLegacyWebSearchConfig, diff --git a/src/commands/doctor/shared/legacy-web-search-migrate.ts b/src/commands/doctor/shared/legacy-web-search-migrate.ts index 6de4fddd7df..70fcae186ea 100644 --- a/src/commands/doctor/shared/legacy-web-search-migrate.ts +++ b/src/commands/doctor/shared/legacy-web-search-migrate.ts @@ -1,5 +1,4 @@ import { mergeMissing } from "../../../config/legacy.shared.js"; -import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js"; import { cloneRecord, ensureRecord, @@ -10,24 +9,28 @@ import { const MODERN_SCOPED_WEB_SEARCH_KEYS = new Set(["openaiCodex"]); +const BUNDLED_LEGACY_WEB_SEARCH_OWNERS = new Map([ + ["brave", "brave"], + ["duckduckgo", "duckduckgo"], + ["exa", "exa"], + ["firecrawl", "firecrawl"], + ["gemini", "google"], + ["grok", "xai"], + ["kimi", "moonshot"], + ["minimax", "minimax"], + ["ollama", "ollama"], + ["perplexity", "perplexity"], + ["searxng", "searxng"], + ["tavily", "tavily"], +]); + // Tavily only ever used the plugin-owned config path, so there is no legacy // `tools.web.search.tavily.*` shape to migrate. const NON_MIGRATED_LEGACY_WEB_SEARCH_PROVIDER_IDS = new Set(["tavily"]); const LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID = "brave"; function getBundledLegacyWebSearchOwners(): ReadonlyMap { - const owners = new Map(); - for (const plugin of loadManifestMetadataSnapshot({ config: {}, env: process.env }).plugins) { - if (plugin.origin !== "bundled") { - continue; - } - for (const providerId of plugin.contracts?.webSearchProviders ?? []) { - if (!owners.has(providerId)) { - owners.set(providerId, plugin.id); - } - } - } - return owners; + return BUNDLED_LEGACY_WEB_SEARCH_OWNERS; } function getLegacyWebSearchProviderIds( diff --git a/src/image-generation/provider-registry.test.ts b/src/image-generation/provider-registry.test.ts index c2a544ed173..420a89a7093 100644 --- a/src/image-generation/provider-registry.test.ts +++ b/src/image-generation/provider-registry.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.js"; import type { ImageGenerationProviderPlugin } from "../plugins/types.js"; -import type * as ProviderRegistry from "./provider-registry.js"; +import { getImageGenerationProvider, listImageGenerationProviders } from "./provider-registry.js"; const { resolvePluginCapabilityProvidersMock } = vi.hoisted(() => ({ resolvePluginCapabilityProvidersMock: vi.fn<() => ImageGenerationProviderPlugin[]>(() => []), @@ -11,9 +11,6 @@ vi.mock("../plugins/capability-provider-runtime.js", () => ({ resolvePluginCapabilityProviders: resolvePluginCapabilityProvidersMock, })); -let getImageGenerationProvider: typeof ProviderRegistry.getImageGenerationProvider; -let listImageGenerationProviders: typeof ProviderRegistry.listImageGenerationProviders; - function createProvider( params: Pick & Partial, ): ImageGenerationProviderPlugin { @@ -31,12 +28,9 @@ function createProvider( } describe("image-generation provider registry", () => { - beforeEach(async () => { - vi.resetModules(); + beforeEach(() => { resolvePluginCapabilityProvidersMock.mockReset(); resolvePluginCapabilityProvidersMock.mockReturnValue([]); - ({ getImageGenerationProvider, listImageGenerationProviders } = - await import("./provider-registry.js")); }); it("delegates provider resolution to the capability provider boundary", () => { diff --git a/src/video-generation/provider-registry.test.ts b/src/video-generation/provider-registry.test.ts index 108dc0c376d..380a10bdc74 100644 --- a/src/video-generation/provider-registry.test.ts +++ b/src/video-generation/provider-registry.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { VideoGenerationProviderPlugin } from "../plugins/types.js"; +import { getVideoGenerationProvider, listVideoGenerationProviders } from "./provider-registry.js"; const { resolvePluginCapabilityProvidersMock } = vi.hoisted(() => ({ resolvePluginCapabilityProvidersMock: vi.fn<() => VideoGenerationProviderPlugin[]>(() => []), @@ -22,20 +23,13 @@ function createProvider( }; } -async function loadProviderRegistry() { - vi.resetModules(); - return await import("./provider-registry.js"); -} describe("video-generation provider registry", () => { beforeEach(() => { - vi.resetModules(); resolvePluginCapabilityProvidersMock.mockReset(); resolvePluginCapabilityProvidersMock.mockReturnValue([]); }); - it("delegates provider resolution to the capability provider boundary", async () => { - const { listVideoGenerationProviders } = await loadProviderRegistry(); - + it("delegates provider resolution to the capability provider boundary", () => { expect(listVideoGenerationProviders()).toEqual([]); expect(resolvePluginCapabilityProvidersMock).toHaveBeenCalledWith({ key: "videoGenerationProviders", @@ -43,9 +37,8 @@ describe("video-generation provider registry", () => { }); }); - it("uses active plugin providers without loading from disk", async () => { + it("uses active plugin providers without loading from disk", () => { resolvePluginCapabilityProvidersMock.mockReturnValue([createProvider({ id: "custom-video" })]); - const { getVideoGenerationProvider } = await loadProviderRegistry(); const provider = getVideoGenerationProvider("custom-video"); @@ -56,13 +49,11 @@ describe("video-generation provider registry", () => { }); }); - it("ignores prototype-like provider ids and aliases", async () => { + it("ignores prototype-like provider ids and aliases", () => { resolvePluginCapabilityProvidersMock.mockReturnValue([ createProvider({ id: "__proto__", aliases: ["constructor", "prototype"] }), createProvider({ id: "safe-video", aliases: ["safe-alias", "constructor"] }), ]); - const { getVideoGenerationProvider, listVideoGenerationProviders } = - await loadProviderRegistry(); expect(listVideoGenerationProviders().map((provider) => provider.id)).toEqual(["safe-video"]); expect(getVideoGenerationProvider("__proto__")).toBeUndefined();