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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1203 additions and 65 deletions

View File

@ -117,6 +117,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: trigger overflow recovery from the tool-result guard once post-compaction context still exceeds the safe threshold, so long tool loops compact before the next model call hard-fails. (#29371) thanks @keshav55.
- macOS/exec approvals: harden exec-host request HMAC verification to use a timing-safe compare and keep malformed or truncated signatures fail-closed in focused IPC auth coverage.
- Gateway/exec approvals: surface requested env override keys in gateway-host approval prompts so operators can review surviving env context without inheriting noisy base host env.
- Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman.
### Fixes

View File

@ -515,6 +515,8 @@ public struct PollParams: Codable, Sendable {
public struct AgentParams: Codable, Sendable {
public let message: String
public let agentid: String?
public let provider: String?
public let model: String?
public let to: String?
public let replyto: String?
public let sessionid: String?
@ -542,6 +544,8 @@ public struct AgentParams: Codable, Sendable {
public init(
message: String,
agentid: String?,
provider: String?,
model: String?,
to: String?,
replyto: String?,
sessionid: String?,
@ -568,6 +572,8 @@ public struct AgentParams: Codable, Sendable {
{
self.message = message
self.agentid = agentid
self.provider = provider
self.model = model
self.to = to
self.replyto = replyto
self.sessionid = sessionid
@ -596,6 +602,8 @@ public struct AgentParams: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case message
case agentid = "agentId"
case provider
case model
case to
case replyto = "replyTo"
case sessionid = "sessionId"

View File

@ -515,6 +515,8 @@ public struct PollParams: Codable, Sendable {
public struct AgentParams: Codable, Sendable {
public let message: String
public let agentid: String?
public let provider: String?
public let model: String?
public let to: String?
public let replyto: String?
public let sessionid: String?
@ -542,6 +544,8 @@ public struct AgentParams: Codable, Sendable {
public init(
message: String,
agentid: String?,
provider: String?,
model: String?,
to: String?,
replyto: String?,
sessionid: String?,
@ -568,6 +572,8 @@ public struct AgentParams: Codable, Sendable {
{
self.message = message
self.agentid = agentid
self.provider = provider
self.model = model
self.to = to
self.replyto = replyto
self.sessionid = sessionid
@ -596,6 +602,8 @@ public struct AgentParams: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case message
case agentid = "agentId"
case provider
case model
case to
case replyto = "replyTo"
case sessionid = "sessionId"

View File

@ -2419,6 +2419,8 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio
- `plugins.entries.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
- `plugins.entries.<id>.env`: plugin-scoped env var map.
- `plugins.entries.<id>.hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories.
- `plugins.entries.<id>.subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs.
- `plugins.entries.<id>.subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model.
- `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).
- Enabled Claude bundle plugins can also contribute embedded Pi defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches.
- `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins.

View File

@ -862,6 +862,26 @@ Notes:
- Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input).
- `api.runtime.stt.transcribeAudioFile(...)` remains as a compatibility alias.
Plugins can also launch background subagent runs through `api.runtime.subagent`:
```ts
const result = await api.runtime.subagent.run({
sessionKey: "agent:main:subagent:search-helper",
message: "Expand this query into focused follow-up searches.",
provider: "openai",
model: "gpt-4.1-mini",
deliver: false,
});
```
Notes:
- `provider` and `model` are optional per-run overrides, not persistent session changes.
- OpenClaw only honors those override fields for trusted callers.
- For plugin-owned fallback runs, operators must opt in with `plugins.entries.<id>.subagent.allowModelOverride: true`.
- Use `plugins.entries.<id>.subagent.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets, or `"*"` to allow any target explicitly.
- Untrusted plugin subagent runs still work, but override requests are rejected instead of silently falling back.
For web search, plugins can consume the shared runtime helper instead of
reaching into the agent tool wiring:

View File

@ -623,6 +623,7 @@ export class DiscordVoiceManager {
agentId: entry.route.agentId,
messageChannel: "discord",
senderIsOwner: speaker.senderIsOwner,
allowModelOverride: false,
deliver: false,
},
this.params.runtime,

View File

@ -308,7 +308,6 @@ describe("acp session UX bridge behavior", () => {
"low",
"medium",
"high",
"xhigh",
"adaptive",
]);
expect(result.configOptions).toEqual(

View File

@ -51,6 +51,7 @@ import { applyVerboseOverride } from "../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { resolveMessageChannel } from "../utils/message-channel.js";
import {
listAgentIds,
@ -82,6 +83,7 @@ import {
modelKey,
normalizeModelRef,
normalizeProviderId,
parseModelRef,
resolveConfiguredModelRef,
resolveDefaultModelForAgent,
resolveThinkingDefault,
@ -124,6 +126,36 @@ const OVERRIDE_FIELDS_CLEARED_BY_DELETE: OverrideFieldClearedByDelete[] = [
"claudeCliSessionId",
];
const OVERRIDE_VALUE_MAX_LENGTH = 256;
function containsControlCharacters(value: string): boolean {
for (const char of value) {
const code = char.codePointAt(0);
if (code === undefined) {
continue;
}
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) {
return true;
}
}
return false;
}
function normalizeExplicitOverrideInput(raw: string, kind: "provider" | "model"): string {
const trimmed = raw.trim();
const label = kind === "provider" ? "Provider" : "Model";
if (!trimmed) {
throw new Error(`${label} override must be non-empty.`);
}
if (trimmed.length > OVERRIDE_VALUE_MAX_LENGTH) {
throw new Error(`${label} override exceeds ${String(OVERRIDE_VALUE_MAX_LENGTH)} characters.`);
}
if (containsControlCharacters(trimmed)) {
throw new Error(`${label} override contains invalid control characters.`);
}
return trimmed;
}
async function persistSessionEntry(params: PersistSessionEntryParams): Promise<void> {
const persisted = await updateSessionStore(params.storePath, (store) => {
const merged = mergeSessionEntry(store[params.sessionKey], params.entry);
@ -340,7 +372,7 @@ function runAgentAttempt(params: {
resolvedVerboseLevel: VerboseLevel | undefined;
agentDir: string;
onAgentEvent: (evt: { stream: string; data?: Record<string, unknown> }) => void;
primaryProvider: string;
authProfileProvider: string;
sessionStore?: Record<string, SessionEntry>;
storePath?: string;
allowTransientCooldownProbe?: boolean;
@ -388,7 +420,7 @@ function runAgentAttempt(params: {
params.storePath
) {
log.warn(
`CLI session expired, clearing from session store: provider=${params.providerOverride} sessionKey=${params.sessionKey}`,
`CLI session expired, clearing from session store: provider=${sanitizeForLog(params.providerOverride)} sessionKey=${params.sessionKey}`,
);
// Clear the expired session ID from the session store
@ -452,7 +484,7 @@ function runAgentAttempt(params: {
}
const authProfileId =
params.providerOverride === params.primaryProvider
params.providerOverride === params.authProfileProvider
? params.sessionEntry?.authProfileOverride
: undefined;
return runEmbeddedPiAgent({
@ -937,7 +969,19 @@ async function agentCommandInternal(
const hasStoredOverride = Boolean(
sessionEntry?.modelOverride || sessionEntry?.providerOverride,
);
const needsModelCatalog = hasAllowlist || hasStoredOverride;
const explicitProviderOverride =
typeof opts.provider === "string"
? normalizeExplicitOverrideInput(opts.provider, "provider")
: undefined;
const explicitModelOverride =
typeof opts.model === "string"
? normalizeExplicitOverrideInput(opts.model, "model")
: undefined;
const hasExplicitRunOverride = Boolean(explicitProviderOverride || explicitModelOverride);
if (hasExplicitRunOverride && opts.allowModelOverride !== true) {
throw new Error("Model override is not authorized for this caller.");
}
const needsModelCatalog = hasAllowlist || hasStoredOverride || hasExplicitRunOverride;
let allowedModelKeys = new Set<string>();
let allowedModelCatalog: Awaited<ReturnType<typeof loadModelCatalog>> = [];
let modelCatalog: Awaited<ReturnType<typeof loadModelCatalog>> | null = null;
@ -1000,13 +1044,38 @@ async function agentCommandInternal(
model = normalizedStored.model;
}
}
const providerForAuthProfileValidation = provider;
if (hasExplicitRunOverride) {
const explicitRef = explicitModelOverride
? explicitProviderOverride
? normalizeModelRef(explicitProviderOverride, explicitModelOverride)
: parseModelRef(explicitModelOverride, provider)
: explicitProviderOverride
? normalizeModelRef(explicitProviderOverride, model)
: null;
if (!explicitRef) {
throw new Error("Invalid model override.");
}
const explicitKey = modelKey(explicitRef.provider, explicitRef.model);
if (
!isCliProvider(explicitRef.provider, cfg) &&
!allowAnyModel &&
!allowedModelKeys.has(explicitKey)
) {
throw new Error(
`Model override "${sanitizeForLog(explicitRef.provider)}/${sanitizeForLog(explicitRef.model)}" is not allowed for agent "${sessionAgentId}".`,
);
}
provider = explicitRef.provider;
model = explicitRef.model;
}
if (sessionEntry) {
const authProfileId = sessionEntry.authProfileOverride;
if (authProfileId) {
const entry = sessionEntry;
const store = ensureAuthProfileStore();
const profile = store.profiles[authProfileId];
if (!profile || profile.provider !== provider) {
if (!profile || profile.provider !== providerForAuthProfileValidation) {
if (sessionStore && sessionKey) {
await clearSessionAuthProfileOverride({
sessionEntry: entry,
@ -1068,6 +1137,7 @@ async function agentCommandInternal(
const resolvedSessionFile = await resolveSessionTranscriptFile({
sessionId,
sessionKey: sessionKey ?? sessionId,
storePath,
sessionEntry,
agentId: sessionAgentId,
threadId: opts.threadId,
@ -1132,7 +1202,7 @@ async function agentCommandInternal(
skillsSnapshot,
resolvedVerboseLevel,
agentDir,
primaryProvider: provider,
authProfileProvider: providerForAuthProfileValidation,
sessionStore,
storePath,
allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
@ -1230,6 +1300,8 @@ export async function agentCommand(
// Ingress callers must opt into owner semantics explicitly via
// agentCommandFromIngress so network-facing paths cannot inherit this default by accident.
senderIsOwner: opts.senderIsOwner ?? true,
// Local/CLI callers are trusted by default for per-run model overrides.
allowModelOverride: opts.allowModelOverride ?? true,
},
runtime,
deps,
@ -1246,10 +1318,14 @@ export async function agentCommandFromIngress(
// This keeps network-facing callers from silently picking up the local trusted default.
throw new Error("senderIsOwner must be explicitly set for ingress agent runs.");
}
if (typeof opts.allowModelOverride !== "boolean") {
throw new Error("allowModelOverride must be explicitly set for ingress agent runs.");
}
return await agentCommandInternal(
{
...opts,
senderIsOwner: opts.senderIsOwner,
allowModelOverride: opts.allowModelOverride,
},
runtime,
deps,

View File

@ -39,6 +39,10 @@ export type AgentCommandOpts = {
clientTools?: ClientToolDefinition[];
/** Agent id override (must exist in config). */
agentId?: string;
/** Per-run provider override. */
provider?: string;
/** Per-run model override. */
model?: string;
to?: string;
sessionId?: string;
sessionKey?: string;
@ -65,6 +69,8 @@ export type AgentCommandOpts = {
runContext?: AgentRunContext;
/** Whether this caller is authorized for owner-only tools (defaults true for local CLI calls). */
senderIsOwner?: boolean;
/** Whether this caller is authorized to use provider/model per-run overrides. */
allowModelOverride?: boolean;
/** Group/spawn metadata for subagent policy inheritance and routing context. */
groupId?: SpawnedRunMetadata["groupId"];
groupChannel?: SpawnedRunMetadata["groupChannel"];
@ -84,7 +90,12 @@ export type AgentCommandOpts = {
workspaceDir?: SpawnedRunMetadata["workspaceDir"];
};
export type AgentCommandIngressOpts = Omit<AgentCommandOpts, "senderIsOwner"> & {
/** Ingress callsites must always pass explicit owner authorization state. */
export type AgentCommandIngressOpts = Omit<
AgentCommandOpts,
"senderIsOwner" | "allowModelOverride"
> & {
/** Ingress callsites must always pass explicit owner-tool authorization state. */
senderIsOwner: boolean;
/** Ingress callsites must always pass explicit model-override authorization state. */
allowModelOverride: boolean;
};

View File

@ -3,6 +3,7 @@ import path from "node:path";
import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import "../cron/isolated-agent.mocks.js";
import * as authProfilesModule from "../agents/auth-profiles.js";
import * as cliRunnerModule from "../agents/cli-runner.js";
import { FailoverError } from "../agents/failover-error.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
@ -11,7 +12,7 @@ import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js";
import type { OpenClawConfig } from "../config/config.js";
import * as configModule from "../config/config.js";
import * as sessionsModule from "../config/sessions.js";
import * as sessionPathsModule from "../config/sessions/paths.js";
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import type { RuntimeEnv } from "../runtime.js";
@ -19,6 +20,24 @@ import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/chan
import { agentCommand, agentCommandFromIngress } from "./agent.js";
import * as agentDeliveryModule from "./agent/delivery.js";
vi.mock("../logging/subsystem.js", () => {
const createMockLogger = () => ({
subsystem: "test",
isEnabled: vi.fn(() => true),
trace: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
raw: vi.fn(),
child: vi.fn(() => createMockLogger()),
});
return {
createSubsystemLogger: vi.fn(() => createMockLogger()),
};
});
vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../agents/auth-profiles.js")>();
return {
@ -27,10 +46,13 @@ vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
};
});
vi.mock("../agents/workspace.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../agents/workspace.js")>();
vi.mock("../agents/workspace.js", () => {
const resolveDefaultAgentWorkspaceDir = () => "/tmp/openclaw-workspace";
return {
...actual,
DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace",
DEFAULT_AGENTS_FILENAME: "AGENTS.md",
DEFAULT_IDENTITY_FILENAME: "IDENTITY.md",
resolveDefaultAgentWorkspaceDir,
ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => ({ dir })),
};
});
@ -405,13 +427,35 @@ describe("agentCommand", () => {
});
});
it("requires explicit allowModelOverride for ingress runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await expect(
// Runtime guard for non-TS callers; TS callsites are statically typed.
agentCommandFromIngress(
{
message: "hi",
to: "+1555",
senderIsOwner: false,
} as never,
runtime,
),
).rejects.toThrow("allowModelOverride must be explicitly set for ingress agent runs.");
});
});
it("honors explicit senderIsOwner for ingress runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await agentCommandFromIngress({ message: "hi", to: "+1555", senderIsOwner: false }, runtime);
await agentCommandFromIngress(
{ message: "hi", to: "+1555", senderIsOwner: false, allowModelOverride: false },
runtime,
);
const ingressCall = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(ingressCall?.senderIsOwner).toBe(false);
expect(ingressCall).not.toHaveProperty("allowModelOverride");
});
});
@ -462,7 +506,7 @@ describe("agentCommand", () => {
const store = path.join(customStoreDir, "sessions.json");
writeSessionStoreSeed(store, {});
mockConfig(home, store);
const resolveSessionFilePathSpy = vi.spyOn(sessionsModule, "resolveSessionFilePath");
const resolveSessionFilePathSpy = vi.spyOn(sessionPathsModule, "resolveSessionFilePath");
await agentCommand({ message: "resume me", sessionId: "session-custom-123" }, runtime);
@ -686,6 +730,149 @@ describe("agentCommand", () => {
});
});
it("applies per-run provider and model overrides without persisting them", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, {
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
});
await agentCommand(
{
message: "use the override",
sessionKey: "agent:main:subagent:run-override",
provider: "openai",
model: "gpt-4.1-mini",
},
runtime,
);
expectLastRunProviderModel("openai", "gpt-4.1-mini");
const saved = readSessionStore<{
providerOverride?: string;
modelOverride?: string;
}>(store);
expect(saved["agent:main:subagent:run-override"]?.providerOverride).toBeUndefined();
expect(saved["agent:main:subagent:run-override"]?.modelOverride).toBeUndefined();
});
});
it("rejects explicit override values that contain control characters", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, {
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
});
await expect(
agentCommand(
{
message: "use an invalid override",
sessionKey: "agent:main:subagent:invalid-override",
provider: "openai\u001b[31m",
model: "gpt-4.1-mini",
},
runtime,
),
).rejects.toThrow("Provider override contains invalid control characters.");
});
});
it("sanitizes provider/model text in model-allowlist errors", async () => {
const parseModelRefSpy = vi.spyOn(modelSelectionModule, "parseModelRef");
parseModelRefSpy.mockImplementationOnce(() => ({
provider: "anthropic\u001b[31m",
model: "claude-haiku-4-5\u001b[32m",
}));
try {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, {
models: {
"openai/gpt-4.1-mini": {},
},
});
await expect(
agentCommand(
{
message: "use disallowed override",
sessionKey: "agent:main:subagent:sanitized-override-error",
model: "claude-haiku-4-5",
},
runtime,
),
).rejects.toThrow(
'Model override "anthropic/claude-haiku-4-5" is not allowed for agent "main".',
);
});
} finally {
parseModelRefSpy.mockRestore();
}
});
it("keeps stored auth profile overrides during one-off cross-provider runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
writeSessionStoreSeed(store, {
"agent:main:subagent:temp-openai-run": {
sessionId: "session-temp-openai-run",
updatedAt: Date.now(),
authProfileOverride: "anthropic:work",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 2,
},
});
mockConfig(home, store, {
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
});
vi.mocked(authProfilesModule.ensureAuthProfileStore).mockReturnValue({
version: 1,
profiles: {
"anthropic:work": {
provider: "anthropic",
},
},
} as never);
await agentCommand(
{
message: "use a different provider once",
sessionKey: "agent:main:subagent:temp-openai-run",
provider: "openai",
model: "gpt-4.1-mini",
},
runtime,
);
expectLastRunProviderModel("openai", "gpt-4.1-mini");
expect(getLastEmbeddedCall()?.authProfileId).toBeUndefined();
const saved = readSessionStore<{
authProfileOverride?: string;
authProfileOverrideSource?: string;
authProfileOverrideCompactionCount?: number;
}>(store);
expect(saved["agent:main:subagent:temp-openai-run"]?.authProfileOverride).toBe(
"anthropic:work",
);
expect(saved["agent:main:subagent:temp-openai-run"]?.authProfileOverrideSource).toBe("user");
expect(saved["agent:main:subagent:temp-openai-run"]?.authProfileOverrideCompactionCount).toBe(
2,
);
});
});
it("keeps explicit sessionKey even when sessionId exists elsewhere", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");

View File

@ -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-5"],
},
},
},
},
});
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(

View File

@ -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",

View File

@ -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":

View File

@ -863,6 +863,9 @@ export const FIELD_LABELS: Record<string, string> = {
"plugins.entries.*.enabled": "Plugin Enabled",
"plugins.entries.*.hooks": "Plugin Hook Policy",
"plugins.entries.*.hooks.allowPromptInjection": "Allow Prompt Injection Hooks",
"plugins.entries.*.subagent": "Plugin Subagent Policy",
"plugins.entries.*.subagent.allowModelOverride": "Allow Plugin Subagent Model Override",
"plugins.entries.*.subagent.allowedModels": "Plugin Subagent Allowed Models",
"plugins.entries.*.apiKey": "Plugin API Key", // pragma: allowlist secret
"plugins.entries.*.env": "Plugin Environment Variables",
"plugins.entries.*.config": "Plugin Config",

View File

@ -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>;
};

View File

@ -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();

View File

@ -117,6 +117,7 @@ function buildAgentCommandInput(params: {
bestEffortDeliver: false as const,
// HTTP API callers are authenticated operator clients for this gateway context.
senderIsOwner: true as const,
allowModelOverride: true as const,
};
}

View File

@ -256,6 +256,7 @@ async function runResponsesAgentCommand(params: {
bestEffortDeliver: false,
// HTTP API callers are authenticated operator clients for this gateway context.
senderIsOwner: true,
allowModelOverride: true,
},
defaultRuntime,
params.deps,

View File

@ -75,6 +75,8 @@ export const AgentParamsSchema = Type.Object(
{
message: NonEmptyString,
agentId: Type.Optional(NonEmptyString),
provider: Type.Optional(Type.String()),
model: Type.Optional(Type.String()),
to: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
sessionId: Type.Optional(Type.String()),

View File

@ -303,6 +303,107 @@ describe("gateway agent handler", () => {
expect(capturedEntry?.acp).toEqual(existingAcpMeta);
});
it("forwards provider and model overrides for admin-scoped callers", async () => {
primeMainAgentRun();
await invokeAgent(
{
message: "test override",
agentId: "main",
sessionKey: "agent:main:main",
provider: "anthropic",
model: "claude-haiku-4-5",
idempotencyKey: "test-idem-model-override",
},
{
reqId: "test-idem-model-override",
client: {
connect: {
scopes: ["operator.admin"],
},
} as AgentHandlerArgs["client"],
},
);
const lastCall = mocks.agentCommand.mock.calls.at(-1);
expect(lastCall?.[0]).toEqual(
expect.objectContaining({
provider: "anthropic",
model: "claude-haiku-4-5",
}),
);
});
it("rejects provider and model overrides for write-scoped callers", async () => {
primeMainAgentRun();
mocks.agentCommand.mockClear();
const respond = vi.fn();
await invokeAgent(
{
message: "test override",
agentId: "main",
sessionKey: "agent:main:main",
provider: "anthropic",
model: "claude-haiku-4-5",
idempotencyKey: "test-idem-model-override-write",
},
{
reqId: "test-idem-model-override-write",
client: {
connect: {
scopes: ["operator.write"],
},
} as AgentHandlerArgs["client"],
respond,
},
);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: "provider/model overrides are not authorized for this caller.",
}),
);
});
it("forwards provider and model overrides when internal override authorization is set", async () => {
primeMainAgentRun();
await invokeAgent(
{
message: "test override",
agentId: "main",
sessionKey: "agent:main:main",
provider: "anthropic",
model: "claude-haiku-4-5",
idempotencyKey: "test-idem-model-override-internal",
},
{
reqId: "test-idem-model-override-internal",
client: {
connect: {
scopes: ["operator.write"],
},
internal: {
allowModelOverride: true,
},
} as AgentHandlerArgs["client"],
},
);
const lastCall = mocks.agentCommand.mock.calls.at(-1);
expect(lastCall?.[0]).toEqual(
expect.objectContaining({
provider: "anthropic",
model: "claude-haiku-4-5",
senderIsOwner: false,
}),
);
});
it("preserves cliSessionIds from existing session entry", async () => {
const existingCliSessionIds = { "claude-cli": "abc-123-def" };
const existingClaudeCliSessionId = "abc-123-def";

View File

@ -71,6 +71,12 @@ function resolveSenderIsOwnerFromClient(client: GatewayRequestHandlerOptions["cl
return scopes.includes(ADMIN_SCOPE);
}
function resolveAllowModelOverrideFromClient(
client: GatewayRequestHandlerOptions["client"],
): boolean {
return resolveSenderIsOwnerFromClient(client) || client?.internal?.allowModelOverride === true;
}
async function runSessionResetFromAgent(params: {
key: string;
reason: "new" | "reset";
@ -162,6 +168,8 @@ export const agentHandlers: GatewayRequestHandlers = {
const request = p as {
message: string;
agentId?: string;
provider?: string;
model?: string;
to?: string;
replyTo?: string;
sessionId?: string;
@ -192,6 +200,21 @@ export const agentHandlers: GatewayRequestHandlers = {
inputProvenance?: InputProvenance;
};
const senderIsOwner = resolveSenderIsOwnerFromClient(client);
const allowModelOverride = resolveAllowModelOverrideFromClient(client);
const requestedModelOverride = Boolean(request.provider || request.model);
if (requestedModelOverride && !allowModelOverride) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"provider/model overrides are not authorized for this caller.",
),
);
return;
}
const providerOverride = allowModelOverride ? request.provider : undefined;
const modelOverride = allowModelOverride ? request.model : undefined;
const cfg = loadConfig();
const idem = request.idempotencyKey;
const normalizedSpawned = normalizeSpawnedRunMetadata({
@ -584,6 +607,8 @@ export const agentHandlers: GatewayRequestHandlers = {
ingressOpts: {
message,
images,
provider: providerOverride,
model: modelOverride,
to: resolvedTo,
sessionId: resolvedSessionId,
sessionKey: resolvedSessionKey,
@ -619,6 +644,7 @@ export const agentHandlers: GatewayRequestHandlers = {
workspaceDir: sessionEntry?.spawnedWorkspaceDir,
}),
senderIsOwner,
allowModelOverride,
},
runId,
idempotencyKey: idem,

View File

@ -21,6 +21,10 @@ export type GatewayClient = {
canvasHostUrl?: string;
canvasCapability?: string;
canvasCapabilityExpiresAtMs?: number;
/** Internal-only auth context that cannot be supplied through gateway RPC payloads. */
internal?: {
allowModelOverride?: boolean;
};
};
export type RespondFn = (

View File

@ -310,6 +310,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
sourceTool: "gateway.voice.transcript",
},
senderIsOwner: false,
allowModelOverride: false,
},
defaultRuntime,
ctx.deps,
@ -441,6 +442,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
typeof link?.timeoutSeconds === "number" ? link.timeoutSeconds.toString() : undefined,
messageChannel: "node",
senderIsOwner: false,
allowModelOverride: false,
},
defaultRuntime,
ctx.deps,

View File

@ -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";
@ -20,6 +21,19 @@ vi.mock("./server-methods.js", () => ({
handleGatewayRequest,
}));
vi.mock("../channels/registry.js", () => ({
CHAT_CHANNEL_ORDER: [],
CHANNEL_IDS: [],
listChatChannels: () => [],
listChatChannelAliases: () => [],
getChatChannelMeta: () => null,
normalizeChatChannelId: () => null,
normalizeChannelId: () => null,
normalizeAnyChannelId: () => null,
formatChannelPrimerLine: () => "",
formatChannelSelectionLine: () => "",
}));
const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
plugins: [],
tools: [],
@ -51,12 +65,24 @@ function getLastDispatchedContext(): GatewayRequestContext | undefined {
return call?.context;
}
function getLastDispatchedParams(): Record<string, unknown> | undefined {
const call = handleGatewayRequest.mock.calls.at(-1)?.[0];
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(),
@ -66,7 +92,7 @@ async function createSubagentRuntime(
};
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
serverPlugins.loadGatewayPlugins({
cfg: {},
cfg,
workspaceDir: "/tmp",
log,
coreGatewayHandlers: {},
@ -178,6 +204,215 @@ describe("loadGatewayPlugins", () => {
expect(typeof subagent?.getSession).toBe("function");
});
test("forwards provider and model overrides when the request scope is authorized", async () => {
const serverPlugins = await importServerPluginsModule();
const runtime = await createSubagentRuntime(serverPlugins);
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 gatewayScopeModule.withPluginRuntimeGatewayRequestScope(scope, () =>
runtime.run({
sessionKey: "s-override",
message: "use the override",
provider: "anthropic",
model: "claude-haiku-4-5",
deliver: false,
}),
);
expect(getLastDispatchedParams()).toMatchObject({
sessionKey: "s-override",
message: "use the override",
provider: "anthropic",
model: "claude-haiku-4-5",
deliver: false,
});
});
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-5",
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-5"],
},
},
},
},
});
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-5",
deliver: false,
}),
);
expect(getLastDispatchedParams()).toMatchObject({
sessionKey: "s-trusted-override",
provider: "anthropic",
model: "claude-haiku-4-5",
});
});
test("allows trusted fallback model-only overrides when the model ref is canonical", async () => {
const serverPlugins = await importServerPluginsModule();
const runtime = await createSubagentRuntime(serverPlugins, {
plugins: {
entries: {
"voice-call": {
subagent: {
allowModelOverride: true,
allowedModels: ["anthropic/claude-haiku-4-5"],
},
},
},
},
});
serverPlugins.setFallbackGatewayContext(createTestContext("fallback-model-only-override"));
const gatewayScopeModule = await import("../plugins/runtime/gateway-request-scope.js");
await gatewayScopeModule.withPluginRuntimePluginIdScope("voice-call", () =>
runtime.run({
sessionKey: "s-model-only-override",
message: "use trusted model-only override",
model: "anthropic/claude-haiku-4-5",
deliver: false,
}),
);
expect(getLastDispatchedParams()).toMatchObject({
sessionKey: "s-model-only-override",
model: "anthropic/claude-haiku-4-5",
});
expect(getLastDispatchedParams()).not.toHaveProperty("provider");
});
test("rejects trusted fallback overrides when the configured allowlist normalizes to empty", async () => {
const serverPlugins = await importServerPluginsModule();
const runtime = await createSubagentRuntime(serverPlugins, {
plugins: {
entries: {
"voice-call": {
subagent: {
allowModelOverride: true,
allowedModels: ["anthropic"],
},
},
},
},
});
serverPlugins.setFallbackGatewayContext(createTestContext("fallback-invalid-allowlist"));
const gatewayScopeModule = await import("../plugins/runtime/gateway-request-scope.js");
await expect(
gatewayScopeModule.withPluginRuntimePluginIdScope("voice-call", () =>
runtime.run({
sessionKey: "s-invalid-allowlist",
message: "use trusted override",
provider: "anthropic",
model: "claude-haiku-4-5",
deliver: false,
}),
),
).rejects.toThrow(
'plugin "voice-call" configured subagent.allowedModels, but none of the entries normalized to a valid provider/model target.',
);
});
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("allows fallback session reads with synthetic write scope", async () => {
const serverPlugins = await importServerPluginsModule();
const runtime = await createSubagentRuntime(serverPlugins);
serverPlugins.setFallbackGatewayContext(createTestContext("synthetic-session-read"));
const { authorizeOperatorScopesForMethod } = await import("./method-scopes.js");
handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => {
const scopes = Array.isArray(opts.client?.connect?.scopes) ? opts.client.connect.scopes : [];
const auth = authorizeOperatorScopesForMethod("sessions.get", scopes);
if (!auth.allowed) {
opts.respond(false, undefined, {
code: "INVALID_REQUEST",
message: `missing scope: ${auth.missingScope}`,
});
return;
}
opts.respond(true, { messages: [{ id: "m-1" }] });
});
await expect(
runtime.getSessionMessages({
sessionKey: "s-read",
}),
).resolves.toEqual({
messages: [{ id: "m-1" }],
});
expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]);
expect(getLastDispatchedClientScopes()).not.toContain("operator.admin");
});
test("keeps admin scope for fallback session deletion", async () => {
const serverPlugins = await importServerPluginsModule();
const runtime = await createSubagentRuntime(serverPlugins);
serverPlugins.setFallbackGatewayContext(createTestContext("synthetic-delete-session"));
await runtime.deleteSession({
sessionKey: "s-delete",
deleteTranscript: true,
});
expect(getLastDispatchedClientScopes()).toEqual(["operator.admin"]);
});
test("can prefer setup-runtime channel plugins during startup loads", async () => {
const { loadGatewayPlugins } = await importServerPluginsModule();
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
@ -236,7 +471,6 @@ describe("loadGatewayPlugins", () => {
expect(log.error).not.toHaveBeenCalled();
expect(log.info).not.toHaveBeenCalled();
});
test("shares fallback context across module reloads for existing runtimes", async () => {
const first = await importServerPluginsModule();
const runtime = await createSubagentRuntime(first);

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

View File

@ -10,26 +10,28 @@ import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js";
// Module mocks
// ---------------------------------------------------------------------------
vi.mock("../agents/model-auth.js", () => ({
resolveApiKeyForProvider: vi.fn(async () => ({
type ResolveApiKeyForProvider = typeof import("../agents/model-auth.js").resolveApiKeyForProvider;
const resolveApiKeyForProviderMock = vi.hoisted(() =>
vi.fn<ResolveApiKeyForProvider>(async () => ({
apiKey: "test-key", // pragma: allowlist secret
source: "test",
mode: "api-key",
})),
requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => {
if (auth?.apiKey) {
return auth.apiKey;
}
throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth?.mode}).`);
},
resolveAwsSdkEnvVarName: vi.fn(() => undefined),
resolveEnvApiKey: vi.fn(() => null),
resolveModelAuthMode: vi.fn(() => "api-key"),
getApiKeyForModel: vi.fn(async () => ({ apiKey: "test-key", source: "test", mode: "api-key" })),
getCustomProviderApiKey: vi.fn(() => undefined),
ensureAuthProfileStore: vi.fn(async () => ({})),
resolveAuthProfileOrder: vi.fn(() => []),
}));
);
const hasAvailableAuthForProviderMock = vi.hoisted(() =>
vi.fn(async (...args: Parameters<ResolveApiKeyForProvider>) => {
const resolved = await resolveApiKeyForProviderMock(...args);
return Boolean(resolved?.apiKey);
}),
);
const getApiKeyForModelMock = vi.hoisted(() =>
vi.fn(async () => ({ apiKey: "test-key", source: "test", mode: "api-key" })),
);
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
const runExecMock = vi.hoisted(() => vi.fn());
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());
const mockDeliverOutboundPayloads = vi.hoisted(() => vi.fn());
const { MediaFetchErrorMock } = vi.hoisted(() => {
class MediaFetchErrorMock extends Error {
@ -43,22 +45,6 @@ const { MediaFetchErrorMock } = vi.hoisted(() => {
return { MediaFetchErrorMock };
});
vi.mock("../media/fetch.js", () => ({
fetchRemoteMedia: vi.fn(),
MediaFetchError: MediaFetchErrorMock,
}));
vi.mock("../process/exec.js", () => ({
runExec: vi.fn(),
runCommandWithTimeout: vi.fn(),
}));
const mockDeliverOutboundPayloads = vi.fn();
vi.mock("../infra/outbound/deliver.js", () => ({
deliverOutboundPayloads: (...args: unknown[]) => mockDeliverOutboundPayloads(...args),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@ -145,6 +131,38 @@ function createAudioConfigWithoutEchoFlag() {
describe("applyMediaUnderstanding echo transcript", () => {
beforeAll(async () => {
vi.resetModules();
vi.doMock("../agents/model-auth.js", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
hasAvailableAuthForProvider: hasAvailableAuthForProviderMock,
requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => {
if (auth?.apiKey) {
return auth.apiKey;
}
throw new Error(
`No API key resolved for provider "${provider}" (auth mode: ${auth?.mode}).`,
);
},
resolveAwsSdkEnvVarName: vi.fn(() => undefined),
resolveEnvApiKey: vi.fn(() => null),
resolveModelAuthMode: vi.fn(() => "api-key"),
getApiKeyForModel: getApiKeyForModelMock,
getCustomProviderApiKey: vi.fn(() => undefined),
ensureAuthProfileStore: vi.fn(async () => ({})),
resolveAuthProfileOrder: vi.fn(() => []),
}));
vi.doMock("../media/fetch.js", () => ({
fetchRemoteMedia: fetchRemoteMediaMock,
MediaFetchError: MediaFetchErrorMock,
}));
vi.doMock("../process/exec.js", () => ({
runExec: runExecMock,
runCommandWithTimeout: runCommandWithTimeoutMock,
}));
vi.doMock("../infra/outbound/deliver-runtime.js", () => ({
deliverOutboundPayloads: (...args: unknown[]) => mockDeliverOutboundPayloads(...args),
}));
const baseDir = resolvePreferredOpenClawTmpDir();
await fs.mkdir(baseDir, { recursive: true });
suiteTempMediaRootDir = await fs.mkdtemp(path.join(baseDir, TEMP_MEDIA_PREFIX));
@ -155,6 +173,12 @@ describe("applyMediaUnderstanding echo transcript", () => {
});
beforeEach(() => {
resolveApiKeyForProviderMock.mockClear();
hasAvailableAuthForProviderMock.mockClear();
getApiKeyForModelMock.mockClear();
fetchRemoteMediaMock.mockClear();
runExecMock.mockReset();
runCommandWithTimeoutMock.mockReset();
mockDeliverOutboundPayloads.mockClear();
mockDeliverOutboundPayloads.mockResolvedValue([{ channel: "whatsapp", messageId: "echo-1" }]);
clearMediaUnderstandingBinaryCacheForTests?.();

View File

@ -78,6 +78,58 @@ 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-5 ", "", "openai/gpt-4.1-mini"],
},
},
},
});
expect(result.entries["voice-call"]?.subagent).toEqual({
allowModelOverride: true,
hasAllowedModelsConfig: true,
allowedModels: ["anthropic/claude-haiku-4-5", "openai/gpt-4.1-mini"],
});
});
it("preserves explicit subagent allowlist intent even when all entries are invalid", () => {
const result = normalizePluginsConfig({
entries: {
"voice-call": {
subagent: {
allowModelOverride: true,
allowedModels: [42, null, "anthropic"],
} as unknown as { allowModelOverride: boolean; allowedModels: string[] },
},
},
});
expect(result.entries["voice-call"]?.subagent).toEqual({
allowModelOverride: true,
hasAllowedModelsConfig: true,
allowedModels: ["anthropic"],
});
});
it("keeps explicit invalid subagent allowlist config visible to callers", () => {
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).toEqual({
hasAllowedModelsConfig: true,
});
});
it("normalizes legacy plugin ids to their merged bundled plugin id", () => {
const result = normalizePluginsConfig({
allow: ["openai-codex", "minimax-portal-auth"],

View File

@ -18,6 +18,11 @@ export type NormalizedPluginsConfig = {
hooks?: {
allowPromptInjection?: boolean;
};
subagent?: {
allowModelOverride?: boolean;
allowedModels?: string[];
hasAllowedModelsConfig?: boolean;
};
config?: unknown;
}
>;
@ -123,11 +128,43 @@ 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,
hasAllowedModelsConfig: Array.isArray(
(subagentRaw as { allowedModels?: unknown }).allowedModels,
),
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" ||
subagent.hasAllowedModelsConfig ||
(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0))
? {
...(typeof subagent.allowModelOverride === "boolean"
? { allowModelOverride: subagent.allowModelOverride }
: {}),
...(subagent.hasAllowedModelsConfig ? { hasAllowedModelsConfig: true } : {}),
...(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,
};
}

View File

@ -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) : () => {},

View File

@ -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",
});
});
});
});
});

View File

@ -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.
*/

View File

@ -8,6 +8,8 @@ export type { RuntimeLogger };
export type SubagentRunParams = {
sessionKey: string;
message: string;
provider?: string;
model?: string;
extraSystemPrompt?: string;
lane?: string;
deliver?: boolean;