Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/core/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { CliCredentialManager } from "./cliCredentialManager";
import { CliManager } from "./cliManager";
import { CommandManager } from "./commandManager";
import { ContextManager } from "./contextManager";
import { DismissibleNotifier } from "./dismissibleNotifier";
import { MementoManager } from "./mementoManager";
import { PathResolver } from "./pathResolver";
import { SecretsManager } from "./secretsManager";
Expand All @@ -39,6 +40,7 @@ export class ServiceContainer implements vscode.Disposable {
private readonly speedtestPanelFactory: SpeedtestPanelFactory;
private readonly telemetryService: TelemetryService;
private readonly commandManager: CommandManager;
private readonly dismissibleNotifier: DismissibleNotifier;

constructor(context: vscode.ExtensionContext) {
this.logger = vscode.window.createOutputChannel("Coder", { log: true });
Expand Down Expand Up @@ -119,6 +121,7 @@ export class ServiceContainer implements vscode.Disposable {
);

this.commandManager = new CommandManager(this.telemetryService);
this.dismissibleNotifier = new DismissibleNotifier(context.globalState);
}

getPathResolver(): PathResolver {
Expand Down Expand Up @@ -173,6 +176,10 @@ export class ServiceContainer implements vscode.Disposable {
return this.commandManager;
}

getDismissibleNotifier(): DismissibleNotifier {
return this.dismissibleNotifier;
}

/** Dispose logger last so telemetry teardown warnings still reach it. */
async dispose(): Promise<void> {
this.commandManager.dispose();
Expand Down
49 changes: 49 additions & 0 deletions src/core/dismissibleNotifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as vscode from "vscode";

import type { Memento } from "vscode";

const DISMISS = "Don't Show Again";

/** globalState keys under which "Don't Show Again" dismissals are stored. */
export const DISMISSIBLE_NOTIFICATION_KEYS = [
"coder.proxyUseLocalServerWarningDismissed",
] as const;

export type DismissibleNotificationKey =
(typeof DISMISSIBLE_NOTIFICATION_KEYS)[number];
Comment on lines +8 to +13

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not enum? This looks basically like an enum.

Or, if the idea is that we want to pass string literals then we could just do type DISMISSIBLE_NOTIFICATION_KEYS = "key" | "key2" since it does not look like we use the const for anything right now.

But if we will need to iterate over it or something like that in the future, then ignore me.


export class DismissibleNotifier {
public constructor(private readonly globalState: Memento) {}

/**
* Show a warning notification with a "Don't Show Again" button that persists
* dismissal under `key`. Returns the chosen action, or undefined if dismissed,
* closed, or already dismissed before. Pass `modal` to block until the user
* answers; non-modal toasts can auto-dismiss, so blocking callers must set it.
*/
public async showDismissible(
key: DismissibleNotificationKey,
message: string,
{
actions = [],
modal = false,
}: { actions?: string[]; modal?: boolean } = {},
): Promise<string | undefined> {
if (this.globalState.get<boolean>(key)) {
return undefined;
}

const choice = await vscode.window.showWarningMessage(
message,
{ modal },
...actions,
DISMISS,
);

if (choice === DISMISS) {
await this.globalState.update(key, true);
return undefined;
}
return choice;
}
}
124 changes: 124 additions & 0 deletions src/remote/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { getProxyForUrl } from "../api/proxy";

import type { WorkspaceConfiguration } from "vscode";

type Environment = Record<string, string | undefined>;
type PreviousValue = [key: string, existed: boolean, value: 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" },
];

/**
* Sets SSH-related environment variables on this extension host's process.env so
* the spawned `coder ssh` ProxyCommand inherits them. For now that is just the
* proxy configuration (HTTP_PROXY/HTTPS_PROXY/NO_PROXY): the coder CLI reads them
* like any Go HTTP client and has no proxy flag. We mutate process.env rather
* than baking values into the SSH config so no credentialed proxy URL is written
* to disk and multiple windows onto the same workspace stay independent.
*
* Best-effort: only processes spawned afterwards inherit the change. MS VS Code
* with `remote.SSH.useLocalServer=false` spawns ssh off a path that does not
* inherit, so propagation there needs `useLocalServer=true`. Returns a disposable
* that restores the previous values.
Comment on lines +32 to +35

@code-asher code-asher Jun 23, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would the location of the SSH binary change whether environment variables are inherited? Or by path do you mean the code path and not the binary path?

If I understand correctly, true spawns a single SSH process, and then VS Code multiplexes all windows connected to that remote using that single SSH connection.

And then false spawns SSH in a hidden terminal per window. And I guess when they do it this way, they are probably explicitly setting env which prevents inheriting as a side effect?

*/
export function applySshEnvironment(
baseUrl: string,
cfg: Pick<WorkspaceConfiguration, "get">,
env: Environment = process.env,
): { dispose(): void } {
return applyEnvironment(getSshProxyEnvironment(baseUrl, cfg), env);
}

/**
* The proxy portion of the SSH environment. Exposed so callers can check whether
* a proxy actually applies to a deployment via `.HTTP_PROXY`.
*/
export function getSshProxyEnvironment(
baseUrl: string,
cfg: Pick<WorkspaceConfiguration, "get">,
): SshEnvironment {
const httpProxy = getSetting(cfg, "http.proxy");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nbd but could just do cfg.get right? A blank or even untrimmed string with spaces would not break anything here (and we are not doing it elsewhere when we get the proxy URL, so if it does break we should do it there as well).

const noProxy = getSetting(cfg, "coder.proxyBypass") ?? getHttpNoProxy(cfg);
const proxy = httpProxy
? getProxyForUrl(baseUrl, httpProxy, noProxy, undefined)
Comment on lines +55 to +56

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we call this differently here than in the plugin? We could just pass the fallback as the fourth option to get the same behavior, right?

I think maybe it is so we can use noProxy below but this seems a bit wrong because getProxyForUrl also checks other variables that were not included here (like npm_config_no_proxy). Maybe getProxyForUrl could return both proxy and noProxy?

Actually, there is no need to even set noProxy right? Because getProxyForUrl will return a blank string if any noProxy is in effect.

Or, maybe better to skip getProxyForUrl and just pass everything in and let the CLI figure it out. For example maybe the CLI tries to use a different region rather than the main deployment domain or something.

: "";

return {
...(proxy ? { HTTP_PROXY: proxy, HTTPS_PROXY: proxy } : {}),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it right to set both http_proxy and https_proxy? If the user set http_proxy I imagine we should not set it on https_proxy, and maybe vice versa. idk why someone would have Coder on an http URL though, other than for maybe testing.

...(noProxy ? { NO_PROXY: noProxy } : {}),
};
}

function applyEnvironment(
values: SshEnvironment,
env: Environment,
): { dispose(): void } {
const previous: PreviousValue[] = [];
for (const [key, value] of Object.entries(values)) {
if (value === undefined) {
continue;
}
for (const envKey of getEnvKeys(env, key)) {
previous.push([envKey, Object.hasOwn(env, envKey), env[envKey]]);
env[envKey] = value;
}
}

let disposed = false;
return {
dispose: () => {
if (disposed) {
return;
}
disposed = true;
for (let i = previous.length - 1; i >= 0; i--) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this not using a for of like above because it needs to iterate backwards? The keys are unique though so I am not sure why the order matters.

const [key, existed, value] = previous[i];
if (existed) {
env[key] = value;
} else {
delete env[key];
}
}
},
};
}

function getEnvKeys(env: Environment, key: string): string[] {
const keys = Object.keys(env).filter(
(envKey) => envKey.toLowerCase() === key.toLowerCase(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Environment variables are case-sensitive (at least, in Linux, Windows might be different) so I think we should not compare with lowercase or we technically cause collisions.

);
return keys.length > 0 ? keys : [key];
}

function getSetting(
cfg: Pick<WorkspaceConfiguration, "get">,
setting: string,
): string | undefined {
const value = cfg.get<string | null>(setting);
return typeof value === "string" ? value.trim() || undefined : undefined;
}

function getHttpNoProxy(
cfg: Pick<WorkspaceConfiguration, "get">,
): string | undefined {
return (
cfg
.get<string[]>("http.noProxy", [])
.map((value) => value.trim())
.filter(Boolean)
.join(",") || undefined
);
}
95 changes: 81 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,11 @@ import {
import { vscodeProposed } from "../vscodeProposed";
import { WorkspaceMonitor } from "../workspace/workspaceMonitor";

import {
applySshEnvironment,
getSshProxyEnvironment,
SSH_PROXY_SETTINGS,
} from "./environment";
import {
SshConfig,
type SshValues,
Expand All @@ -73,6 +64,23 @@ 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 { DismissibleNotifier } from "../core/dismissibleNotifier";
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 @@ -103,6 +111,7 @@ export class Remote {
private readonly contextManager: ContextManager;
private readonly secretsManager: SecretsManager;
private readonly loginCoordinator: LoginCoordinator;
private readonly dismissibleNotifier: DismissibleNotifier;
private readonly setupTelemetry: RemoteSetupTelemetry;
private readonly authTelemetry: AuthTelemetry;

Expand All @@ -117,6 +126,7 @@ export class Remote {
this.contextManager = serviceContainer.getContextManager();
this.secretsManager = serviceContainer.getSecretsManager();
this.loginCoordinator = serviceContainer.getLoginCoordinator();
this.dismissibleNotifier = serviceContainer.getDismissibleNotifier();
this.setupTelemetry = new RemoteSetupTelemetry(
serviceContainer.getTelemetryService(),
);
Expand Down Expand Up @@ -202,6 +212,10 @@ export class Remote {
const { args, parts, workspaceName, baseUrl, token, disposables } = context;

try {
disposables.push(
applySshEnvironment(baseUrl, vscode.workspace.getConfiguration()),
);
await this.warnIfProxyEnvNotInherited(baseUrl);
// Create OAuth session manager for this remote deployment
const remoteOAuthManager = OAuthSessionManager.create(
{ url: baseUrl, safeHostname: parts.safeHostname },
Expand Down Expand Up @@ -454,6 +468,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 Expand Up @@ -818,6 +837,54 @@ export class Remote {
return this.pathResolver.getProxyLogPath();
}

/**
* MS VS Code with `remote.SSH.useLocalServer=false` spawns ssh without
* inheriting process.env, so the proxy variables never reach it. Warn once and
* offer to enable the local server when a proxy applies to this deployment.
*
* Blocks setup with a modal: the write must land before ssh spawns (which
* happens after setup returns) for it to apply without a reload. Catches
* internally so a failure here never aborts the connection.
*/
private async warnIfProxyEnvNotInherited(baseUrl: string): Promise<void> {
try {
const cfg = vscode.workspace.getConfiguration();
// HTTP_PROXY is only set when a proxy actually applies to this deployment.
if (!getSshProxyEnvironment(baseUrl, cfg).HTTP_PROXY) {
return;
}
if (cfg.get<boolean>("remote.SSH.useLocalServer") !== false) {
return;
}

const ENABLE = "Enable Local Server";
const choice = await this.dismissibleNotifier.showDismissible(
"coder.proxyUseLocalServerWarningDismissed",
"Your proxy settings may not reach the SSH connection because `remote.SSH.useLocalServer` is disabled. Enable it so Coder can apply the proxy to the connection.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we validated this? I wonder if VS Code might actually explicitly include http_proxy when spawning, it does seem reasonable. But maybe not.

{ actions: [ENABLE], modal: true },
);
if (choice !== ENABLE) {
return;
}

// Use the jsonc writer, not cfg.update which can hang during remote setup.
// No reload needed: ssh hasn't spawned yet, so it picks this up on connect.
const ok = await applySettingOverrides(
this.pathResolver.getUserSettingsPath(),
[{ key: "remote.SSH.useLocalServer", value: true }],
this.logger,
);
if (!ok) {
this.logger.warn("Failed to enable remote.SSH.useLocalServer");
}
Comment on lines +870 to +879

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we change the setting through the API or does it only let us change our own settings? I think I remember trying it through the API before but it was not possible.

} catch (error) {
this.logger.debug(
"Failed to surface proxy useLocalServer warning",
error,
);
}
}

/**
* Builds the ProxyCommand for SSH connections to Coder workspaces.
* Uses `coder ssh` for modern deployments with wildcard support,
Expand Down
Loading