test: align extension runtime mocks with plugin-sdk (#51289)

* test: align extension runtime mocks with plugin-sdk

Update stale extension tests to mock the plugin-sdk runtime barrels that production code now imports, and harden the Signal tool-result harness around system-event assertions so the channels lane matches current extension boundaries.

Regeneration-Prompt: |
  Verify the failing channels-lane tests against current origin/main in an isolated worktree before changing anything. If the failures reproduce on main, keep the fix test-only unless production behavior is clearly wrong. Recent extension refactors moved Telegram, WhatsApp, and Signal code onto plugin-sdk runtime barrels, so update stale tests that still mock old core module paths to intercept the seams production code now uses. For Signal reaction notifications, avoid brittle assertions that depend on shared queued system-event state when a direct harness spy on enqueue behavior is sufficient. Preserve scope: only touch the failing tests and their local harness, then rerun the reproduced targeted tests plus the full channels lane and repo check gate.

* test: fix extension test drift on main

* fix: lazy-load bundled web search plugin registry

* test: make matrix sweeper failure injection portable

* fix: split heavy matrix runtime-api seams

* fix: simplify bundled web search id lookup

* test: tolerate windows env key casing
This commit is contained in:
Josh Lehman 2026-03-20 15:59:53 -07:00 committed by GitHub
parent e635cedb85
commit 2364e45fe4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 287 additions and 96 deletions

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import "./test-mocks.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import type { PluginRuntime } from "./runtime-api.js";
import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js";

View File

@ -62,14 +62,16 @@ export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
export function installBlueBubblesFetchTestHooks(params: {
mockFetch: ReturnType<typeof vi.fn>;
privateApiStatusMock: {
mockReset: () => unknown;
mockReset?: () => unknown;
mockClear?: () => unknown;
mockReturnValue: (value: boolean | null) => unknown;
};
}) {
beforeEach(() => {
vi.stubGlobal("fetch", params.mockFetch);
params.mockFetch.mockReset();
params.privateApiStatusMock.mockReset();
params.privateApiStatusMock.mockReset?.();
params.privateApiStatusMock.mockClear?.();
params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown);
});

View File

@ -11,7 +11,7 @@ export {
ssrfPolicyFromAllowPrivateNetwork,
type LookupFn,
type SsrFPolicy,
} from "openclaw/plugin-sdk/infra-runtime";
} from "openclaw/plugin-sdk/ssrf-runtime";
export {
setMatrixThreadBindingIdleTimeoutBySessionKey,
setMatrixThreadBindingMaxAgeBySessionKey,

View File

@ -53,11 +53,19 @@ function createHandlerHarness() {
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
markRunComplete: vi.fn(),
}),
resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined),
dispatchReplyFromConfig: vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }),
withReplyDispatcher: vi.fn().mockImplementation(async ({ run, onSettled }) => {
try {
return await run();
} finally {
await onSettled?.();
}
}),
},
commands: {
shouldHandleTextCommands: vi.fn().mockReturnValue(true),

View File

@ -1,8 +1,8 @@
import type {
BindingTargetKind,
SessionBindingRecord,
} from "openclaw/plugin-sdk/conversation-runtime";
import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/conversation-runtime";
} from "openclaw/plugin-sdk/thread-bindings-runtime";
import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/thread-bindings-runtime";
export type MatrixThreadBindingTargetKind = "subagent" | "acp";

View File

