Skip to content

Commit e411397

Browse files
committed
fix: skip remotePlatform when RemoteCommand is configured (#549)
When a user configures RemoteCommand in their SSH config, VS Code's Remote SSH extension conflicts with it by appending 'bash' to the SSH command whenever remotePlatform is set for the host. This extension unconditionally set remotePlatform, making RemoteCommand unusable. Now buildSshOverrides checks remote.SSH.enableRemoteCommand and the host's effective RemoteCommand (via computeSshProperties). When both indicate active RemoteCommand usage, the remotePlatform entry is skipped (or removed if stale) so VS Code does not append a shell.
1 parent abf2652 commit e411397

File tree

4 files changed

+198
-42
lines changed

4 files changed

+198
-42
lines changed

src/remote/remote.ts

Lines changed: 40 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { OAuthSessionManager } from "../oauth/sessionManager";
4545
import {
4646
AuthorityPrefix,
4747
escapeCommandArg,
48+
expandPath,
4849
parseRemoteAuthority,
4950
} from "../util";
5051
import { vscodeProposed } from "../vscodeProposed";
@@ -59,7 +60,11 @@ import {
5960
} from "./sshConfig";
6061
import { SshProcessMonitor } from "./sshProcess";
6162
import { computeSshProperties, sshSupportsSetEnv } from "./sshSupport";
62-
import { applySettingOverrides, buildSshOverrides } from "./userSettings";
63+
import {
64+
applySettingOverrides,
65+
buildSshOverrides,
66+
isActiveRemoteCommand,
67+
} from "./userSettings";
6368
import { WorkspaceStateMachine } from "./workspaceStateMachine";
6469

6570
export interface RemoteDetails extends vscode.Disposable {
@@ -459,33 +464,12 @@ export class Remote {
459464
const inbox = await Inbox.create(workspace, workspaceClient, this.logger);
460465
disposables.push(inbox);
461466

462-
this.logger.info("Modifying settings...");
463-
const overrides = buildSshOverrides(
464-
vscodeProposed.workspace.getConfiguration(),
465-
parts.sshHost,
466-
agent.operating_system,
467-
);
468-
if (overrides.length > 0) {
469-
const ok = await applySettingOverrides(
470-
this.pathResolver.getUserSettingsPath(),
471-
overrides,
472-
this.logger,
473-
);
474-
if (ok) {
475-
this.logger.info("Settings modified successfully");
476-
}
477-
}
478-
479467
const logDir = this.getLogDir(featureSet);
480468

481-
// This ensures the Remote SSH extension resolves the host to execute the
482-
// Coder binary properly.
483-
//
484-
// If we didn't write to the SSH config file, connecting would fail with
485-
// "Host not found".
469+
let computedSshProperties: Record<string, string> = {};
486470
try {
487471
this.logger.info("Updating SSH config...");
488-
await this.updateSSHConfig(
472+
computedSshProperties = await this.updateSSHConfig(
489473
workspaceClient,
490474
parts.safeHostname,
491475
parts.sshHost,
@@ -499,6 +483,29 @@ export class Remote {
499483
throw error;
500484
}
501485

486+
const remoteCommand = computedSshProperties.RemoteCommand;
487+
if (isActiveRemoteCommand(remoteCommand)) {
488+
this.logger.info("RemoteCommand detected in SSH config");
489+
}
490+
491+
this.logger.info("Modifying settings...");
492+
const overrides = buildSshOverrides(
493+
vscodeProposed.workspace.getConfiguration(),
494+
parts.sshHost,
495+
agent.operating_system,
496+
remoteCommand,
497+
);
498+
if (overrides.length > 0) {
499+
const ok = await applySettingOverrides(
500+
this.pathResolver.getUserSettingsPath(),
501+
overrides,
502+
this.logger,
503+
);
504+
if (ok) {
505+
this.logger.info("Settings modified successfully");
506+
}
507+
}
508+
502509
// Monitor SSH process and display network status
503510
const sshMonitor = SshProcessMonitor.start({
504511
sshHost: parts.sshHost,
@@ -731,6 +738,13 @@ export class Remote {
731738
return ["--log-dir", escapeCommandArg(logDir), "-v"];
732739
}
733740

741+
private getSshConfigPath(): string {
742+
const configured = vscode.workspace
743+
.getConfiguration()
744+
.get<string>("remote.SSH.configFile");
745+
return expandPath(configured || path.join("~", ".ssh", "config"));
746+
}
747+
734748
// updateSSHConfig updates the SSH configuration with a wildcard that handles
735749
// all Coder entries.
736750
private async updateSSHConfig(
@@ -761,17 +775,7 @@ export class Remote {
761775
}
762776
}
763777

764-
let sshConfigFile = vscode.workspace
765-
.getConfiguration()
766-
.get<string>("remote.SSH.configFile");
767-
if (!sshConfigFile) {
768-
sshConfigFile = path.join(os.homedir(), ".ssh", "config");
769-
}
770-
// VS Code Remote resolves ~ to the home directory.
771-
// This is required for the tilde to work on Windows.
772-
if (sshConfigFile.startsWith("~")) {
773-
sshConfigFile = path.join(os.homedir(), sshConfigFile.slice(1));
774-
}
778+
const sshConfigFile = this.getSshConfigPath();
775779

776780
const sshConfig = new SshConfig(sshConfigFile, this.logger);
777781
await sshConfig.load();
@@ -852,7 +856,7 @@ export class Remote {
852856
throw new Error("SSH config mismatch, closing remote");
853857
}
854858

855-
return sshConfig.getRaw();
859+
return computedProperties;
856860
}
857861

858862
private watchSettings(

src/remote/userSettings.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ const AUTO_SETUP_DEFAULTS = {
5959
"remote.SSH.maxReconnectionAttempts": null, // max allowed
6060
} as const satisfies Partial<Record<SshSettingKey, number | null>>;
6161

62+
/**
63+
* Whether the given RemoteCommand value represents an active command
64+
* (i.e. present, non-empty, and not the SSH default "none").
65+
*/
66+
export function isActiveRemoteCommand(cmd: string | undefined): boolean {
67+
return !!cmd && cmd.toLowerCase() !== "none";
68+
}
69+
6270
/**
6371
* Build the list of VS Code setting overrides needed for a remote SSH
6472
* connection to a Coder workspace.
@@ -67,19 +75,43 @@ export function buildSshOverrides(
6775
config: Pick<WorkspaceConfiguration, "get">,
6876
sshHost: string,
6977
agentOS: string,
78+
remoteCommand?: string,
7079
): SettingOverride[] {
7180
const overrides: SettingOverride[] = [];
7281

73-
// Set the remote platform for this host to bypass the platform prompt.
82+
// When enableRemoteCommand is true and the host has an active
83+
// RemoteCommand, we must not set remotePlatform: it causes VS Code
84+
// to append 'bash', which conflicts with RemoteCommand. We gate on
85+
// enableRemoteCommand so users who haven't opted in don't get an
86+
// unexpected platform prompt.
87+
const enableRemoteCommand = config.get<boolean>(
88+
"remote.SSH.enableRemoteCommand",
89+
false,
90+
);
91+
const skipRemotePlatform =
92+
enableRemoteCommand && isActiveRemoteCommand(remoteCommand);
93+
7494
const remotePlatforms = config.get<Record<string, string>>(
7595
"remote.SSH.remotePlatform",
7696
{},
7797
);
78-
if (remotePlatforms[sshHost] !== agentOS) {
79-
overrides.push({
80-
key: "remote.SSH.remotePlatform",
81-
value: { ...remotePlatforms, [sshHost]: agentOS },
82-
});
98+
if (skipRemotePlatform) {
99+
// Remove any stale entry so it doesn't block RemoteCommand.
100+
if (sshHost in remotePlatforms) {
101+
const { [sshHost]: _removed, ...rest } = remotePlatforms;
102+
overrides.push({
103+
key: "remote.SSH.remotePlatform",
104+
value: rest,
105+
});
106+
}
107+
} else {
108+
// Set the remote platform to bypass the platform prompt.
109+
if (remotePlatforms[sshHost] !== agentOS) {
110+
overrides.push({
111+
key: "remote.SSH.remotePlatform",
112+
value: { ...remotePlatforms, [sshHost]: agentOS },
113+
});
114+
}
83115
}
84116

85117
// Default 15s is too short for startup scripts; enforce a minimum.

test/unit/remote/sshSupport.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,57 @@ Host coder-v?code--*
7676
});
7777
});
7878

79+
it("picks up RemoteCommand from a user Host block alongside a Coder block", () => {
80+
const props = computeSshProperties(
81+
"coder-vscode.example.com--user--ws",
82+
`# --- START CODER VSCODE example.com ---
83+
Host coder-vscode.example.com--*
84+
ProxyCommand /path/to/coder ssh --stdio %h
85+
StrictHostKeyChecking no
86+
# --- END CODER VSCODE example.com ---
87+
88+
Host coder-vscode.example.com--*
89+
RequestTTY yes
90+
RemoteCommand exec /bin/bash -l
91+
`,
92+
);
93+
expect(props.RemoteCommand).toBe("exec /bin/bash -l");
94+
expect(props.ProxyCommand).toBe("/path/to/coder ssh --stdio %h");
95+
});
96+
97+
it("returns RemoteCommand none literally", () => {
98+
const props = computeSshProperties(
99+
"coder-vscode.example.com--user--ws",
100+
`Host coder-vscode.example.com--*
101+
RemoteCommand none
102+
`,
103+
);
104+
expect(props.RemoteCommand).toBe("none");
105+
});
106+
107+
it("inherits RemoteCommand from a Host * block", () => {
108+
const props = computeSshProperties(
109+
"coder-vscode.example.com--user--ws",
110+
`Host *
111+
RemoteCommand exec /bin/zsh -l
112+
113+
Host coder-vscode.example.com--*
114+
ProxyCommand /path/to/coder ssh --stdio %h
115+
`,
116+
);
117+
expect(props.RemoteCommand).toBe("exec /bin/zsh -l");
118+
});
119+
120+
it("handles RemoteCommand with = delimiter", () => {
121+
const props = computeSshProperties(
122+
"coder-vscode.example.com--user--ws",
123+
`Host coder-vscode.example.com--*
124+
RemoteCommand=exec /bin/bash -l
125+
`,
126+
);
127+
expect(props.RemoteCommand).toBe("exec /bin/bash -l");
128+
});
129+
79130
it("properly escapes meaningful regex characters", () => {
80131
const properties = computeSshProperties(
81132
"coder-vscode.dev.coder.com--matalfi--dogfood",

test/unit/remote/userSettings.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
55
import {
66
applySettingOverrides,
77
buildSshOverrides,
8+
isActiveRemoteCommand,
89
} from "@/remote/userSettings";
910

1011
import {
@@ -32,6 +33,18 @@ interface TimeoutCase {
3233
label: string;
3334
}
3435

36+
describe("isActiveRemoteCommand", () => {
37+
it.each(["exec bash -l", "exec /bin/zsh", "/usr/bin/tmux"])(
38+
"returns true for %j",
39+
(cmd) => expect(isActiveRemoteCommand(cmd)).toBe(true),
40+
);
41+
42+
it.each([undefined, "", "none", "None", "NONE"])(
43+
"returns false for %j",
44+
(cmd) => expect(isActiveRemoteCommand(cmd)).toBe(false),
45+
);
46+
});
47+
3548
describe("buildSshOverrides", () => {
3649
describe("remote platform", () => {
3750
it("adds host when missing or OS differs", () => {
@@ -66,6 +79,62 @@ describe("buildSshOverrides", () => {
6679
),
6780
).toBeUndefined();
6881
});
82+
83+
describe("RemoteCommand compatibility", () => {
84+
it("removes host from remotePlatform when enableRemoteCommand is true", () => {
85+
const config = new MockConfigurationProvider();
86+
config.set("remote.SSH.enableRemoteCommand", true);
87+
config.set("remote.SSH.remotePlatform", {
88+
"my-host": "linux",
89+
"other-host": "darwin",
90+
});
91+
expect(
92+
findOverride(
93+
buildSshOverrides(config, "my-host", "linux", "exec bash -l"),
94+
"remote.SSH.remotePlatform",
95+
),
96+
).toEqual({ "other-host": "darwin" });
97+
});
98+
99+
it("produces no override when host has no stale remotePlatform entry", () => {
100+
const config = new MockConfigurationProvider();
101+
config.set("remote.SSH.enableRemoteCommand", true);
102+
config.set("remote.SSH.remotePlatform", {});
103+
expect(
104+
findOverride(
105+
buildSshOverrides(config, "my-host", "linux", "exec bash -l"),
106+
"remote.SSH.remotePlatform",
107+
),
108+
).toBeUndefined();
109+
});
110+
111+
it("sets platform normally when enableRemoteCommand is false", () => {
112+
const config = new MockConfigurationProvider();
113+
config.set("remote.SSH.enableRemoteCommand", false);
114+
config.set("remote.SSH.remotePlatform", {});
115+
expect(
116+
findOverride(
117+
buildSshOverrides(config, "my-host", "linux", "exec bash -l"),
118+
"remote.SSH.remotePlatform",
119+
),
120+
).toEqual({ "my-host": "linux" });
121+
});
122+
123+
it.each(["none", "None", "NONE", "", undefined])(
124+
"sets platform normally when remoteCommand is %j",
125+
(cmd) => {
126+
const config = new MockConfigurationProvider();
127+
config.set("remote.SSH.enableRemoteCommand", true);
128+
config.set("remote.SSH.remotePlatform", {});
129+
expect(
130+
findOverride(
131+
buildSshOverrides(config, "my-host", "linux", cmd),
132+
"remote.SSH.remotePlatform",
133+
),
134+
).toEqual({ "my-host": "linux" });
135+
},
136+
);
137+
});
69138
});
70139

71140
describe("connect timeout", () => {

0 commit comments

Comments
 (0)