mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 11:50:20 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user