@ -16,30 +16,14 @@ import {
setMatrixThreadBindingMaxAgeBySessionKey,
} from "./thread-bindings.js";
const pluginSdkActual = vi.hoisted(() => ({
writeJsonFileAtomically: null as null | ((filePath: string, value: unknown) => Promise<void>),
}));
const sendMessageMatrixMock = vi.hoisted(() =>
vi.fn(async (_to: string, _message: string, opts?: { threadId?: string }) => ({
messageId: opts?.threadId ? "$reply" : "$root",
roomId: "!room:example",
})),
);
const writeJsonFileAtomicallyMock = vi.hoisted(() =>
vi.fn<(filePath: string, value: unknown) => Promise<void>>(),
);
vi.mock("../../runtime-api.js", async () => {
const actual =
await vi.importActual<typeof import("../../runtime-api.js")>("../../runtime-api.js");
pluginSdkActual.writeJsonFileAtomically = actual.writeJsonFileAtomically;
return {
...actual,
writeJsonFileAtomically: (filePath: string, value: unknown) =>
writeJsonFileAtomicallyMock(filePath, value),
};
});
const actualRename = fs.rename.bind(fs);
const renameMock = vi.spyOn(fs, "rename");
vi.mock("./send.js", async () => {
const actual = await vi.importActual<typeof import("./send.js")>("./send.js");
@ -82,10 +66,8 @@ describe("matrix thread bindings", () => {
__testing.resetSessionBindingAdaptersForTests();
resetMatrixThreadBindingsForTests();
sendMessageMatrixMock.mockClear();
writeJsonFileAtomicallyMock.mockReset();
writeJsonFileAtomicallyMock.mockImplementation(async (filePath: string, value: unknown) => {
await pluginSdkActual.writeJsonFileAtomically?.(filePath, value);
});
renameMock.mockReset();
renameMock.mockImplementation(actualRename);
setMatrixRuntime({
state: {
resolveStateDir: () => stateDir,
@ -216,7 +198,7 @@ describe("matrix thread bindings", () => {
}
});
it("persists a batch of expired bindings once per sweep", async () => {
it("persists expired bindings after a sweep", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z"));
try {
@ -251,12 +233,8 @@ describe("matrix thread bindings", () => {
placement: "current",
});
writeJsonFileAtomicallyMock.mockClear();
await vi.advanceTimersByTimeAsync(61_000);
await vi.waitFor(() => {
expect(writeJsonFileAtomicallyMock).toHaveBeenCalledTimes(1);
});
await Promise.resolve();
await vi.waitFor(async () => {
const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8");
@ -296,13 +274,23 @@ describe("matrix thread bindings", () => {
placement: "current",
});
writeJsonFileAtomicallyMock.mockClear();
writeJsonFileAtomicallyMock.mockRejectedValueOnce(new Error("disk full"));
renameMock.mockRejectedValueOnce(new Error("disk full"));
await vi.advanceTimersByTimeAsync(61_000);
await Promise.resolve();
await vi.waitFor(() => {
expect(
logVerboseMessage.mock.calls.some(
([message]) =>
typeof message === "string" &&
message.includes("failed auto-unbinding expired bindings"),
),
).toBe(true);
});
await vi.waitFor(() => {
expect(logVerboseMessage).toHaveBeenCalledWith(
expect.stringContaining("failed auto-unbinding expired bindings"),
expect.stringContaining("matrix: auto-unbinding $thread due to idle-expired"),
);
});

View File

@ -8,6 +8,12 @@ export {
type LookupFn,
type SsrFPolicy,
} from "openclaw/plugin-sdk/infra-runtime";
export {
dispatchReplyFromConfigWithSettledDispatcher,
ensureConfiguredAcpBindingReady,
maybeCreateMatrixMigrationSnapshot,
resolveConfiguredAcpBindingRecord,
} from "openclaw/plugin-sdk/matrix-runtime-heavy";
// Keep auth-precedence available internally without re-exporting helper-api
// twice through both plugin-sdk/matrix and ../runtime-api.js.
export * from "./auth-precedence.js";

View File

@ -1,7 +1,7 @@
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resolveAgentRoute } from "../../../src/routing/resolve-route.js";
import { normalizeE164 } from "../../../src/utils.js";
import type { SignalDaemonExitEvent } from "./daemon.js";
import {
createMockSignalDaemonHandle,
@ -16,16 +16,14 @@ installSignalToolResultTestHooks();
// Import after the harness registers `vi.mock(...)` for Signal internals.
vi.resetModules();
const [{ peekSystemEvents }, { monitorSignalProvider }] = await Promise.all([
import("openclaw/plugin-sdk/infra-runtime"),
import("./monitor.js"),
]);
const { monitorSignalProvider } = await import("./monitor.js");
const {
replyMock,
sendMock,
streamMock,
updateLastRouteMock,
enqueueSystemEventMock,
upsertPairingRequestMock,
waitForTransportReadyMock,
spawnSignalDaemonMock,
@ -109,14 +107,23 @@ async function receiveSignalPayloads(params: {
await flush();
}
function getDirectSignalEventsFor(sender: string) {
function hasQueuedReactionEventFor(sender: string) {
const route = resolveAgentRoute({
cfg: config as OpenClawConfig,
channel: "signal",
accountId: "default",
peer: { kind: "direct", id: normalizeE164(sender) },
});
return peekSystemEvents(route.sessionKey);
return enqueueSystemEventMock.mock.calls.some(([text, options]) => {
return (
typeof text === "string" &&
text.includes("Signal reaction added") &&
typeof options === "object" &&
options !== null &&
"sessionKey" in options &&
(options as { sessionKey?: string }).sessionKey === route.sessionKey
);
});
}
function makeBaseEnvelope(overrides: Record<string, unknown> = {}) {
@ -383,8 +390,7 @@ describe("monitorSignalProvider tool results", () => {
},
});
const events = getDirectSignalEventsFor("+15550001111");
expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true);
expect(hasQueuedReactionEventFor("+15550001111")).toBe(true);
});
it.each([
@ -424,8 +430,7 @@ describe("monitorSignalProvider tool results", () => {
},
});
const events = getDirectSignalEventsFor("+15550001111");
expect(events.some((text) => text.includes("Signal reaction added"))).toBe(shouldEnqueue);
expect(hasQueuedReactionEventFor("+15550001111")).toBe(shouldEnqueue);
expect(sendMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
});
@ -442,8 +447,7 @@ describe("monitorSignalProvider tool results", () => {
},
});
const events = getDirectSignalEventsFor("+15550001111");
expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true);
expect(hasQueuedReactionEventFor("+15550001111")).toBe(true);
});
it("processes messages when reaction metadata is present", async () => {

View File

@ -4,6 +4,7 @@ import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js";
type SignalToolResultTestMocks = {
waitForTransportReadyMock: MockFn;
enqueueSystemEventMock: MockFn;
sendMock: MockFn;
replyMock: MockFn;
updateLastRouteMock: MockFn;
@ -16,6 +17,7 @@ type SignalToolResultTestMocks = {
};
const waitForTransportReadyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
const enqueueSystemEventMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
const sendMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
const replyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
const updateLastRouteMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
@ -29,6 +31,7 @@ const spawnSignalDaemonMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
export function getSignalToolResultTestMocks(): SignalToolResultTestMocks {
return {
waitForTransportReadyMock,
enqueueSystemEventMock,
sendMock,
replyMock,
updateLastRouteMock,
@ -162,6 +165,10 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async () => {
return {
...actual,
waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args),
enqueueSystemEvent: (...args: Parameters<typeof actual.enqueueSystemEvent>) => {
enqueueSystemEventMock(...args);
return actual.enqueueSystemEvent(...args);
},
};
});
@ -189,6 +196,7 @@ export function installSignalToolResultTestHooks() {
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
enqueueSystemEventMock.mockReset();
resetSystemEventsForTest();
});

View File

@ -21,8 +21,10 @@ const { resolveTelegramFetch } = vi.hoisted(() => ({
resolveTelegramFetch: vi.fn(),
}));
vi.mock("../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
loadConfig,

View File

@ -8,8 +8,10 @@ const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true });
const saveMediaBufferSpy = vi.fn();
vi.mock("../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
loadConfig: vi.fn().mockReturnValue({
@ -37,8 +39,10 @@ vi.mock("../../../src/pairing/pairing-store.js", () => {
};
});
vi.mock("../../../src/media/store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/media/store.js")>();
vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/media-runtime")>(
"openclaw/plugin-sdk/media-runtime",
);
return {
...actual,
saveMediaBuffer: vi.fn(async (...args: Parameters<typeof actual.saveMediaBuffer>) => {

View File

@ -19,25 +19,30 @@ function resolveTestAuthDir() {
const authDir = resolveTestAuthDir();
vi.mock("../../../src/config/config.js", () => ({
loadConfig: () =>
({
channels: {
whatsapp: {
accounts: {
default: { enabled: true, authDir: resolveTestAuthDir() },
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
loadConfig: () =>
({
channels: {
whatsapp: {
accounts: {
default: { enabled: true, authDir: resolveTestAuthDir() },
},
},
},
},
}) as never,
}));
}) as never,
};
});
vi.mock("./session.js", () => {
const authDir = resolveTestAuthDir();
const sockA = { ws: { close: vi.fn() } };
const sockB = { ws: { close: vi.fn() } };
let call = 0;
const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB));
const createWaSocket = vi.fn(async () => (createWaSocket.mock.calls.length <= 1 ? sockA : sockB));
const waitForWaConnection = vi.fn();
const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`);
const getStatusCode = vi.fn(
@ -78,6 +83,10 @@ describe("loginWeb coverage", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
createWaSocketMock.mockClear();
waitForWaConnectionMock.mockReset().mockResolvedValue(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReset().mockResolvedValue(undefined);
formatErrorMock.mockReset().mockImplementation((err: unknown) => `formatted:${String(err)}`);
rmMock.mockClear();
});
afterEach(() => {

View File

@ -121,6 +121,10 @@
"types": "./dist/plugin-sdk/infra-runtime.d.ts",
"default": "./dist/plugin-sdk/infra-runtime.js"
},
"./plugin-sdk/ssrf-runtime": {
"types": "./dist/plugin-sdk/ssrf-runtime.d.ts",
"default": "./dist/plugin-sdk/ssrf-runtime.js"
},
"./plugin-sdk/media-runtime": {
"types": "./dist/plugin-sdk/media-runtime.d.ts",
"default": "./dist/plugin-sdk/media-runtime.js"
@ -133,6 +137,18 @@
"types": "./dist/plugin-sdk/conversation-runtime.d.ts",
"default": "./dist/plugin-sdk/conversation-runtime.js"
},
"./plugin-sdk/matrix-runtime-heavy": {
"types": "./dist/plugin-sdk/matrix-runtime-heavy.d.ts",
"default": "./dist/plugin-sdk/matrix-runtime-heavy.js"
},
"./plugin-sdk/matrix-runtime-shared": {
"types": "./dist/plugin-sdk/matrix-runtime-shared.d.ts",
"default": "./dist/plugin-sdk/matrix-runtime-shared.js"
},
"./plugin-sdk/thread-bindings-runtime": {
"types": "./dist/plugin-sdk/thread-bindings-runtime.d.ts",
"default": "./dist/plugin-sdk/thread-bindings-runtime.js"
},
"./plugin-sdk/text-runtime": {
"types": "./dist/plugin-sdk/text-runtime.d.ts",
"default": "./dist/plugin-sdk/text-runtime.js"

View File

@ -20,9 +20,13 @@
"channel-runtime",
"interactive-runtime",
"infra-runtime",
"ssrf-runtime",
"media-runtime",
"media-understanding-runtime",
"conversation-runtime",
"matrix-runtime-heavy",
"matrix-runtime-shared",
"thread-bindings-runtime",
"text-runtime",
"agent-runtime",
"speech-runtime",

View File

@ -13,14 +13,49 @@ type RegistrablePlugin = {
};
export const bundledWebSearchPluginRegistrations: ReadonlyArray<{
plugin: RegistrablePlugin;
readonly plugin: RegistrablePlugin;
credentialValue: unknown;
}> = [
{ plugin: bravePlugin, credentialValue: "BSA-test" },
{ plugin: firecrawlPlugin, credentialValue: "fc-test" },
{ plugin: googlePlugin, credentialValue: "AIza-test" },
{ plugin: moonshotPlugin, credentialValue: "sk-test" },
{ plugin: perplexityPlugin, credentialValue: "pplx-test" },
{ plugin: tavilyPlugin, credentialValue: "tvly-test" },
{ plugin: xaiPlugin, credentialValue: "xai-test" },
{
get plugin() {
return bravePlugin;
},
credentialValue: "BSA-test",
},
{
get plugin() {
return firecrawlPlugin;
},
credentialValue: "fc-test",
},
{
get plugin() {
return googlePlugin;
},
credentialValue: "AIza-test",
},
{
get plugin() {
return moonshotPlugin;
},
credentialValue: "sk-test",
},
{
get plugin() {
return perplexityPlugin;
},
credentialValue: "pplx-test",
},
{
get plugin() {
return tavilyPlugin;
},
credentialValue: "tvly-test",
},
{
get plugin() {
return xaiPlugin;
},
credentialValue: "xai-test",
},
];

View File

@ -3,6 +3,19 @@ import { withEnv } from "../test-utils/env.js";
import { decodeCapturedOutputBuffer, parseWindowsCodePage, sanitizeEnv } from "./invoke.js";
import { buildNodeInvokeResultParams } from "./runner.js";
function getEnvValueCaseInsensitive(
env: Record<string, string>,
expectedKey: string,
): string | undefined {
const direct = env[expectedKey];
if (direct !== undefined) {
return direct;
}
const upper = expectedKey.toUpperCase();
const actualKey = Object.keys(env).find((key) => key.toUpperCase() === upper);
return actualKey ? env[actualKey] : undefined;
}
describe("node-host sanitizeEnv", () => {
it("ignores PATH overrides", () => {
withEnv({ PATH: "/usr/bin" }, () => {
@ -55,7 +68,7 @@ describe("node-host sanitizeEnv", () => {
it("preserves inherited non-portable Windows-style env keys", () => {
withEnv({ "ProgramFiles(x86)": "C:\\Program Files (x86)" }, () => {
const env = sanitizeEnv(undefined);
expect(env["ProgramFiles(x86)"]).toBe("C:\\Program Files (x86)");
expect(getEnvValueCaseInsensitive(env, "ProgramFiles(x86)")).toBe("C:\\Program Files (x86)");
});
});
});

View File

@ -0,0 +1,7 @@
// Matrix runtime helpers that are needed internally by the bundled extension
// but are too heavy for the light external runtime-api surface.
export { ensureConfiguredAcpBindingReady } from "../acp/persistent-bindings.lifecycle.js";
export { resolveConfiguredAcpBindingRecord } from "../acp/persistent-bindings.resolve.js";
export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js";
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";

View File

@ -0,0 +1,11 @@
// Narrow shared Matrix runtime exports for light runtime-api consumers.
export type {
ChannelDirectoryEntry,
ChannelMessageActionContext,
} from "../channels/plugins/types.js";
export type { OpenClawConfig } from "../config/config.js";
export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js";
export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js";
export type { RuntimeEnv } from "../runtime.js";
export type { WizardPrompter } from "../wizard/prompts.js";

View File

@ -27,8 +27,6 @@ export {
patchAllowlistUsersInConfigEntries,
summarizeMapping,
} from "../channels/allowlists/resolve-utils.js";
export { ensureConfiguredAcpBindingReady } from "../acp/persistent-bindings.lifecycle.js";
export { resolveConfiguredAcpBindingRecord } from "../acp/persistent-bindings.resolve.js";
export { resolveControlCommandGate } from "../channels/command-gating.js";
export type { NormalizedLocation } from "../channels/location.js";
export { formatLocationText, toLocationContext } from "../channels/location.js";
@ -112,7 +110,6 @@ export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";
export { MarkdownConfigSchema } from "../config/zod-schema.core.js";
export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js";
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js";
export {
getSessionBindingService,
registerSessionBindingAdapter,
@ -150,7 +147,6 @@ export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
export { runPluginCommandWithTimeout } from "./run-command.js";
export { createLoggerBackedRuntime, resolveRuntimeEnv } from "./runtime.js";
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
export {
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,

View File

@ -38,7 +38,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
"extensions/matrix/runtime-api.ts": [
'export * from "./src/auth-precedence.js";',
'export * from "./helper-api.js";',
'export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime";',
'export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";',
'export { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey } from "./thread-bindings-runtime.js";',
'export { writeJsonFileAtomically } from "../../src/plugin-sdk/json-store.js";',
'export type { ChannelDirectoryEntry, ChannelMessageActionContext, OpenClawConfig, PluginRuntime, RuntimeLogger, RuntimeEnv, WizardPrompter } from "../../src/plugin-sdk/matrix.js";',

View File

@ -0,0 +1,14 @@
// Narrow SSRF helpers for extensions that need pinned-dispatcher and policy
// utilities without loading the full infra-runtime surface.
export {
closeDispatcher,
createPinnedDispatcher,
resolvePinnedHostnameWithPolicy,
type LookupFn,
type SsrFPolicy,
} from "../infra/net/ssrf.js";
export {
assertHttpUrlTargetsPrivateNetwork,
ssrfPolicyFromAllowPrivateNetwork,
} from "./ssrf-policy.js";

View File

@ -36,6 +36,7 @@ import type {
import * as directoryRuntimeSdk from "openclaw/plugin-sdk/directory-runtime";
import * as infraRuntimeSdk from "openclaw/plugin-sdk/infra-runtime";
import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime";
import * as matrixRuntimeSharedSdk from "openclaw/plugin-sdk/matrix-runtime-shared";
import * as mediaRuntimeSdk from "openclaw/plugin-sdk/media-runtime";
import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup";
import * as providerAuthSdk from "openclaw/plugin-sdk/provider-auth";
@ -50,7 +51,9 @@ import * as sandboxSdk from "openclaw/plugin-sdk/sandbox";
import * as secretInputSdk from "openclaw/plugin-sdk/secret-input";
import * as selfHostedProviderSetupSdk from "openclaw/plugin-sdk/self-hosted-provider-setup";
import * as setupSdk from "openclaw/plugin-sdk/setup";
import * as ssrfRuntimeSdk from "openclaw/plugin-sdk/ssrf-runtime";
import * as testingSdk from "openclaw/plugin-sdk/testing";
import * as threadBindingsRuntimeSdk from "openclaw/plugin-sdk/thread-bindings-runtime";
import * as webhookIngressSdk from "openclaw/plugin-sdk/webhook-ingress";
import { describe, expect, expectTypeOf, it } from "vitest";
import type { ChannelMessageActionContext } from "../channels/plugins/types.js";
@ -523,6 +526,22 @@ describe("plugin-sdk subpath exports", () => {
expect(typeof conversationRuntimeSdk.createTopLevelChannelReplyToModeResolver).toBe("function");
});
it("exports narrow binding lifecycle helpers from the dedicated subpath", () => {
expect(typeof threadBindingsRuntimeSdk.resolveThreadBindingLifecycle).toBe("function");
});
it("exports narrow matrix runtime helpers from the dedicated subpath", () => {
expect(typeof matrixRuntimeSharedSdk.formatZonedTimestamp).toBe("function");
});
it("exports narrow ssrf helpers from the dedicated subpath", () => {
expect(typeof ssrfRuntimeSdk.closeDispatcher).toBe("function");
expect(typeof ssrfRuntimeSdk.createPinnedDispatcher).toBe("function");
expect(typeof ssrfRuntimeSdk.resolvePinnedHostnameWithPolicy).toBe("function");
expect(typeof ssrfRuntimeSdk.assertHttpUrlTargetsPrivateNetwork).toBe("function");
expect(typeof ssrfRuntimeSdk.ssrfPolicyFromAllowPrivateNetwork).toBe("function");
});
it("exports provider setup helpers from the dedicated subpath", () => {
expect(typeof providerSetupSdk.buildVllmProvider).toBe("function");
expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function");

View File

@ -0,0 +1,9 @@
// Narrow thread-binding lifecycle helpers for extensions that need binding
// expiry and session-binding record types without loading the full
// conversation-runtime surface.
export { resolveThreadBindingLifecycle } from "../channels/thread-bindings-policy.js";
export type {
BindingTargetKind,
SessionBindingRecord,
} from "../infra/outbound/session-binding-service.js";

View File

@ -4,23 +4,58 @@ import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type { PluginWebSearchProviderEntry } from "./types.js";
export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = bundledWebSearchPluginRegistrations
.map((entry) => entry.plugin.id)
.toSorted((left, right) => left.localeCompare(right));
const bundledWebSearchPluginIdSet = new Set<string>(BUNDLED_WEB_SEARCH_PLUGIN_IDS);
type BundledWebSearchProviderEntry = PluginWebSearchProviderEntry & { pluginId: string };
type BundledWebSearchPluginRegistration = (typeof bundledWebSearchPluginRegistrations)[number];
let bundledWebSearchProvidersCache: BundledWebSearchProviderEntry[] | null = null;
let bundledWebSearchPluginIdsCache: string[] | null = null;
function resolveBundledWebSearchPlugin(
entry: BundledWebSearchPluginRegistration,
): BundledWebSearchPluginRegistration["plugin"] | null {
try {
return entry.plugin;
} catch {
return null;
}
}
function listBundledWebSearchPluginRegistrations() {
return bundledWebSearchPluginRegistrations
.map((entry) => {
const plugin = resolveBundledWebSearchPlugin(entry);
return plugin ? { ...entry, plugin } : null;
})
.filter(
(
entry,
): entry is BundledWebSearchPluginRegistration & {
plugin: BundledWebSearchPluginRegistration["plugin"];
} => Boolean(entry),
);
}
function loadBundledWebSearchPluginIds(): string[] {
if (!bundledWebSearchPluginIdsCache) {
bundledWebSearchPluginIdsCache = listBundledWebSearchPluginRegistrations()
.map(({ plugin }) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
return bundledWebSearchPluginIdsCache;
}
export function listBundledWebSearchPluginIds(): string[] {
return loadBundledWebSearchPluginIds();
}
function loadBundledWebSearchProviders(): BundledWebSearchProviderEntry[] {
if (!bundledWebSearchProvidersCache) {
bundledWebSearchProvidersCache = bundledWebSearchPluginRegistrations.flatMap(({ plugin }) =>
capturePluginRegistration(plugin).webSearchProviders.map((provider) => ({
...provider,
pluginId: plugin.id,
})),
bundledWebSearchProvidersCache = listBundledWebSearchPluginRegistrations().flatMap(
({ plugin }) =>
capturePluginRegistration(plugin).webSearchProviders.map((provider) => ({
...provider,
pluginId: plugin.id,
})),
);
}
return bundledWebSearchProvidersCache;
@ -36,6 +71,7 @@ export function resolveBundledWebSearchPluginIds(params: {
workspaceDir: params.workspaceDir,
env: params.env,
});
const bundledWebSearchPluginIdSet = new Set<string>(loadBundledWebSearchPluginIds());
return registry.plugins
.filter((plugin) => plugin.origin === "bundled" && bundledWebSearchPluginIdSet.has(plugin.id))
.map((plugin) => plugin.id)

View File

@ -1,7 +1,7 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import {
BUNDLED_WEB_SEARCH_PLUGIN_IDS,
listBundledWebSearchPluginIds,
resolveBundledWebSearchPluginId,
} from "../plugins/bundled-web-search.js";
import type {
@ -82,7 +82,7 @@ function hasCustomWebSearchPluginRisk(config: OpenClawConfig): boolean {
return true;
}
const bundledPluginIds = new Set<string>(BUNDLED_WEB_SEARCH_PLUGIN_IDS);
const bundledPluginIds = new Set<string>(listBundledWebSearchPluginIds());
const hasNonBundledPluginId = (pluginId: string) => !bundledPluginIds.has(pluginId.trim());
if (Array.isArray(plugins.allow) && plugins.allow.some(hasNonBundledPluginId)) {
return true;