Skip to content

Commit 69081f3

Browse files
committed
feat: apply coder config-ssh --ssh-option to VS Code connections
SSH options set via `coder config-ssh --ssh-option` (e.g. ForwardX11=yes) were not applied to VS Code connections because the extension never read the CLI's config block. Parse `# :ssh-option=` comments from the CLI's START-CODER/END-CODER block and merge them into the SSH config with lowest priority (CLI options < deployment API < user VS Code settings). Fixes #832
1 parent 837df11 commit 69081f3

File tree

5 files changed

+169
-27
lines changed

5 files changed

+169
-27
lines changed

src/remote/remote.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,11 @@ import {
5555
SSHConfig,
5656
type SSHValues,
5757
mergeSshConfigValues,
58+
parseCoderSshOptions,
5859
parseSshConfig,
5960
} from "./sshConfig";
6061
import { SshProcessMonitor } from "./sshProcess";
61-
import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport";
62+
import { computeSshProperties, sshSupportsSetEnv } from "./sshSupport";
6263
import { WorkspaceStateMachine } from "./workspaceStateMachine";
6364

6465
export interface RemoteDetails extends vscode.Disposable {
@@ -816,17 +817,6 @@ export class Remote {
816817
}
817818
}
818819

819-
// deploymentConfig is now set from the remote coderd deployment.
820-
// Now override with the user's config.
821-
const userConfigSsh = vscode.workspace
822-
.getConfiguration("coder")
823-
.get<string[]>("sshConfig", []);
824-
const userConfig = parseSshConfig(userConfigSsh);
825-
const sshConfigOverrides = mergeSshConfigValues(
826-
deploymentSSHConfig,
827-
userConfig,
828-
);
829-
830820
let sshConfigFile = vscode.workspace
831821
.getConfiguration()
832822
.get<string>("remote.SSH.configFile");
@@ -842,6 +832,20 @@ export class Remote {
842832
const sshConfig = new SSHConfig(sshConfigFile);
843833
await sshConfig.load();
844834

835+
// Merge SSH config from three sources (lowest to highest priority):
836+
// 1. coder config-ssh --ssh-option flags from the CLI block
837+
// 2. Deployment SSH config from the coderd API
838+
// 3. User's VS Code coder.sshConfig setting
839+
const configSshOptions = parseCoderSshOptions(sshConfig.getRaw());
840+
const userConfigSsh = vscode.workspace
841+
.getConfiguration("coder")
842+
.get<string[]>("sshConfig", []);
843+
const userConfig = parseSshConfig(userConfigSsh);
844+
const sshConfigOverrides = mergeSshConfigValues(
845+
mergeSshConfigValues(configSshOptions, deploymentSSHConfig),
846+
userConfig,
847+
);
848+
845849
const hostPrefix = safeHostname
846850
? `${AuthorityPrefix}.${safeHostname}--`
847851
: `${AuthorityPrefix}--`;
@@ -874,7 +878,7 @@ export class Remote {
874878
// A user can provide a "Host *" entry in their SSH config to add options
875879
// to all hosts. We need to ensure that the options we set are not
876880
// overridden by the user's config.
877-
const computedProperties = computeSSHProperties(
881+
const computedProperties = computeSshProperties(
878882
hostName,
879883
sshConfig.getRaw(),
880884
);

src/remote/sshConfig.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ const defaultFileSystem: FileSystem = {
3636
writeFile,
3737
};
3838

39+
// Matches an SSH config key at the start of a line (e.g. "ConnectTimeout", "LogLevel").
40+
const sshKeyRegex = /^[a-zA-Z0-9-]+/;
41+
42+
// Matches the Coder CLI's START-CODER / END-CODER block, flexible on dash count.
43+
const coderBlockRegex = /^# -+START-CODER-+$(.*?)^# -+END-CODER-+$/ms;
44+
3945
/**
4046
* Parse an array of SSH config lines into a Record.
4147
* Handles both "Key value" and "Key=value" formats.
@@ -44,8 +50,7 @@ const defaultFileSystem: FileSystem = {
4450
export function parseSshConfig(lines: string[]): Record<string, string> {
4551
return lines.reduce(
4652
(acc, line) => {
47-
// Match key pattern (same as VS Code settings: ^[a-zA-Z0-9-]+)
48-
const keyMatch = /^[a-zA-Z0-9-]+/.exec(line);
53+
const keyMatch = sshKeyRegex.exec(line);
4954
if (!keyMatch) {
5055
return acc; // Malformed line
5156
}
@@ -74,6 +79,25 @@ export function parseSshConfig(lines: string[]): Record<string, string> {
7479
);
7580
}
7681

82+
/**
83+
* Extract `# :ssh-option=` values from the Coder CLI's config block.
84+
* Returns `{}` if no CLI block is found.
85+
*/
86+
export function parseCoderSshOptions(raw: string): Record<string, string> {
87+
const blockMatch = coderBlockRegex.exec(raw);
88+
const block = blockMatch?.[1];
89+
if (!block) {
90+
return {};
91+
}
92+
const prefix = "# :ssh-option=";
93+
const sshOptionLines = block
94+
.split(/\r?\n/)
95+
.filter((line) => line.startsWith(prefix))
96+
.map((line) => line.slice(prefix.length));
97+
98+
return parseSshConfig(sshOptionLines);
99+
}
100+
77101
// mergeSSHConfigValues will take a given ssh config and merge it with the overrides
78102
// provided. The merge handles key case insensitivity, so casing in the "key" does
79103
// not matter.
@@ -255,7 +279,12 @@ export class SSHConfig {
255279
overrides?: Record<string, string>,
256280
) {
257281
const { Host, ...otherValues } = values;
258-
const lines = [this.startBlockComment(safeHostname), `Host ${Host}`];
282+
const lines = [
283+
this.startBlockComment(safeHostname),
284+
"# This section is managed by the Coder VS Code extension.",
285+
"# Changes will be overwritten on the next workspace connection.",
286+
`Host ${Host}`,
287+
];
259288

260289
// configValues is the merged values of the defaults and the overrides.
261290
const configValues = mergeSshConfigValues(otherValues, overrides ?? {});

src/remote/sshSupport.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as childProcess from "child_process";
22

3+
/** Check if the local SSH installation supports the `SetEnv` directive. */
34
export function sshSupportsSetEnv(): boolean {
45
try {
56
// Run `ssh -V` to get the version string.
@@ -11,10 +12,10 @@ export function sshSupportsSetEnv(): boolean {
1112
}
1213
}
1314

14-
// sshVersionSupportsSetEnv ensures that the version string from the SSH
15-
// command line supports the `SetEnv` directive.
16-
//
17-
// It was introduced in SSH 7.8 and not all versions support it.
15+
/**
16+
* Check if an SSH version string supports the `SetEnv` directive.
17+
* Requires OpenSSH 7.8 or later.
18+
*/
1819
export function sshVersionSupportsSetEnv(sshVersionString: string): boolean {
1920
const match = /OpenSSH.*_([\d.]+)[^,]*/.exec(sshVersionString);
2021
if (match?.[1]) {
@@ -37,9 +38,11 @@ export function sshVersionSupportsSetEnv(sshVersionString: string): boolean {
3738
return false;
3839
}
3940

40-
// computeSSHProperties accepts an SSH config and a host name and returns
41-
// the properties that should be set for that host.
42-
export function computeSSHProperties(
41+
/**
42+
* Compute the effective SSH properties for a given host by evaluating
43+
* all matching Host blocks in the provided SSH config.
44+
*/
45+
export function computeSshProperties(
4346
host: string,
4447
config: string,
4548
): Record<string, string> {

test/unit/remote/sshConfig.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { it, afterEach, vi, expect, describe } from "vitest";
22

33
import {
44
SSHConfig,
5+
parseCoderSshOptions,
56
parseSshConfig,
67
mergeSshConfigValues,
78
} from "@/remote/sshConfig";
@@ -12,6 +13,8 @@ import {
1213
const sshFilePath = "/Path/To/UserHomeDir/.sshConfigDir/sshConfigFile";
1314
const sshTempFilePrefix =
1415
"/Path/To/UserHomeDir/.sshConfigDir/.sshConfigFile.vscode-coder-tmp-";
16+
const managedHeader = `# This section is managed by the Coder VS Code extension.
17+
# Changes will be overwritten on the next workspace connection.`;
1518

1619
const mockFileSystem = {
1720
mkdir: vi.fn(),
@@ -41,6 +44,7 @@ it("creates a new file and adds config with empty label", async () => {
4144
});
4245

4346
const expectedOutput = `# --- START CODER VSCODE ---
47+
${managedHeader}
4448
Host coder-vscode--*
4549
ConnectTimeout 0
4650
LogLevel ERROR
@@ -83,6 +87,7 @@ it("creates a new file and adds the config", async () => {
8387
});
8488

8589
const expectedOutput = `# --- START CODER VSCODE dev.coder.com ---
90+
${managedHeader}
8691
Host coder-vscode.dev.coder.com--*
8792
ConnectTimeout 0
8893
LogLevel ERROR
@@ -134,6 +139,7 @@ it("adds a new coder config in an existent SSH configuration", async () => {
134139
const expectedOutput = `${existentSSHConfig}
135140
136141
# --- START CODER VSCODE dev.coder.com ---
142+
${managedHeader}
137143
Host coder-vscode.dev.coder.com--*
138144
ConnectTimeout 0
139145
LogLevel ERROR
@@ -204,6 +210,7 @@ Host *
204210
const expectedOutput = `${keepSSHConfig}
205211
206212
# --- START CODER VSCODE dev.coder.com ---
213+
${managedHeader}
207214
Host coder-vscode.dev-updated.coder.com--*
208215
ConnectTimeout 1
209216
LogLevel ERROR
@@ -261,6 +268,7 @@ Host coder-vscode--*
261268
const expectedOutput = `${existentSSHConfig}
262269
263270
# --- START CODER VSCODE dev.coder.com ---
271+
${managedHeader}
264272
Host coder-vscode.dev.coder.com--*
265273
ConnectTimeout 0
266274
LogLevel ERROR
@@ -304,6 +312,7 @@ it("it does not remove a user-added block that only matches the host of an old c
304312
ForwardAgent=yes
305313
306314
# --- START CODER VSCODE dev.coder.com ---
315+
${managedHeader}
307316
Host coder-vscode.dev.coder.com--*
308317
ConnectTimeout 0
309318
LogLevel ERROR
@@ -574,6 +583,7 @@ Host donotdelete
574583
User please
575584
576585
# --- START CODER VSCODE dev.coder.com ---
586+
${managedHeader}
577587
Host coder-vscode.dev.coder.com--*
578588
ConnectTimeout 0
579589
LogLevel ERROR
@@ -638,6 +648,7 @@ it("override values", async () => {
638648
);
639649

640650
const expectedOutput = `# --- START CODER VSCODE dev.coder.com ---
651+
${managedHeader}
641652
Host coder-vscode.dev.coder.com--*
642653
Buzz baz
643654
ConnectTimeout 500
@@ -854,3 +865,98 @@ describe("mergeSshConfigValues", () => {
854865
expect(mergeSshConfigValues(config, overrides)).toEqual(expected);
855866
});
856867
});
868+
869+
describe("parseCoderSshOptions", () => {
870+
const coderBlock = (...lines: string[]) =>
871+
`# ------------START-CODER-----------\n${lines.join("\n")}\n# ------------END-CODER------------`;
872+
873+
interface SshOptionTestCase {
874+
name: string;
875+
raw: string;
876+
expected: Record<string, string>;
877+
}
878+
it.each<SshOptionTestCase>([
879+
{
880+
name: "empty string",
881+
raw: "",
882+
expected: {},
883+
},
884+
{
885+
name: "no CLI block",
886+
raw: "Host myhost\n HostName example.com",
887+
expected: {},
888+
},
889+
{
890+
name: "single option",
891+
raw: coderBlock("# :ssh-option=ForwardX11=yes"),
892+
expected: { ForwardX11: "yes" },
893+
},
894+
{
895+
name: "multiple options",
896+
raw: coderBlock(
897+
"# :ssh-option=ForwardX11=yes",
898+
"# :ssh-option=ForwardX11Trusted=yes",
899+
),
900+
expected: { ForwardX11: "yes", ForwardX11Trusted: "yes" },
901+
},
902+
{
903+
name: "ignores non-ssh-option keys",
904+
raw: coderBlock(
905+
"# :wait=yes",
906+
"# :disable-autostart=true",
907+
"# :ssh-option=ForwardX11=yes",
908+
),
909+
expected: { ForwardX11: "yes" },
910+
},
911+
{
912+
name: "accumulates SetEnv across lines",
913+
raw: coderBlock(
914+
"# :ssh-option=SetEnv=FOO=1",
915+
"# :ssh-option=SetEnv=BAR=2",
916+
),
917+
expected: { SetEnv: "FOO=1 BAR=2" },
918+
},
919+
{
920+
name: "tolerates different dash counts in markers",
921+
raw: `# ---START-CODER---\n# :ssh-option=ForwardX11=yes\n# ---END-CODER---`,
922+
expected: { ForwardX11: "yes" },
923+
},
924+
])("$name", ({ raw, expected }) => {
925+
expect(parseCoderSshOptions(raw)).toEqual(expected);
926+
});
927+
928+
it("extracts only ssh-options from a full config", () => {
929+
const raw = `Host personal-server
930+
HostName 10.0.0.1
931+
User admin
932+
933+
# ------------START-CODER-----------
934+
# This file is managed by coder. DO NOT EDIT.
935+
#
936+
# You should not hand-edit this file, changes may be overwritten.
937+
# For more information, see https://coder.com/docs
938+
#
939+
# :wait=yes
940+
# :disable-autostart=true
941+
# :ssh-option=ForwardX11=yes
942+
# :ssh-option=ForwardX11Trusted=yes
943+
944+
Host coder.mydeployment--*
945+
ConnectTimeout 0
946+
ForwardX11 yes
947+
ForwardX11Trusted yes
948+
StrictHostKeyChecking no
949+
UserKnownHostsFile /dev/null
950+
LogLevel ERROR
951+
ProxyCommand /usr/bin/coder ssh --stdio --ssh-host-prefix coder.mydeployment-- %h
952+
# ------------END-CODER------------
953+
954+
Host work-server
955+
HostName 10.0.0.2
956+
User work`;
957+
expect(parseCoderSshOptions(raw)).toEqual({
958+
ForwardX11: "yes",
959+
ForwardX11Trusted: "yes",
960+
});
961+
});
962+
});

test/unit/remote/sshSupport.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { it, expect } from "vitest";
22

33
import {
4-
computeSSHProperties,
4+
computeSshProperties,
55
sshSupportsSetEnv,
66
sshVersionSupportsSetEnv,
77
} from "@/remote/sshSupport";
@@ -25,7 +25,7 @@ it("current shell supports ssh", () => {
2525
});
2626

2727
it("computes the config for a host", () => {
28-
const properties = computeSSHProperties(
28+
const properties = computeSshProperties(
2929
"coder-vscode--testing",
3030
`Host *
3131
StrictHostKeyChecking yes
@@ -47,7 +47,7 @@ Host coder-vscode--*
4747
});
4848

4949
it("handles ? wildcards", () => {
50-
const properties = computeSSHProperties(
50+
const properties = computeSshProperties(
5151
"coder-vscode--testing",
5252
`Host *
5353
StrictHostKeyChecking yes
@@ -75,7 +75,7 @@ Host coder-v?code--*
7575
});
7676

7777
it("properly escapes meaningful regex characters", () => {
78-
const properties = computeSSHProperties(
78+
const properties = computeSshProperties(
7979
"coder-vscode.dev.coder.com--matalfi--dogfood",
8080
`Host *
8181
StrictHostKeyChecking yes

0 commit comments

Comments
 (0)