diff --git a/vscode/src/orpcAllowlist.test.ts b/vscode/src/orpcAllowlist.test.ts index f028ba81a2..178bb4bc1e 100644 --- a/vscode/src/orpcAllowlist.test.ts +++ b/vscode/src/orpcAllowlist.test.ts @@ -127,6 +127,58 @@ describe("policy (#4739)", () => { const noPolicy = { source: "none", status: { state: "disabled" }, policy: null }; expect(redactWebviewOrpcResult(["policy", "get"], noPolicy)).toEqual(noPolicy); const other = { forcedBaseUrl: "kept" }; - expect(redactWebviewOrpcResult(["providers", "getConfig"], other)).toBe(other); + expect(redactWebviewOrpcResult(["workspace", "getPlanContent"], other)).toBe(other); + }); +}); + +describe("app and providers config (#4766)", () => { + test("allows reading the app config and its change signal only", () => { + expect(isAllowedOrpcPath(["config", "getConfig"])).toBe(true); + expect(isAllowedOrpcPath(["config", "onConfigChanged"])).toBe(true); + expect(isAllowedOrpcPath(["config", "saveConfig"])).toBe(false); + expect(isAllowedOrpcPath(["config", "updateRoutePreferences"])).toBe(false); + }); + + test("projects config.getConfig to the model-routing and thinking-floor fields", () => { + const config = { + routePriority: ["mux-gateway", "direct"], + routeOverrides: { "openai:gpt-5.6-terra": "direct" }, + minThinkingLevelByModel: { "anthropic:claude-opus-5-5": "high" }, + muxGovernorUrl: "https://governor.corp.example", + heartbeatDefaultPrompt: "private prompt", + userPreferences: { name: "alice" }, + taskSettings: { maxParallelAgentTasks: 3 }, + }; + expect(redactWebviewOrpcResult(["config", "getConfig"], config)).toEqual({ + routePriority: ["mux-gateway", "direct"], + routeOverrides: { "openai:gpt-5.6-terra": "direct" }, + minThinkingLevelByModel: { "anthropic:claude-opus-5-5": "high" }, + }); + }); + + test("strips URL and key-file fields from providers.getConfig and keeps everything else", () => { + const providers = { + openai: { + apiKeySet: true, + isEnabled: true, + isConfigured: true, + baseUrl: "https://user:token@proxy.corp.example/v1", + baseUrlResolved: "https://proxy.corp.example/v1?key=secret", + apiKeyFile: "/home/alice/.secrets/openai", + models: ["gpt-5.6-terra"], + }, + coder: { + apiKeySet: false, + isConfigured: true, + deploymentUrl: "https://coder.corp.example", + discoveredModels: ["openai/gpt-5.6-sol"], + }, + }; + expect(redactWebviewOrpcResult(["providers", "getConfig"], providers)).toEqual({ + openai: { apiKeySet: true, isEnabled: true, isConfigured: true, models: ["gpt-5.6-terra"] }, + coder: { apiKeySet: false, isConfigured: true, discoveredModels: ["openai/gpt-5.6-sol"] }, + }); + // The input object is not mutated. + expect(providers.openai.baseUrl).toBeDefined(); }); }); diff --git a/vscode/src/orpcAllowlist.ts b/vscode/src/orpcAllowlist.ts index a2fecbaa88..ac5e3adc3b 100644 --- a/vscode/src/orpcAllowlist.ts +++ b/vscode/src/orpcAllowlist.ts @@ -30,7 +30,12 @@ const ALLOWED_PROCEDURES = { "answerAskUserQuestion", "getPlanContent", ]), + // redactWebviewOrpcResult strips URL and key-file fields from providers.getConfig (#4766). providers: new Set(["list", "getConfig", "onConfigChanged", "setModels"]), + // App config for the model routing and thinking-floor stores (#4766). getConfig is projected to + // the fields those stores read (see redactWebviewOrpcResult); onConfigChanged only emits empty + // change signals. Every write (saveConfig, update*) stays blocked. + config: new Set(["getConfig", "onConfigChanged"]), // Read-only agent descriptors (names, descriptions, UI flags, model defaults, tool patterns) for // the agent picker and agent-cycle shortcut (#4751). agents.get (full prompt bodies) stays // blocked, and sanitizeWebviewOrpcInput limits the input to workspaces the webview is shown. @@ -67,6 +72,8 @@ export function isAllowedOrpcPath(path: string[]): boolean { return ALLOWED_PROCEDURES.agents.has(procedure); case "policy": return ALLOWED_PROCEDURES.policy.has(procedure); + case "config": + return ALLOWED_PROCEDURES.config.has(procedure); default: return false; } @@ -78,9 +85,24 @@ export function isAllowedOrpcPath(path: string[]): boolean { * policy.get: a provider's forcedBaseUrl is an internal gateway URL that could embed credentials, * and no webview code reads it; only the allowlists and flags are forwarded. The input is not * mutated. Every other result passes through unchanged. + * + * config.getConfig (#4766): the app config also holds prompts, the governor URL, preferences and + * task settings. Only the fields AppConfigStore reads are forwarded (an allow-list, so fields added + * later stay in the host). + * + * providers.getConfig (#4766): it carries no keys (only apiKeySet-style booleans), but base URLs and + * the deployment URL can embed credentials and apiKeyFile is a local path; no webview code reads + * them, so they are removed from every provider entry. */ export function redactWebviewOrpcResult(path: string[], value: unknown): unknown { - if (path[0] !== "policy" || path[1] !== "get") { + const procedure = path.join("."); + if (procedure === "config.getConfig") { + return projectAppConfig(value); + } + if (procedure === "providers.getConfig") { + return redactProvidersConfig(value); + } + if (procedure !== "policy.get") { return value; } if (typeof value !== "object" || value === null) { @@ -106,6 +128,44 @@ export function redactWebviewOrpcResult(path: string[], value: unknown): unknown }; } +const WEBVIEW_APP_CONFIG_FIELDS = ["routePriority", "routeOverrides", "minThinkingLevelByModel"]; +const REDACTED_PROVIDER_CONFIG_FIELDS = new Set([ + "baseUrl", + "baseUrlResolved", + "deploymentUrl", + "apiKeyFile", +]); + +function projectAppConfig(value: unknown): Record { + const projected: Record = {}; + if (typeof value !== "object" || value === null) { + return projected; + } + const config = value as Record; + for (const field of WEBVIEW_APP_CONFIG_FIELDS) { + if (config[field] !== undefined) { + projected[field] = config[field]; + } + } + return projected; +} + +function redactProvidersConfig(value: unknown): unknown { + if (typeof value !== "object" || value === null) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([provider, info]: [string, unknown]) => [ + provider, + typeof info === "object" && info !== null + ? Object.fromEntries( + Object.entries(info).filter(([field]) => !REDACTED_PROVIDER_CONFIG_FIELDS.has(field)) + ) + : info, + ]) + ); +} + export type SanitizedOrpcInput = { ok: true; input: unknown } | { ok: false; error: string }; /** diff --git a/vscode/src/webview/App.test.tsx b/vscode/src/webview/App.test.tsx index c9cd0129d5..a3e2c3c4ea 100644 --- a/vscode/src/webview/App.test.tsx +++ b/vscode/src/webview/App.test.tsx @@ -6,6 +6,8 @@ import { act, cleanup, fireEvent, render } from "@testing-library/react"; import { installDom } from "../../../tests/ui/dom"; import { readPersistedState, updatePersistedState } from "xum/browser/hooks/usePersistedState"; import { getAgentIdKey, getModelKey, getThinkingLevelKey } from "xum/common/constants/storage"; +import { getAppConfigStore } from "xum/browser/stores/AppConfigStore"; +import { getProvidersConfigStore } from "xum/browser/stores/ProvidersConfigStore"; import { App } from "./App"; import type { UiWorkspace, WebviewToExtensionMessage } from "./protocol"; import type { VscodeBridge } from "./vscodeBridge"; @@ -76,6 +78,15 @@ const WORKSPACE: UiWorkspace = { createdAt: "2026-09-26T00:00:00.000Z", }; +// ProvidersConfigStore is an app-wide singleton with no reset. A test that loads a providers config +// clears it through the still-mounted app (refetch answered with null) so later tests start without +// one. +async function clearProvidersConfig(bridge: TestBridge): Promise { + const refreshed = getProvidersConfigStore().refresh(); + await bridge.answer("providers.getConfig", null); + await refreshed; +} + async function selectWorkspace(bridge: TestBridge, history: unknown[] = []): Promise { await bridge.emit({ type: "connectionStatus", status: { mode: "api", baseUrl: "http://x" } }); await bridge.emit({ type: "workspaces", workspaces: [WORKSPACE] }); @@ -612,6 +623,9 @@ describe("vscode webview policy-excluded model", () => { const { bridge, view } = await renderWithPolicy( enforcedPolicy([{ id: "openai", allowedModels: ["gpt-5.6-terra"] }]) ); + await bridge.answer("providers.getConfig", { + openai: { apiKeySet: true, isEnabled: true, isConfigured: true }, + }); expect(view.getByRole("status").textContent).toContain("anthropic:claude-opus-5-5"); await clickSend(view); @@ -622,6 +636,7 @@ describe("vscode webview policy-excluded model", () => { // 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); + await clearProvidersConfig(bridge); }); test("keeps the stored model and says so when the policy allows no listed model", async () => { @@ -650,3 +665,127 @@ describe("vscode webview policy-excluded model", () => { expect(input.options.model).toBe("anthropic:claude-opus-5-5"); }); }); + +// #4766: the webview loads the user's routing and thinking-floor config and the providers config. +describe("vscode webview app and providers config", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + // The store is an app-wide singleton; drop the floors a test loaded so later tests start clean. + getAppConfigStore().updateOptimistically({ minThinkingLevelByModel: undefined }); + cleanupDom?.(); + cleanupDom = null; + }); + + test("shows the thinking level raised to the user's configured minimum", async () => { + // "low" is below both the built-in minimum (MED) and the configured one (HIGH). + updatePersistedState(getThinkingLevelKey(WORKSPACE.id), "low"); + const bridge = new TestBridge(); + const view = render(); + await selectWorkspace(bridge); + expect(bridge.orpcCalls("config.getConfig")).toHaveLength(1); + expect(bridge.orpcCalls("providers.getConfig")).toHaveLength(1); + + await bridge.answer("config.getConfig", { + minThinkingLevelByModel: { "anthropic:claude-opus-5-5": "high" }, + }); + expect(view.getByText("HIGH")).toBeDefined(); + expect(view.queryByText("MED")).toBeNull(); + }); + + test("falls back to a policy-allowed model of a configured provider (#4808 review)", async () => { + updatePersistedState(getModelKey(WORKSPACE.id), "openai:gpt-5.6-terra"); + const bridge = new TestBridge(); + const view = render(); + await selectWorkspace(bridge); + // Anthropic is allowed and listed first, but only Google has credentials. + await bridge.answer("policy.get", { + source: "governor", + status: { state: "enforced" }, + policy: { + policyFormatVersion: "0.1", + providerAccess: [ + { id: "anthropic", allowedModels: null }, + { id: "google", allowedModels: null }, + ], + mcp: { allowUserDefined: { stdio: true, remote: true } }, + runtimes: null, + }, + }); + await bridge.answer("providers.getConfig", { + google: { apiKeySet: true, isEnabled: true, isConfigured: true }, + }); + + const textarea = view.container.querySelector("textarea"); + if (!textarea) throw new Error("composer textarea did not render"); + await typeInto(textarea, "hello"); + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Send message" })); + await Promise.resolve(); + }); + const input = bridge.orpcCalls("workspace.sendMessage")[0].input as { + options: Record; + }; + expect(String(input.options.model)).toStartWith("google:"); + await clearProvidersConfig(bridge); + }); + + test("does not pick a fallback before the providers config arrives (#4813 review)", async () => { + updatePersistedState(getModelKey(WORKSPACE.id), "openai:gpt-5.6-terra"); + const bridge = new TestBridge(); + const view = render(); + await selectWorkspace(bridge); + await bridge.answer("policy.get", { + source: "governor", + status: { state: "enforced" }, + policy: { + policyFormatVersion: "0.1", + providerAccess: [{ id: "anthropic", allowedModels: null }], + mcp: { allowUserDefined: { stdio: true, remote: true } }, + runtimes: null, + }, + }); + // providers.getConfig is still pending: availability is unknown, so nothing is substituted. + const textarea = view.container.querySelector("textarea"); + if (!textarea) throw new Error("composer textarea did not render"); + await typeInto(textarea, "hello"); + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Send message" })); + await Promise.resolve(); + }); + const input = bridge.orpcCalls("workspace.sendMessage")[0].input as { + options: Record; + }; + expect(input.options.model).toBe("openai:gpt-5.6-terra"); + }); + + test("reloads the config when the connection switches to another server (#4813 review)", async () => { + const bridge = new TestBridge(); + render(); + await bridge.emit({ type: "connectionStatus", status: { mode: "api", baseUrl: "http://a" } }); + // A refresh against the same server does not refetch. + await bridge.emit({ type: "connectionStatus", status: { mode: "api", baseUrl: "http://a" } }); + expect(bridge.orpcCalls("config.getConfig")).toHaveLength(1); + + await bridge.emit({ type: "connectionStatus", status: { mode: "api", baseUrl: "http://b" } }); + expect(bridge.orpcCalls("config.getConfig")).toHaveLength(2); + expect(bridge.orpcCalls("providers.getConfig")).toHaveLength(2); + }); + + test("loads the config again when the connection recovers from file mode", async () => { + const bridge = new TestBridge(); + render(); + await bridge.emit({ type: "connectionStatus", status: { mode: "file", error: "offline" } }); + expect(bridge.orpcCalls("config.getConfig")).toHaveLength(0); + expect(bridge.orpcCalls("providers.getConfig")).toHaveLength(0); + + await bridge.emit({ type: "connectionStatus", status: { mode: "api", baseUrl: "http://x" } }); + expect(bridge.orpcCalls("config.getConfig")).toHaveLength(1); + expect(bridge.orpcCalls("providers.getConfig")).toHaveLength(1); + }); +}); diff --git a/vscode/src/webview/App.tsx b/vscode/src/webview/App.tsx index fdf5bddded..a870e0270e 100644 --- a/vscode/src/webview/App.tsx +++ b/vscode/src/webview/App.tsx @@ -27,6 +27,8 @@ import { import { Button } from "xum/browser/components/Button/Button"; import { matchesKeybind, KEYBINDS } from "xum/browser/utils/ui/keybinds"; import { readPersistedState } from "xum/browser/hooks/usePersistedState"; +import { getAppConfigStore } from "xum/browser/stores/AppConfigStore"; +import { getProvidersConfigStore } from "xum/browser/stores/ProvidersConfigStore"; import { VIM_ENABLED_KEY } from "xum/common/constants/storage"; import { useAutoScroll } from "xum/browser/hooks/useAutoScroll"; import { applyWorkspaceChatEventToAggregator } from "xum/browser/utils/messages/applyWorkspaceChatEventToAggregator"; @@ -266,6 +268,28 @@ export function App(props: { bridge: VscodeBridge }): JSX.Element { pushNoticeRef.current = pushNotice; const canChat = Boolean(connectionStatus?.mode === "api" && selectedWorkspaceId); + // Identifies the server connection: switching servers keeps mode "api" but changes the URL. + const apiConnectionKey = + connectionStatus?.mode === "api" ? (connectionStatus.baseUrl ?? "api") : null; + + // #4766: the model list, model routing and thinking floors read the shared providers and app + // config stores, which the desktop connects in AppLoader. Connect them while the host has a + // server connection (it rejects calls in file mode), and reconnect them for each server, so a + // recovery or a switch to another server fetches them again. The host + // redacts both results (see redactWebviewOrpcResult). + useEffect(() => { + if (apiConnectionKey === null) { + return; + } + const providersConfigStore = getProvidersConfigStore(); + const appConfigStore = getAppConfigStore(); + providersConfigStore.setClient(apiClient); + appConfigStore.setClient(apiClient); + return () => { + providersConfigStore.setClient(null); + appConfigStore.setClient(null); + }; + }, [apiClient, apiConnectionKey]); useEffect(() => { const unsubscribe = bridge.onMessage((raw) => { diff --git a/vscode/src/webview/ChatComposer.tsx b/vscode/src/webview/ChatComposer.tsx index 5b1b8ec990..a5216367e7 100644 --- a/vscode/src/webview/ChatComposer.tsx +++ b/vscode/src/webview/ChatComposer.tsx @@ -15,6 +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 { useProvidersConfig } from "xum/browser/hooks/useProvidersConfig"; import { normalizeSelectedModel, normalizeToCanonical } from "xum/common/utils/ai/models"; import { useProviderOptions } from "xum/browser/hooks/useProviderOptions"; import { useAutoCompactionSettings } from "xum/browser/hooks/useAutoCompactionSettings"; @@ -158,9 +159,13 @@ function ChatComposerInner(props: { // 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); + // Until the providers config arrives, the model list is not filtered by provider availability, + // so a fallback could pick a provider without credentials; substitute nothing until then. + const { config: providersConfig } = useProvidersConfig(); + const policyFallbackModel = + storedModelAllowed || providersConfig === null + ? null + : (models.find((model) => isAllowedByPolicyOnActiveRoute(model)) ?? null); const baseModel = storedModelAllowed ? storedModel : (policyFallbackModel ?? storedModel); const inputKey = getInputKey(props.workspaceId);