Skip to content

Commit c62d3c1

Browse files
authored
fix: propagate proxy settings to SSH environment (#1012)
VS Code's http.proxy setting lives only in config, so the spawned coder ssh ProxyCommand never saw it and connected directly, ignoring the user's proxy. Translate the proxy settings into HTTP_PROXY/HTTPS_PROXY/NO_PROXY and apply them around the connection: - Read http.proxy, coder.proxyBypass, and http.noProxy; map bypass to NO_PROXY (preferring coder.proxyBypass over http.noProxy). - Apply via both process.env (SSH spawned as a child, useLocalServer=true) and the terminal env collection (SSH spawned in a terminal, useLocalServer=false), since the mode isn't knowable up front. - Restore the prior environment on disconnect via a disposable. - Watch the proxy settings so changing them prompts for a reload. Mutating the environment instead of the SSH config keeps credentialed proxy URLs off disk. Standard proxy env vars are already inherited by both spawn modes, so only the settings need translating. Fixes #1010
1 parent 80d181a commit c62d3c1

6 files changed

Lines changed: 425 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
from published versions since it shows up in the VS Code extension changelog
66
tab and is confusing to users. Add it back between releases if needed. -->
77

8+
## Unreleased
9+
10+
### Fixed
11+
12+
- Propagate VS Code's proxy settings (`http.proxy`, `http.noProxy`, and
13+
`coder.proxyBypass`) to the SSH environment as `HTTP_PROXY`/`HTTPS_PROXY`/
14+
`NO_PROXY`, so the `coder ssh` ProxyCommand connects through the configured
15+
proxy whether SSH runs as a child process or in a terminal.
16+
817
## [v1.15.0](https://github.com/coder/vscode-coder/releases/tag/v1.15.0) 2026-06-12
918

1019
### Added

src/api/proxy.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ const DEFAULT_PORTS: Record<string, number> = {
1212
wss: 443,
1313
};
1414

