Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion vscode/src/orpcAllowlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
62 changes: 61 additions & 1 deletion vscode/src/orpcAllowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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<string, unknown> {
const projected: Record<string, unknown> = {};
if (typeof value !== "object" || value === null) {
return projected;
}
const config = value as Record<string, unknown>;
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 };

/**
Expand Down
139 changes: 139 additions & 0 deletions vscode/src/webview/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> {
const refreshed = getProvidersConfigStore().refresh();
await bridge.answer("providers.getConfig", null);
await refreshed;
}

async function selectWorkspace(bridge: TestBridge, history: unknown[] = []): Promise<void> {
await bridge.emit({ type: "connectionStatus", status: { mode: "api", baseUrl: "http://x" } });
await bridge.emit({ type: "workspaces", workspaces: [WORKSPACE] });
Expand Down Expand Up @@ -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);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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(<App bridge={bridge} />);
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(<App bridge={bridge} />);
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<string, unknown>;
};
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(<App bridge={bridge} />);
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<string, unknown>;
};
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(<App bridge={bridge} />);
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(<App bridge={bridge} />);
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);
});
});
24 changes: 24 additions & 0 deletions vscode/src/webview/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Comment thread
ThomasK33 marked this conversation as resolved.
return () => {
providersConfigStore.setClient(null);
appConfigStore.setClient(null);
};
}, [apiClient, apiConnectionKey]);

useEffect(() => {
const unsubscribe = bridge.onMessage((raw) => {
Expand Down
11 changes: 8 additions & 3 deletions vscode/src/webview/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Comment thread
ThomasK33 marked this conversation as resolved.
const baseModel = storedModelAllowed ? storedModel : (policyFallbackModel ?? storedModel);

const inputKey = getInputKey(props.workspaceId);
Expand Down
Loading