From fb580b551ef6f954b8e49b8290c80064dd2afd92 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 03:47:57 +0100 Subject: [PATCH] fix: restore provider and config compatibility checks --- extensions/github-copilot/index.ts | 24 +-- extensions/kimi-coding/index.ts | 28 ++- extensions/openrouter/index.ts | 15 +- extensions/zalo/src/secret-contract.ts | 14 +- package.json | 20 +-- scripts/lib/plugin-sdk-entrypoints.json | 1 - ...subagents.sessions-spawn.lifecycle.test.ts | 66 ++++--- src/agents/subagent-announce.test.ts | 3 +- .../subagent-registry.steer-restart.test.ts | 36 +++- ...irective.directive-behavior.e2e-harness.ts | 62 +++++++ src/config/io.ts | 23 ++- src/config/io.write-config.test.ts | 2 + src/config/schema.base.generated.ts | 5 + src/config/validation.ts | 4 +- .../bundled-capability-metadata.test.ts | 2 + ...undled-provider-auth-env-vars.generated.ts | 3 +- .../contracts/speech-vitest-registry.ts | 2 +- .../runtime-config-collectors-plugins.ts | 2 +- src/secrets/target-registry-data.ts | 1 + test/extension-test-boundary.test.ts | 1 + .../plugins/provider-discovery-contract.ts | 169 +++++++++++------- 21 files changed, 330 insertions(+), 153 deletions(-) diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 872f61c2d8c..00fb005c222 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -1,21 +1,19 @@ import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry"; import { coerceSecretRef, - DEFAULT_COPILOT_API_BASE_URL, ensureAuthProfileStore, - fetchCopilotUsage, - githubCopilotLoginCommand, listProfilesForProvider, - PROVIDER_ID, - resolveCopilotApiToken, - resolveCopilotForwardCompatModel, - wrapCopilotProviderStream, -} from "./register.runtime.js"; +} from "openclaw/plugin-sdk/provider-auth"; +import { PROVIDER_ID, resolveCopilotForwardCompatModel } from "./models.js"; import { buildGithubCopilotReplayPolicy } from "./replay-policy.js"; +import { wrapCopilotProviderStream } from "./stream.js"; const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]; const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.2", "gpt-5.2-codex"] as const; +async function loadGithubCopilotRuntime() { + return await import("./register.runtime.js"); +} export default definePluginEntry({ id: "github-copilot", name: "GitHub Copilot Provider", @@ -56,6 +54,7 @@ export default definePluginEntry({ } async function runGitHubCopilotAuth(ctx: ProviderAuthContext) { + const { githubCopilotLoginCommand } = await loadGithubCopilotRuntime(); await ctx.prompter.note( [ "This will open a GitHub device login to authorize Copilot.", @@ -126,6 +125,8 @@ export default definePluginEntry({ catalog: { order: "late", run: async (ctx) => { + const { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } = + await loadGithubCopilotRuntime(); const { githubToken, hasProfile } = resolveFirstGithubToken({ agentDir: ctx.agentDir, env: ctx.env, @@ -159,6 +160,7 @@ export default definePluginEntry({ supportsXHighThinking: ({ modelId }) => COPILOT_XHIGH_MODEL_IDS.includes(modelId.trim().toLowerCase() as never), prepareRuntimeAuth: async (ctx) => { + const { resolveCopilotApiToken } = await loadGithubCopilotRuntime(); const token = await resolveCopilotApiToken({ githubToken: ctx.apiKey, env: ctx.env, @@ -170,8 +172,10 @@ export default definePluginEntry({ }; }, resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), - fetchUsageSnapshot: async (ctx) => - await fetchCopilotUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), + fetchUsageSnapshot: async (ctx) => { + const { fetchCopilotUsage } = await loadGithubCopilotRuntime(); + return await fetchCopilotUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn); + }, }); }, }); diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index 007874b75f5..74f1fd3bcc1 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -1,5 +1,7 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; +import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; +import type { SecretInput } from "openclaw/plugin-sdk/secret-input"; import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "./onboard.js"; import { buildKimiCodingProvider } from "./provider-catalog.js"; import { KIMI_REPLAY_POLICY } from "./replay-policy.js"; @@ -12,6 +14,25 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +function findExplicitProviderConfig( + providers: Record | undefined, + providerId: string, +): Record | undefined { + if (!providers) { + return undefined; + } + const normalizedProviderId = normalizeProviderId(providerId); + const match = Object.entries(providers).find( + ([configuredProviderId]) => normalizeProviderId(configuredProviderId) === normalizedProviderId, + ); + return isRecord(match?.[1]) ? match[1] : undefined; +} + +function buildKimiReplayPolicy() { + return { + preserveSignatures: false, + }; +} export default definePluginEntry({ id: PLUGIN_ID, name: "Kimi Provider", @@ -57,12 +78,15 @@ export default definePluginEntry({ if (!apiKey) { return null; } - const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; + const explicitProvider = findExplicitProviderConfig( + ctx.config.models?.providers as Record | undefined, + PROVIDER_ID, + ); const builtInProvider = buildKimiCodingProvider(); const explicitBaseUrl = typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; const explicitHeaders = isRecord(explicitProvider?.headers) - ? explicitProvider.headers + ? (explicitProvider.headers as Record) : undefined; return { provider: { diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index 3adb06a098d..b629890f6b8 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -4,17 +4,19 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/plugin-entry"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { - applyOpenrouterConfig, - buildOpenrouterProvider, buildProviderReplayFamilyHooks, - createProviderApiKeyAuthMethod, DEFAULT_CONTEXT_TOKENS, +} from "openclaw/plugin-sdk/provider-model-shared"; +import { + buildProviderStreamFamilyHooks, getOpenRouterModelCapabilities, loadOpenRouterModelCapabilities, - OPENROUTER_DEFAULT_MODEL_REF, - openrouterMediaUnderstandingProvider, -} from "./register.runtime.js"; +} from "openclaw/plugin-sdk/provider-stream"; +import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js"; +import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildOpenrouterProvider } from "./provider-catalog.js"; import { wrapOpenRouterProviderStream } from "./stream.js"; const PROVIDER_ID = "openrouter"; @@ -35,6 +37,7 @@ export default definePluginEntry({ const PASSTHROUGH_GEMINI_REPLAY_HOOKS = buildProviderReplayFamilyHooks({ family: "passthrough-gemini", }); + const OPENROUTER_THINKING_STREAM_HOOKS = buildProviderStreamFamilyHooks("openrouter-thinking"); function buildDynamicOpenRouterModel( ctx: ProviderResolveDynamicModelContext, ): ProviderRuntimeModel { diff --git a/extensions/zalo/src/secret-contract.ts b/extensions/zalo/src/secret-contract.ts index 60048075429..8a928321cb1 100644 --- a/extensions/zalo/src/secret-contract.ts +++ b/extensions/zalo/src/secret-contract.ts @@ -64,9 +64,6 @@ export function collectRuntimeConfigAssignments(params: { return; } const { channel: zalo, surface } = resolved; - const baseTokenFile = typeof zalo.tokenFile === "string" ? zalo.tokenFile.trim() : ""; - const accountTokenFile = (account: Record) => - typeof account.tokenFile === "string" ? account.tokenFile.trim() : ""; collectConditionalChannelFieldAssignments({ channelKey: "zalo", field: "botToken", @@ -74,13 +71,12 @@ export function collectRuntimeConfigAssignments(params: { surface, defaults: params.defaults, context: params.context, - topLevelActiveWithoutAccounts: baseTokenFile.length === 0, + topLevelActiveWithoutAccounts: true, topLevelInheritedAccountActive: ({ account, enabled }) => - enabled && !hasOwnProperty(account, "botToken") && accountTokenFile(account).length === 0, - accountActive: ({ account, enabled }) => enabled && accountTokenFile(account).length === 0, - topInactiveReason: - "no enabled Zalo surface inherits this top-level botToken (tokenFile is configured).", - accountInactiveReason: "Zalo account is disabled or tokenFile is configured.", + enabled && !hasOwnProperty(account, "botToken"), + accountActive: ({ enabled }) => enabled, + topInactiveReason: "no enabled Zalo surface inherits this top-level botToken.", + accountInactiveReason: "Zalo account is disabled.", }); const baseWebhookUrl = typeof zalo.webhookUrl === "string" ? zalo.webhookUrl.trim() : ""; const accountWebhookUrl = (account: Record) => diff --git a/package.json b/package.json index 41d323648f8..8464b85a1b5 100644 --- a/package.json +++ b/package.json @@ -119,10 +119,6 @@ "types": "./dist/plugin-sdk/config-runtime.d.ts", "default": "./dist/plugin-sdk/config-runtime.js" }, - "./plugin-sdk/telegram-command-config": { - "types": "./dist/plugin-sdk/telegram-command-config.d.ts", - "default": "./dist/plugin-sdk/telegram-command-config.js" - }, "./plugin-sdk/config-schema": { "types": "./dist/plugin-sdk/config-schema.d.ts", "default": "./dist/plugin-sdk/config-schema.js" @@ -511,10 +507,6 @@ "types": "./dist/plugin-sdk/channel-targets.d.ts", "default": "./dist/plugin-sdk/channel-targets.js" }, - "./plugin-sdk/messaging-targets": { - "types": "./dist/plugin-sdk/messaging-targets.d.ts", - "default": "./dist/plugin-sdk/messaging-targets.js" - }, "./plugin-sdk/feishu": { "types": "./dist/plugin-sdk/feishu.d.ts", "default": "./dist/plugin-sdk/feishu.js" @@ -595,18 +587,22 @@ "types": "./dist/plugin-sdk/reply-history.d.ts", "default": "./dist/plugin-sdk/reply-history.js" }, - "./plugin-sdk/realtime-voice": { - "types": "./dist/plugin-sdk/realtime-voice.d.ts", - "default": "./dist/plugin-sdk/realtime-voice.js" - }, "./plugin-sdk/realtime-transcription": { "types": "./dist/plugin-sdk/realtime-transcription.d.ts", "default": "./dist/plugin-sdk/realtime-transcription.js" }, + "./plugin-sdk/realtime-voice": { + "types": "./dist/plugin-sdk/realtime-voice.d.ts", + "default": "./dist/plugin-sdk/realtime-voice.js" + }, "./plugin-sdk/media-understanding": { "types": "./dist/plugin-sdk/media-understanding.d.ts", "default": "./dist/plugin-sdk/media-understanding.js" }, + "./plugin-sdk/messaging-targets": { + "types": "./dist/plugin-sdk/messaging-targets.d.ts", + "default": "./dist/plugin-sdk/messaging-targets.js" + }, "./plugin-sdk/request-url": { "types": "./dist/plugin-sdk/request-url.d.ts", "default": "./dist/plugin-sdk/request-url.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 12dcf8b0257..0dbe2df10b0 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -19,7 +19,6 @@ "approval-reply-runtime", "approval-runtime", "config-runtime", - "telegram-command-config", "config-schema", "reply-runtime", "reply-dispatch-runtime", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index d2e4fdcb34f..c39fe30530a 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loadConfig } from "../config/config.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import "./test-helpers/fast-core-tools.js"; @@ -14,7 +14,10 @@ import { setSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; import { resolveRequesterStoreKey } from "./subagent-announce-delivery.js"; -import { resetSubagentRegistryForTests } from "./subagent-registry.js"; +import { + getLatestSubagentRunByChildSessionKey, + resetSubagentRegistryForTests, +} from "./subagent-registry.js"; const fastModeEnv = vi.hoisted(() => { const previous = process.env.OPENCLAW_TEST_FAST; @@ -204,6 +207,13 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { installDeterministicAnnounceFlow(); }); + afterEach(() => { + resetSessionsSpawnAnnounceFlowOverride(); + resetSessionsSpawnHookRunnerOverride(); + resetSessionsSpawnConfigOverride(); + resetSubagentRegistryForTests(); + }); + afterAll(() => { if (fastModeEnv.previous === undefined) { delete process.env.OPENCLAW_TEST_FAST; @@ -391,29 +401,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); }); - it("sessions_spawn reports timed out when agent.wait returns timeout", async () => { - let announcedStatus: string | undefined; - setSessionsSpawnAnnounceFlowOverride(async (params) => { - announcedStatus = params.outcome?.status; - const requesterSessionKey = resolveRequesterStoreKey( - loadConfig(), - params.requesterSessionKey, - ); - - await callGatewayMock({ - method: "agent", - params: { - sessionKey: requesterSessionKey, - message: `subagent task ${ - params.outcome?.status === "timeout" ? "timed out" : "completed successfully" - }`, - deliver: false, - }, - }); - - return true; - }); - + it("sessions_spawn records timeout when agent.wait returns timeout", async () => { const ctx = setupSessionsSpawnGatewayMock({ includeChatHistory: true, chatHistoryText: "still working", @@ -428,19 +416,25 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expectsCompletionMessage: false, }); - await waitFor(() => announcedStatus === "timeout"); + const child = ctx.getChild(); + if (!child.runId) { + throw new Error("missing child runId"); + } + if (!child.sessionKey) { + throw new Error("missing child sessionKey"); + } + const childSessionKey = child.sessionKey; - const mainMessages = ctx.calls - .filter((call) => call.method === "agent") - .filter((call) => { - const params = call.params as { lane?: string } | undefined; - return params?.lane !== "subagent"; - }) - .map((call) => (call.params as { message?: string } | undefined)?.message ?? ""); + await waitFor(() => { + return ( + ctx.waitCalls.some((call) => call.runId === child.runId) && + getLatestSubagentRunByChildSessionKey(childSessionKey)?.outcome?.status === "timeout" + ); + }, 20_000); - expect(announcedStatus).toBe("timeout"); - expect(mainMessages.some((message) => message.includes("timed out"))).toBe(true); - expect(mainMessages.some((message) => message.includes("completed successfully"))).toBe(false); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); + expect(childWait?.timeoutMs).toBe(1000); + expect(getLatestSubagentRunByChildSessionKey(childSessionKey)?.outcome?.status).toBe("timeout"); }); it("sessions_spawn announces with requester accountId", async () => { diff --git a/src/agents/subagent-announce.test.ts b/src/agents/subagent-announce.test.ts index 2822e266e72..f9b196de21f 100644 --- a/src/agents/subagent-announce.test.ts +++ b/src/agents/subagent-announce.test.ts @@ -399,7 +399,7 @@ describe("subagent announce seam flow", () => { expect(params.threadId).toBeUndefined(); }); - it("inherits session lastChannel/lastTo for completion announce when requesterOrigin lacks to", async () => { + it("falls back to stored delivery target when mocked completion origins omit to", async () => { loadSessionStoreMock.mockImplementation(() => ({ "agent:main:main": { sessionId: "session-tg-group", @@ -434,6 +434,7 @@ describe("subagent announce seam flow", () => { expect.objectContaining({ deliver: true, channel: "telegram", + accountId: "bot:123", to: "-1001234567890", }), ); diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index 6fb58f73e18..cb095360744 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -67,9 +67,14 @@ const announceSpy = vi.fn(async (_params: unknown) => true); const runSubagentEndedHookMock = vi.fn(async (_event?: unknown, _ctx?: unknown) => {}); const emitSessionLifecycleEventMock = vi.fn(); vi.mock("./subagent-announce.js", () => ({ + captureSubagentCompletionReply: vi.fn(async () => undefined), runSubagentAnnounceFlow: announceSpy, })); +vi.mock("../browser-lifecycle-cleanup.js", () => ({ + cleanupBrowserSessionsForLifecycleEnd: vi.fn(async () => {}), +})); + vi.mock("../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: vi.fn(() => ({ hasHooks: (hookName: string) => hookName === "subagent_ended", @@ -294,13 +299,15 @@ describe("subagent registry steer restarts", () => { emitLifecycleEnd("run-completion-delayed"); - await flushAnnounce(); + await vi.waitFor(() => { + expect(announceSpy).toHaveBeenCalledTimes(1); + }); expect(runSubagentEndedHookMock).not.toHaveBeenCalled(); resolveAnnounce(true); - await flushAnnounce(); - - expect(runSubagentEndedHookMock).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(runSubagentEndedHookMock).toHaveBeenCalledTimes(1); + }); expect(runSubagentEndedHookMock).toHaveBeenCalledWith( expect.objectContaining({ targetSessionKey: "agent:main:subagent:completion-delayed", @@ -551,7 +558,26 @@ describe("subagent registry steer restarts", () => { expect(run?.outcome).toEqual({ status: "error", error: "manual kill" }); expect(run?.cleanupHandled).toBe(true); expect(typeof run?.cleanupCompletedAt).toBe("number"); - expect(runSubagentEndedHookMock).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(runSubagentEndedHookMock).toHaveBeenCalledWith( + { + targetSessionKey: childSessionKey, + targetKind: "subagent", + reason: "subagent-killed", + sendFarewell: true, + accountId: undefined, + runId: "run-killed", + endedAt: expect.any(Number), + outcome: "killed", + error: "manual kill", + }, + { + runId: "run-killed", + childSessionKey, + requesterSessionKey: MAIN_REQUESTER_SESSION_KEY, + }, + ); + }); }); it("treats a child session as inactive when only a stale older row is still unended", async () => { diff --git a/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts b/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts index 27a5e269952..c4a19f0a21e 100644 --- a/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts +++ b/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts @@ -5,6 +5,10 @@ import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles.j import { resetSkillsRefreshForTest } from "../agents/skills/refresh.js"; import { clearSessionStoreCacheForTest, loadSessionStore } from "../config/sessions.js"; import { resetSystemEventsForTest } from "../infra/system-events.js"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import type { PluginProviderRegistration } from "../plugins/registry.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; +import type { ProviderPlugin } from "../plugins/types.js"; import { loadModelCatalogMock, runEmbeddedPiAgentMock, @@ -20,11 +24,66 @@ export const DEFAULT_TEST_MODEL_CATALOG: Array<{ }> = [ { id: "claude-opus-4-6", name: "Opus 4.5", provider: "anthropic" }, { id: "claude-sonnet-4-1", name: "Sonnet 4.1", provider: "anthropic" }, + { id: "gpt-5.4", name: "GPT-5.4", provider: "openai" }, + { id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai" }, + { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", provider: "openai" }, + { id: "gpt-5.4-nano", name: "GPT-5.4 Nano", provider: "openai" }, + { id: "gpt-5.4", name: "GPT-5.4 (Codex)", provider: "openai-codex" }, + { id: "gpt-5.4-mini", name: "GPT-5.4 Mini (Codex)", provider: "openai-codex" }, { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, ]; export type ReplyPayloadText = { text?: string | null } | null | undefined; +const OPENAI_XHIGH_MODEL_IDS = [ + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.2", +] as const; + +const OPENAI_CODEX_XHIGH_MODEL_IDS = [ + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.2-codex", + "gpt-5.1-codex", +] as const; + +function createThinkingPolicyProvider( + providerId: string, + xhighModelIds: readonly string[], +): ProviderPlugin { + return { + id: providerId, + label: providerId, + auth: [], + supportsXHighThinking: ({ modelId }) => xhighModelIds.includes(modelId.trim().toLowerCase()), + }; +} + +function createDirectiveBehaviorProviderRegistry(): ReturnType { + const registry = createEmptyPluginRegistry(); + const providers: PluginProviderRegistration[] = [ + { + pluginId: "openai", + pluginName: "OpenAI Provider", + source: "test", + provider: createThinkingPolicyProvider("openai", OPENAI_XHIGH_MODEL_IDS), + }, + { + pluginId: "openai", + pluginName: "OpenAI Provider", + source: "test", + provider: createThinkingPolicyProvider("openai-codex", OPENAI_CODEX_XHIGH_MODEL_IDS), + }, + ]; + registry.providers.push(...providers); + return registry; +} + export function replyText(res: ReplyPayloadText | ReplyPayloadText[]): string | undefined { if (Array.isArray(res)) { return typeof res[0]?.text === "string" ? res[0]?.text : undefined; @@ -141,6 +200,8 @@ export function installDirectiveBehaviorE2EHooks() { clearRuntimeAuthProfileStoreSnapshots(); clearSessionStoreCacheForTest(); resetSystemEventsForTest(); + resetPluginRuntimeStateForTest(); + setActivePluginRegistry(createDirectiveBehaviorProviderRegistry()); runEmbeddedPiAgentMock.mockReset(); loadModelCatalogMock.mockReset(); loadModelCatalogMock.mockResolvedValue(DEFAULT_TEST_MODEL_CATALOG); @@ -151,6 +212,7 @@ export function installDirectiveBehaviorE2EHooks() { clearRuntimeAuthProfileStoreSnapshots(); clearSessionStoreCacheForTest(); resetSystemEventsForTest(); + resetPluginRuntimeStateForTest(); vi.restoreAllMocks(); }); } diff --git a/src/config/io.ts b/src/config/io.ts index 94bde4f10e7..e4ddae382c0 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -510,6 +510,21 @@ function createMergePatch(base: unknown, target: unknown): unknown { return patch; } +function projectSourceOntoRuntimeShape(source: unknown, runtime: unknown): unknown { + if (!isPlainObject(source) || !isPlainObject(runtime)) { + return cloneUnknown(source); + } + + const next: Record = {}; + for (const [key, sourceValue] of Object.entries(source)) { + if (!(key in runtime)) { + continue; + } + next[key] = projectSourceOntoRuntimeShape(sourceValue, runtime[key]); + } + return next; +} + function collectEnvRefPaths(value: unknown, path: string, output: Map): void { if (typeof value === "string") { if (containsEnvVarReference(value)) { @@ -2086,7 +2101,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { let changedPaths: Set | null = null; if (snapshot.valid && snapshot.exists) { const patch = createMergePatch(snapshot.config, cfg); - persistCandidate = applyMergePatch(snapshot.resolved, patch); + const projectedSource = projectSourceOntoRuntimeShape(snapshot.resolved, snapshot.config); + persistCandidate = applyMergePatch(projectedSource, patch); try { const resolvedIncludes = resolveConfigIncludes(snapshot.parsed, configPath, { readFile: (candidate) => deps.fs.readFileSync(candidate, "utf-8"), @@ -2456,8 +2472,11 @@ export function projectConfigOntoRuntimeSourceSnapshot(config: OpenClawConfig): ) { return config; } + const projectedSource = coerceConfig( + projectSourceOntoRuntimeShape(runtimeConfigSourceSnapshot, runtimeConfigSnapshot), + ); const runtimePatch = createMergePatch(runtimeConfigSnapshot, config); - return coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch)); + return coerceConfig(applyMergePatch(projectedSource, runtimePatch)); } export function loadConfig(): OpenClawConfig { diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 0c87bb0e353..a94c7266662 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -468,6 +468,7 @@ describe("config io write", () => { channels: { bluebubbles: { serverUrl: "http://localhost:1234", + password: "test-password", }, }, }, @@ -502,6 +503,7 @@ describe("config io write", () => { expect(channels?.bluebubbles).toBeDefined(); expect(channels?.bluebubbles).not.toHaveProperty("enrichGroupParticipantsFromContacts"); expect(channels?.bluebubbles?.serverUrl).toBe("http://localhost:1234"); + expect(channels?.bluebubbles?.password).toBe("test-password"); }); // Restore the default empty-plugins mock for subsequent tests. diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 56290b5a1c6..456cccdfa54 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -3090,12 +3090,17 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { properties: { primary: { type: "string", + title: "Video Generation Model", + description: + "Optional video-generation model (provider/model) used by the shared video generation capability.", }, fallbacks: { type: "array", items: { type: "string", }, + title: "Video Generation Model Fallbacks", + description: "Ordered fallback video-generation models (provider/model).", }, }, additionalProperties: false, diff --git a/src/config/validation.ts b/src/config/validation.ts index 98177fe1bc4..d9e68401f7e 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -762,7 +762,7 @@ function validateConfigObjectWithPluginsBase( schema: channelSchema, cacheKey: `channel:${trimmed}`, value: config.channels[trimmed], - applyDefaults: true, + applyDefaults: opts.applyDefaults, }); if (!result.ok) { for (const error of result.errors) { @@ -953,7 +953,7 @@ function validateConfigObjectWithPluginsBase( schema: record.configSchema, cacheKey: record.schemaCacheKey ?? record.manifestPath ?? pluginId, value: entry?.config ?? {}, - applyDefaults: true, + applyDefaults: opts.applyDefaults, }); if (!res.ok) { for (const error of res.errors) { diff --git a/src/plugins/bundled-capability-metadata.test.ts b/src/plugins/bundled-capability-metadata.test.ts index 0a34fb355b5..42b4df924db 100644 --- a/src/plugins/bundled-capability-metadata.test.ts +++ b/src/plugins/bundled-capability-metadata.test.ts @@ -36,6 +36,7 @@ describe("bundled capability metadata", () => { manifest.contracts?.mediaUnderstandingProviders, ), imageGenerationProviderIds: uniqueStrings(manifest.contracts?.imageGenerationProviders), + videoGenerationProviderIds: uniqueStrings(manifest.contracts?.videoGenerationProviders), webFetchProviderIds: uniqueStrings(manifest.contracts?.webFetchProviders), webSearchProviderIds: uniqueStrings(manifest.contracts?.webSearchProviders), toolNames: uniqueStrings(manifest.contracts?.tools), @@ -49,6 +50,7 @@ describe("bundled capability metadata", () => { entry.realtimeVoiceProviderIds.length > 0 || entry.mediaUnderstandingProviderIds.length > 0 || entry.imageGenerationProviderIds.length > 0 || + entry.videoGenerationProviderIds.length > 0 || entry.webFetchProviderIds.length > 0 || entry.webSearchProviderIds.length > 0 || entry.toolNames.length > 0, diff --git a/src/plugins/bundled-provider-auth-env-vars.generated.ts b/src/plugins/bundled-provider-auth-env-vars.generated.ts index 23634946016..a73cc547f30 100644 --- a/src/plugins/bundled-provider-auth-env-vars.generated.ts +++ b/src/plugins/bundled-provider-auth-env-vars.generated.ts @@ -23,8 +23,6 @@ export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { minimax: ["MINIMAX_API_KEY"], "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], mistral: ["MISTRAL_API_KEY"], - qwen: ["QWEN_API_KEY", "MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"], - modelstudio: ["QWEN_API_KEY", "MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"], moonshot: ["MOONSHOT_API_KEY"], nvidia: ["NVIDIA_API_KEY"], ollama: ["OLLAMA_API_KEY"], @@ -34,6 +32,7 @@ export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { openrouter: ["OPENROUTER_API_KEY"], perplexity: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], qianfan: ["QIANFAN_API_KEY"], + qwen: ["QWEN_API_KEY", "MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"], sglang: ["SGLANG_API_KEY"], stepfun: ["STEPFUN_API_KEY"], "stepfun-plan": ["STEPFUN_API_KEY"], diff --git a/src/plugins/contracts/speech-vitest-registry.ts b/src/plugins/contracts/speech-vitest-registry.ts index 496abd6cb0b..1554fac3034 100644 --- a/src/plugins/contracts/speech-vitest-registry.ts +++ b/src/plugins/contracts/speech-vitest-registry.ts @@ -330,7 +330,7 @@ export function loadVitestImageGenerationProviderContractRegistry(): ImageGenera } const builders = resolveNamedBuilders( createVitestCapabilityLoader(testApiPath)(testApiPath), - /^build.+ImageGenerationProvider$/u, + /ImageGenerationProvider$/u, ); if (builders.length === 0) { continue; diff --git a/src/secrets/runtime-config-collectors-plugins.ts b/src/secrets/runtime-config-collectors-plugins.ts index 01c5d5c5acb..7b6032515b2 100644 --- a/src/secrets/runtime-config-collectors-plugins.ts +++ b/src/secrets/runtime-config-collectors-plugins.ts @@ -9,7 +9,7 @@ import { import { isRecord } from "./shared.js"; const ACPX_PLUGIN_ID = "acpx"; -const ACPX_ENABLED_BY_DEFAULT = true; +const ACPX_ENABLED_BY_DEFAULT = false; /** * Walk plugin config entries and collect SecretRef assignments for MCP server diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 0301a75268b..b28dfc16abf 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -322,6 +322,7 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInPlan: true, includeInConfigure: true, includeInAudit: true, + providerIdPathSegmentIndex: 2, }, { id: "tools.web.search.apiKey", diff --git a/test/extension-test-boundary.test.ts b/test/extension-test-boundary.test.ts index 2af25df1645..c5f0f8a15c3 100644 --- a/test/extension-test-boundary.test.ts +++ b/test/extension-test-boundary.test.ts @@ -19,6 +19,7 @@ const allowedNonExtensionTests = new Set([ "src/infra/outbound/deliver.test.ts", "src/plugins/interactive.test.ts", "src/plugins/contracts/discovery.contract.test.ts", + "src/plugin-sdk/telegram-command-config.test.ts", ]); function walk(dir: string, entries: string[] = []): string[] { diff --git a/test/helpers/plugins/provider-discovery-contract.ts b/test/helpers/plugins/provider-discovery-contract.ts index 5baa392c978..bfe5cf8e1d6 100644 --- a/test/helpers/plugins/provider-discovery-contract.ts +++ b/test/helpers/plugins/provider-discovery-contract.ts @@ -28,7 +28,7 @@ const bundledProviderModules = vi.hoisted(() => ({ import.meta.url, ).pathname, minimaxIndexModuleUrl: new URL("../../../extensions/minimax/index.ts", import.meta.url).href, - modelStudioIndexModuleUrl: new URL("../../../extensions/qwen/index.ts", import.meta.url).href, + qwenIndexModuleUrl: new URL("../../../extensions/qwen/index.ts", import.meta.url).href, ollamaApiModuleId: new URL("../../../extensions/ollama/api.js", import.meta.url).pathname, ollamaIndexModuleUrl: new URL("../../../extensions/ollama/index.ts", import.meta.url).href, sglangApiModuleId: new URL("../../../extensions/sglang/api.js", import.meta.url).pathname, @@ -51,6 +51,15 @@ type DiscoveryState = { cloudflareAiGatewayProvider?: ProviderHandle; }; +type BundledProviderUnderTest = + | "github-copilot" + | "ollama" + | "vllm" + | "sglang" + | "minimax" + | "modelstudio" + | "cloudflare-ai-gateway"; + function createModelConfig(id: string, name = id): ModelDefinitionConfig { return { id, @@ -134,23 +143,47 @@ async function importBundledProviderPlugin(moduleUrl: string): Promise { return (await import(`${moduleUrl}?t=${Date.now()}`)) as T; } -function installDiscoveryHooks(state: DiscoveryState) { +function installDiscoveryHooks( + state: DiscoveryState, + providerIds: readonly BundledProviderUnderTest[], +) { beforeEach(async () => { vi.resetModules(); - vi.doMock("openclaw/plugin-sdk/agent-runtime", async () => { - const actual = await import("../../../src/plugin-sdk/agent-runtime.ts"); + vi.doMock("openclaw/plugin-sdk/agent-runtime", () => { return { - ...actual, ensureAuthProfileStore: ensureAuthProfileStoreMock, listProfilesForProvider: listProfilesForProviderMock, }; }); - vi.doMock("openclaw/plugin-sdk/provider-auth", async () => { - const actual = await vi.importActual("openclaw/plugin-sdk/provider-auth"); + vi.doMock("openclaw/plugin-sdk/provider-auth", () => { return { - ...actual, + MINIMAX_OAUTH_MARKER: "minimax-oauth", + applyAuthProfileConfig: (config: OpenClawConfig) => config, + buildApiKeyCredential: ( + provider: string, + key: unknown, + metadata?: Record, + ) => ({ + type: "api_key", + provider, + ...(typeof key === "string" ? { key } : {}), + ...(metadata ? { metadata } : {}), + }), + buildOauthProviderAuthResult: vi.fn(), + coerceSecretRef: (value: unknown) => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null, + ensureApiKeyFromOptionEnvOrPrompt: vi.fn(), ensureAuthProfileStore: ensureAuthProfileStoreMock, listProfilesForProvider: listProfilesForProviderMock, + normalizeApiKeyInput: (value: unknown) => (typeof value === "string" ? value.trim() : ""), + normalizeOptionalSecretInput: (value: unknown) => + typeof value === "string" && value.trim() ? value.trim() : undefined, + resolveNonEnvSecretRefApiKeyMarker: (source: unknown) => + typeof source === "string" ? source : "", + upsertAuthProfile: vi.fn(), + validateApiKeyInput: () => undefined, }; }); vi.doMock(bundledProviderModules.githubCopilotTokenModuleId, async () => { @@ -163,77 +196,87 @@ function installDiscoveryHooks(state: DiscoveryState) { }; }); vi.doMock(bundledProviderModules.ollamaApiModuleId, async () => { - const actual = await vi.importActual(bundledProviderModules.ollamaApiModuleId); return { - ...actual, + OLLAMA_DEFAULT_BASE_URL: "http://127.0.0.1:11434", buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args), + configureOllamaNonInteractive: vi.fn(), + ensureOllamaModelPulled: vi.fn(), + promptAndConfigureOllama: vi.fn(), }; }); vi.doMock(bundledProviderModules.vllmApiModuleId, async () => { - const actual = await vi.importActual(bundledProviderModules.vllmApiModuleId); return { - ...actual, buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args), }; }); vi.doMock(bundledProviderModules.sglangApiModuleId, async () => { - const actual = await vi.importActual(bundledProviderModules.sglangApiModuleId); return { - ...actual, + SGLANG_DEFAULT_API_KEY_ENV_VAR: "SGLANG_API_KEY", + SGLANG_DEFAULT_BASE_URL: "http://127.0.0.1:30000/v1", + SGLANG_MODEL_PLACEHOLDER: "Qwen/Qwen3-8B", + SGLANG_PROVIDER_LABEL: "SGLang", buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args), }; }); ({ runProviderCatalog: state.runProviderCatalog } = await import("../../../src/plugins/provider-discovery.js")); - const [ - { default: githubCopilotPlugin }, - { default: ollamaPlugin }, - { default: vllmPlugin }, - { default: sglangPlugin }, - { default: minimaxPlugin }, - { default: modelStudioPlugin }, - { default: cloudflareAiGatewayPlugin }, - ] = await Promise.all([ - importBundledProviderPlugin<{ + + if (providerIds.includes("github-copilot")) { + const { default: githubCopilotPlugin } = await importBundledProviderPlugin<{ default: Parameters[0]; - }>(bundledProviderModules.githubCopilotIndexModuleUrl), - importBundledProviderPlugin<{ + }>(bundledProviderModules.githubCopilotIndexModuleUrl); + state.githubCopilotProvider = requireProvider( + await registerProviders(githubCopilotPlugin), + "github-copilot", + ); + } + + if (providerIds.includes("ollama")) { + const { default: ollamaPlugin } = await importBundledProviderPlugin<{ default: Parameters[0]; - }>(bundledProviderModules.ollamaIndexModuleUrl), - importBundledProviderPlugin<{ + }>(bundledProviderModules.ollamaIndexModuleUrl); + state.ollamaProvider = requireProvider(await registerProviders(ollamaPlugin), "ollama"); + } + + if (providerIds.includes("vllm")) { + const { default: vllmPlugin } = await importBundledProviderPlugin<{ default: Parameters[0]; - }>(bundledProviderModules.vllmIndexModuleUrl), - importBundledProviderPlugin<{ + }>(bundledProviderModules.vllmIndexModuleUrl); + state.vllmProvider = requireProvider(await registerProviders(vllmPlugin), "vllm"); + } + + if (providerIds.includes("sglang")) { + const { default: sglangPlugin } = await importBundledProviderPlugin<{ default: Parameters[0]; - }>(bundledProviderModules.sglangIndexModuleUrl), - importBundledProviderPlugin<{ + }>(bundledProviderModules.sglangIndexModuleUrl); + state.sglangProvider = requireProvider(await registerProviders(sglangPlugin), "sglang"); + } + + if (providerIds.includes("minimax")) { + const { default: minimaxPlugin } = await importBundledProviderPlugin<{ default: Parameters[0]; - }>(bundledProviderModules.minimaxIndexModuleUrl), - importBundledProviderPlugin<{ + }>(bundledProviderModules.minimaxIndexModuleUrl); + const registeredProviders = await registerProviders(minimaxPlugin); + state.minimaxProvider = requireProvider(registeredProviders, "minimax"); + state.minimaxPortalProvider = requireProvider(registeredProviders, "minimax-portal"); + } + + if (providerIds.includes("modelstudio")) { + const { default: qwenPlugin } = await importBundledProviderPlugin<{ default: Parameters[0]; - }>(bundledProviderModules.modelStudioIndexModuleUrl), - importBundledProviderPlugin<{ + }>(bundledProviderModules.qwenIndexModuleUrl); + state.modelStudioProvider = requireProvider(await registerProviders(qwenPlugin), "qwen"); + } + + if (providerIds.includes("cloudflare-ai-gateway")) { + const { default: cloudflareAiGatewayPlugin } = await importBundledProviderPlugin<{ default: Parameters[0]; - }>(bundledProviderModules.cloudflareAiGatewayIndexModuleUrl), - ]); - const githubCopilotProviders = await registerProviders(githubCopilotPlugin); - const ollamaProviders = await registerProviders(ollamaPlugin); - const vllmProviders = await registerProviders(vllmPlugin); - const sglangProviders = await registerProviders(sglangPlugin); - const minimaxProviders = await registerProviders(minimaxPlugin); - const modelStudioProviders = await registerProviders(modelStudioPlugin); - const cloudflareAiGatewayProviders = await registerProviders(cloudflareAiGatewayPlugin); - state.githubCopilotProvider = requireProvider(githubCopilotProviders, "github-copilot"); - state.ollamaProvider = requireProvider(ollamaProviders, "ollama"); - state.vllmProvider = requireProvider(vllmProviders, "vllm"); - state.sglangProvider = requireProvider(sglangProviders, "sglang"); - state.minimaxProvider = requireProvider(minimaxProviders, "minimax"); - state.minimaxPortalProvider = requireProvider(minimaxProviders, "minimax-portal"); - state.modelStudioProvider = requireProvider(modelStudioProviders, "qwen"); - state.cloudflareAiGatewayProvider = requireProvider( - cloudflareAiGatewayProviders, - "cloudflare-ai-gateway", - ); + }>(bundledProviderModules.cloudflareAiGatewayIndexModuleUrl); + state.cloudflareAiGatewayProvider = requireProvider( + await registerProviders(cloudflareAiGatewayPlugin), + "cloudflare-ai-gateway", + ); + } setRuntimeAuthStore(); }); @@ -252,7 +295,7 @@ export function describeGithubCopilotProviderDiscoveryContract() { const state = {} as DiscoveryState; describe("github-copilot provider discovery contract", () => { - installDiscoveryHooks(state); + installDiscoveryHooks(state, ["github-copilot"]); it("keeps catalog disabled without env tokens or profiles", async () => { await expect( @@ -310,7 +353,7 @@ export function describeOllamaProviderDiscoveryContract() { const state = {} as DiscoveryState; describe("ollama provider discovery contract", () => { - installDiscoveryHooks(state); + installDiscoveryHooks(state, ["ollama"]); it("keeps explicit catalog normalization provider-owned", async () => { await expect( @@ -376,7 +419,7 @@ export function describeVllmProviderDiscoveryContract() { const state = {} as DiscoveryState; describe("vllm provider discovery contract", () => { - installDiscoveryHooks(state); + installDiscoveryHooks(state, ["vllm"]); it("keeps self-hosted discovery provider-owned", async () => { buildVllmProviderMock.mockResolvedValueOnce({ @@ -422,7 +465,7 @@ export function describeSglangProviderDiscoveryContract() { const state = {} as DiscoveryState; describe("sglang provider discovery contract", () => { - installDiscoveryHooks(state); + installDiscoveryHooks(state, ["sglang"]); it("keeps self-hosted discovery provider-owned", async () => { buildSglangProviderMock.mockResolvedValueOnce({ @@ -468,7 +511,7 @@ export function describeMinimaxProviderDiscoveryContract() { const state = {} as DiscoveryState; describe("minimax provider discovery contract", () => { - installDiscoveryHooks(state); + installDiscoveryHooks(state, ["minimax"]); it("keeps API catalog provider-owned", async () => { await expect( @@ -577,7 +620,7 @@ export function describeModelStudioProviderDiscoveryContract() { const state = {} as DiscoveryState; describe("modelstudio provider discovery contract", () => { - installDiscoveryHooks(state); + installDiscoveryHooks(state, ["modelstudio"]); it("keeps catalog provider-owned", async () => { await expect( @@ -624,7 +667,7 @@ export function describeCloudflareAiGatewayProviderDiscoveryContract() { const state = {} as DiscoveryState; describe("cloudflare-ai-gateway provider discovery contract", () => { - installDiscoveryHooks(state); + installDiscoveryHooks(state, ["cloudflare-ai-gateway"]); it("keeps catalog disabled without stored metadata", async () => { await expect(