Merge 74275c5f768d6c3109ab7d022c45c44f8d560756 into d78e13f545136fcbba1feceecc5e0485a06c33a6

This commit is contained in:
j.osawa 2026-03-21 04:50:43 +00:00 committed by GitHub
commit 85cd1d3aa5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 921 additions and 86 deletions

View File

@ -0,0 +1,31 @@
import { afterEach, describe, expect, test, vi } from "vitest";
const loadOpenClawPlugins = vi.hoisted(() => vi.fn());
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins,
}));
import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js";
afterEach(() => {
loadOpenClawPlugins.mockReset();
});
describe("ensureRuntimePluginsLoaded", () => {
test("opts into shared runtime inheritance", () => {
const config = { plugins: { enabled: true } };
ensureRuntimePluginsLoaded({
config,
workspaceDir: "/tmp/workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
config,
inheritSharedRuntimeOptions: true,
}),
);
});
});

View File

@ -20,5 +20,6 @@ export function ensureRuntimePluginsLoaded(params: {
allowGatewaySubagentBinding: true,
}
: undefined,
inheritSharedRuntimeOptions: true,
});
}

View File

@ -123,6 +123,8 @@ export type MsgContext = {
/** Explicit owner allowlist overrides (trusted, configuration-derived). */
OwnerAllowFrom?: Array<string | number>;
SenderName?: string;
/** Provider-managed account id when the inbound sender is one of our configured bot accounts. */
SenderManagedAccountId?: string;
SenderId?: string;
SenderUsername?: string;
SenderTag?: string;

View File

@ -66,10 +66,7 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) {
const compatibilityWarnings = buildPluginCompatibilityWarnings({
config: cfg,
workspaceDir,
report: {
workspaceDir,
...pluginRegistry,
},
report: pluginRegistry,
});
if (compatibilityWarnings.length > 0) {
note(compatibilityWarnings.map((line) => `- ${line}`).join("\n"), "Plugin compatibility");

View File

@ -15,7 +15,6 @@ const mocks = vi.hoisted(() => ({
probeGateway: vi.fn(),
resolveGatewayProbeAuthResolution: vi.fn(),
ensurePluginRegistryLoaded: vi.fn(),
buildPluginCompatibilityNotices: vi.fn(() => []),
}));
beforeEach(() => {
@ -101,10 +100,6 @@ vi.mock("../cli/plugin-registry.js", () => ({
ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded,
}));
vi.mock("../plugins/status.js", () => ({
buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices,
}));
import { scanStatus } from "./status.scan.js";
describe("scanStatus", () => {
@ -416,14 +411,14 @@ describe("scanStatus", () => {
session: {},
plugins: { enabled: false },
gateway: {},
channels: { telegram: { enabled: false } },
channels: { telegram: { enabled: false, botToken: "token" } },
});
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig: {
session: {},
plugins: { enabled: false },
gateway: {},
channels: { telegram: { enabled: false } },
channels: { telegram: { enabled: false, botToken: "token" } },
},
diagnostics: [],
});

View File

@ -1,6 +1,11 @@
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import type { PluginRegistry } from "../plugins/registry.js";
import type { PluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
import {
clearSharedPluginRuntimeOptions,
getSharedPluginRuntimeOptions,
setSharedPluginRuntimeOptions,
} from "../plugins/runtime/shared-runtime-options.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import type { PluginDiagnostic } from "../plugins/types.js";
import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js";
@ -29,14 +34,15 @@ vi.mock("./server-methods.js", () => ({
}));
vi.mock("../channels/registry.js", () => ({
CHAT_CHANNEL_ORDER: [],
CHANNEL_IDS: [],
CHAT_CHANNEL_ORDER: ["telegram", "discord", "slack"],
CHANNEL_IDS: ["telegram", "discord", "slack"],
listChatChannels: () => [],
listChatChannelAliases: () => [],
getChatChannelMeta: () => null,
normalizeChatChannelId: () => null,
normalizeChannelId: () => null,
normalizeAnyChannelId: () => null,
normalizeAnyChannelId: (raw?: string | null) =>
typeof raw === "string" && raw.trim().length > 0 ? raw.trim().toLowerCase() : null,
formatChannelPrimerLine: () => "",
formatChannelSelectionLine: () => "",
}));
@ -92,6 +98,24 @@ function getLastDispatchedClientScopes(): string[] {
return Array.isArray(scopes) ? scopes : [];
}
function getLastDispatchedRequest():
| { method: string; params?: Record<string, unknown> }
| undefined {
const call = handleGatewayRequest.mock.calls.at(-1)?.[0];
const req = call?.req;
if (!req) {
return undefined;
}
const params =
"params" in req && req.params != null && typeof req.params === "object"
? (req.params as Record<string, unknown>)
: undefined;
return {
method: req.method,
params,
};
}
async function loadTestModules() {
serverPluginsModule = await import("./server-plugins.js");
runtimeModule = await import("../plugins/runtime/index.js");
@ -99,6 +123,10 @@ async function loadTestModules() {
methodScopesModule = await import("./method-scopes.js");
}
async function importServerPluginsModule(): Promise<ServerPluginsModule> {
return import("./server-plugins.js");
}
async function createSubagentRuntime(
serverPlugins: ServerPluginsModule,
cfg: Record<string, unknown> = {},
@ -144,10 +172,21 @@ beforeEach(() => {
handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => {
switch (opts.req.method) {
case "agent":
case "agent.enqueue":
opts.respond(true, { runId: "run-1" });
return;
case "agent.wait":
opts.respond(true, { status: "ok" });
opts.respond(true, {
status: "ok",
stopReason: "tool_calls",
pendingToolCalls: [
{
id: "call-1",
name: "emit_structured_result",
arguments: '{"entries":[]}',
},
],
});
return;
case "sessions.get":
opts.respond(true, { messages: [] });
@ -163,6 +202,8 @@ beforeEach(() => {
afterEach(() => {
runtimeModule.clearGatewaySubagentRuntime();
clearSharedPluginRuntimeOptions();
vi.resetModules();
});
describe("loadGatewayPlugins", () => {
@ -407,8 +448,8 @@ describe("loadGatewayPlugins", () => {
expect(getLastDispatchedClientScopes()).not.toContain("operator.admin");
});
test("allows fallback session reads with synthetic write scope", async () => {
const serverPlugins = serverPluginsModule;
test("allows fallback session reads with synthetic read scope", async () => {
const serverPlugins = await importServerPluginsModule();
const runtime = await createSubagentRuntime(serverPlugins);
serverPlugins.setFallbackGatewayContext(createTestContext("synthetic-session-read"));
@ -433,7 +474,7 @@ describe("loadGatewayPlugins", () => {
messages: [{ id: "m-1" }],
});
expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]);
expect(getLastDispatchedClientScopes()).toEqual(["operator.read"]);
expect(getLastDispatchedClientScopes()).not.toContain("operator.admin");
});
@ -531,6 +572,81 @@ describe("loadGatewayPlugins", () => {
expect(log.error).not.toHaveBeenCalled();
expect(log.info).not.toHaveBeenCalled();
});
test("publishes shared runtime options for later plugin reloads", async () => {
const { loadGatewayPlugins } = await importServerPluginsModule();
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
loadGatewayPlugins({
cfg: {},
workspaceDir: "/tmp",
log: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
coreGatewayHandlers: {},
baseMethods: [],
});
expect(typeof getSharedPluginRuntimeOptions()?.subagent?.run).toBe("function");
});
test("rolls back shared runtime options when plugin loading fails", async () => {
const { loadGatewayPlugins } = await importServerPluginsModule();
loadOpenClawPlugins.mockImplementation(() => {
throw new Error("plugin load failed");
});
expect(() =>
loadGatewayPlugins({
cfg: {},
workspaceDir: "/tmp",
log: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
coreGatewayHandlers: {},
baseMethods: [],
}),
).toThrow("plugin load failed");
expect(getSharedPluginRuntimeOptions()).toBeUndefined();
});
test("restores previous shared runtime options when plugin loading fails", async () => {
const { loadGatewayPlugins } = await importServerPluginsModule();
const previousRuntime = {
subagent: {
run: vi.fn(),
},
} as unknown as NonNullable<ReturnType<typeof getSharedPluginRuntimeOptions>>;
setSharedPluginRuntimeOptions(previousRuntime);
loadOpenClawPlugins.mockImplementation(() => {
throw new Error("plugin load failed");
});
expect(() =>
loadGatewayPlugins({
cfg: {},
workspaceDir: "/tmp",
log: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
coreGatewayHandlers: {},
baseMethods: [],
}),
).toThrow("plugin load failed");
expect(getSharedPluginRuntimeOptions()).toBe(previousRuntime);
});
test("shares fallback context across module reloads for existing runtimes", async () => {
const first = serverPluginsModule;
const runtime = await createSubagentRuntime(first);
@ -579,4 +695,137 @@ describe("loadGatewayPlugins", () => {
| undefined;
expect(dispatched?.marker).toBe("after-mutation");
});
test("mints idempotency keys for plugin subagent requests when absent", async () => {
const serverPlugins = await importServerPluginsModule();
const runtime = await createSubagentRuntime(serverPlugins);
serverPlugins.setFallbackGatewayContext(createTestContext("idempotency-generated"));
await runtime.run({ sessionKey: "s-run", message: "hello" });
const runRequest = getLastDispatchedRequest();
expect(runRequest?.method).toBe("agent");
expect(runRequest?.params).toMatchObject({
sessionKey: "s-run",
message: "hello",
deliver: false,
});
expect(runRequest?.params?.idempotencyKey).toEqual(
expect.stringMatching(/^plugin-subagent:agent:s-run:/),
);
await runtime.enqueue({ sessionKey: "s-enqueue", message: "queued" });
const enqueueRequest = getLastDispatchedRequest();
expect(enqueueRequest?.method).toBe("agent.enqueue");
expect(enqueueRequest?.params).toMatchObject({
sessionKey: "s-enqueue",
message: "queued",
deliver: false,
});
expect(enqueueRequest?.params?.idempotencyKey).toEqual(
expect.stringMatching(/^plugin-subagent:agent\.enqueue:s-enqueue:/),
);
});
test("preserves caller-provided idempotency keys for plugin subagent requests", async () => {
const serverPlugins = await importServerPluginsModule();
const runtime = await createSubagentRuntime(serverPlugins);
serverPlugins.setFallbackGatewayContext(createTestContext("idempotency-preserved"));
await runtime.run({
sessionKey: "s-run",
message: "hello",
idempotencyKey: "plugin-run-idem",
});
expect(getLastDispatchedRequest()?.params?.idempotencyKey).toBe("plugin-run-idem");
await runtime.enqueue({
sessionKey: "s-enqueue",
message: "queued",
idempotencyKey: "plugin-enqueue-idem",
});
expect(getLastDispatchedRequest()?.params?.idempotencyKey).toBe("plugin-enqueue-idem");
});
test("forwards structured plugin subagent options to gateway agent methods", async () => {
const serverPlugins = await importServerPluginsModule();
const runtime = await createSubagentRuntime(serverPlugins);
serverPlugins.setFallbackGatewayContext(createTestContext("structured-output"));
await runtime.run({
sessionKey: "s-structured",
message: "extract memories",
disableTools: true,
clientTools: [
{
type: "function",
function: {
name: "emit_structured_result",
description: "Return a structured result payload.",
parameters: {
type: "object",
properties: {
entries: {
type: "array",
},
},
},
},
},
],
streamParams: {
toolChoice: {
type: "function",
function: {
name: "emit_structured_result",
},
},
},
});
const dispatched = getLastDispatchedRequest();
expect(dispatched?.method).toBe("agent");
expect(dispatched?.params).toMatchObject({
sessionKey: "s-structured",
message: "extract memories",
disableTools: true,
clientTools: [
{
type: "function",
function: expect.objectContaining({
name: "emit_structured_result",
}),
},
],
streamParams: {
toolChoice: {
type: "function",
function: {
name: "emit_structured_result",
},
},
},
});
});
test("returns pending tool calls from gateway agent.wait", async () => {
const serverPlugins = await importServerPluginsModule();
const runtime = await createSubagentRuntime(serverPlugins);
const result = await runtime.waitForRun({
runId: "run-1",
timeoutMs: 1_000,
});
expect(result).toEqual({
status: "ok",
stopReason: "tool_calls",
pendingToolCalls: [
{
id: "call-1",
name: "emit_structured_result",
arguments: '{"entries":[]}',
},
],
});
});
});

View File

@ -6,8 +6,13 @@ import { normalizePluginsConfig } from "../plugins/config-state.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
import { setGatewaySubagentRuntime } from "../plugins/runtime/index.js";
import {
clearSharedPluginRuntimeOptions,
getSharedPluginRuntimeOptions,
setSharedPluginRuntimeOptions,
} from "../plugins/runtime/shared-runtime-options.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import { ADMIN_SCOPE, WRITE_SCOPE } from "./method-scopes.js";
import { ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE } from "./method-scopes.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./protocol/client-info.js";
import type { ErrorShape } from "./protocol/index.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
@ -155,10 +160,7 @@ function authorizeFallbackModelOverride(params: {
if (!policy?.allowModelOverride) {
return {
allowed: false,
reason:
`plugin "${pluginId}" is not trusted for fallback provider/model override requests. ` +
"See https://docs.openclaw.ai/tools/plugin#runtime-helpers and search for: " +
"plugins.entries.<id>.subagent.allowModelOverride",
reason: `plugin "${pluginId}" is not trusted for fallback provider/model override requests.`,
};
}
if (policy.allowAnyModel) {
@ -261,6 +263,25 @@ async function dispatchGatewayMethod<T>(
}
let result: { ok: boolean; payload?: unknown; error?: ErrorShape } | undefined;
const resolvedClient = scope?.client
? {
...scope.client,
connect: {
...scope.client.connect,
scopes: [
...(Array.isArray(scope.client.connect?.scopes) ? scope.client.connect.scopes : []),
...(options?.syntheticScopes ?? []),
],
},
internal: {
...scope.client.internal,
...(options?.allowSyntheticModelOverride === true ? { allowModelOverride: true } : {}),
},
}
: createSyntheticOperatorClient({
allowModelOverride: options?.allowSyntheticModelOverride === true,
scopes: options?.syntheticScopes,
});
await handleGatewayRequest({
req: {
type: "req",
@ -268,12 +289,7 @@ async function dispatchGatewayMethod<T>(
method,
params,
},
client:
scope?.client ??
createSyntheticOperatorClient({
allowModelOverride: options?.allowSyntheticModelOverride === true,
scopes: options?.syntheticScopes,
}),
client: resolvedClient,
isWebchatConnect,
respond: (ok, payload, error) => {
if (!result) {
@ -292,12 +308,30 @@ async function dispatchGatewayMethod<T>(
return result.payload as T;
}
function resolvePluginSubagentIdempotencyKey(params: {
idempotencyKey?: string;
sessionKey: string;
method: "agent" | "agent.enqueue";
}): string {
const provided = typeof params.idempotencyKey === "string" ? params.idempotencyKey.trim() : "";
if (provided) {
return provided;
}
return `plugin-subagent:${params.method}:${params.sessionKey}:${randomUUID()}`;
}
function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
const getSessionMessages: PluginRuntime["subagent"]["getSessionMessages"] = async (params) => {
const payload = await dispatchGatewayMethod<{ messages?: unknown[] }>("sessions.get", {
key: params.sessionKey,
...(params.limit != null && { limit: params.limit }),
});
const payload = await dispatchGatewayMethod<{ messages?: unknown[] }>(
"sessions.get",
{
key: params.sessionKey,
...(params.limit != null && { limit: params.limit }),
},
{
syntheticScopes: [READ_SCOPE],
},
);
return { messages: Array.isArray(payload?.messages) ? payload.messages : [] };
};
@ -308,7 +342,7 @@ function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
const hasRequestScopeClient = Boolean(scope?.client);
let allowOverride = hasRequestScopeClient && canClientUseModelOverride(scope?.client ?? null);
let allowSyntheticModelOverride = false;
if (overrideRequested && !allowOverride && !hasRequestScopeClient) {
if (overrideRequested && !allowOverride) {
const fallbackAuth = authorizeFallbackModelOverride({
pluginId: scope?.pluginId,
provider: params.provider,
@ -320,23 +354,29 @@ function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
allowOverride = true;
allowSyntheticModelOverride = true;
}
if (overrideRequested && !allowOverride) {
throw new Error("provider/model override is not authorized for this plugin subagent run.");
}
const payload = await dispatchGatewayMethod<{ runId?: string }>(
"agent",
{
sessionKey: params.sessionKey,
message: params.message,
deliver: params.deliver ?? false,
idempotencyKey: resolvePluginSubagentIdempotencyKey({
idempotencyKey: params.idempotencyKey,
sessionKey: params.sessionKey,
method: "agent",
}),
...(allowOverride && params.provider && { provider: params.provider }),
...(allowOverride && params.model && { model: params.model }),
...(params.extraSystemPrompt && { extraSystemPrompt: params.extraSystemPrompt }),
...(params.lane && { lane: params.lane }),
...(params.clientTools && { clientTools: params.clientTools }),
...(params.disableTools === true && { disableTools: true }),
...(params.streamParams && { streamParams: params.streamParams }),
...(params.idempotencyKey && { idempotencyKey: params.idempotencyKey }),
},
{
allowSyntheticModelOverride,
syntheticScopes: [WRITE_SCOPE],
},
);
const runId = payload?.runId;
@ -345,13 +385,83 @@ function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
}
return { runId };
},
async enqueue(params) {
const scope = getPluginRuntimeGatewayRequestScope();
const overrideRequested = Boolean(params.provider || params.model);
const hasRequestScopeClient = Boolean(scope?.client);
let allowOverride = hasRequestScopeClient && canClientUseModelOverride(scope?.client ?? null);
let allowSyntheticModelOverride = false;
if (overrideRequested && !allowOverride) {
const fallbackAuth = authorizeFallbackModelOverride({
pluginId: scope?.pluginId,
provider: params.provider,
model: params.model,
});
if (!fallbackAuth.allowed) {
throw new Error(fallbackAuth.reason);
}
allowOverride = true;
allowSyntheticModelOverride = true;
}
const payload = await dispatchGatewayMethod<{ runId?: string }>(
"agent.enqueue",
{
sessionKey: params.sessionKey,
message: params.message,
deliver: params.deliver ?? false,
idempotencyKey: resolvePluginSubagentIdempotencyKey({
idempotencyKey: params.idempotencyKey,
sessionKey: params.sessionKey,
method: "agent.enqueue",
}),
...(allowOverride && params.provider && { provider: params.provider }),
...(allowOverride && params.model && { model: params.model }),
...(params.extraSystemPrompt && { extraSystemPrompt: params.extraSystemPrompt }),
...(params.lane && { lane: params.lane }),
},
{
allowSyntheticModelOverride,
syntheticScopes: [WRITE_SCOPE],
},
);
const runId = payload?.runId;
if (typeof runId !== "string" || !runId) {
throw new Error("Gateway agent.enqueue method returned an invalid runId.");
}
return { runId };
},
async abort(params) {
const payload = await dispatchGatewayMethod<{ aborted?: boolean }>(
"agent.abort",
{
runId: params.runId,
...(params.sessionKey && { sessionKey: params.sessionKey }),
},
{
syntheticScopes: [ADMIN_SCOPE],
},
);
return { aborted: payload?.aborted === true };
},
async waitForRun(params) {
const payload = await dispatchGatewayMethod<{ status?: string; error?: string }>(
const payload = await dispatchGatewayMethod<{
status?: string;
error?: string;
stopReason?: string;
pendingToolCalls?: Array<{
id: string;
name: string;
arguments: string;
}>;
}>(
"agent.wait",
{
runId: params.runId,
...(params.timeoutMs != null && { timeoutMs: params.timeoutMs }),
},
{
syntheticScopes: [WRITE_SCOPE],
},
);
const status = payload?.status;
if (status !== "ok" && status !== "error" && status !== "timeout") {
@ -360,6 +470,11 @@ function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
return {
status,
...(typeof payload?.error === "string" && payload.error && { error: payload.error }),
...(typeof payload?.stopReason === "string" &&
payload.stopReason && { stopReason: payload.stopReason }),
...(Array.isArray(payload?.pendingToolCalls) && payload.pendingToolCalls.length > 0
? { pendingToolCalls: payload.pendingToolCalls }
: {}),
};
},
getSessionMessages,
@ -398,29 +513,42 @@ export function loadGatewayPlugins(params: {
logDiagnostics?: boolean;
}) {
setPluginSubagentOverridePolicies(params.cfg);
// Set the process-global gateway subagent runtime BEFORE loading plugins.
// Set the process-global gateway subagent runtime before loading plugins.
// Gateway-owned registries may already exist from schema loads, so the
// gateway path opts those runtimes into late binding rather than changing
// the default subagent behavior for every plugin runtime in the process.
const gatewaySubagent = createGatewaySubagentRuntime();
setGatewaySubagentRuntime(gatewaySubagent);
const pluginRegistry = loadOpenClawPlugins({
config: params.cfg,
workspaceDir: params.workspaceDir,
logger: {
info: (msg) => params.log.info(msg),
warn: (msg) => params.log.warn(msg),
error: (msg) => params.log.error(msg),
debug: (msg) => params.log.debug(msg),
},
coreGatewayHandlers: params.coreGatewayHandlers,
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins,
const gatewaySubagentRuntime = createGatewaySubagentRuntime();
setGatewaySubagentRuntime(gatewaySubagentRuntime);
const previousSharedRuntimeOptions = getSharedPluginRuntimeOptions();
setSharedPluginRuntimeOptions({
subagent: gatewaySubagentRuntime,
});
primeConfiguredBindingRegistry({ cfg: params.cfg });
let pluginRegistry;
try {
primeConfiguredBindingRegistry({ cfg: params.cfg });
pluginRegistry = loadOpenClawPlugins({
config: params.cfg,
workspaceDir: params.workspaceDir,
logger: {
info: (msg) => params.log.info(msg),
warn: (msg) => params.log.warn(msg),
error: (msg) => params.log.error(msg),
debug: (msg) => params.log.debug(msg),
},
coreGatewayHandlers: params.coreGatewayHandlers,
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins,
});
} catch (error) {
if (previousSharedRuntimeOptions) {
setSharedPluginRuntimeOptions(previousSharedRuntimeOptions);
} else {
clearSharedPluginRuntimeOptions();
}
throw error;
}
const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers);
const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods]));
if ((params.logDiagnostics ?? true) && pluginRegistry.diagnostics.length > 0) {

View File

@ -23,19 +23,24 @@ async function importFreshPluginTestModules() {
vi.doUnmock("./hooks.js");
vi.doUnmock("./loader.js");
vi.doUnmock("jiti");
const [loader, hookRunnerGlobal, hooks, runtime, registry] = await Promise.all([
import("./loader.js"),
import("./hook-runner-global.js"),
import("./hooks.js"),
import("./runtime.js"),
import("./registry.js"),
]);
const [loader, hookRunnerGlobal, hooks, runtime, registry, sharedRuntimeOptions, tools] =
await Promise.all([
import("./loader.js"),
import("./hook-runner-global.js"),
import("./hooks.js"),
import("./runtime.js"),
import("./registry.js"),
import("./runtime/shared-runtime-options.js"),
import("./tools.js"),
]);
return {
...loader,
...hookRunnerGlobal,
...hooks,
...runtime,
...registry,
...sharedRuntimeOptions,
...tools,
};
}
@ -44,12 +49,15 @@ const {
clearPluginLoaderCache,
createHookRunner,
createEmptyPluginRegistry,
clearSharedPluginRuntimeOptions,
getActivePluginRegistry,
getActivePluginRegistryKey,
getGlobalHookRunner,
loadOpenClawPlugins,
resetGlobalHookRunner,
resolvePluginTools,
setActivePluginRegistry,
setSharedPluginRuntimeOptions,
} = await importFreshPluginTestModules();
type TempPlugin = { dir: string; file: string; id: string };
@ -704,6 +712,8 @@ function resolvePluginRuntimeModule(params: {
afterEach(() => {
clearPluginLoaderCache();
resetDiagnosticEventsForTest();
clearSharedPluginRuntimeOptions();
resetGlobalHookRunner();
if (prevBundledDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
@ -1244,6 +1254,281 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
resetGlobalHookRunner();
});
it("does not reuse cached registries across runtime capability changes", async () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "runtime-capability-cache",
filename: "runtime-capability-cache.cjs",
body: `module.exports = {
id: "runtime-capability-cache",
register(api) {
api.on("agent_end", async () => {
await api.runtime.subagent.run({ sessionKey: "runtime-capability", message: "hello" });
});
},
};`,
});
const options = {
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["runtime-capability-cache"],
},
},
};
const first = loadOpenClawPlugins(options);
const subagent = {
run: vi.fn(async () => ({ runId: "run-1" })),
enqueue: vi.fn(async () => ({ runId: "run-1" })),
abort: vi.fn(async () => ({ aborted: true })),
waitForRun: vi.fn(async () => ({ status: "ok" as const })),
getSessionMessages: vi.fn(async () => ({ messages: [] })),
getSession: vi.fn(async () => ({ messages: [] })),
deleteSession: vi.fn(async () => undefined),
};
const second = loadOpenClawPlugins({
...options,
runtimeOptions: { subagent },
});
expect(second).not.toBe(first);
expect(second.plugins.map((entry) => entry.id)).toEqual(first.plugins.map((entry) => entry.id));
const third = loadOpenClawPlugins({
...options,
runtimeOptions: { subagent },
});
expect(third).toBe(second);
});
it("does not inherit shared runtime options without explicit opt-in", async () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "shared-runtime-options-no-inherit",
filename: "shared-runtime-options-no-inherit.cjs",
body: `module.exports = {
id: "shared-runtime-options-no-inherit",
register(api) {
api.on("agent_end", async () => {
await api.runtime.subagent.run({ sessionKey: "shared-runtime-no-inherit", message: "hello" });
});
},
};`,
});
const subagent = {
run: vi.fn(async () => ({ runId: "run-shared" })),
enqueue: vi.fn(async () => ({ runId: "run-shared" })),
abort: vi.fn(async () => ({ aborted: true })),
waitForRun: vi.fn(async () => ({ status: "ok" as const })),
getSessionMessages: vi.fn(async () => ({ messages: [] })),
getSession: vi.fn(async () => ({ messages: [] })),
deleteSession: vi.fn(async () => undefined),
};
setSharedPluginRuntimeOptions({ subagent });
const registry = loadOpenClawPlugins({
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["shared-runtime-options-no-inherit"],
},
},
});
const inherited = loadOpenClawPlugins({
workspaceDir: plugin.dir,
inheritSharedRuntimeOptions: true,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["shared-runtime-options-no-inherit"],
},
},
});
expect(inherited).not.toBe(registry);
expect(
loadOpenClawPlugins({
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["shared-runtime-options-no-inherit"],
},
},
}),
).toBe(registry);
});
it("does not replace the global hook runner when activation is disabled", async () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "shared-runtime-options-no-global-replace",
filename: "shared-runtime-options-no-global-replace.cjs",
body: `module.exports = { id: "shared-runtime-options-no-global-replace", register() {} };`,
});
const activeRegistry = loadOpenClawPlugins({
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["shared-runtime-options-no-global-replace"],
},
},
});
const globalHookRunner = getGlobalHookRunner();
expect(globalHookRunner).not.toBeNull();
const previewRegistry = loadOpenClawPlugins({
workspaceDir: plugin.dir,
inheritSharedRuntimeOptions: true,
activate: false,
cache: false,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["shared-runtime-options-no-global-replace"],
},
},
});
expect(getGlobalHookRunner()).toBe(globalHookRunner);
expect(activeRegistry).not.toBe(previewRegistry);
expect(previewRegistry).not.toBe(activeRegistry);
});
it("keeps the global hook runner stable while resolving plugin tools", async () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "tool-resolution-preserves-hook-runner",
filename: "tool-resolution-preserves-hook-runner.cjs",
body: `module.exports = {
id: "tool-resolution-preserves-hook-runner",
register(api) {
api.on("agent_end", async () => {
await api.runtime.subagent.run({ sessionKey: "tool-resolution-preserved", message: "hello" });
});
api.registerTool({
name: "echo",
description: "echo",
parameters: { type: "object", properties: {} },
execute: async () => ({ content: [{ type: "text", text: "ok" }] }),
});
},
};`,
});
const subagent = {
run: vi.fn(async () => ({ runId: "run-shared" })),
enqueue: vi.fn(async () => ({ runId: "run-shared" })),
abort: vi.fn(async () => ({ aborted: true })),
waitForRun: vi.fn(async () => ({ status: "ok" as const })),
getSessionMessages: vi.fn(async () => ({ messages: [] })),
getSession: vi.fn(async () => ({ messages: [] })),
deleteSession: vi.fn(async () => undefined),
};
const activeRegistry = loadOpenClawPlugins({
workspaceDir: plugin.dir,
runtimeOptions: { subagent },
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["tool-resolution-preserves-hook-runner"],
},
},
});
const globalHookRunner = getGlobalHookRunner();
expect(globalHookRunner).not.toBeNull();
const tools = resolvePluginTools({
context: {
config: {
plugins: {
enabled: true,
load: { paths: [plugin.file] },
allow: ["tool-resolution-preserves-hook-runner"],
},
},
workspaceDir: plugin.dir,
} as never,
});
expect(tools).toHaveLength(1);
expect(getGlobalHookRunner()).toBe(globalHookRunner);
expect(activeRegistry).not.toBeNull();
});
it("uses shared runtime options when explicit runtime options are absent and inheritance is enabled", async () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "shared-runtime-options",
filename: "shared-runtime-options.cjs",
body: `module.exports = {
id: "shared-runtime-options",
register(api) {
api.on("agent_end", async () => {
await api.runtime.subagent.run({ sessionKey: "shared-runtime", message: "hello" });
});
},
};`,
});
const subagent = {
run: vi.fn(async () => ({ runId: "run-shared" })),
enqueue: vi.fn(async () => ({ runId: "run-shared" })),
abort: vi.fn(async () => ({ aborted: true })),
waitForRun: vi.fn(async () => ({ status: "ok" as const })),
getSessionMessages: vi.fn(async () => ({ messages: [] })),
getSession: vi.fn(async () => ({ messages: [] })),
deleteSession: vi.fn(async () => undefined),
};
setSharedPluginRuntimeOptions({ subagent });
const registry = loadOpenClawPlugins({
workspaceDir: plugin.dir,
inheritSharedRuntimeOptions: true,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["shared-runtime-options"],
},
},
});
const inheritedAgain = loadOpenClawPlugins({
workspaceDir: plugin.dir,
inheritSharedRuntimeOptions: true,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["shared-runtime-options"],
},
},
});
const plain = loadOpenClawPlugins({
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["shared-runtime-options"],
},
},
});
expect(inheritedAgain).toBe(registry);
expect(plain).not.toBe(registry);
});
it.each([
{
name: "does not reuse cached bundled plugin registries across env changes",

View File

@ -28,6 +28,10 @@ import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./
import { resolvePluginCacheInputs } from "./roots.js";
import { setActivePluginRegistry } from "./runtime.js";
import type { CreatePluginRuntimeOptions } from "./runtime/index.js";
import {
getPluginRuntimeCapabilityKey,
getSharedPluginRuntimeOptions,
} from "./runtime/shared-runtime-options.js";
import type { PluginRuntime } from "./runtime/types.js";
import { validateJsonSchemaValue } from "./schema-validator.js";
import {
@ -61,6 +65,7 @@ export type PluginLoadOptions = {
logger?: PluginLogger;
coreGatewayHandlers?: Record<string, GatewayRequestHandler>;
runtimeOptions?: CreatePluginRuntimeOptions;
inheritSharedRuntimeOptions?: boolean;
cache?: boolean;
mode?: "full" | "validate";
onlyPluginIds?: string[];
@ -242,7 +247,7 @@ function buildCacheKey(params: {
onlyPluginIds?: string[];
includeSetupOnlyChannelPlugins?: boolean;
preferSetupRuntimeForChannelPlugins?: boolean;
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
runtimeOptions?: CreatePluginRuntimeOptions;
}): string {
const { roots, loadPaths } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
@ -273,7 +278,8 @@ function buildCacheKey(params: {
...params.plugins,
installs,
loadPaths,
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${params.runtimeSubagentMode ?? "default"}`;
runtimeCapabilities: getPluginRuntimeCapabilityKey(params.runtimeOptions),
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}`;
}
function normalizeScopedPluginIds(ids?: string[]): string[] | undefined {
@ -740,6 +746,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true;
const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true;
const shouldActivate = options.activate !== false;
const effectiveRuntimeOptions =
options.runtimeOptions ??
(options.inheritSharedRuntimeOptions ? getSharedPluginRuntimeOptions() : undefined);
// NOTE: `activate` is intentionally excluded from the cache key. All non-activating
// (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they
// never read from or write to the cache. Including `activate` here would be misleading
@ -752,12 +761,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
onlyPluginIds,
includeSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
runtimeSubagentMode:
options.runtimeOptions?.allowGatewaySubagentBinding === true
? "gateway-bindable"
: options.runtimeOptions?.subagent
? "explicit"
: "default",
runtimeOptions: effectiveRuntimeOptions,
});
const cacheEnabled = options.cache !== false;
if (cacheEnabled) {
@ -828,7 +832,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
// not eagerly load every channel/runtime dependency tree.
let resolvedRuntime: PluginRuntime | null = null;
const resolveRuntime = (): PluginRuntime => {
resolvedRuntime ??= resolveCreatePluginRuntime()(options.runtimeOptions);
resolvedRuntime ??= resolveCreatePluginRuntime()(effectiveRuntimeOptions);
return resolvedRuntime;
};
const lazyRuntimeReflectionKeySet = new Set<PropertyKey>(LAZY_RUNTIME_REFLECTION_KEYS);

View File

@ -14,7 +14,6 @@ import { normalizePluginHttpPath } from "./http-path.js";
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
import { registerPluginInteractiveHandler } from "./interactive.js";
import { normalizeRegisteredProvider } from "./provider-validation.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js";
import type { PluginRuntime } from "./runtime/types.js";
import { defaultSlotIdForKey } from "./slots.js";
@ -241,7 +240,28 @@ const constrainLegacyPromptInjectionHook = (
};
};
export { createEmptyPluginRegistry } from "./registry-empty.js";
export function createEmptyPluginRegistry(): PluginRegistry {
return {
plugins: [],
tools: [],
hooks: [],
typedHooks: [],
channels: [],
channelSetups: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],
};
}
export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registry = createEmptyPluginRegistry();
@ -857,6 +877,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
const subagent = Reflect.get(target, prop, receiver);
return {
run: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.run(params)),
enqueue: (params) =>
withPluginRuntimePluginIdScope(pluginId, () => subagent.enqueue(params)),
abort: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.abort(params)),
waitForRun: (params) =>
withPluginRuntimePluginIdScope(pluginId, () => subagent.waitForRun(params)),
getSessionMessages: (params) =>

View File

@ -113,6 +113,8 @@ describe("plugin runtime command execution", () => {
const runtime = createPluginRuntime();
setGatewaySubagentRuntime({
run: vi.fn(),
enqueue: vi.fn(),
abort: vi.fn(),
waitForRun: vi.fn(),
getSessionMessages: vi.fn(),
getSession: vi.fn(),
@ -130,6 +132,8 @@ describe("plugin runtime command execution", () => {
setGatewaySubagentRuntime({
run,
enqueue: vi.fn(),
abort: vi.fn(),
waitForRun: vi.fn(),
getSessionMessages: vi.fn(),
getSession: vi.fn(),
@ -141,4 +145,30 @@ describe("plugin runtime command execution", () => {
});
expect(run).toHaveBeenCalledWith({ sessionKey: "s-2", message: "hello" });
});
it("uses the provided subagent runtime when available", async () => {
const subagent = {
run: vi.fn(async () => ({ runId: "run-1" })),
enqueue: vi.fn(async () => ({ runId: "run-1" })),
abort: vi.fn(async () => ({ aborted: true })),
waitForRun: vi.fn(async () => ({ status: "ok" as const })),
getSessionMessages: vi.fn(async () => ({ messages: [] })),
getSession: vi.fn(async () => ({ messages: [] })),
deleteSession: vi.fn(async () => undefined),
};
const runtime = createPluginRuntime({ subagent });
await expect(runtime.subagent.run({ sessionKey: "s", message: "hi" })).resolves.toEqual({
runId: "run-1",
});
await expect(runtime.subagent.enqueue({ sessionKey: "s", message: "hi" })).resolves.toEqual({
runId: "run-1",
});
await expect(runtime.subagent.abort({ runId: "run-1" })).resolves.toEqual({
aborted: true,
});
expect(subagent.run).toHaveBeenCalledWith({ sessionKey: "s", message: "hi" });
expect(subagent.enqueue).toHaveBeenCalledWith({ sessionKey: "s", message: "hi" });
expect(subagent.abort).toHaveBeenCalledWith({ runId: "run-1" });
});
});

View File

@ -50,6 +50,8 @@ function createUnavailableSubagentRuntime(): PluginRuntime["subagent"] {
};
return {
run: unavailable,
enqueue: unavailable,
abort: unavailable,
waitForRun: unavailable,
getSessionMessages: unavailable,
getSession: unavailable,

View File

@ -0,0 +1,43 @@
import type { CreatePluginRuntimeOptions } from "./index.js";
const SHARED_PLUGIN_RUNTIME_OPTIONS_KEY: unique symbol = Symbol.for(
"openclaw.sharedPluginRuntimeOptions",
);
type SharedPluginRuntimeOptionsState = {
options?: CreatePluginRuntimeOptions;
};
function getSharedPluginRuntimeOptionsState(): SharedPluginRuntimeOptionsState {
const globalState = globalThis as typeof globalThis & {
[SHARED_PLUGIN_RUNTIME_OPTIONS_KEY]?: SharedPluginRuntimeOptionsState;
};
const existing = globalState[SHARED_PLUGIN_RUNTIME_OPTIONS_KEY];
if (existing) {
return existing;
}
const created: SharedPluginRuntimeOptionsState = {};
globalState[SHARED_PLUGIN_RUNTIME_OPTIONS_KEY] = created;
return created;
}
export function setSharedPluginRuntimeOptions(options: CreatePluginRuntimeOptions): void {
getSharedPluginRuntimeOptionsState().options = options;
}
export function getSharedPluginRuntimeOptions(): CreatePluginRuntimeOptions | undefined {
return getSharedPluginRuntimeOptionsState().options;
}
export function clearSharedPluginRuntimeOptions(): void {
getSharedPluginRuntimeOptionsState().options = undefined;
}
export function getPluginRuntimeCapabilityKey(
options?: CreatePluginRuntimeOptions,
): Record<string, boolean> {
return {
subagent: Boolean(options?.subagent),
gatewaySubagentBinding: options?.allowGatewaySubagentBinding === true,
};
}

View File

@ -1,3 +1,5 @@
import type { ClientToolDefinition } from "../../agents/pi-embedded-runner/run/params.js";
import type { AgentStreamParams } from "../../commands/agent/types.js";
import type { PluginRuntimeChannel } from "./types-channel.js";
import type { PluginRuntimeCore, RuntimeLogger } from "./types-core.js";
@ -14,12 +16,19 @@ export type SubagentRunParams = {
lane?: string;
deliver?: boolean;
idempotencyKey?: string;
clientTools?: ClientToolDefinition[];
disableTools?: boolean;
streamParams?: AgentStreamParams;
};
export type SubagentRunResult = {
runId: string;
};
export type SubagentEnqueueParams = SubagentRunParams;
export type SubagentEnqueueResult = SubagentRunResult;
export type SubagentWaitParams = {
runId: string;
timeoutMs?: number;
@ -28,6 +37,21 @@ export type SubagentWaitParams = {
export type SubagentWaitResult = {
status: "ok" | "error" | "timeout";
error?: string;
stopReason?: string;
pendingToolCalls?: Array<{
id: string;
name: string;
arguments: string;
}>;
};
export type SubagentAbortParams = {
runId: string;
sessionKey?: string;
};
export type SubagentAbortResult = {
aborted: boolean;
};
export type SubagentGetSessionMessagesParams = {
@ -53,6 +77,8 @@ export type SubagentDeleteSessionParams = {
export type PluginRuntime = PluginRuntimeCore & {
subagent: {
run: (params: SubagentRunParams) => Promise<SubagentRunResult>;
enqueue: (params: SubagentEnqueueParams) => Promise<SubagentEnqueueResult>;
abort: (params: SubagentAbortParams) => Promise<SubagentAbortResult>;
waitForRun: (params: SubagentWaitParams) => Promise<SubagentWaitResult>;
getSessionMessages: (
params: SubagentGetSessionMessagesParams,

View File

@ -225,17 +225,6 @@ export function buildPluginInspectReport(params: {
const diagnostics = report.diagnostics.filter((entry) => entry.pluginId === plugin.id);
const policyEntry = normalizePluginsConfig(config.plugins).entries[plugin.id];
const capabilityCount = capabilities.length;
const shape = deriveInspectShape({
capabilityCount,
typedHookCount: typedHooks.length,
customHookCount: customHooks.length,
toolCount: tools.length,
commandCount: plugin.commands.length,
cliCount: plugin.cliCommands.length,
serviceCount: plugin.services.length,
gatewayMethodCount: plugin.gatewayMethods.length,
httpRouteCount: plugin.httpRoutes,
});
// Populate MCP server info for bundle-format plugins with a known rootDir.
let mcpServers: PluginInspectReport["mcpServers"] = [];
@ -257,6 +246,18 @@ export function buildPluginInspectReport(params: {
];
}
const shape = deriveInspectShape({
capabilityCount,
typedHookCount: typedHooks.length,
customHookCount: customHooks.length,
toolCount: tools.length,
commandCount: plugin.commands.length,
cliCount: plugin.cliCommands.length,
serviceCount: plugin.services.length,
gatewayMethodCount: plugin.gatewayMethods.length,
httpRouteCount: plugin.httpRoutes,
});
// Populate LSP server info for bundle-format plugins with a known rootDir.
let lspServers: PluginInspectReport["lspServers"] = [];
if (plugin.format === "bundle" && plugin.bundleFormat && plugin.rootDir) {

View File

@ -157,6 +157,20 @@ describe("resolvePluginTools optional tools", () => {
expect(registry.diagnostics).toHaveLength(0);
});
it("loads plugin tools without re-initializing the global hook runner", () => {
setOptionalDemoRegistry();
resolvePluginTools({
context: createContext() as never,
toolAllowlist: ["optional_tool"],
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
inheritSharedRuntimeOptions: true,
}),
);
});
it("forwards an explicit env to plugin loading", () => {
setOptionalDemoRegistry();
const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv;

View File

@ -68,6 +68,7 @@ export function resolvePluginTools(params: {
}
: undefined,
env,
inheritSharedRuntimeOptions: true,
logger: createPluginLoaderLogger(log),
});

View File

@ -1670,6 +1670,7 @@ export type PluginHookMessageSentEvent = {
content: string;
success: boolean;
error?: string;
metadata?: Record<string, unknown>;
};
// Tool context

View File

@ -327,6 +327,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
},
subagent: {
run: vi.fn(),
enqueue: vi.fn(),
abort: vi.fn(),
waitForRun: vi.fn(),
getSessionMessages: vi.fn(),
getSession: vi.fn(),