From c675c80fceeba623d20f52424809cf09e66dce01 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Mon, 16 Mar 2026 21:07:46 -0700 Subject: [PATCH] Security: add trusted plugin override policy --- src/config/config-misc.test.ts | 34 +++ src/config/schema.help.quality.test.ts | 3 + src/config/schema.help.ts | 6 + src/config/types.plugins.ts | 9 + src/config/zod-schema.ts | 7 + src/gateway/server-plugins.test.ts | 106 +++++++++- src/gateway/server-plugins.ts | 194 ++++++++++++++++-- src/plugins/config-state.test.ts | 31 +++ src/plugins/config-state.ts | 31 +++ src/plugins/registry.ts | 33 ++- .../runtime/gateway-request-scope.test.ts | 13 ++ src/plugins/runtime/gateway-request-scope.ts | 15 ++ 12 files changed, 458 insertions(+), 24 deletions(-) diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 177711dcc03..ff5b2d1b062 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -93,6 +93,40 @@ describe("plugins.entries.*.hooks.allowPromptInjection", () => { }); }); +describe("plugins.entries.*.subagent", () => { + it("accepts trusted subagent override settings", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + entries: { + "voice-call": { + subagent: { + allowModelOverride: true, + allowedModels: ["anthropic/claude-haiku-4-6"], + }, + }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid trusted subagent override settings", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + entries: { + "voice-call": { + subagent: { + allowModelOverride: "yes", + allowedModels: [1], + }, + }, + }, + }, + }); + expect(result.success).toBe(false); + }); +}); + describe("web search provider config", () => { it("accepts kimi provider and config", () => { const res = validateConfigObject( diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index e915350ee62..f1542bcb7de 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -349,6 +349,9 @@ const TARGET_KEYS = [ "plugins.entries.*.enabled", "plugins.entries.*.hooks", "plugins.entries.*.hooks.allowPromptInjection", + "plugins.entries.*.subagent", + "plugins.entries.*.subagent.allowModelOverride", + "plugins.entries.*.subagent.allowedModels", "plugins.entries.*.apiKey", "plugins.entries.*.env", "plugins.entries.*.config", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index bb059bf5cad..e6b02e2ec3c 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -979,6 +979,12 @@ export const FIELD_HELP: Record = { "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "plugins.entries.*.hooks.allowPromptInjection": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "plugins.entries.*.subagent": + "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "plugins.entries.*.subagent.allowModelOverride": + "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "plugins.entries.*.subagent.allowedModels": + 'Allowed override targets for trusted plugin subagent runs as canonical "provider/model" refs. Use "*" only when you intentionally allow any model.', "plugins.entries.*.apiKey": "Optional API key field consumed by plugins that accept direct key configuration in entry settings. Use secret/env substitution and avoid committing real credentials into config files.", "plugins.entries.*.env": diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 62d750b0470..af37ba2020f 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -4,6 +4,15 @@ export type PluginEntryConfig = { /** Controls prompt mutation via before_prompt_build and prompt fields from legacy before_agent_start. */ allowPromptInjection?: boolean; }; + subagent?: { + /** Explicitly allow this plugin to request per-run provider/model overrides for subagent runs. */ + allowModelOverride?: boolean; + /** + * Allowed override targets as canonical provider/model refs. + * Use "*" to explicitly allow any model for this plugin. + */ + allowedModels?: string[]; + }; config?: Record; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index b32a86dc68f..f8ad6bfcbc9 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -155,6 +155,13 @@ const PluginEntrySchema = z }) .strict() .optional(), + subagent: z + .object({ + allowModelOverride: z.boolean().optional(), + allowedModels: z.array(z.string()).optional(), + }) + .strict() + .optional(), config: z.record(z.string(), z.unknown()).optional(), }) .strict(); diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index a4c9c1811d5..c558a89d461 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -1,5 +1,6 @@ import { afterEach, 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 type { PluginRuntime } from "../plugins/runtime/types.js"; import type { PluginDiagnostic } from "../plugins/types.js"; import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js"; @@ -56,12 +57,19 @@ function getLastDispatchedParams(): Record | undefined { return call?.req?.params as Record | undefined; } +function getLastDispatchedClientScopes(): string[] { + const call = handleGatewayRequest.mock.calls.at(-1)?.[0]; + const scopes = call?.client?.connect?.scopes; + return Array.isArray(scopes) ? scopes : []; +} + async function importServerPluginsModule(): Promise { return import("./server-plugins.js"); } async function createSubagentRuntime( serverPlugins: ServerPluginsModule, + cfg: Record = {}, ): Promise { const log = { info: vi.fn(), @@ -71,7 +79,7 @@ async function createSubagentRuntime( }; loadOpenClawPlugins.mockReturnValue(createRegistry([])); serverPlugins.loadGatewayPlugins({ - cfg: {}, + cfg, workspaceDir: "/tmp", log, coreGatewayHandlers: {}, @@ -183,18 +191,29 @@ describe("loadGatewayPlugins", () => { expect(typeof subagent?.getSession).toBe("function"); }); - test("forwards provider and model overrides for agent runs", async () => { + test("forwards provider and model overrides when the request scope is authorized", async () => { const serverPlugins = await importServerPluginsModule(); const runtime = await createSubagentRuntime(serverPlugins); - serverPlugins.setFallbackGatewayContext(createTestContext("forward-overrides")); + const gatewayScopeModule = await import("../plugins/runtime/gateway-request-scope.js"); + const scope = { + context: createTestContext("request-scope-forward-overrides"), + client: { + connect: { + scopes: ["operator.admin"], + }, + } as GatewayRequestOptions["client"], + isWebchatConnect: () => false, + } satisfies PluginRuntimeGatewayRequestScope; - await runtime.run({ - sessionKey: "s-override", - message: "use the override", - provider: "anthropic", - model: "claude-haiku-4-6", - deliver: false, - }); + await gatewayScopeModule.withPluginRuntimeGatewayRequestScope(scope, () => + runtime.run({ + sessionKey: "s-override", + message: "use the override", + provider: "anthropic", + model: "claude-haiku-4-6", + deliver: false, + }), + ); expect(getLastDispatchedParams()).toMatchObject({ sessionKey: "s-override", @@ -205,6 +224,73 @@ describe("loadGatewayPlugins", () => { }); }); + test("rejects provider/model overrides for fallback runs without explicit authorization", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins); + serverPlugins.setFallbackGatewayContext(createTestContext("fallback-deny-overrides")); + + await expect( + runtime.run({ + sessionKey: "s-fallback-override", + message: "use the override", + provider: "anthropic", + model: "claude-haiku-4-6", + deliver: false, + }), + ).rejects.toThrow( + "provider/model override requires plugin identity in fallback subagent runs.", + ); + }); + + test("allows trusted fallback provider/model overrides when plugin config is explicit", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins, { + plugins: { + entries: { + "voice-call": { + subagent: { + allowModelOverride: true, + allowedModels: ["anthropic/claude-haiku-4-6"], + }, + }, + }, + }, + }); + serverPlugins.setFallbackGatewayContext(createTestContext("fallback-trusted-overrides")); + const gatewayScopeModule = await import("../plugins/runtime/gateway-request-scope.js"); + + await gatewayScopeModule.withPluginRuntimePluginIdScope("voice-call", () => + runtime.run({ + sessionKey: "s-trusted-override", + message: "use trusted override", + provider: "anthropic", + model: "claude-haiku-4-6", + deliver: false, + }), + ); + + expect(getLastDispatchedParams()).toMatchObject({ + sessionKey: "s-trusted-override", + provider: "anthropic", + model: "claude-haiku-4-6", + }); + }); + + test("uses least-privilege synthetic fallback scopes without admin", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins); + serverPlugins.setFallbackGatewayContext(createTestContext("synthetic-least-privilege")); + + await runtime.run({ + sessionKey: "s-synthetic", + message: "run synthetic", + deliver: false, + }); + + expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]); + expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); + }); + test("can prefer setup-runtime channel plugins during startup loads", async () => { const { loadGatewayPlugins } = await importServerPluginsModule(); loadOpenClawPlugins.mockReturnValue(createRegistry([])); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 150147bc7ba..c6de19a44af 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -1,9 +1,12 @@ import { randomUUID } from "node:crypto"; +import { normalizeModelRef } from "../agents/model-selection.js"; import type { loadConfig } from "../config/config.js"; +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 type { PluginRuntime } from "../plugins/runtime/types.js"; +import { ADMIN_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"; @@ -46,9 +49,128 @@ export function setFallbackGatewayContext(ctx: GatewayRequestContext): void { fallbackGatewayContextState.context = ctx; } +type PluginSubagentOverridePolicy = { + allowModelOverride: boolean; + allowAnyModel: boolean; + allowedModels: Set; +}; + +type PluginSubagentPolicyState = { + policies: Record; +}; + +const PLUGIN_SUBAGENT_POLICY_STATE_KEY: unique symbol = Symbol.for( + "openclaw.pluginSubagentOverridePolicyState", +); + +const pluginSubagentPolicyState: PluginSubagentPolicyState = (() => { + const globalState = globalThis as typeof globalThis & { + [PLUGIN_SUBAGENT_POLICY_STATE_KEY]?: PluginSubagentPolicyState; + }; + const existing = globalState[PLUGIN_SUBAGENT_POLICY_STATE_KEY]; + if (existing) { + return existing; + } + const created: PluginSubagentPolicyState = { + policies: {}, + }; + globalState[PLUGIN_SUBAGENT_POLICY_STATE_KEY] = created; + return created; +})(); + +function normalizeAllowedModelRef(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + if (trimmed === "*") { + return "*"; + } + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash >= trimmed.length - 1) { + return null; + } + const providerRaw = trimmed.slice(0, slash).trim(); + const modelRaw = trimmed.slice(slash + 1).trim(); + if (!providerRaw || !modelRaw) { + return null; + } + const normalized = normalizeModelRef(providerRaw, modelRaw); + return `${normalized.provider}/${normalized.model}`; +} + +function setPluginSubagentOverridePolicies(cfg: ReturnType): void { + const normalized = normalizePluginsConfig(cfg.plugins); + const policies: PluginSubagentPolicyState["policies"] = {}; + for (const [pluginId, entry] of Object.entries(normalized.entries)) { + const allowModelOverride = entry.subagent?.allowModelOverride === true; + const configuredAllowedModels = entry.subagent?.allowedModels ?? []; + const allowedModels = new Set(); + let allowAnyModel = false; + for (const modelRef of configuredAllowedModels) { + const normalizedModelRef = normalizeAllowedModelRef(modelRef); + if (!normalizedModelRef) { + continue; + } + if (normalizedModelRef === "*") { + allowAnyModel = true; + continue; + } + allowedModels.add(normalizedModelRef); + } + if (!allowModelOverride && allowedModels.size === 0 && !allowAnyModel) { + continue; + } + policies[pluginId] = { + allowModelOverride, + allowAnyModel, + allowedModels, + }; + } + pluginSubagentPolicyState.policies = policies; +} + +function authorizeFallbackModelOverride(params: { + pluginId?: string; + provider?: string; + model?: string; +}): { allowed: true } | { allowed: false; reason: string } { + const pluginId = params.pluginId?.trim(); + if (!pluginId) { + return { + allowed: false, + reason: "provider/model override requires plugin identity in fallback subagent runs.", + }; + } + const policy = pluginSubagentPolicyState.policies[pluginId]; + if (!policy?.allowModelOverride) { + return { + allowed: false, + reason: `plugin "${pluginId}" is not trusted for fallback provider/model override requests.`, + }; + } + if (!params.provider || !params.model) { + return { + allowed: false, + reason: "fallback provider/model overrides must include both provider and model values.", + }; + } + const normalizedRequest = normalizeModelRef(params.provider, params.model); + const requestedModelRef = `${normalizedRequest.provider}/${normalizedRequest.model}`; + if (policy.allowAnyModel || policy.allowedModels.has(requestedModelRef)) { + return { allowed: true }; + } + return { + allowed: false, + reason: `model override "${requestedModelRef}" is not allowlisted for plugin "${pluginId}".`, + }; +} + // ── Internal gateway dispatch for plugin runtime ──────────────────── -function createSyntheticOperatorClient(): GatewayRequestOptions["client"] { +function createSyntheticOperatorClient(params?: { + allowModelOverride?: boolean; +}): GatewayRequestOptions["client"] { return { connect: { minProtocol: PROTOCOL_VERSION, @@ -60,14 +182,29 @@ function createSyntheticOperatorClient(): GatewayRequestOptions["client"] { mode: GATEWAY_CLIENT_MODES.BACKEND, }, role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], + scopes: [WRITE_SCOPE], + }, + internal: { + allowModelOverride: params?.allowModelOverride === true, }, }; } +function hasAdminScope(client: GatewayRequestOptions["client"]): boolean { + const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; + return scopes.includes(ADMIN_SCOPE); +} + +function canClientUseModelOverride(client: GatewayRequestOptions["client"]): boolean { + return hasAdminScope(client) || client?.internal?.allowModelOverride === true; +} + async function dispatchGatewayMethod( method: string, params: Record, + options?: { + allowSyntheticModelOverride?: boolean; + }, ): Promise { const scope = getPluginRuntimeGatewayRequestScope(); const context = scope?.context ?? fallbackGatewayContextState.context; @@ -86,7 +223,11 @@ async function dispatchGatewayMethod( method, params, }, - client: scope?.client ?? createSyntheticOperatorClient(), + client: + scope?.client ?? + createSyntheticOperatorClient({ + allowModelOverride: options?.allowSyntheticModelOverride === true, + }), isWebchatConnect, respond: (ok, payload, error) => { if (!result) { @@ -116,16 +257,42 @@ function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { return { async run(params) { - const payload = await dispatchGatewayMethod<{ runId?: string }>("agent", { - sessionKey: params.sessionKey, - message: params.message, - deliver: params.deliver ?? false, - ...(params.provider && { provider: params.provider }), - ...(params.model && { model: params.model }), - ...(params.extraSystemPrompt && { extraSystemPrompt: params.extraSystemPrompt }), - ...(params.lane && { lane: params.lane }), - ...(params.idempotencyKey && { idempotencyKey: params.idempotencyKey }), - }); + 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 && !hasRequestScopeClient) { + const fallbackAuth = authorizeFallbackModelOverride({ + pluginId: scope?.pluginId, + provider: params.provider, + model: params.model, + }); + if (!fallbackAuth.allowed) { + throw new Error(fallbackAuth.reason); + } + 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, + ...(allowOverride && params.provider && { provider: params.provider }), + ...(allowOverride && params.model && { model: params.model }), + ...(params.extraSystemPrompt && { extraSystemPrompt: params.extraSystemPrompt }), + ...(params.lane && { lane: params.lane }), + ...(params.idempotencyKey && { idempotencyKey: params.idempotencyKey }), + }, + { + allowSyntheticModelOverride, + }, + ); const runId = payload?.runId; if (typeof runId !== "string" || !runId) { throw new Error("Gateway agent method returned an invalid runId."); @@ -178,6 +345,7 @@ export function loadGatewayPlugins(params: { preferSetupRuntimeForChannelPlugins?: boolean; logDiagnostics?: boolean; }) { + setPluginSubagentOverridePolicies(params.cfg); // 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 diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 8becf375f96..998077254eb 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -78,6 +78,37 @@ describe("normalizePluginsConfig", () => { expect(result.entries["voice-call"]?.hooks).toBeUndefined(); }); + it("normalizes plugin subagent override policy settings", () => { + const result = normalizePluginsConfig({ + entries: { + "voice-call": { + subagent: { + allowModelOverride: true, + allowedModels: [" anthropic/claude-haiku-4-6 ", "", "openai/gpt-4.1-mini"], + }, + }, + }, + }); + expect(result.entries["voice-call"]?.subagent).toEqual({ + allowModelOverride: true, + allowedModels: ["anthropic/claude-haiku-4-6", "openai/gpt-4.1-mini"], + }); + }); + + it("drops invalid plugin subagent override policy values", () => { + const result = normalizePluginsConfig({ + entries: { + "voice-call": { + subagent: { + allowModelOverride: "nope", + allowedModels: [42, null], + } as unknown as { allowModelOverride: boolean; allowedModels: string[] }, + }, + }, + }); + expect(result.entries["voice-call"]?.subagent).toBeUndefined(); + }); + it("normalizes legacy plugin ids to their merged bundled plugin id", () => { const result = normalizePluginsConfig({ allow: ["openai-codex", "minimax-portal-auth"], diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 8700cf8226b..2c97514e9c9 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -18,6 +18,10 @@ export type NormalizedPluginsConfig = { hooks?: { allowPromptInjection?: boolean; }; + subagent?: { + allowModelOverride?: boolean; + allowedModels?: string[]; + }; config?: unknown; } >; @@ -123,11 +127,38 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr allowPromptInjection: hooks.allowPromptInjection, } : undefined; + const subagentRaw = entry.subagent; + const subagent = + subagentRaw && typeof subagentRaw === "object" && !Array.isArray(subagentRaw) + ? { + allowModelOverride: (subagentRaw as { allowModelOverride?: unknown }) + .allowModelOverride, + allowedModels: Array.isArray((subagentRaw as { allowedModels?: unknown }).allowedModels) + ? ((subagentRaw as { allowedModels?: unknown }).allowedModels as unknown[]) + .map((model) => (typeof model === "string" ? model.trim() : "")) + .filter(Boolean) + : undefined, + } + : undefined; + const normalizedSubagent = + subagent && + (typeof subagent.allowModelOverride === "boolean" || + (Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0)) + ? { + ...(typeof subagent.allowModelOverride === "boolean" + ? { allowModelOverride: subagent.allowModelOverride } + : {}), + ...(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0 + ? { allowedModels: subagent.allowedModels } + : {}), + } + : undefined; normalized[normalizedKey] = { ...normalized[normalizedKey], enabled: typeof entry.enabled === "boolean" ? entry.enabled : normalized[normalizedKey]?.enabled, hooks: normalizedHooks ?? normalized[normalizedKey]?.hooks, + subagent: normalizedSubagent ?? normalized[normalizedKey]?.subagent, config: "config" in entry ? entry.config : normalized[normalizedKey]?.config, }; } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index ca4e40ee54c..4c863c3bdf4 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -14,6 +14,7 @@ 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 { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js"; import type { PluginRuntime } from "./runtime/types.js"; import { defaultSlotIdForKey } from "./slots.js"; import { @@ -835,6 +836,36 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { debug: logger.debug, }); + const pluginRuntimeById = new Map(); + + const resolvePluginRuntime = (pluginId: string): PluginRuntime => { + const cached = pluginRuntimeById.get(pluginId); + if (cached) { + return cached; + } + const runtime = new Proxy(registryParams.runtime, { + get(target, prop, receiver) { + if (prop !== "subagent") { + return Reflect.get(target, prop, receiver); + } + const subagent = Reflect.get(target, prop, receiver); + return { + run: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.run(params)), + waitForRun: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.waitForRun(params)), + getSessionMessages: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.getSessionMessages(params)), + getSession: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.getSession(params)), + deleteSession: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.deleteSession(params)), + } satisfies PluginRuntime["subagent"]; + }, + }); + pluginRuntimeById.set(pluginId, runtime); + return runtime; + }; + const createApi = ( record: PluginRecord, params: { @@ -855,7 +886,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registrationMode, config: params.config, pluginConfig: params.pluginConfig, - runtime: registryParams.runtime, + runtime: resolvePluginRuntime(record.id), logger: normalizeLogger(registryParams.logger), registerTool: registrationMode === "full" ? (tool, opts) => registerTool(record, tool, opts) : () => {}, diff --git a/src/plugins/runtime/gateway-request-scope.test.ts b/src/plugins/runtime/gateway-request-scope.test.ts index ef31350e2a3..4d00d04fd74 100644 --- a/src/plugins/runtime/gateway-request-scope.test.ts +++ b/src/plugins/runtime/gateway-request-scope.test.ts @@ -20,4 +20,17 @@ describe("gateway request scope", () => { expect(second.getPluginRuntimeGatewayRequestScope()).toEqual(TEST_SCOPE); }); }); + + it("attaches plugin id to the active scope", async () => { + const runtimeScope = await import("./gateway-request-scope.js"); + + await runtimeScope.withPluginRuntimeGatewayRequestScope(TEST_SCOPE, async () => { + await runtimeScope.withPluginRuntimePluginIdScope("voice-call", async () => { + expect(runtimeScope.getPluginRuntimeGatewayRequestScope()).toEqual({ + ...TEST_SCOPE, + pluginId: "voice-call", + }); + }); + }); + }); }); diff --git a/src/plugins/runtime/gateway-request-scope.ts b/src/plugins/runtime/gateway-request-scope.ts index 72a6f5af402..7a4ffbb608b 100644 --- a/src/plugins/runtime/gateway-request-scope.ts +++ b/src/plugins/runtime/gateway-request-scope.ts @@ -8,6 +8,7 @@ export type PluginRuntimeGatewayRequestScope = { context?: GatewayRequestContext; client?: GatewayRequestOptions["client"]; isWebchatConnect: GatewayRequestOptions["isWebchatConnect"]; + pluginId?: string; }; const PLUGIN_RUNTIME_GATEWAY_REQUEST_SCOPE_KEY: unique symbol = Symbol.for( @@ -37,6 +38,20 @@ export function withPluginRuntimeGatewayRequestScope( return pluginRuntimeGatewayRequestScope.run(scope, run); } +/** + * Runs work under the current gateway request scope while attaching plugin identity. + */ +export function withPluginRuntimePluginIdScope(pluginId: string, run: () => T): T { + const current = pluginRuntimeGatewayRequestScope.getStore(); + const scoped: PluginRuntimeGatewayRequestScope = current + ? { ...current, pluginId } + : { + pluginId, + isWebchatConnect: () => false, + }; + return pluginRuntimeGatewayRequestScope.run(scoped, run); +} + /** * Returns the current plugin gateway request scope when called from a plugin request handler. */