diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c090e31784..9bd6db6fdaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 3003ae79f7b..fcd04955e8c 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 3003ae79f7b..fcd04955e8c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 0913040949b..dccd87da423 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2419,6 +2419,8 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. - `plugins.entries..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..subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs. +- `plugins.entries..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..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. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index b0eec032bcf..a5df54761cc 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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..subagent.allowModelOverride: true`. +- Use `plugins.entries..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: diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 5f9f66242ad..e7d3b099fe4 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -623,6 +623,7 @@ export class DiscordVoiceManager { agentId: entry.route.agentId, messageChannel: "discord", senderIsOwner: speaker.senderIsOwner, + allowModelOverride: false, deliver: false, }, this.params.runtime, diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 162afe6160c..55446550f9f 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -308,7 +308,6 @@ describe("acp session UX bridge behavior", () => { "low", "medium", "high", - "xhigh", "adaptive", ]); expect(result.configOptions).toEqual( diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 5ed69abd71f..5db40b13a27 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -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 { 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 }) => void; - primaryProvider: string; + authProfileProvider: string; sessionStore?: Record; 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(); let allowedModelCatalog: Awaited> = []; let modelCatalog: Awaited> | 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, diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index 66d0209bdfb..a85157bb191 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -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 & { - /** 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; }; diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 5b4fc2c9040..04d92a2d76d 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -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(); return { @@ -27,10 +46,13 @@ vi.mock("../agents/auth-profiles.js", async (importOriginal) => { }; }); -vi.mock("../agents/workspace.js", async (importOriginal) => { - const actual = await importOriginal(); +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"); diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 177711dcc03..43dec5acfef 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -93,6 +93,40 @@ describe("plugins.entries.*.hooks.allowPromptInjection", () => { }); }); +describe("plugins.entries.*.subagent", () => { + it("accepts trusted subagent override settings", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + entries: { + "voice-call": { + subagent: { + allowModelOverride: true, + allowedModels: ["anthropic/claude-haiku-4-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( diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index e915350ee62..f1542bcb7de 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -349,6 +349,9 @@ const TARGET_KEYS = [ "plugins.entries.*.enabled", "plugins.entries.*.hooks", "plugins.entries.*.hooks.allowPromptInjection", + "plugins.entries.*.subagent", + "plugins.entries.*.subagent.allowModelOverride", + "plugins.entries.*.subagent.allowedModels", "plugins.entries.*.apiKey", "plugins.entries.*.env", "plugins.entries.*.config", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index bb059bf5cad..e6b02e2ec3c 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -979,6 +979,12 @@ export const FIELD_HELP: Record = { "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "plugins.entries.*.hooks.allowPromptInjection": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "plugins.entries.*.subagent": + "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "plugins.entries.*.subagent.allowModelOverride": + "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "plugins.entries.*.subagent.allowedModels": + 'Allowed override targets for trusted plugin subagent runs as canonical "provider/model" refs. Use "*" only when you intentionally allow any model.', "plugins.entries.*.apiKey": "Optional API key field consumed by plugins that accept direct key configuration in entry settings. Use secret/env substitution and avoid committing real credentials into config files.", "plugins.entries.*.env": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 62302e976af..ae1c8d2829d 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -863,6 +863,9 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 62d750b0470..af37ba2020f 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -4,6 +4,15 @@ export type PluginEntryConfig = { /** Controls prompt mutation via before_prompt_build and prompt fields from legacy before_agent_start. */ allowPromptInjection?: boolean; }; + subagent?: { + /** Explicitly allow this plugin to request per-run provider/model overrides for subagent runs. */ + allowModelOverride?: boolean; + /** + * Allowed override targets as canonical provider/model refs. + * Use "*" to explicitly allow any model for this plugin. + */ + allowedModels?: string[]; + }; config?: Record; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index b32a86dc68f..f8ad6bfcbc9 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -155,6 +155,13 @@ const PluginEntrySchema = z }) .strict() .optional(), + subagent: z + .object({ + allowModelOverride: z.boolean().optional(), + allowedModels: z.array(z.string()).optional(), + }) + .strict() + .optional(), config: z.record(z.string(), z.unknown()).optional(), }) .strict(); diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 5ac82138f28..5809da5bcee 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -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, }; } diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 065b20cdf62..9c9e7384445 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -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, diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 11369a4ed4a..b9c844b135b 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -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()), diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index f3b74416c70..06613d9e180 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -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"; diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 5a7507345df..9ab032a2edd 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -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, diff --git a/src/gateway/server-methods/types.ts b/src/gateway/server-methods/types.ts index 4998a84c842..ab3a5c889c2 100644 --- a/src/gateway/server-methods/types.ts +++ b/src/gateway/server-methods/types.ts @@ -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 = ( diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 8ab24644101..c2aa3c454c7 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -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, diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index ddaaa64c02b..7887d43f24f 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { PluginRegistry } from "../plugins/registry.js"; +import type { PluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { PluginDiagnostic } from "../plugins/types.js"; import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js"; @@ -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 | undefined { + const call = handleGatewayRequest.mock.calls.at(-1)?.[0]; + return call?.req?.params as Record | undefined; +} + +function getLastDispatchedClientScopes(): string[] { + const call = handleGatewayRequest.mock.calls.at(-1)?.[0]; + const scopes = call?.client?.connect?.scopes; + return Array.isArray(scopes) ? scopes : []; +} + async function importServerPluginsModule(): Promise { return import("./server-plugins.js"); } async function createSubagentRuntime( serverPlugins: ServerPluginsModule, + cfg: Record = {}, ): Promise { const log = { info: vi.fn(), @@ -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); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 587aa71dc41..2ea249b28b4 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -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; +}; + +type PluginSubagentPolicyState = { + policies: Record; +}; + +const PLUGIN_SUBAGENT_POLICY_STATE_KEY: unique symbol = Symbol.for( + "openclaw.pluginSubagentOverridePolicyState", +); + +const pluginSubagentPolicyState: PluginSubagentPolicyState = (() => { + const globalState = globalThis as typeof globalThis & { + [PLUGIN_SUBAGENT_POLICY_STATE_KEY]?: PluginSubagentPolicyState; + }; + const existing = globalState[PLUGIN_SUBAGENT_POLICY_STATE_KEY]; + if (existing) { + return existing; + } + const created: PluginSubagentPolicyState = { + policies: {}, + }; + globalState[PLUGIN_SUBAGENT_POLICY_STATE_KEY] = created; + return created; +})(); + +function normalizeAllowedModelRef(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + if (trimmed === "*") { + return "*"; + } + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash >= trimmed.length - 1) { + return null; + } + const providerRaw = trimmed.slice(0, slash).trim(); + const modelRaw = trimmed.slice(slash + 1).trim(); + if (!providerRaw || !modelRaw) { + return null; + } + const normalized = normalizeModelRef(providerRaw, modelRaw); + return `${normalized.provider}/${normalized.model}`; +} + +function setPluginSubagentOverridePolicies(cfg: ReturnType): void { + const normalized = normalizePluginsConfig(cfg.plugins); + const policies: PluginSubagentPolicyState["policies"] = {}; + for (const [pluginId, entry] of Object.entries(normalized.entries)) { + const allowModelOverride = entry.subagent?.allowModelOverride === true; + const hasConfiguredAllowlist = entry.subagent?.hasAllowedModelsConfig === true; + const configuredAllowedModels = entry.subagent?.allowedModels ?? []; + const allowedModels = new Set(); + let allowAnyModel = false; + for (const modelRef of configuredAllowedModels) { + const normalizedModelRef = normalizeAllowedModelRef(modelRef); + if (!normalizedModelRef) { + continue; + } + if (normalizedModelRef === "*") { + allowAnyModel = true; + continue; + } + allowedModels.add(normalizedModelRef); + } + if ( + !allowModelOverride && + !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( method: string, params: Record, + options?: { + allowSyntheticModelOverride?: boolean; + syntheticScopes?: string[]; + }, ): Promise { const scope = getPluginRuntimeGatewayRequestScope(); const context = scope?.context ?? fallbackGatewayContextState.context; @@ -86,7 +264,12 @@ async function dispatchGatewayMethod( 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 diff --git a/src/media-understanding/apply.echo-transcript.test.ts b/src/media-understanding/apply.echo-transcript.test.ts index ae62d294989..6411ab0f48d 100644 --- a/src/media-understanding/apply.echo-transcript.test.ts +++ b/src/media-understanding/apply.echo-transcript.test.ts @@ -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(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) => { + 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?.(); diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 8becf375f96..915f647950e 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -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"], diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 8700cf8226b..0dde14a8941 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -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, }; } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index ca4e40ee54c..4c863c3bdf4 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -14,6 +14,7 @@ import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { registerPluginInteractiveHandler } from "./interactive.js"; import { normalizeRegisteredProvider } from "./provider-validation.js"; +import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js"; import type { PluginRuntime } from "./runtime/types.js"; import { defaultSlotIdForKey } from "./slots.js"; import { @@ -835,6 +836,36 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { debug: logger.debug, }); + const pluginRuntimeById = new Map(); + + const resolvePluginRuntime = (pluginId: string): PluginRuntime => { + const cached = pluginRuntimeById.get(pluginId); + if (cached) { + return cached; + } + const runtime = new Proxy(registryParams.runtime, { + get(target, prop, receiver) { + if (prop !== "subagent") { + return Reflect.get(target, prop, receiver); + } + const subagent = Reflect.get(target, prop, receiver); + return { + run: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.run(params)), + waitForRun: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.waitForRun(params)), + getSessionMessages: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.getSessionMessages(params)), + getSession: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.getSession(params)), + deleteSession: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.deleteSession(params)), + } satisfies PluginRuntime["subagent"]; + }, + }); + pluginRuntimeById.set(pluginId, runtime); + return runtime; + }; + const createApi = ( record: PluginRecord, params: { @@ -855,7 +886,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registrationMode, config: params.config, pluginConfig: params.pluginConfig, - runtime: registryParams.runtime, + runtime: resolvePluginRuntime(record.id), logger: normalizeLogger(registryParams.logger), registerTool: registrationMode === "full" ? (tool, opts) => registerTool(record, tool, opts) : () => {}, diff --git a/src/plugins/runtime/gateway-request-scope.test.ts b/src/plugins/runtime/gateway-request-scope.test.ts index ef31350e2a3..4d00d04fd74 100644 --- a/src/plugins/runtime/gateway-request-scope.test.ts +++ b/src/plugins/runtime/gateway-request-scope.test.ts @@ -20,4 +20,17 @@ describe("gateway request scope", () => { expect(second.getPluginRuntimeGatewayRequestScope()).toEqual(TEST_SCOPE); }); }); + + it("attaches plugin id to the active scope", async () => { + const runtimeScope = await import("./gateway-request-scope.js"); + + await runtimeScope.withPluginRuntimeGatewayRequestScope(TEST_SCOPE, async () => { + await runtimeScope.withPluginRuntimePluginIdScope("voice-call", async () => { + expect(runtimeScope.getPluginRuntimeGatewayRequestScope()).toEqual({ + ...TEST_SCOPE, + pluginId: "voice-call", + }); + }); + }); + }); }); diff --git a/src/plugins/runtime/gateway-request-scope.ts b/src/plugins/runtime/gateway-request-scope.ts index 72a6f5af402..7a4ffbb608b 100644 --- a/src/plugins/runtime/gateway-request-scope.ts +++ b/src/plugins/runtime/gateway-request-scope.ts @@ -8,6 +8,7 @@ export type PluginRuntimeGatewayRequestScope = { context?: GatewayRequestContext; client?: GatewayRequestOptions["client"]; isWebchatConnect: GatewayRequestOptions["isWebchatConnect"]; + pluginId?: string; }; const PLUGIN_RUNTIME_GATEWAY_REQUEST_SCOPE_KEY: unique symbol = Symbol.for( @@ -37,6 +38,20 @@ export function withPluginRuntimeGatewayRequestScope( return pluginRuntimeGatewayRequestScope.run(scope, run); } +/** + * Runs work under the current gateway request scope while attaching plugin identity. + */ +export function withPluginRuntimePluginIdScope(pluginId: string, run: () => T): T { + const current = pluginRuntimeGatewayRequestScope.getStore(); + const scoped: PluginRuntimeGatewayRequestScope = current + ? { ...current, pluginId } + : { + pluginId, + isWebchatConnect: () => false, + }; + return pluginRuntimeGatewayRequestScope.run(scoped, run); +} + /** * Returns the current plugin gateway request scope when called from a plugin request handler. */ diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 245e8dd1274..aa1118ecf92 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -8,6 +8,8 @@ export type { RuntimeLogger }; export type SubagentRunParams = { sessionKey: string; message: string; + provider?: string; + model?: string; extraSystemPrompt?: string; lane?: string; deliver?: boolean;