Merge 74275c5f768d6c3109ab7d022c45c44f8d560756 into d78e13f545136fcbba1feceecc5e0485a06c33a6
This commit is contained in:
commit
85cd1d3aa5
31
src/agents/runtime-plugins.test.ts
Normal file
31
src/agents/runtime-plugins.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -20,5 +20,6 @@ export function ensureRuntimePluginsLoaded(params: {
|
||||
allowGatewaySubagentBinding: true,
|
||||
}
|
||||
: undefined,
|
||||
inheritSharedRuntimeOptions: true,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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: [],
|
||||
});
|
||||
|
||||
@ -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":[]}',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -50,6 +50,8 @@ function createUnavailableSubagentRuntime(): PluginRuntime["subagent"] {
|
||||
};
|
||||
return {
|
||||
run: unavailable,
|
||||
enqueue: unavailable,
|
||||
abort: unavailable,
|
||||
waitForRun: unavailable,
|
||||
getSessionMessages: unavailable,
|
||||
getSession: unavailable,
|
||||
|
||||
43
src/plugins/runtime/shared-runtime-options.ts
Normal file
43
src/plugins/runtime/shared-runtime-options.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -68,6 +68,7 @@ export function resolvePluginTools(params: {
|
||||
}
|
||||
: undefined,
|
||||
env,
|
||||
inheritSharedRuntimeOptions: true,
|
||||
logger: createPluginLoaderLogger(log),
|
||||
});
|
||||
|
||||
|
||||
@ -1670,6 +1670,7 @@ export type PluginHookMessageSentEvent = {
|
||||
content: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Tool context
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user