Skip to content

Commit 0d111a7

Browse files
committed
feat: warn when useLocalServer disables proxy propagation
When a proxy applies to the deployment but remote.SSH.useLocalServer is off, the spawned SSH connection won't inherit the proxy env. Surface a dismissable warning offering to enable the local server, via a reusable showDismissibleNotification helper.
1 parent 73ed2df commit 0d111a7

3 files changed

Lines changed: 153 additions & 1 deletion

File tree

src/remote/remote.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,15 @@ import {
5858
expandPath,
5959
parseRemoteAuthority,
6060
} from "../util";
61+
import { showDismissibleNotification } from "../util/notifications";
6162
import { vscodeProposed } from "../vscodeProposed";
6263
import { WorkspaceMonitor } from "../workspace/workspaceMonitor";
6364

64-
import { applySshProxyEnvironment, SSH_PROXY_SETTINGS } from "./environment";
65+
import {
66+
applySshProxyEnvironment,
67+
getSshProxyEnvironment,
68+
SSH_PROXY_SETTINGS,
69+
} from "./environment";
6570
import {
6671
SshConfig,
6772
type SshValues,
@@ -206,6 +211,7 @@ export class Remote {
206211
disposables.push(
207212
applySshProxyEnvironment(baseUrl, vscode.workspace.getConfiguration()),
208213
);
214+
void this.warnIfProxyEnvNotInherited(baseUrl);
209215
// Create OAuth session manager for this remote deployment
210216
const remoteOAuthManager = OAuthSessionManager.create(
211217
{ url: baseUrl, safeHostname: parts.safeHostname },
@@ -827,6 +833,51 @@ export class Remote {
827833
return this.pathResolver.getProxyLogPath();
828834
}
829835

836+
/**
837+
* MS VS Code with `remote.SSH.useLocalServer=false` spawns ssh without
838+
* inheriting process.env, so the proxy variables never reach it. Warn once and
839+
* offer to enable the local server when a proxy applies to this deployment.
840+
* Catches internally so the caller can safely fire-and-forget.
841+
*/
842+
private async warnIfProxyEnvNotInherited(baseUrl: string): Promise<void> {
843+
try {
844+
const cfg = vscode.workspace.getConfiguration();
845+
// HTTP_PROXY is only set when a proxy actually applies to this deployment.
846+
if (!getSshProxyEnvironment(baseUrl, cfg).HTTP_PROXY) {
847+
return;
848+
}
849+
if (cfg.get<boolean>("remote.SSH.useLocalServer") !== false) {
850+
return;
851+
}
852+
853+
const ENABLE = "Enable Local Server";
854+
const choice = await showDismissibleNotification(
855+
"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.",
856+
this.extensionContext.globalState,
857+
{ key: "coder.proxyUseLocalServerWarningDismissed", actions: [ENABLE] },
858+
);
859+
if (choice !== ENABLE) {
860+
return;
861+
}
862+
863+
await cfg.update(
864+
"remote.SSH.useLocalServer",
865+
true,
866+
vscode.ConfigurationTarget.Global,
867+
);
868+
const RELOAD = "Reload";
869+
const reload = await vscode.window.showInformationMessage(
870+
"Local server enabled. Reload the window to apply.",
871+
RELOAD,
872+
);
873+
if (reload === RELOAD) {
874+
await vscode.commands.executeCommand("workbench.action.reloadWindow");
875+
}
876+
} catch (error) {
877+
this.logger.debug("Failed to surface proxy useLocalServer warning", error);
878+
}
879+
}
880+
830881
/**
831882
* Builds the ProxyCommand for SSH connections to Coder workspaces.
832883
* Uses `coder ssh` for modern deployments with wildcard support,

src/util/notifications.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as vscode from "vscode";
2+
3+
type NotificationLevel = "info" | "warning" | "error";
4+
5+
/**
6+
* Show a notification with a "Don't Show Again" button that persists dismissal
7+
* under `key`. Returns the chosen action, or undefined if dismissed or closed.
8+
*/
9+
export async function showDismissibleNotification(
10+
message: string,
11+
memento: vscode.Memento,
12+
options: {
13+
key: string;
14+
actions?: string[];
15+
level?: NotificationLevel;
16+
dismissLabel?: string;
17+
},
18+
): Promise<string | undefined> {
19+
const {
20+
key,
21+
actions = [],
22+
level = "info",
23+
dismissLabel = "Don't Show Again",
24+
} = options;
25+
26+
if (memento.get<boolean>(key)) {
27+
return undefined;
28+
}
29+
30+
const items = [...actions, dismissLabel];
31+
const choice =
32+
level === "error"
33+
? await vscode.window.showErrorMessage(message, ...items)
34+
: level === "warning"
35+
? await vscode.window.showWarningMessage(message, ...items)
36+
: await vscode.window.showInformationMessage(message, ...items);
37+
38+
if (choice === dismissLabel) {
39+
await memento.update(key, true);
40+
return undefined;
41+
}
42+
return choice;
43+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import * as vscode from "vscode";
3+
4+
import { showDismissibleNotification } from "@/util/notifications";
5+
6+
function fakeMemento(initial: Record<string, unknown> = {}): vscode.Memento {
7+
const store = new Map(Object.entries(initial));
8+
return {
9+
get: (key: string) => store.get(key),
10+
update: (key: string, value: unknown) => {
11+
store.set(key, value);
12+
return Promise.resolve();
13+
},
14+
keys: () => [...store.keys()],
15+
};
16+
}
17+
18+
describe("showDismissibleNotification", () => {
19+
beforeEach(() => {
20+
vi.clearAllMocks();
21+
});
22+
23+
it("returns the chosen action without persisting", async () => {
24+
vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(
25+
"Enable" as never,
26+
);
27+
const memento = fakeMemento();
28+
29+
const choice = await showDismissibleNotification("m", memento, {
30+
key: "k",
31+
actions: ["Enable"],
32+
});
33+
34+
expect(choice).toBe("Enable");
35+
expect(memento.get("k")).toBeUndefined();
36+
});
37+
38+
it("persists dismissal and returns undefined when dismissed", async () => {
39+
vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(
40+
"Don't Show Again" as never,
41+
);
42+
const memento = fakeMemento();
43+
44+
const choice = await showDismissibleNotification("m", memento, { key: "k" });
45+
46+
expect(choice).toBeUndefined();
47+
expect(memento.get("k")).toBe(true);
48+
});
49+
50+
it("shows nothing once dismissed", async () => {
51+
const memento = fakeMemento({ k: true });
52+
53+
const choice = await showDismissibleNotification("m", memento, { key: "k" });
54+
55+
expect(choice).toBeUndefined();
56+
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled();
57+
});
58+
});

0 commit comments

Comments
 (0)