fix(gateway): preserve trusted subagent cleanup

This commit is contained in:
Josh Lehman
2026-03-16 22:30:03 -07:00
parent f69cc431af
commit e2ece61cdd
6 changed files with 118 additions and 28 deletions

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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<T>(
params: Record<string, unknown>,
options?: {
allowSyntheticModelOverride?: boolean;
syntheticScopes?: string[];
},
): Promise<T> {
const scope = getPluginRuntimeGatewayRequestScope();
@@ -227,6 +251,7 @@ async function dispatchGatewayMethod<T>(
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],
},
);
},
};
}

View File

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