From e2ece61cddb8a1a4a39f13f6a0026c0d6796d4ff Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Mon, 16 Mar 2026 22:30:03 -0700 Subject: [PATCH] fix(gateway): preserve trusted subagent cleanup --- src/commands/agent.test.ts | 6 +- src/config/config-misc.test.ts | 2 +- src/gateway/server-methods/agent.test.ts | 10 ++-- src/gateway/server-plugins.test.ts | 71 ++++++++++++++++++++++-- src/gateway/server-plugins.ts | 53 ++++++++++++++---- src/plugins/config-state.test.ts | 4 +- 6 files changed, 118 insertions(+), 28 deletions(-) diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index e49ae4bd81e..04d92a2d76d 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -789,7 +789,7 @@ describe("agentCommand", () => { const parseModelRefSpy = vi.spyOn(modelSelectionModule, "parseModelRef"); parseModelRefSpy.mockImplementationOnce(() => ({ provider: "anthropic\u001b[31m", - model: "claude-haiku-4-6\u001b[32m", + model: "claude-haiku-4-5\u001b[32m", })); try { await withTempHome(async (home) => { @@ -805,12 +805,12 @@ describe("agentCommand", () => { { message: "use disallowed override", sessionKey: "agent:main:subagent:sanitized-override-error", - model: "claude-haiku-4-6", + model: "claude-haiku-4-5", }, runtime, ), ).rejects.toThrow( - 'Model override "anthropic/claude-haiku-4-6" is not allowed for agent "main".', + 'Model override "anthropic/claude-haiku-4-5" is not allowed for agent "main".', ); }); } finally { diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index ff5b2d1b062..43dec5acfef 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -101,7 +101,7 @@ describe("plugins.entries.*.subagent", () => { "voice-call": { subagent: { allowModelOverride: true, - allowedModels: ["anthropic/claude-haiku-4-6"], + allowedModels: ["anthropic/claude-haiku-4-5"], }, }, }, diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 8fe487c139c..06613d9e180 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -312,7 +312,7 @@ describe("gateway agent handler", () => { agentId: "main", sessionKey: "agent:main:main", provider: "anthropic", - model: "claude-haiku-4-6", + model: "claude-haiku-4-5", idempotencyKey: "test-idem-model-override", }, { @@ -329,7 +329,7 @@ describe("gateway agent handler", () => { expect(lastCall?.[0]).toEqual( expect.objectContaining({ provider: "anthropic", - model: "claude-haiku-4-6", + model: "claude-haiku-4-5", }), ); }); @@ -345,7 +345,7 @@ describe("gateway agent handler", () => { agentId: "main", sessionKey: "agent:main:main", provider: "anthropic", - model: "claude-haiku-4-6", + model: "claude-haiku-4-5", idempotencyKey: "test-idem-model-override-write", }, { @@ -378,7 +378,7 @@ describe("gateway agent handler", () => { agentId: "main", sessionKey: "agent:main:main", provider: "anthropic", - model: "claude-haiku-4-6", + model: "claude-haiku-4-5", idempotencyKey: "test-idem-model-override-internal", }, { @@ -398,7 +398,7 @@ describe("gateway agent handler", () => { expect(lastCall?.[0]).toEqual( expect.objectContaining({ provider: "anthropic", - model: "claude-haiku-4-6", + model: "claude-haiku-4-5", senderIsOwner: false, }), ); diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index c558a89d461..5c349685eaa 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -21,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: [], @@ -210,7 +223,7 @@ describe("loadGatewayPlugins", () => { sessionKey: "s-override", message: "use the override", provider: "anthropic", - model: "claude-haiku-4-6", + model: "claude-haiku-4-5", deliver: false, }), ); @@ -219,7 +232,7 @@ describe("loadGatewayPlugins", () => { sessionKey: "s-override", message: "use the override", provider: "anthropic", - model: "claude-haiku-4-6", + model: "claude-haiku-4-5", deliver: false, }); }); @@ -234,7 +247,7 @@ describe("loadGatewayPlugins", () => { sessionKey: "s-fallback-override", message: "use the override", provider: "anthropic", - model: "claude-haiku-4-6", + model: "claude-haiku-4-5", deliver: false, }), ).rejects.toThrow( @@ -250,7 +263,7 @@ describe("loadGatewayPlugins", () => { "voice-call": { subagent: { allowModelOverride: true, - allowedModels: ["anthropic/claude-haiku-4-6"], + allowedModels: ["anthropic/claude-haiku-4-5"], }, }, }, @@ -264,7 +277,7 @@ describe("loadGatewayPlugins", () => { sessionKey: "s-trusted-override", message: "use trusted override", provider: "anthropic", - model: "claude-haiku-4-6", + model: "claude-haiku-4-5", deliver: false, }), ); @@ -272,10 +285,43 @@ describe("loadGatewayPlugins", () => { expect(getLastDispatchedParams()).toMatchObject({ sessionKey: "s-trusted-override", provider: "anthropic", - model: "claude-haiku-4-6", + 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("uses least-privilege synthetic fallback scopes without admin", async () => { const serverPlugins = await importServerPluginsModule(); const runtime = await createSubagentRuntime(serverPlugins); @@ -291,6 +337,19 @@ describe("loadGatewayPlugins", () => { 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([])); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index c6de19a44af..68158d1ad50 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { normalizeModelRef } from "../agents/model-selection.js"; +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"; @@ -149,15 +149,18 @@ function authorizeFallbackModelOverride(params: { reason: `plugin "${pluginId}" is not trusted for fallback provider/model override requests.`, }; } - if (!params.provider || !params.model) { + if (policy.allowAnyModel || policy.allowedModels.size === 0) { + return { allowed: true }; + } + const requestedModelRef = resolveRequestedFallbackModelRef(params); + if (!requestedModelRef) { return { allowed: false, - reason: "fallback provider/model overrides must include both provider and model values.", + reason: + "fallback provider/model overrides that use an allowlist must resolve to a canonical provider/model target.", }; } - const normalizedRequest = normalizeModelRef(params.provider, params.model); - const requestedModelRef = `${normalizedRequest.provider}/${normalizedRequest.model}`; - if (policy.allowAnyModel || policy.allowedModels.has(requestedModelRef)) { + if (policy.allowedModels.has(requestedModelRef)) { return { allowed: true }; } return { @@ -166,10 +169,30 @@ function authorizeFallbackModelOverride(params: { }; } +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(params?: { allowModelOverride?: boolean; + scopes?: string[]; }): GatewayRequestOptions["client"] { return { connect: { @@ -182,7 +205,7 @@ function createSyntheticOperatorClient(params?: { mode: GATEWAY_CLIENT_MODES.BACKEND, }, role: "operator", - scopes: [WRITE_SCOPE], + scopes: params?.scopes ?? [WRITE_SCOPE], }, internal: { allowModelOverride: params?.allowModelOverride === true, @@ -204,6 +227,7 @@ async function dispatchGatewayMethod( params: Record, options?: { allowSyntheticModelOverride?: boolean; + syntheticScopes?: string[]; }, ): Promise { const scope = getPluginRuntimeGatewayRequestScope(); @@ -227,6 +251,7 @@ async function dispatchGatewayMethod( scope?.client ?? createSyntheticOperatorClient({ allowModelOverride: options?.allowSyntheticModelOverride === true, + scopes: options?.syntheticScopes, }), isWebchatConnect, respond: (ok, payload, error) => { @@ -321,10 +346,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], + }, + ); }, }; } diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 998077254eb..80817b804bc 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -84,14 +84,14 @@ describe("normalizePluginsConfig", () => { "voice-call": { subagent: { allowModelOverride: true, - allowedModels: [" anthropic/claude-haiku-4-6 ", "", "openai/gpt-4.1-mini"], + allowedModels: [" anthropic/claude-haiku-4-5 ", "", "openai/gpt-4.1-mini"], }, }, }, }); expect(result.entries["voice-call"]?.subagent).toEqual({ allowModelOverride: true, - allowedModels: ["anthropic/claude-haiku-4-6", "openai/gpt-4.1-mini"], + allowedModels: ["anthropic/claude-haiku-4-5", "openai/gpt-4.1-mini"], }); });