mirror of https://github.com/openclaw/openclaw.git
Security: add trusted plugin override policy
This commit is contained in:
parent
d1aa55b13c
commit
c675c80fce
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -979,6 +979,12 @@ export const FIELD_HELP: Record<string, string> = {
|
|||
"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":
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | undefined {
|
|||
return call?.req?.params as Record<string, unknown> | 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<ServerPluginsModule> {
|
||||
return import("./server-plugins.js");
|
||||
}
|
||||
|
||||
async function createSubagentRuntime(
|
||||
serverPlugins: ServerPluginsModule,
|
||||
cfg: Record<string, unknown> = {},
|
||||
): Promise<PluginRuntime["subagent"]> {
|
||||
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([]));
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
};
|
||||
|
||||
type PluginSubagentPolicyState = {
|
||||
policies: Record<string, PluginSubagentOverridePolicy>;
|
||||
};
|
||||
|
||||
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<typeof loadConfig>): 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<string>();
|
||||
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<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
options?: {
|
||||
allowSyntheticModelOverride?: boolean;
|
||||
},
|
||||
): Promise<T> {
|
||||
const scope = getPluginRuntimeGatewayRequestScope();
|
||||
const context = scope?.context ?? fallbackGatewayContextState.context;
|
||||
|
|
@ -86,7 +223,11 @@ async function dispatchGatewayMethod<T>(
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, PluginRuntime>();
|
||||
|
||||
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) : () => {},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<T>(
|
|||
return pluginRuntimeGatewayRequestScope.run(scope, run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs work under the current gateway request scope while attaching plugin identity.
|
||||
*/
|
||||
export function withPluginRuntimePluginIdScope<T>(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.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue