From c4bfe44aadbd699a02f4aa37d9824e853e74e37a Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 22 Oct 2026 00:16:12 +0300 Subject: [PATCH] fix(remote): keep local proxy settings after the remote connects When the remote leaves `http.useLocalProxyConfiguration` off, VS Code makes the `http.*` proxy settings machine scoped once connected and reads them from the remote. The reload watcher saw that as a change and prompted after every reload, and API requests switched to the remote's proxy. Add `LocalProxySettings`, which keeps the last local values of the five proxy settings, and read them through it for the HTTP agent, the SSH environment, and the reload watcher. A lint rule rejects reading these keys through VS Code's configuration. --- .oxlintrc.jsonc | 4 ++ CHANGELOG.md | 9 ++++ src/api/httpAgent.ts | 15 ++++--- src/container.ts | 4 ++ src/remote/environment.ts | 49 ++++++++++++++------ src/remote/remote.ts | 6 +-- src/settings/proxy.ts | 77 ++++++++++++++++++++++++++++++++ test/unit/settings/proxy.test.ts | 72 +++++++++++++++++++++++++++++ test/unit/setup.ts | 16 +++++++ vitest.config.mts | 1 + 10 files changed, 230 insertions(+), 23 deletions(-) create mode 100644 src/settings/proxy.ts create mode 100644 test/unit/settings/proxy.test.ts create mode 100644 test/unit/setup.ts diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 8399b23aa4..913a399077 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -333,6 +333,10 @@ "selector": "MemberExpression[property.name='remoteAuthority'][object.property.name='env'][object.object.name='vscode']", "message": "env.remoteAuthority is a proposed API (resolvers) and throws through our own vscode module. Read it via vscodeProposed.env.remoteAuthority." }, + { + "selector": "CallExpression[callee.property.name=/^(get|inspect)$/][arguments.0.value=/^http\\.(proxy|proxySupport|noProxy|proxyAuthorization|proxyStrictSSL)$/]:not([callee.object.name='LocalProxySettings'])", + "message": "Do not read http.* proxy settings through VS Code's configuration. Use LocalProxySettings.get(), which keeps the local values once a remote window connects." + }, { "selector": "JSXExpressionContainer > LogicalExpression[operator='&&'][left.property.name='length']", "message": "Numeric '.length' as a JSX '&&' operand renders 0 when empty. Use '.length > 0' or a ternary." diff --git a/CHANGELOG.md b/CHANGELOG.md index 611ca542a3..3eaa79fdb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ from published versions since it shows up in the VS Code extension changelog tab and is confusing to users. Add it back between releases if needed. --> +## Unreleased + +### Fixed + +- Fix a "settings changed. Reload window to apply." prompt for proxy settings + that returned after every reload when `http.useLocalProxyConfiguration` is + off. API requests now also keep using the local proxy settings after the + remote connects. + ## [v1.17.1](https://github.com/coder/vscode-coder/releases/tag/v1.17.1) 2026-10-15 ### Changed diff --git a/src/api/httpAgent.ts b/src/api/httpAgent.ts index d7a60d8bfc..febcb06f9a 100644 --- a/src/api/httpAgent.ts +++ b/src/api/httpAgent.ts @@ -3,6 +3,7 @@ import { ProxyAgent } from "proxy-agent"; import { type WorkspaceConfiguration } from "vscode"; import { expandPath } from "../common/fs"; +import { LocalProxySettings, type LocalProxySetting } from "../settings/proxy"; import { getProxyForUrl, joinNoProxy } from "./proxy"; @@ -27,16 +28,20 @@ export async function createHttpAgent( cfg: Pick, ): Promise { const insecure = cfg.get("coder.insecure", false); - const proxyStrictSSL = cfg.get("http.proxyStrictSSL", true); + const proxyStrictSSL = + LocalProxySettings.get("http.proxyStrictSSL") ?? true; // "off" ignores VS Code proxy config; inherited env proxies still apply. - const proxyEnabled = cfg.get("http.proxySupport") !== "off"; - const proxySetting = (key: string) => - proxyEnabled ? cfg.get(key) : undefined; + const proxyEnabled = + LocalProxySettings.get("http.proxySupport") !== "off"; + const proxySetting = (key: LocalProxySetting) => + proxyEnabled ? LocalProxySettings.get(key) : undefined; const proxyAuthorization = proxySetting( "http.proxyAuthorization", ); const httpProxy = proxySetting("http.proxy"); - const coderProxyBypass = proxySetting("coder.proxyBypass"); + const coderProxyBypass = proxyEnabled + ? cfg.get("coder.proxyBypass") + : undefined; const httpNoProxy = proxySetting("http.noProxy"); const certFile = expandPath( diff --git a/src/container.ts b/src/container.ts index 7ce423973a..6c115db2f0 100644 --- a/src/container.ts +++ b/src/container.ts @@ -13,6 +13,7 @@ import { CONNECTION_LOG_BUFFER_SIZE_SETTING, readConnectionLogBufferSize, } from "./settings/logger"; +import { LocalProxySettings } from "./settings/proxy"; import { MementoManager } from "./storage/mementoManager"; import { PathResolver } from "./storage/pathResolver"; import { SecretsManager } from "./storage/secretsManager"; @@ -33,6 +34,7 @@ import type { Logger } from "./logging/logger"; * Centralizes the creation and management of all core services. */ export class ServiceContainer implements vscode.Disposable { + private readonly localProxySettings: vscode.Disposable; private readonly outputChannel: vscode.LogOutputChannel; private readonly logger: FlightRecorder; private readonly connectionLogBufferConfigSubscription: vscode.Disposable; @@ -51,6 +53,7 @@ export class ServiceContainer implements vscode.Disposable { private readonly commandManager: CommandManager; constructor(context: vscode.ExtensionContext) { + this.localProxySettings = LocalProxySettings.initialize(); this.outputChannel = vscode.window.createOutputChannel("Coder", { log: true, }); @@ -215,6 +218,7 @@ export class ServiceContainer implements vscode.Disposable { this.contextManager.dispose(); this.loginCoordinator.dispose(); this.connectionLogBufferConfigSubscription.dispose(); + this.localProxySettings.dispose(); try { await this.telemetryService.dispose(); } finally { diff --git a/src/remote/environment.ts b/src/remote/environment.ts index 1fcec8379c..ad7cbef1f3 100644 --- a/src/remote/environment.ts +++ b/src/remote/environment.ts @@ -1,4 +1,7 @@ +import * as vscode from "vscode"; + import { joinNoProxy } from "../api/proxy"; +import { LocalProxySettings } from "../settings/proxy"; import { sessionId } from "../telemetry/ids"; import type { @@ -6,23 +9,41 @@ import type { WorkspaceConfiguration, } from "vscode"; +import type { WatchedSetting } from "../configWatcher"; + type Environment = Record; type SshProxyEnvironment = Partial< Record<"HTTP_PROXY" | "HTTPS_PROXY" | "NO_PROXY", string> >; /** - * The settings {@link getSshProxyEnvironment} reads, paired with display titles. - * Watch these to prompt for a reload when the SSH proxy environment changes. + * The settings {@link getSshProxyEnvironment} reads, with a display title and a + * getter for each current value. */ -export const SSH_PROXY_SETTINGS: ReadonlyArray<{ - setting: string; - title: string; -}> = [ - { setting: "http.proxy", title: "HTTP Proxy" }, - { setting: "http.proxySupport", title: "HTTP Proxy Support" }, - { setting: "http.noProxy", title: "HTTP No Proxy" }, - { setting: "coder.proxyBypass", title: "Proxy Bypass" }, +export const SSH_PROXY_SETTINGS: ReadonlyArray< + WatchedSetting & { title: string } +> = [ + { + setting: "http.proxy", + title: "HTTP Proxy", + getValue: () => LocalProxySettings.get("http.proxy"), + }, + { + setting: "http.proxySupport", + title: "HTTP Proxy Support", + getValue: () => LocalProxySettings.get("http.proxySupport"), + }, + { + setting: "http.noProxy", + title: "HTTP No Proxy", + getValue: () => LocalProxySettings.get("http.noProxy"), + }, + { + setting: "coder.proxyBypass", + title: "Proxy Bypass", + getValue: () => + vscode.workspace.getConfiguration().get("coder.proxyBypass"), + }, ]; /** @@ -71,14 +92,16 @@ export function applySshEnvironment( export function getSshProxyEnvironment( cfg: Pick, ): SshProxyEnvironment { - if (cfg.get("http.proxySupport") === "off") { + if (LocalProxySettings.get("http.proxySupport") === "off") { return {}; } - const httpProxy = trimmed(cfg.get("http.proxy")); + const httpProxy = trimmed( + LocalProxySettings.get("http.proxy"), + ); const noProxy = trimmed(cfg.get("coder.proxyBypass")) ?? - joinNoProxy(cfg.get("http.noProxy")); + joinNoProxy(LocalProxySettings.get("http.noProxy")); return { HTTP_PROXY: httpProxy, diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 79b8cc975d..18fbedfc52 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -529,11 +529,7 @@ export class Remote { title: "SSH Flags", getValue: () => getSshFlags(vscode.workspace.getConfiguration()), }, - ...SSH_PROXY_SETTINGS.map(({ setting, title }) => ({ - setting, - title, - getValue: () => vscode.workspace.getConfiguration().get(setting), - })), + ...SSH_PROXY_SETTINGS, ]; if (cliFeatures.proxyLogDirectory) { settingsToWatch.push({ diff --git a/src/settings/proxy.ts b/src/settings/proxy.ts new file mode 100644 index 0000000000..6a1f057e72 --- /dev/null +++ b/src/settings/proxy.ts @@ -0,0 +1,77 @@ +import * as vscode from "vscode"; + +const LOCAL_PROXY_SETTINGS = [ + "http.proxy", + "http.proxySupport", + "http.noProxy", + "http.proxyAuthorization", + "http.proxyStrictSSL", +] as const; + +/** The `http.*` proxy settings that the HTTP agent and SSH environment read. */ +export type LocalProxySetting = (typeof LOCAL_PROXY_SETTINGS)[number]; + +/** + * Local proxy settings for API requests and the SSH process. Without + * `http.useLocalProxyConfiguration`, a connected remote window reads `http.*` + * from the remote, so this keeps the last local values. + */ +export class LocalProxySettings implements vscode.Disposable { + static #instance: LocalProxySettings | undefined; + + #local: ReadonlyMap; + readonly #listener: vscode.Disposable; + + /** @throws Error if already initialized and not yet disposed. */ + public static initialize(): vscode.Disposable { + if (LocalProxySettings.#instance) { + throw new Error("LocalProxySettings has already been initialized"); + } + LocalProxySettings.#instance = new LocalProxySettings(); + return LocalProxySettings.#instance; + } + + /** @throws Error if not initialized. */ + public static get(section: LocalProxySetting): T | undefined { + const instance = LocalProxySettings.#instance; + if (!instance) { + throw new Error("LocalProxySettings has not been initialized"); + } + return readsRemoteProxySettings() + ? (instance.#local.get(section) as T | undefined) + : vscode.workspace.getConfiguration().get(section); + } + + private constructor() { + this.#local = readProxySettings(); + this.#listener = vscode.workspace.onDidChangeConfiguration((e) => { + if ( + !readsRemoteProxySettings() && + LOCAL_PROXY_SETTINGS.some((s) => e.affectsConfiguration(s)) + ) { + this.#local = readProxySettings(); + } + }); + } + + public dispose(): void { + this.#listener.dispose(); + if (LocalProxySettings.#instance === this) { + LocalProxySettings.#instance = undefined; + } + } +} + +function readProxySettings(): ReadonlyMap { + const cfg = vscode.workspace.getConfiguration(); + return new Map(LOCAL_PROXY_SETTINGS.map((s) => [s, cfg.get(s)])); +} + +function readsRemoteProxySettings(): boolean { + return ( + vscode.env.remoteName !== undefined && + !vscode.workspace + .getConfiguration() + .get("http.useLocalProxyConfiguration", true) + ); +} diff --git a/test/unit/settings/proxy.test.ts b/test/unit/settings/proxy.test.ts new file mode 100644 index 0000000000..9658d885f5 --- /dev/null +++ b/test/unit/settings/proxy.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import * as vscode from "vscode"; + +import { LocalProxySettings } from "@/settings/proxy"; + +import { config, type Settings } from "../../mocks/testHelpers"; + +const LOCAL_PROXY = "http://local-proxy.example.com:8080"; +const REMOTE_PROXY = "http://remote-proxy.example.com:8080"; + +// Uses the instance that test/unit/setup.ts initializes for each test. +function setup(remoteName: string | undefined, initial: Settings) { + vi.mocked(vscode.env).remoteName = remoteName; + onTestFinished(() => { + vi.mocked(vscode.env).remoteName = undefined; + }); + const cfg = config(initial); + return { + cfg, + proxy: () => LocalProxySettings.get("http.proxy"), + /** Mimic VS Code making `http.*` machine scoped after connecting. */ + switchToRemoteSettings(remote: Settings) { + cfg.set("http.useLocalProxyConfiguration", false); + for (const [key, value] of Object.entries(remote)) { + cfg.set(key, value); + } + }, + }; +} + +describe("LocalProxySettings", () => { + it("keeps the last local proxy values after VS Code switches to remote settings", () => { + const { cfg, proxy, switchToRemoteSettings } = setup("ssh-remote", { + "http.proxy": "http://stale.example.com", + }); + cfg.set("http.proxy", LOCAL_PROXY); + + switchToRemoteSettings({ + "http.proxy": REMOTE_PROXY, + "http.proxyStrictSSL": false, + }); + + expect(proxy()).toBe(LOCAL_PROXY); + expect(LocalProxySettings.get("http.proxyStrictSSL")).toBeUndefined(); + }); + + it("reads live values when the remote uses the local proxy configuration", () => { + const { cfg, proxy } = setup("ssh-remote", { + "http.useLocalProxyConfiguration": true, + }); + + cfg.set("http.proxy", LOCAL_PROXY); + + expect(proxy()).toBe(LOCAL_PROXY); + }); + + it("reads live values in a local window", () => { + const { proxy, switchToRemoteSettings } = setup(undefined, { + "http.proxy": LOCAL_PROXY, + }); + + switchToRemoteSettings({ "http.proxy": REMOTE_PROXY }); + + expect(proxy()).toBe(REMOTE_PROXY); + }); + + it("throws when initialized twice", () => { + expect(() => LocalProxySettings.initialize()).toThrow( + "LocalProxySettings has already been initialized", + ); + }); +}); diff --git a/test/unit/setup.ts b/test/unit/setup.ts new file mode 100644 index 0000000000..01bcffafe7 --- /dev/null +++ b/test/unit/setup.ts @@ -0,0 +1,16 @@ +import { beforeEach, vi } from "vitest"; +import * as vscode from "vscode"; + +import { LocalProxySettings } from "@/settings/proxy"; + +// The service container creates this on activation; tests skip the container. +beforeEach(() => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValueOnce({ + get: vi.fn(), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn(), + }); + const settings = LocalProxySettings.initialize(); + return () => settings.dispose(); +}); diff --git a/vitest.config.mts b/vitest.config.mts index a455a1848a..12d2400c62 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -22,6 +22,7 @@ export default defineConfig({ exclude: ["**/node_modules/**", "**/out/**", "**/*.d.ts"], environment: "node", globals: true, + setupFiles: ["test/unit/setup.ts"], }, resolve: { alias: {