fix(plugins): forward plugin subagent overrides (#48277)

Merged via squash.

Prepared head SHA: ffa45893e0
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Josh Lehman
2026-03-17 07:20:27 -07:00
committed by GitHub
parent 1561c6a71c
commit 1399ca5fcb
32 changed files with 1203 additions and 65 deletions

View File

@@ -1,9 +1,12 @@
import { randomUUID } from "node:crypto";
import { normalizeModelRef, parseModelRef } 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,168 @@ export function setFallbackGatewayContext(ctx: GatewayRequestContext): void {
fallbackGatewayContextState.context = ctx;
}
type PluginSubagentOverridePolicy = {
allowModelOverride: boolean;
allowAnyModel: boolean;
hasConfiguredAllowlist: 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 hasConfiguredAllowlist = entry.subagent?.hasAllowedModelsConfig === 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 &&
!hasConfiguredAllowlist &&
allowedModels.size === 0 &&
!allowAnyModel
) {
continue;
}
policies[pluginId] = {
allowModelOverride,
allowAnyModel,
hasConfiguredAllowlist,
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 (policy.allowAnyModel) {
return { allowed: true };
}
if (policy.hasConfiguredAllowlist && policy.allowedModels.size === 0) {
return {
allowed: false,
reason: `plugin "${pluginId}" configured subagent.allowedModels, but none of the entries normalized to a valid provider/model target.`,
};
}
if (policy.allowedModels.size === 0) {
return { allowed: true };
}
const requestedModelRef = resolveRequestedFallbackModelRef(params);
if (!requestedModelRef) {
return {
allowed: false,
reason:
"fallback provider/model overrides that use an allowlist must resolve to a canonical provider/model target.",
};
}
if (policy.allowedModels.has(requestedModelRef)) {
return { allowed: true };
}
return {
allowed: false,
reason: `model override "${requestedModelRef}" is not allowlisted for plugin "${pluginId}".`,
};
}
function resolveRequestedFallbackModelRef(params: {
provider?: string;
model?: string;
}): string | null {
if (params.provider && params.model) {
const normalizedRequest = normalizeModelRef(params.provider, params.model);
return `${normalizedRequest.provider}/${normalizedRequest.model}`;
}
const rawModel = params.model?.trim();
if (!rawModel || !rawModel.includes("/")) {
return null;
}
const parsed = parseModelRef(rawModel, "");
if (!parsed?.provider || !parsed.model) {
return null;
}
return `${parsed.provider}/${parsed.model}`;
}
// ── Internal gateway dispatch for plugin runtime ────────────────────
function createSyntheticOperatorClient(): GatewayRequestOptions["client"] {
function createSyntheticOperatorClient(params?: {
allowModelOverride?: boolean;
scopes?: string[];
}): GatewayRequestOptions["client"] {
return {
connect: {
minProtocol: PROTOCOL_VERSION,
@@ -60,14 +222,30 @@ function createSyntheticOperatorClient(): GatewayRequestOptions["client"] {
mode: GATEWAY_CLIENT_MODES.BACKEND,
},
role: "operator",
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
scopes: params?.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;
syntheticScopes?: string[];
},
): Promise<T> {
const scope = getPluginRuntimeGatewayRequestScope();
const context = scope?.context ?? fallbackGatewayContextState.context;
@@ -86,7 +264,12 @@ async function dispatchGatewayMethod<T>(
method,
params,
},
client: scope?.client ?? createSyntheticOperatorClient(),
client:
scope?.client ??
createSyntheticOperatorClient({
allowModelOverride: options?.allowSyntheticModelOverride === true,
scopes: options?.syntheticScopes,
}),
isWebchatConnect,
respond: (ok, payload, error) => {
if (!result) {
@@ -116,14 +299,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.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.");
@@ -152,10 +363,16 @@ function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
return getSessionMessages(params);
},
async deleteSession(params) {
await dispatchGatewayMethod("sessions.delete", {
key: params.sessionKey,
deleteTranscript: params.deleteTranscript ?? true,
});
await dispatchGatewayMethod(
"sessions.delete",
{
key: params.sessionKey,
deleteTranscript: params.deleteTranscript ?? true,
},
{
syntheticScopes: [ADMIN_SCOPE],
},
);
},
};
}
@@ -176,6 +393,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