Merge branch 'main' into fix/tts-tool-no-channel-hang

This commit is contained in:
Hiago Silva 2026-03-16 10:04:10 -03:00 committed by GitHub
commit cbc3a93ccd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 795 additions and 119 deletions

View File

@ -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

View File

@ -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 = {

View File

@ -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";

View File

@ -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,

View File

@ -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 = {

View File

@ -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");
});
});

View File

@ -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<T extends Constructor>(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<SlackAppConstructor>(app) ||
!isConstructorFunction<SlackHttpReceiverConstructor>(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<SlackAppConstructor>(defaultImport) &&
isConstructorFunction<SlackHttpReceiverConstructor>(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,
};

View File

@ -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();

View File

@ -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<ResolvedTelegramAccount, TelegramProb
collectStatusIssues: collectTelegramStatusIssues,
buildChannelSummary: ({ snapshot }) => 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<ResolvedTelegramAccount, TelegramProb
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
cfg.channels?.telegram?.groups;
const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } =
getTelegramRuntime().channel.telegram.collectUnmentionedGroupIds(groups);
collectTelegramUnmentionedGroupIds(groups);
if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) {
return undefined;
}
@ -746,7 +748,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
elapsedMs: 0,
};
}
const audit = await getTelegramRuntime().channel.telegram.auditGroupMembership({
const audit = await auditTelegramGroupMembership({
token: account.token,
botId,
groupIds,
@ -815,7 +817,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
const token = (account.token ?? "").trim();
let telegramBotLabel = "";
try {
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(token, 2500, {
const probe = await probeTelegram(token, 2500, {
accountId: account.accountId,
proxyUrl: account.config.proxy,
network: account.config.network,
@ -830,7 +832,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
}
}
ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`);
return getTelegramRuntime().channel.telegram.monitorTelegramProvider({
return monitorTelegramProvider({
token,
accountId: account.accountId,
config: ctx.cfg,

View File

@ -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 = {

View File

@ -1,4 +1,4 @@
import { normalizeProviderId } from "../../src/agents/model-selection.js";
import { normalizeProviderId } from "../../src/agents/provider-id.js";
import {
createPluginBackedWebSearchProvider,
getScopedCredentialValue,

View File

@ -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"

View File

@ -2,8 +2,10 @@
"index",
"core",
"compat",
"ollama-setup",
"provider-setup",
"sandbox",
"self-hosted-provider-setup",
"routing",
"telegram",
"discord",

View File

@ -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 {

View File

@ -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");
}

View File

@ -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();
});
}

View File

@ -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() ?? "");

View File

@ -1,2 +1,5 @@
export { resolveProviderPluginChoice } from "../../../plugins/provider-wizard.js";
export { resolvePluginProviders } from "../../../plugins/providers.js";
export {
resolveOwningPluginIdsForProvider,
resolvePluginProviders,
} from "../../../plugins/providers.js";

View File

@ -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"] } });

View File

@ -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,
}),

View File

@ -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 });
});

View File

@ -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([]);
});

View File

@ -262,7 +262,7 @@ async function assertExplicitTelegramTargetDelivery(params: {
describe("runCronIsolatedAgentTurn", () => {
beforeEach(() => {
vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue(undefined);
vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue("off");
setupIsolatedAgentTurnMocks();
});

View File

@ -16,10 +16,6 @@ import {
} from "./isolated-agent.test-harness.js";
import type { CronJob } from "./types.js";
let resolveThinkingDefaultSpy: ReturnType<
typeof vi.spyOn<typeof modelSelection, "resolveThinkingDefault">
>;
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,

View File

@ -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);

View File

@ -9,6 +9,10 @@ async function readRepoFile(path: string): Promise<string> {
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,
);
});
});

View File

@ -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<typeof resolveApiKeyForProvider>(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<typeof resolveApiKeyForProvider>) => {
const resolved = await resolveApiKeyForProviderMock(...args);
return Boolean(resolved?.apiKey);
}),

View File

@ -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";

View File

@ -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);
}

View File

@ -19,6 +19,7 @@ export type {
SshSandboxSession,
SshSandboxSettings,
} from "../agents/sandbox.js";
export type { OpenClawConfig } from "../config/config.js";
export {
buildExecRemoteCommand,

View File

@ -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";

View File

@ -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");

View File

@ -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<object>("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<object>("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;

View File

@ -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<typeof entry> => entry !== undefined);
}

View File

@ -1,13 +1,24 @@
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 ResolveNonBundledProviderPluginIds =
typeof import("./providers.js").resolveNonBundledProviderPluginIds;
type ResolveOwningPluginIdsForProvider =
typeof import("./providers.js").resolveOwningPluginIdsForProvider;
const resolvePluginProvidersMock = vi.fn<ResolvePluginProviders>((_) => [] as ProviderPlugin[]);
const resolveNonBundledProviderPluginIdsMock = vi.fn<ResolveNonBundledProviderPluginIds>(
(_) => [] as string[],
);
const resolveOwningPluginIdsForProviderMock = vi.fn<ResolveOwningPluginIdsForProvider>(
(_) => 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),
}));
@ -30,6 +41,7 @@ import {
normalizeProviderResolvedModelWithPlugin,
prepareProviderDynamicModel,
prepareProviderRuntimeAuth,
resetProviderRuntimeHookCacheForTest,
refreshProviderOAuthCredentialWithPlugin,
resolveProviderRuntimePlugin,
runProviderDynamicModel,
@ -51,8 +63,11 @@ const MODEL: ProviderRuntimeModel = {
describe("provider-runtime", () => {
beforeEach(() => {
resetProviderRuntimeHookCacheForTest();
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockReturnValue([]);
resolveNonBundledProviderPluginIdsMock.mockReset();
resolveNonBundledProviderPluginIdsMock.mockReturnValue([]);
resolveOwningPluginIdsForProviderMock.mockReset();
resolveOwningPluginIdsForProviderMock.mockReturnValue(undefined);
});
@ -98,7 +113,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"];
}
@ -450,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();
});
});

View File

@ -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<string, ProviderPlugin[]>
>();
let cachedHookProvidersByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>
>();
function resolveHookProviderCacheBucket(params: {
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}) {
if (!params.config) {
let bucket = cachedHookProvidersWithoutConfig.get(params.env);
if (!bucket) {
bucket = new Map<string, ProviderPlugin[]>();
cachedHookProvidersWithoutConfig.set(params.env, bucket);
}
return bucket;
}
let envBuckets = cachedHookProvidersByConfig.get(params.config);
if (!envBuckets) {
envBuckets = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
cachedHookProvidersByConfig.set(params.config, envBuckets);
}
let bucket = envBuckets.get(params.env);
if (!bucket) {
bucket = new Map<string, ProviderPlugin[]>();
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<string, ProviderPlugin[]>
>();
cachedHookProvidersByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>
>();
}
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;

View File

@ -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: [

View File

@ -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";
@ -62,14 +63,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));
}
@ -99,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;
@ -116,6 +151,7 @@ export function resolvePluginProviders(params: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
onlyPluginIds: params.onlyPluginIds,
})
: [];
const maybeAllowlistCompat = params.bundledProviderAllowlistCompat

View File

@ -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;

View File

@ -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,

View File

@ -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 });
});

View File

@ -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,

View File

@ -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",

View File

@ -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<string>();
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) {