15+
/** Join a no-proxy list into a comma string, dropping blanks. */
16+
export function joinNoProxy(
17+
entries: string[] | null | undefined,
18+
): string | undefined {
19+
return (
20+
entries
21+
?.map((entry) => entry.trim())
22+
.filter(Boolean)
23+
.join(",") || undefined
24+
);
25+
}
26+
1527
/**
1628
* @param {string|object} url - The URL, or the result from url.parse.
1729
* @param {string} httpProxy - The proxy URL to use.

src/api/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type WorkspaceConfiguration } from "vscode";
44

55
import { expandPath } from "../util";
66

7-
import { getProxyForUrl } from "./proxy";
7+
import { getProxyForUrl, joinNoProxy } from "./proxy";
88

99
/**
1010
* Return whether the API will need a token for authorization.
@@ -56,7 +56,7 @@ export async function createHttpAgent(
5656
url,
5757
cfg.get("http.proxy"),
5858
cfg.get("coder.proxyBypass"),
59-
httpNoProxy?.map((noProxy) => noProxy.trim())?.join(","),
59+
joinNoProxy(httpNoProxy),
6060
);
6161
},
6262
headers,

src/remote/environment.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { joinNoProxy } from "../api/proxy";
2+
3+
import type {
4+
GlobalEnvironmentVariableCollection,
5+
WorkspaceConfiguration,
6+
} from "vscode";
7+
8+
type Environment = Record<string, string | undefined>;
9+
type SshEnvironment = Partial<
10+
Record<"HTTP_PROXY" | "HTTPS_PROXY" | "NO_PROXY", string>
11+
>;
12+
13+
/**
14+
* The settings {@link getSshProxyEnvironment} reads, paired with display titles.
15+
* Watch these to prompt for a reload when the SSH proxy environment changes.
16+
*/
17+
export const SSH_PROXY_SETTINGS: ReadonlyArray<{
18+
setting: string;
19+
title: string;
20+
}> = [
21+
{ setting: "http.proxy", title: "HTTP Proxy" },
22+
{ setting: "http.noProxy", title: "HTTP No Proxy" },
23+
{ setting: "coder.proxyBypass", title: "Proxy Bypass" },
24+
];
25+
26+
/**
27+
* Apply the SSH environment that the spawned `coder ssh` ProxyCommand inherits.
28+
* Currently just the proxy config (HTTP_PROXY/HTTPS_PROXY/NO_PROXY), read by the
29+
* coder CLI like any Go HTTP client. Applied via both process.env (ssh spawned as
30+
* a child, `remote.SSH.useLocalServer=true`) and the terminal env collection (ssh
31+
* spawned in a terminal, `useLocalServer=false`, which can't see process.env),
32+
* since the mode isn't knowable up front. Mutating env rather than the SSH config
33+
* keeps credentialed URLs off disk and windows independent. Disposable restores
34+
* both.
35+
*/
36+
export function applySshEnvironment(
37+
cfg: Pick<WorkspaceConfiguration, "get">,
38+
collection: Pick<
39+
GlobalEnvironmentVariableCollection,
40+
"persistent" | "replace" | "clear"
41+
>,
42+
env: Environment = process.env,
43+
): { dispose(): void } {
44+
const values = getSshProxyEnvironment(cfg);
45+
const restoreEnv = applyEnvironment(values, env);
46+
47+
collection.persistent = false;
48+
// Drop stale vars from a prior connect (e.g. NO_PROXY set last time, not now).
49+
collection.clear();
50+
for (const [key, value] of Object.entries(values)) {
51+
if (value) {
52+
collection.replace(key, value);
53+
}
54+
}
55+
56+
return {
57+
dispose() {
58+
restoreEnv.dispose();
59+
collection.clear();
60+
},
61+
};
62+
}
63+
64+
/** The proxy portion of the SSH environment, derived from VS Code's settings. */
65+
export function getSshProxyEnvironment(
66+
cfg: Pick<WorkspaceConfiguration, "get">,
67+
): SshEnvironment {
68+
const httpProxy = trimmed(cfg.get<string | null>("http.proxy"));
69+
const noProxy =
70+
trimmed(cfg.get<string | null>("coder.proxyBypass")) ??
71+
joinNoProxy(cfg.get<string[]>("http.noProxy"));
72+
73+
return {
74+
HTTP_PROXY: httpProxy,
75+
HTTPS_PROXY: httpProxy,
76+
NO_PROXY: noProxy,
77+
};
78+
}
79+
80+
function applyEnvironment(
81+
values: SshEnvironment,
82+
env: Environment,
83+
): { dispose(): void } {
84+
// Stored `undefined` means the key was absent and should be deleted on cleanup.
85+
const previous: Environment = {};
86+
for (const [key, value] of Object.entries(values)) {
87+
if (value === undefined) {
88+
continue;
89+
}
90+
previous[key] = env[key];
91+
env[key] = value;
92+
}
93+
94+
let disposed = false;
95+
return {
96+
dispose: () => {
97+
if (disposed) {
98+
return;
99+
}
100+
disposed = true;
101+
for (const [key, value] of Object.entries(previous)) {
102+
if (value === undefined) {
103+
delete env[key];
104+
} else {
105+
env[key] = value;
106+
}
107+
}
108+
},
109+
};
110+
}
111+
112+
function trimmed(value: string | null | undefined): string | undefined {
113+
return typeof value === "string" ? value.trim() || undefined : undefined;
114+
}

