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
4 changes: 4 additions & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions src/api/httpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -27,16 +28,20 @@ export async function createHttpAgent(
cfg: Pick<WorkspaceConfiguration, "get">,
): Promise<ProxyAgent> {
const insecure = cfg.get<boolean>("coder.insecure", false);
const proxyStrictSSL = cfg.get<boolean>("http.proxyStrictSSL", true);
const proxyStrictSSL =
LocalProxySettings.get<boolean>("http.proxyStrictSSL") ?? true;
// "off" ignores VS Code proxy config; inherited env proxies still apply.
const proxyEnabled = cfg.get<string>("http.proxySupport") !== "off";
const proxySetting = <T>(key: string) =>
proxyEnabled ? cfg.get<T>(key) : undefined;
const proxyEnabled =
LocalProxySettings.get<string>("http.proxySupport") !== "off";
const proxySetting = <T>(key: LocalProxySetting) =>
proxyEnabled ? LocalProxySettings.get<T>(key) : undefined;
const proxyAuthorization = proxySetting<string | null>(
"http.proxyAuthorization",
);
const httpProxy = proxySetting<string | null>("http.proxy");
const coderProxyBypass = proxySetting<string | null>("coder.proxyBypass");
const coderProxyBypass = proxyEnabled
? cfg.get<string | null>("coder.proxyBypass")
: undefined;
const httpNoProxy = proxySetting<string[]>("http.noProxy");

const certFile = expandPath(
Expand Down
4 changes: 4 additions & 0 deletions src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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,
});
Expand Down Expand Up @@ -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 {
Expand Down
49 changes: 36 additions & 13 deletions src/remote/environment.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,49 @@
import * as vscode from "vscode";

import { joinNoProxy } from "../api/proxy";
import { LocalProxySettings } from "../settings/proxy";
import { sessionId } from "../telemetry/ids";

import type {
GlobalEnvironmentVariableCollection,
WorkspaceConfiguration,
} from "vscode";

import type { WatchedSetting } from "../configWatcher";

type Environment = Record<string, string | undefined>;
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"),
},
];

/**
Expand Down Expand Up @@ -71,14 +92,16 @@ export function applySshEnvironment(
export function getSshProxyEnvironment(
cfg: Pick<WorkspaceConfiguration, "get">,
): SshProxyEnvironment {
if (cfg.get<string>("http.proxySupport") === "off") {
if (LocalProxySettings.get<string>("http.proxySupport") === "off") {
return {};
}

const httpProxy = trimmed(cfg.get<string | null>("http.proxy"));
const httpProxy = trimmed(
LocalProxySettings.get<string | null>("http.proxy"),
);
const noProxy =
trimmed(cfg.get<string | null>("coder.proxyBypass")) ??
joinNoProxy(cfg.get<string[]>("http.noProxy"));
joinNoProxy(LocalProxySettings.get<string[]>("http.noProxy"));

return {
HTTP_PROXY: httpProxy,
Expand Down
6 changes: 1 addition & 5 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
77 changes: 77 additions & 0 deletions src/settings/proxy.ts
Original file line number Diff line number Diff line change
@@ -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<LocalProxySetting, unknown>;
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<T>(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<T>(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<LocalProxySetting, unknown> {
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<boolean>("http.useLocalProxyConfiguration", true)
);
}
72 changes: 72 additions & 0 deletions test/unit/settings/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
16 changes: 16 additions & 0 deletions test/unit/setup.ts
Original file line number Diff line number Diff line change
@@ -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();
});
1 change: 1 addition & 0 deletions vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default defineConfig({
exclude: ["**/node_modules/**", "**/out/**", "**/*.d.ts"],
environment: "node",
globals: true,
setupFiles: ["test/unit/setup.ts"],
},
resolve: {
alias: {
Expand Down
Loading