From 71983852e0f90d9f6da6ef2ed256a8b2a3f049b3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 27 Sep 2026 02:19:40 +0000 Subject: [PATCH 1/4] fix: fall back to an allowed model when the policy excludes the webview's selection --- vscode/src/webview/App.test.tsx | 97 ++++++++++++++++++++++++++++- vscode/src/webview/ChatComposer.tsx | 33 +++++++++- 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/vscode/src/webview/App.test.tsx b/vscode/src/webview/App.test.tsx index e5305024c52..b37c67b9387 100644 --- a/vscode/src/webview/App.test.tsx +++ b/vscode/src/webview/App.test.tsx @@ -4,8 +4,8 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { act, cleanup, fireEvent, render } from "@testing-library/react"; import { installDom } from "../../../tests/ui/dom"; -import { updatePersistedState } from "xum/browser/hooks/usePersistedState"; -import { getAgentIdKey, getThinkingLevelKey } from "xum/common/constants/storage"; +import { readPersistedState, updatePersistedState } from "xum/browser/hooks/usePersistedState"; +import { getAgentIdKey, getModelKey, getThinkingLevelKey } from "xum/common/constants/storage"; import { App } from "./App"; import type { UiWorkspace, WebviewToExtensionMessage } from "./protocol"; import type { VscodeBridge } from "./vscodeBridge"; @@ -51,6 +51,13 @@ class TestBridge implements VscodeBridge { }); } + // Plays the host answering every call of `path` so far with `value`. + async answer(path: string, value: unknown): Promise { + for (const call of this.orpcCalls(path)) { + await this.emit({ type: "orpcResponse", requestId: call.requestId, ok: true, kind: "value", value }); + } + } + orpcCalls(path: string): Array> { return this.sent.filter( (message): message is Extract => @@ -554,3 +561,89 @@ describe("vscode webview agent lookup", () => { expect(recoveryLookups[0].input).toMatchObject({ workspaceId: WORKSPACE.id }); }); }); + +// #4808: the admin policy can exclude the workspace's selected model (persisted, seeded or revoked). +describe("vscode webview policy-excluded model", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + function enforcedPolicy(providerAccess: Array<{ id: string; allowedModels: string[] | null }>) { + return { + source: "governor", + status: { state: "enforced" }, + policy: { + policyFormatVersion: "0.1", + providerAccess, + mcp: { allowUserDefined: { stdio: true, remote: true } }, + runtimes: null, + }, + }; + } + + async function renderWithPolicy(policy: unknown) { + updatePersistedState(getModelKey(WORKSPACE.id), "anthropic:claude-opus-5-5"); + const bridge = new TestBridge(); + const view = render(); + await selectWorkspace(bridge); + await bridge.answer("policy.get", policy); + const textarea = view.container.querySelector("textarea"); + if (!textarea) throw new Error("composer textarea did not render"); + await typeInto(textarea, "hello"); + return { bridge, view }; + } + + async function clickSend(view: ReturnType) { + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Send message" })); + await Promise.resolve(); + }); + } + + test("sends with the first allowed model, says so, and keeps the stored choice", async () => { + const { bridge, view } = await renderWithPolicy( + enforcedPolicy([{ id: "openai", allowedModels: ["gpt-5.6-terra"] }]) + ); + + expect(view.getByRole("status").textContent).toContain("anthropic:claude-opus-5-5"); + await clickSend(view); + const sends = bridge.orpcCalls("workspace.sendMessage"); + expect(sends).toHaveLength(1); + const input = sends[0].input as { options: Record }; + expect(input.options.model).toBe("openai:gpt-5.6-terra"); + // Local fallback only: nothing is written, locally or to the workspace. + expect(readPersistedState(getModelKey(WORKSPACE.id), "")).toBe("anthropic:claude-opus-5-5"); + expect(bridge.orpcCalls("workspace.updateAgentAISettings")).toHaveLength(0); + }); + + test("blocks the send when the policy allows no listed model", async () => { + const { bridge, view } = await renderWithPolicy( + enforcedPolicy([{ id: "openai", allowedModels: ["not-a-listed-model"] }]) + ); + + expect(view.getByRole("status").textContent).toContain("anthropic:claude-opus-5-5"); + await clickSend(view); + expect(bridge.orpcCalls("workspace.sendMessage")).toHaveLength(0); + }); + + test("keeps an allowed selection unchanged", async () => { + const { bridge, view } = await renderWithPolicy( + enforcedPolicy([{ id: "anthropic", allowedModels: null }]) + ); + + expect(view.queryByRole("status")).toBeNull(); + await clickSend(view); + const input = bridge.orpcCalls("workspace.sendMessage")[0].input as { + options: Record; + }; + expect(input.options.model).toBe("anthropic:claude-opus-5-5"); + }); +}); diff --git a/vscode/src/webview/ChatComposer.tsx b/vscode/src/webview/ChatComposer.tsx index 52447bb7382..168f4652723 100644 --- a/vscode/src/webview/ChatComposer.tsx +++ b/vscode/src/webview/ChatComposer.tsx @@ -8,6 +8,8 @@ import { getSendOptionsFromStorage } from "xum/browser/utils/messages/sendOption import { matchesKeybind, formatKeybind, KEYBINDS } from "xum/browser/utils/ui/keybinds"; import { useAPI } from "xum/browser/contexts/API"; import { useAgent } from "xum/browser/contexts/AgentContext"; +import { usePolicy } from "xum/browser/contexts/PolicyContext"; +import { isModelAllowedByPolicy } from "xum/browser/utils/policyUi"; import { useThinkingLevel } from "xum/browser/hooks/useThinkingLevel"; import { useReasoningMode } from "xum/browser/hooks/useReasoningMode"; import type { WorkspaceAISettingsCache } from "xum/browser/utils/workspaceModeAi"; @@ -144,7 +146,22 @@ function ChatComposerInner(props: { listener: true, }); - const baseModel = normalizeToCanonical(preferredModel); + const storedModel = normalizeToCanonical(preferredModel); + + // #4808: the stored model can be one the admin policy excludes (persisted earlier, seeded from the + // workspace, or revoked by a policy refresh), and every send with it fails with policy_denied. + // Fall back to the first allowed model for display and send, without writing it anywhere: the + // webview does not persist AI settings, and the stored choice comes back if the policy allows it + // again. With no allowed model in the list, Send stays disabled. Either way, a status line says so. + const policyState = usePolicy(); + const effectivePolicy = + policyState.status.state === "enforced" ? (policyState.policy ?? null) : null; + const storedModelAllowed = isModelAllowedByPolicy(effectivePolicy, storedModel); + const policyFallbackModel = storedModelAllowed + ? null + : (models.find((model) => isModelAllowedByPolicy(effectivePolicy, model)) ?? null); + const baseModel = storedModelAllowed ? storedModel : (policyFallbackModel ?? storedModel); + const blockedByPolicy = !storedModelAllowed && policyFallbackModel === null; const inputKey = getInputKey(props.workspaceId); const [input, setInput] = usePersistedState(inputKey, "", { listener: true }); @@ -202,7 +219,8 @@ function ChatComposerInner(props: { !isSending && input.trim().length > 0 && apiState.status === "connected" && - Boolean(api); + Boolean(api) && + !blockedByPolicy; const onModelChange = (model: string) => { const canonicalModel = normalizeToCanonical(model); @@ -250,7 +268,7 @@ function ChatComposerInner(props: { return; } const trimmed = input.trim(); - if (!trimmed) { + if (!trimmed || blockedByPolicy) { return; } @@ -290,6 +308,8 @@ function ChatComposerInner(props: { // The thinking level is sent as selected: the webview does not load the user's configured // per-model minimums, so only the backend can apply the authoritative floor. skipAiSettingsPersistence: true, + // Only when the stored model is policy-excluded; otherwise keep the stored model string. + ...(policyFallbackModel ? { model: policyFallbackModel } : {}), }; const result = await api.workspace.sendMessage( @@ -383,6 +403,13 @@ function ChatComposerInner(props: { />
+ {storedModelAllowed ? null : ( +
+ {policyFallbackModel + ? `Admin policy does not allow ${storedModel}; using ${policyFallbackModel}.` + : `Admin policy does not allow ${storedModel}, and no allowed model is available.`} +
+ )}
Date: Sun, 27 Sep 2026 02:27:55 +0000 Subject: [PATCH 2/4] fix: route-aware policy check; run local commands before the policy guard --- vscode/src/webview/App.test.tsx | 14 ++++++++++++++ vscode/src/webview/ChatComposer.tsx | 19 +++++++++++-------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/vscode/src/webview/App.test.tsx b/vscode/src/webview/App.test.tsx index b37c67b9387..dced2819be9 100644 --- a/vscode/src/webview/App.test.tsx +++ b/vscode/src/webview/App.test.tsx @@ -634,6 +634,20 @@ describe("vscode webview policy-excluded model", () => { expect(bridge.orpcCalls("workspace.sendMessage")).toHaveLength(0); }); + test("still runs the local /vim command when the policy allows no listed model", async () => { + const { view } = await renderWithPolicy( + enforcedPolicy([{ id: "openai", allowedModels: ["not-a-listed-model"] }]) + ); + const textarea = view.container.querySelector("textarea"); + if (!textarea) throw new Error("composer textarea did not render"); + await typeInto(textarea, "/vim"); + await act(async () => { + fireEvent.keyDown(textarea, { key: "Enter" }); + await Promise.resolve(); + }); + expect(view.getByText("Vim mode enabled.")).toBeDefined(); + }); + test("keeps an allowed selection unchanged", async () => { const { bridge, view } = await renderWithPolicy( enforcedPolicy([{ id: "anthropic", allowedModels: null }]) diff --git a/vscode/src/webview/ChatComposer.tsx b/vscode/src/webview/ChatComposer.tsx index 168f4652723..b6991399e4f 100644 --- a/vscode/src/webview/ChatComposer.tsx +++ b/vscode/src/webview/ChatComposer.tsx @@ -8,8 +8,6 @@ import { getSendOptionsFromStorage } from "xum/browser/utils/messages/sendOption import { matchesKeybind, formatKeybind, KEYBINDS } from "xum/browser/utils/ui/keybinds"; import { useAPI } from "xum/browser/contexts/API"; import { useAgent } from "xum/browser/contexts/AgentContext"; -import { usePolicy } from "xum/browser/contexts/PolicyContext"; -import { isModelAllowedByPolicy } from "xum/browser/utils/policyUi"; import { useThinkingLevel } from "xum/browser/hooks/useThinkingLevel"; import { useReasoningMode } from "xum/browser/hooks/useReasoningMode"; import type { WorkspaceAISettingsCache } from "xum/browser/utils/workspaceModeAi"; @@ -139,6 +137,7 @@ function ChatComposerInner(props: { ensureModelInSettings, defaultModel, setDefaultModel, + isAllowedByPolicyOnActiveRoute, } = useModelsFromSettings(); const modelKey = getModelKey(props.workspaceId); @@ -153,13 +152,12 @@ function ChatComposerInner(props: { // Fall back to the first allowed model for display and send, without writing it anywhere: the // webview does not persist AI settings, and the stored choice comes back if the policy allows it // again. With no allowed model in the list, Send stays disabled. Either way, a status line says so. - const policyState = usePolicy(); - const effectivePolicy = - policyState.status.state === "enforced" ? (policyState.policy ?? null) : null; - const storedModelAllowed = isModelAllowedByPolicy(effectivePolicy, storedModel); + // The check is route-aware, like the model list: the backend enforces policy after routing, so a + // canonical model that the policy allows only through a gateway route is still allowed. + const storedModelAllowed = isAllowedByPolicyOnActiveRoute(storedModel); const policyFallbackModel = storedModelAllowed ? null - : (models.find((model) => isModelAllowedByPolicy(effectivePolicy, model)) ?? null); + : (models.find((model) => isAllowedByPolicyOnActiveRoute(model)) ?? null); const baseModel = storedModelAllowed ? storedModel : (policyFallbackModel ?? storedModel); const blockedByPolicy = !storedModelAllowed && policyFallbackModel === null; @@ -268,7 +266,7 @@ function ChatComposerInner(props: { return; } const trimmed = input.trim(); - if (!trimmed || blockedByPolicy) { + if (!trimmed) { return; } @@ -280,6 +278,11 @@ function ChatComposerInner(props: { return; } + // After local commands such as /vim, which make no provider request. + if (blockedByPolicy) { + return; + } + if (!api) { props.onNotice({ level: "error", message: "Not connected to Xum server." }); return; From bfecb1b70552c99407bfa3365fe8757e99809537 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 27 Sep 2026 02:36:12 +0000 Subject: [PATCH 3/4] fix: drop the blocking state; check the gateway-preserving model identity --- vscode/src/webview/App.test.tsx | 21 +++++---------------- vscode/src/webview/ChatComposer.tsx | 22 ++++++++-------------- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/vscode/src/webview/App.test.tsx b/vscode/src/webview/App.test.tsx index dced2819be9..c9cd0129d57 100644 --- a/vscode/src/webview/App.test.tsx +++ b/vscode/src/webview/App.test.tsx @@ -624,28 +624,17 @@ describe("vscode webview policy-excluded model", () => { expect(bridge.orpcCalls("workspace.updateAgentAISettings")).toHaveLength(0); }); - test("blocks the send when the policy allows no listed model", async () => { + test("keeps the stored model and says so when the policy allows no listed model", async () => { const { bridge, view } = await renderWithPolicy( enforcedPolicy([{ id: "openai", allowedModels: ["not-a-listed-model"] }]) ); expect(view.getByRole("status").textContent).toContain("anthropic:claude-opus-5-5"); await clickSend(view); - expect(bridge.orpcCalls("workspace.sendMessage")).toHaveLength(0); - }); - - test("still runs the local /vim command when the policy allows no listed model", async () => { - const { view } = await renderWithPolicy( - enforcedPolicy([{ id: "openai", allowedModels: ["not-a-listed-model"] }]) - ); - const textarea = view.container.querySelector("textarea"); - if (!textarea) throw new Error("composer textarea did not render"); - await typeInto(textarea, "/vim"); - await act(async () => { - fireEvent.keyDown(textarea, { key: "Enter" }); - await Promise.resolve(); - }); - expect(view.getByText("Vim mode enabled.")).toBeDefined(); + const input = bridge.orpcCalls("workspace.sendMessage")[0].input as { + options: Record; + }; + expect(input.options.model).toBe("anthropic:claude-opus-5-5"); }); test("keeps an allowed selection unchanged", async () => { diff --git a/vscode/src/webview/ChatComposer.tsx b/vscode/src/webview/ChatComposer.tsx index b6991399e4f..e4e9ee7960f 100644 --- a/vscode/src/webview/ChatComposer.tsx +++ b/vscode/src/webview/ChatComposer.tsx @@ -15,7 +15,7 @@ import { normalizeAgentId } from "xum/common/utils/agentIds"; import { ThinkingProvider } from "xum/browser/contexts/ThinkingContext"; import { usePersistedState, updatePersistedState } from "xum/browser/hooks/usePersistedState"; import { useModelsFromSettings } from "xum/browser/hooks/useModelsFromSettings"; -import { normalizeToCanonical } from "xum/common/utils/ai/models"; +import { normalizeSelectedModel, normalizeToCanonical } from "xum/common/utils/ai/models"; import { useProviderOptions } from "xum/browser/hooks/useProviderOptions"; import { useAutoCompactionSettings } from "xum/browser/hooks/useAutoCompactionSettings"; @@ -151,15 +151,15 @@ function ChatComposerInner(props: { // workspace, or revoked by a policy refresh), and every send with it fails with policy_denied. // Fall back to the first allowed model for display and send, without writing it anywhere: the // webview does not persist AI settings, and the stored choice comes back if the policy allows it - // again. With no allowed model in the list, Send stays disabled. Either way, a status line says so. - // The check is route-aware, like the model list: the backend enforces policy after routing, so a - // canonical model that the policy allows only through a gateway route is still allowed. - const storedModelAllowed = isAllowedByPolicyOnActiveRoute(storedModel); + // again. With no allowed model in the list, nothing changes and the backend decides. Either way, + // a status line says so. The check is route-aware, like the model list, because the backend + // enforces policy after routing; it uses the gateway-preserving identity so an explicitly pinned + // gateway model is checked on that gateway. + const storedModelAllowed = isAllowedByPolicyOnActiveRoute(normalizeSelectedModel(preferredModel)); const policyFallbackModel = storedModelAllowed ? null : (models.find((model) => isAllowedByPolicyOnActiveRoute(model)) ?? null); const baseModel = storedModelAllowed ? storedModel : (policyFallbackModel ?? storedModel); - const blockedByPolicy = !storedModelAllowed && policyFallbackModel === null; const inputKey = getInputKey(props.workspaceId); const [input, setInput] = usePersistedState(inputKey, "", { listener: true }); @@ -217,8 +217,7 @@ function ChatComposerInner(props: { !isSending && input.trim().length > 0 && apiState.status === "connected" && - Boolean(api) && - !blockedByPolicy; + Boolean(api); const onModelChange = (model: string) => { const canonicalModel = normalizeToCanonical(model); @@ -278,11 +277,6 @@ function ChatComposerInner(props: { return; } - // After local commands such as /vim, which make no provider request. - if (blockedByPolicy) { - return; - } - if (!api) { props.onNotice({ level: "error", message: "Not connected to Xum server." }); return; @@ -410,7 +404,7 @@ function ChatComposerInner(props: {
{policyFallbackModel ? `Admin policy does not allow ${storedModel}; using ${policyFallbackModel}.` - : `Admin policy does not allow ${storedModel}, and no allowed model is available.`} + : `Admin policy does not allow ${storedModel}. Choose an allowed model.`}
)}
From cab8bac8ed335513449b8ad3c2c4cb7c144fa074 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 27 Sep 2026 02:48:36 +0000 Subject: [PATCH 4/4] fix: name the gateway-preserving selection in the policy status --- vscode/src/webview/ChatComposer.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/vscode/src/webview/ChatComposer.tsx b/vscode/src/webview/ChatComposer.tsx index e4e9ee7960f..5b1b8ec9909 100644 --- a/vscode/src/webview/ChatComposer.tsx +++ b/vscode/src/webview/ChatComposer.tsx @@ -155,7 +155,9 @@ function ChatComposerInner(props: { // a status line says so. The check is route-aware, like the model list, because the backend // enforces policy after routing; it uses the gateway-preserving identity so an explicitly pinned // gateway model is checked on that gateway. - const storedModelAllowed = isAllowedByPolicyOnActiveRoute(normalizeSelectedModel(preferredModel)); + // The status line names this identity too, so a denied gateway pin is not shown as its canonical ID. + const storedSelection = normalizeSelectedModel(preferredModel); + const storedModelAllowed = isAllowedByPolicyOnActiveRoute(storedSelection); const policyFallbackModel = storedModelAllowed ? null : (models.find((model) => isAllowedByPolicyOnActiveRoute(model)) ?? null); @@ -403,8 +405,8 @@ function ChatComposerInner(props: { {storedModelAllowed ? null : (
{policyFallbackModel - ? `Admin policy does not allow ${storedModel}; using ${policyFallbackModel}.` - : `Admin policy does not allow ${storedModel}. Choose an allowed model.`} + ? `Admin policy does not allow ${storedSelection}; using ${policyFallbackModel}.` + : `Admin policy does not allow ${storedSelection}. Choose an allowed model.`}
)}