diff --git a/CHANGELOG.md b/CHANGELOG.md index 83db03484..62317c056 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 + +- Propagate VS Code's proxy settings (`http.proxy`, `http.noProxy`, and + `coder.proxyBypass`) to the SSH environment as `HTTP_PROXY`/`HTTPS_PROXY`/ + `NO_PROXY`, so the `coder ssh` ProxyCommand connects through the configured + proxy whether SSH runs as a child process or in a terminal. + ## [v1.15.0](https://github.com/coder/vscode-coder/releases/tag/v1.15.0) 2026-06-12 ### Added diff --git a/src/api/proxy.ts b/src/api/proxy.ts index d71dc2f66..aa6a28c55 100644 --- a/src/api/proxy.ts +++ b/src/api/proxy.ts @@ -12,6 +12,18 @@ const DEFAULT_PORTS: Record = { wss: 443, }; +/** Join a no-proxy list into a comma string, dropping blanks. */ +export function joinNoProxy( + entries: string[] | null | undefined, +): string | undefined { + return ( + entries + ?.map((entry) => entry.trim()) + .filter(Boolean) + .join(",") || undefined + ); +} + /** * @param {string|object} url - The URL, or the result from url.parse. * @param {string} httpProxy - The proxy URL to use. diff --git a/src/api/utils.ts b/src/api/utils.ts index 2fa1abff5..98b4c61fe 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -4,7 +4,7 @@ import { type WorkspaceConfiguration } from "vscode"; import { expandPath } from "../util"; -import { getProxyForUrl } from "./proxy"; +import { getProxyForUrl, joinNoProxy } from "./proxy"; /** * Return whether the API will need a token for authorization. @@ -56,7 +56,7 @@ export async function createHttpAgent( url, cfg.get("http.proxy"), cfg.get("coder.proxyBypass"), - httpNoProxy?.map((noProxy) => noProxy.trim())?.join(","), + joinNoProxy(httpNoProxy), ); }, headers, diff --git a/src/remote/environment.ts b/src/remote/environment.ts new file mode 100644 index 000000000..62ca17e0d --- /dev/null +++ b/src/remote/environment.ts @@ -0,0 +1,114 @@ +import { joinNoProxy } from "../api/proxy"; + +import type { + GlobalEnvironmentVariableCollection, + WorkspaceConfiguration, +} from "vscode"; + +type Environment = Record; +type SshEnvironment = 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. + */ +export const SSH_PROXY_SETTINGS: ReadonlyArray<{ + setting: string; + title: string; +}> = [ + { setting: "http.proxy", title: "HTTP Proxy" }, + { setting: "http.noProxy", title: "HTTP No Proxy" }, + { setting: "coder.proxyBypass", title: "Proxy Bypass" }, +]; + +/** + * Apply the SSH environment that the spawned `coder ssh` ProxyCommand inherits. + * Currently just the proxy config (HTTP_PROXY/HTTPS_PROXY/NO_PROXY), read by the + * coder CLI like any Go HTTP client. Applied via both process.env (ssh spawned as + * a child, `remote.SSH.useLocalServer=true`) and the terminal env collection (ssh + * spawned in a terminal, `useLocalServer=false`, which can't see process.env), + * since the mode isn't knowable up front. Mutating env rather than the SSH config + * keeps credentialed URLs off disk and windows independent. Disposable restores + * both. + */ +export function applySshEnvironment( + cfg: Pick, + collection: Pick< + GlobalEnvironmentVariableCollection, + "persistent" | "replace" | "clear" + >, + env: Environment = process.env, +): { dispose(): void } { + const values = getSshProxyEnvironment(cfg); + const restoreEnv = applyEnvironment(values, env); + + collection.persistent = false; + // Drop stale vars from a prior connect (e.g. NO_PROXY set last time, not now). + collection.clear(); + for (const [key, value] of Object.entries(values)) { + if (value) { + collection.replace(key, value); + } + } + + return { + dispose() { + restoreEnv.dispose(); + collection.clear(); + }, + }; +} + +/** The proxy portion of the SSH environment, derived from VS Code's settings. */ +export function getSshProxyEnvironment( + cfg: Pick, +): SshEnvironment { + const httpProxy = trimmed(cfg.get("http.proxy")); + const noProxy = + trimmed(cfg.get("coder.proxyBypass")) ?? + joinNoProxy(cfg.get("http.noProxy")); + + return { + HTTP_PROXY: httpProxy, + HTTPS_PROXY: httpProxy, + NO_PROXY: noProxy, + }; +} + +function applyEnvironment( + values: SshEnvironment, + env: Environment, +): { dispose(): void } { + // Stored `undefined` means the key was absent and should be deleted on cleanup. + const previous: Environment = {}; + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + continue; + } + previous[key] = env[key]; + env[key] = value; + } + + let disposed = false; + return { + dispose: () => { + if (disposed) { + return; + } + disposed = true; + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete env[key]; + } else { + env[key] = value; + } + } + }, + }; +} + +function trimmed(value: string | null | undefined): string | undefined { + return typeof value === "string" ? value.trim() || undefined : undefined; +} diff --git a/src/remote/remote.ts b/src/remote/remote.ts index d64c76ede..011bb5420 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -1,9 +1,4 @@ import { isAxiosError } from "axios"; -import { type Api } from "coder/site/src/api/api"; -import { - type Workspace, - type WorkspaceAgent, -} from "coder/site/src/api/typesGenerated"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -20,18 +15,11 @@ import { extractAgents } from "../api/api-helper"; import { AuthInterceptor } from "../api/authInterceptor"; import { CoderApi } from "../api/coderApi"; import { needToken } from "../api/utils"; -import { type Commands } from "../commands"; import { CONFIG_CHANGE_DEBOUNCE_MS, watchConfigurationChanges, } from "../configWatcher"; import { version as cliVersion } from "../core/cliExec"; -import { type CliManager } from "../core/cliManager"; -import { type ServiceContainer } from "../core/container"; -import { type ContextManager } from "../core/contextManager"; -import { type StartupMode } from "../core/mementoManager"; -import { type PathResolver } from "../core/pathResolver"; -import { type SecretsManager } from "../core/secretsManager"; import { toError } from "../error/errorUtils"; import { featureSetForVersion, type FeatureSet } from "../featureSet"; import { Inbox } from "../inbox"; @@ -40,8 +28,6 @@ import { RemoteSetupTelemetry, type RemoteSetupTracer, } from "../instrumentation/remoteSetup"; -import { type Logger } from "../logging/logger"; -import { type LoginCoordinator } from "../login/loginCoordinator"; import { OAuthSessionManager } from "../oauth/sessionManager"; import { type CliAuth, @@ -61,6 +47,7 @@ import { import { vscodeProposed } from "../vscodeProposed"; import { WorkspaceMonitor } from "../workspace/workspaceMonitor"; +import { applySshEnvironment, SSH_PROXY_SETTINGS } from "./environment"; import { SshConfig, type SshValues, @@ -73,6 +60,22 @@ import { SshProcessMonitor } from "./sshProcess"; import { computeSshProperties, sshSupportsSetEnv } from "./sshSupport"; import { WorkspaceStateMachine } from "./workspaceStateMachine"; +import type { Api } from "coder/site/src/api/api"; +import type { + Workspace, + WorkspaceAgent, +} from "coder/site/src/api/typesGenerated"; + +import type { Commands } from "../commands"; +import type { CliManager } from "../core/cliManager"; +import type { ServiceContainer } from "../core/container"; +import type { ContextManager } from "../core/contextManager"; +import type { StartupMode } from "../core/mementoManager"; +import type { PathResolver } from "../core/pathResolver"; +import type { SecretsManager } from "../core/secretsManager"; +import type { Logger } from "../logging/logger"; +import type { LoginCoordinator } from "../login/loginCoordinator"; + export interface RemoteDetails extends vscode.Disposable { safeHostname: string; url: string; @@ -202,6 +205,12 @@ export class Remote { const { args, parts, workspaceName, baseUrl, token, disposables } = context; try { + disposables.push( + applySshEnvironment( + vscode.workspace.getConfiguration(), + this.extensionContext.environmentVariableCollection, + ), + ); // Create OAuth session manager for this remote deployment const remoteOAuthManager = OAuthSessionManager.create( { url: baseUrl, safeHostname: parts.safeHostname }, @@ -454,6 +463,11 @@ 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), + })), ]; if (featureSet.proxyLogDirectory) { settingsToWatch.push({ diff --git a/test/unit/remote/environment.test.ts b/test/unit/remote/environment.test.ts new file mode 100644 index 000000000..630ea49e7 --- /dev/null +++ b/test/unit/remote/environment.test.ts @@ -0,0 +1,260 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; + +import { + applySshEnvironment, + getSshProxyEnvironment, +} from "@/remote/environment"; + +import { MockConfigurationProvider } from "../../mocks/testHelpers"; + +const proxy = "http://proxy.example.com:8080"; + +function setup() { + vi.unstubAllEnvs(); + return { + config(settings: Record = {}): MockConfigurationProvider { + const cfg = new MockConfigurationProvider(); + for (const [key, value] of Object.entries(settings)) { + cfg.set(key, value); + } + return cfg; + }, + }; +} + +describe("getSshProxyEnvironment", () => { + it.each([ + { + name: "sets both proxy variables from http.proxy", + settings: { "http.proxy": proxy }, + expected: { HTTP_PROXY: proxy, HTTPS_PROXY: proxy }, + }, + { + name: "passes through the proxy when the deployment is bypassed", + settings: { + "http.proxy": proxy, + "coder.proxyBypass": "coder.example.com", + }, + expected: { + HTTP_PROXY: proxy, + HTTPS_PROXY: proxy, + NO_PROXY: "coder.example.com", + }, + }, + { + name: "falls back to http.noProxy when coder.proxyBypass is unset", + settings: { + "http.proxy": proxy, + "http.noProxy": [" first.example.com ", "second.example.com "], + }, + expected: { + HTTP_PROXY: proxy, + HTTPS_PROXY: proxy, + NO_PROXY: "first.example.com,second.example.com", + }, + }, + { + name: "prefers coder.proxyBypass over http.noProxy", + settings: { + "http.proxy": proxy, + "coder.proxyBypass": "primary.example.com", + "http.noProxy": ["fallback.example.com"], + }, + expected: { + HTTP_PROXY: proxy, + HTTPS_PROXY: proxy, + NO_PROXY: "primary.example.com", + }, + }, + { + name: "ignores a whitespace-only http.proxy", + settings: { "http.proxy": " " }, + expected: {}, + }, + { + name: "falls back to http.noProxy when coder.proxyBypass is whitespace", + settings: { + "http.proxy": proxy, + "coder.proxyBypass": " ", + "http.noProxy": ["fallback.example.com"], + }, + expected: { + HTTP_PROXY: proxy, + HTTPS_PROXY: proxy, + NO_PROXY: "fallback.example.com", + }, + }, + ])("$name", ({ settings, expected }) => { + const { config } = setup(); + + expect(getSshProxyEnvironment(config(settings))).toEqual(expected); + }); + + it("ignores an existing env proxy when http.proxy is unset", () => { + const { config } = setup(); + vi.stubEnv("HTTPS_PROXY", "http://env-proxy.example.com:8080"); + + expect(getSshProxyEnvironment(config())).toEqual({}); + }); +}); + +describe("applySshEnvironment", () => { + it("applies proxy variables to process.env and the collection, and restores on dispose", () => { + const { config } = setup(); + const env: Record = {}; + const collection = fakeEnvCollection(); + + const applied = applySshEnvironment( + config({ + "http.proxy": proxy, + "coder.proxyBypass": "internal.example.com", + }), + collection, + env, + ); + const expected = { + HTTP_PROXY: proxy, + HTTPS_PROXY: proxy, + NO_PROXY: "internal.example.com", + }; + expect(env).toEqual(expected); + expect(collection.vars).toEqual(expected); + expect(collection.persistent).toBe(false); + + applied.dispose(); + expect(env).toEqual({}); + expect(collection.vars).toEqual({}); + }); + + it("sets nothing when no proxy is configured", () => { + const { config } = setup(); + const env: Record = {}; + const collection = fakeEnvCollection(); + + applySshEnvironment(config(), collection, env); + + expect(env).toEqual({}); + expect(collection.vars).toEqual({}); + }); + + it("does not overwrite existing lowercase variables", () => { + const { config } = setup(); + const original = { + http_proxy: "http://old-http-proxy.example.com:8080", + https_proxy: "http://old-https-proxy.example.com:8080", + }; + const env: Record = { ...original }; + + const applied = applySshEnvironment( + config({ "http.proxy": proxy }), + fakeEnvCollection(), + env, + ); + expect(env).toEqual({ + ...original, + HTTP_PROXY: proxy, + HTTPS_PROXY: proxy, + }); + + applied.dispose(); + expect(env).toEqual(original); + }); + + it("restores existing case-insensitive variables", () => { + const { config } = setup(); + const original = "http://old-http-proxy.example.com:8080"; + const env = caseInsensitiveEnvironment({ http_proxy: original }); + + const applied = applySshEnvironment( + config({ "http.proxy": proxy }), + fakeEnvCollection(), + env, + ); + expect(env.HTTP_PROXY).toBe(proxy); + expect(env.http_proxy).toBe(proxy); + + applied.dispose(); + expect(env.HTTP_PROXY).toBe(original); + expect(env.http_proxy).toBe(original); + }); + + it("propagates proxy variables to newly spawned child processes", () => { + const { config } = setup(); + const applied = applySshEnvironment( + config({ "http.proxy": proxy }), + fakeEnvCollection(), + ); + + try { + expect(getProxyEnvFromChild()).toEqual({ http: proxy, https: proxy }); + } finally { + applied.dispose(); + } + }); +}); + +function fakeEnvCollection() { + const vars: Record = {}; + return { + persistent: true, + replace: (variable: string, value: string) => { + vars[variable] = value; + }, + clear: () => { + for (const key of Object.keys(vars)) { + delete vars[key]; + } + }, + vars, + }; +} + +function caseInsensitiveEnvironment( + values: Record, +): Record { + return new Proxy(values, { + get(target, property) { + if (typeof property !== "string") { + return undefined; + } + return target[getCaseInsensitiveKey(target, property) ?? property]; + }, + set(target, property, value) { + if (typeof property !== "string") { + return false; + } + target[getCaseInsensitiveKey(target, property) ?? property] = value; + return true; + }, + deleteProperty(target, property) { + if (typeof property !== "string") { + return false; + } + return delete target[getCaseInsensitiveKey(target, property) ?? property]; + }, + }); +} + +function getCaseInsensitiveKey( + values: Record, + key: string, +): string | undefined { + return Object.keys(values).find( + (valueKey) => valueKey.toLowerCase() === key.toLowerCase(), + ); +} + +function getProxyEnvFromChild(): { http: string; https: string } { + const result = spawnSync( + process.execPath, + [ + "-e", + "process.stdout.write(JSON.stringify({ http: process.env.HTTP_PROXY || process.env.http_proxy, https: process.env.HTTPS_PROXY || process.env.https_proxy }))", + ], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(0); + return JSON.parse(result.stdout) as { http: string; https: string }; +}