src/remote/remote.ts

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
11
import { isAxiosError } from "axios";
2-
import { type Api } from "coder/site/src/api/api";
3-
import {
4-
type Workspace,
5-
type WorkspaceAgent,
6-
} from "coder/site/src/api/typesGenerated";
72
import * as fs from "node:fs/promises";
83
import * as os from "node:os";
94
import * as path from "node:path";
@@ -20,18 +15,11 @@ import { extractAgents } from "../api/api-helper";
2015
import { AuthInterceptor } from "../api/authInterceptor";
2116
import { CoderApi } from "../api/coderApi";
2217
import { needToken } from "../api/utils";
23-
import { type Commands } from "../commands";
2418
import {
2519
CONFIG_CHANGE_DEBOUNCE_MS,
2620
watchConfigurationChanges,
2721
} from "../configWatcher";
2822
import { version as cliVersion } from "../core/cliExec";
29-
import { type CliManager } from "../core/cliManager";
30-
import { type ServiceContainer } from "../core/container";
31-
import { type ContextManager } from "../core/contextManager";
32-
import { type StartupMode } from "../core/mementoManager";
33-
import { type PathResolver } from "../core/pathResolver";
34-
import { type SecretsManager } from "../core/secretsManager";
3523
import { toError } from "../error/errorUtils";
3624
import { featureSetForVersion, type FeatureSet } from "../featureSet";
3725
import { Inbox } from "../inbox";
@@ -40,8 +28,6 @@ import {
4028
RemoteSetupTelemetry,
4129
type RemoteSetupTracer,
4230
} from "../instrumentation/remoteSetup";
43-
import { type Logger } from "../logging/logger";
44-
import { type LoginCoordinator } from "../login/loginCoordinator";
4531
import { OAuthSessionManager } from "../oauth/sessionManager";
4632
import {
4733
type CliAuth,
@@ -61,6 +47,7 @@ import {
6147
import { vscodeProposed } from "../vscodeProposed";
6248
import { WorkspaceMonitor } from "../workspace/workspaceMonitor";
6349

50+
import { applySshEnvironment, SSH_PROXY_SETTINGS } from "./environment";
6451
import {
6552
SshConfig,
6653
type SshValues,
@@ -73,6 +60,22 @@ import { SshProcessMonitor } from "./sshProcess";
7360
import { computeSshProperties, sshSupportsSetEnv } from "./sshSupport";
7461
import { WorkspaceStateMachine } from "./workspaceStateMachine";
7562

63+
import type { Api } from "coder/site/src/api/api";
64+
import type {
65+
Workspace,
66+
WorkspaceAgent,
67+
} from "coder/site/src/api/typesGenerated";
68+
69+
import type { Commands } from "../commands";
70+
import type { CliManager } from "../core/cliManager";
71+
import type { ServiceContainer } from "../core/container";
72+
import type { ContextManager } from "../core/contextManager";
73+
import type { StartupMode } from "../core/mementoManager";
74+
import type { PathResolver } from "../core/pathResolver";
75+
import type { SecretsManager } from "../core/secretsManager";
76+
import type { Logger } from "../logging/logger";
77+
import type { LoginCoordinator } from "../login/loginCoordinator";
78+
7679
export interface RemoteDetails extends vscode.Disposable {
7780
safeHostname: string;
7881
url: string;
@@ -202,6 +205,12 @@ export class Remote {
202205
const { args, parts, workspaceName, baseUrl, token, disposables } = context;
203206

204207
try {
208+
disposables.push(
209+
applySshEnvironment(
210+
vscode.workspace.getConfiguration(),
211+
this.extensionContext.environmentVariableCollection,
212+
),
213+
);
205214
// Create OAuth session manager for this remote deployment
206215
const remoteOAuthManager = OAuthSessionManager.create(
207216
{ url: baseUrl, safeHostname: parts.safeHostname },
@@ -454,6 +463,11 @@ export class Remote {
454463
title: "SSH Flags",
455464
getValue: () => getSshFlags(vscode.workspace.getConfiguration()),
456465
},
466+
...SSH_PROXY_SETTINGS.map(({ setting, title }) => ({
467+
setting,
468+
title,
469+
getValue: () => vscode.workspace.getConfiguration().get(setting),
470+
})),
457471
];
458472
if (featureSet.proxyLogDirectory) {
459473
settingsToWatch.push({

0 commit comments

Comments
 (0)