mirror of https://github.com/openclaw/openclaw.git
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:
parent
1561c6a71c
commit
1399ca5fcb
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -623,6 +623,7 @@ export class DiscordVoiceManager {
|
|||
agentId: entry.route.agentId,
|
||||
messageChannel: "discord",
|
||||
senderIsOwner: speaker.senderIsOwner,
|
||||
allowModelOverride: false,
|
||||
deliver: false,
|
||||
},
|
||||
this.params.runtime,
|
||||
|
|
|
|||
|
|
@ -308,7 +308,6 @@ describe("acp session UX bridge behavior", () => {
|
|||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"adaptive",
|
||||
]);
|
||||
expect(result.configOptions).toEqual(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -349,6 +349,9 @@ const TARGET_KEYS = [
|
|||
"plugins.entries.*.enabled",
|
||||
"plugins.entries.*.hooks",
|
||||
"plugins.entries.*.hooks.allowPromptInjection",
|
||||
"plugins.entries.*.subagent",
|
||||
"plugins.entries.*.subagent.allowModelOverride",
|
||||
"plugins.entries.*.subagent.allowedModels",
|
||||
"plugins.entries.*.apiKey",
|
||||
"plugins.entries.*.env",
|
||||
"plugins.entries.*.config",
|
||||
|
|
|
|||
|
|
@ -979,6 +979,12 @@ export const FIELD_HELP: Record<string, string> = {
|
|||
"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
|
||||
"plugins.entries.*.hooks.allowPromptInjection":
|
||||
"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
|
||||
"plugins.entries.*.subagent":
|
||||
"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
|
||||
"plugins.entries.*.subagent.allowModelOverride":
|
||||
"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
|
||||
"plugins.entries.*.subagent.allowedModels":
|
||||
'Allowed override targets for trusted plugin subagent runs as canonical "provider/model" refs. Use "*" only when you intentionally allow any model.',
|
||||
"plugins.entries.*.apiKey":
|
||||
"Optional API key field consumed by plugins that accept direct key configuration in entry settings. Use secret/env substitution and avoid committing real credentials into config files.",
|
||||
"plugins.entries.*.env":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@ export type PluginEntryConfig = {
|
|||
/** Controls prompt mutation via before_prompt_build and prompt fields from legacy before_agent_start. */
|
||||
allowPromptInjection?: boolean;
|
||||
};
|
||||
subagent?: {
|
||||
/** Explicitly allow this plugin to request per-run provider/model overrides for subagent runs. */
|
||||
allowModelOverride?: boolean;
|
||||
/**
|
||||
* Allowed override targets as canonical provider/model refs.
|
||||
* Use "*" to explicitly allow any model for this plugin.
|
||||
*/
|
||||
allowedModels?: string[];
|
||||
};
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -155,6 +155,13 @@ const PluginEntrySchema = z
|
|||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
subagent: z
|
||||
.object({
|
||||
allowModelOverride: z.boolean().optional(),
|
||||
allowedModels: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { normalizePluginHttpPath } from "./http-path.js";
|
|||
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
|
||||
import { registerPluginInteractiveHandler } from "./interactive.js";
|
||||
import { normalizeRegisteredProvider } from "./provider-validation.js";
|
||||
import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import { defaultSlotIdForKey } from "./slots.js";
|
||||
import {
|
||||
|
|
@ -835,6 +836,36 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
debug: logger.debug,
|
||||
});
|
||||
|
||||
const pluginRuntimeById = new Map<string, PluginRuntime>();
|
||||
|
||||
const resolvePluginRuntime = (pluginId: string): PluginRuntime => {
|
||||
const cached = pluginRuntimeById.get(pluginId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const runtime = new Proxy(registryParams.runtime, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop !== "subagent") {
|
||||
return Reflect.get(target, prop, receiver);
|
||||
}
|
||||
const subagent = Reflect.get(target, prop, receiver);
|
||||
return {
|
||||
run: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.run(params)),
|
||||
waitForRun: (params) =>
|
||||
withPluginRuntimePluginIdScope(pluginId, () => subagent.waitForRun(params)),
|
||||
getSessionMessages: (params) =>
|
||||
withPluginRuntimePluginIdScope(pluginId, () => subagent.getSessionMessages(params)),
|
||||
getSession: (params) =>
|
||||
withPluginRuntimePluginIdScope(pluginId, () => subagent.getSession(params)),
|
||||
deleteSession: (params) =>
|
||||
withPluginRuntimePluginIdScope(pluginId, () => subagent.deleteSession(params)),
|
||||
} satisfies PluginRuntime["subagent"];
|
||||
},
|
||||
});
|
||||
pluginRuntimeById.set(pluginId, runtime);
|
||||
return runtime;
|
||||
};
|
||||
|
||||
const createApi = (
|
||||
record: PluginRecord,
|
||||
params: {
|
||||
|
|
@ -855,7 +886,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
registrationMode,
|
||||
config: params.config,
|
||||
pluginConfig: params.pluginConfig,
|
||||
runtime: registryParams.runtime,
|
||||
runtime: resolvePluginRuntime(record.id),
|
||||
logger: normalizeLogger(registryParams.logger),
|
||||
registerTool:
|
||||
registrationMode === "full" ? (tool, opts) => registerTool(record, tool, opts) : () => {},
|
||||
|
|
|
|||
|
|
@ -20,4 +20,17 @@ describe("gateway request scope", () => {
|
|||
expect(second.getPluginRuntimeGatewayRequestScope()).toEqual(TEST_SCOPE);
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches plugin id to the active scope", async () => {
|
||||
const runtimeScope = await import("./gateway-request-scope.js");
|
||||
|
||||
await runtimeScope.withPluginRuntimeGatewayRequestScope(TEST_SCOPE, async () => {
|
||||
await runtimeScope.withPluginRuntimePluginIdScope("voice-call", async () => {
|
||||
expect(runtimeScope.getPluginRuntimeGatewayRequestScope()).toEqual({
|
||||
...TEST_SCOPE,
|
||||
pluginId: "voice-call",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type PluginRuntimeGatewayRequestScope = {
|
|||
context?: GatewayRequestContext;
|
||||
client?: GatewayRequestOptions["client"];
|
||||
isWebchatConnect: GatewayRequestOptions["isWebchatConnect"];
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
const PLUGIN_RUNTIME_GATEWAY_REQUEST_SCOPE_KEY: unique symbol = Symbol.for(
|
||||
|
|
@ -37,6 +38,20 @@ export function withPluginRuntimeGatewayRequestScope<T>(
|
|||
return pluginRuntimeGatewayRequestScope.run(scope, run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs work under the current gateway request scope while attaching plugin identity.
|
||||
*/
|
||||
export function withPluginRuntimePluginIdScope<T>(pluginId: string, run: () => T): T {
|
||||
const current = pluginRuntimeGatewayRequestScope.getStore();
|
||||
const scoped: PluginRuntimeGatewayRequestScope = current
|
||||
? { ...current, pluginId }
|
||||
: {
|
||||
pluginId,
|
||||
isWebchatConnect: () => false,
|
||||
};
|
||||
return pluginRuntimeGatewayRequestScope.run(scoped, run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current plugin gateway request scope when called from a plugin request handler.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export type { RuntimeLogger };
|
|||
export type SubagentRunParams = {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
extraSystemPrompt?: string;
|
||||
lane?: string;
|
||||
deliver?: boolean;
|
||||
|
|
|
|||
Loading…
Reference in New Issue