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
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

- 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
Expand Down
12 changes: 12 additions & 0 deletions src/api/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ const DEFAULT_PORTS: Record<string, number> = {
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.
Expand Down
4 changes: 2 additions & 2 deletions src/api/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
114 changes: 114 additions & 0 deletions src/remote/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { joinNoProxy } from "../api/proxy";

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

type Environment = Record<string, string | undefined>;
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<WorkspaceConfiguration, "get">,
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<WorkspaceConfiguration, "get">,
): SshEnvironment {
const httpProxy = trimmed(cfg.get<string | null>("http.proxy"));
const noProxy =
trimmed(cfg.get<string | null>("coder.proxyBypass")) ??
joinNoProxy(cfg.get<string[]>("http.noProxy"));

return {
HTTP_PROXY: httpProxy,
HTTPS_PROXY: httpProxy,
NO_PROXY: noProxy,
};
}
Comment thread
EhabY marked this conversation as resolved.

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;
}
42 changes: 28 additions & 14 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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({
Expand Down
Loading