Skip to content

Commit ce1287e

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 e70dca1 commit ce1287e

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
@@ -56,10 +56,11 @@ import {
5656
SSHConfig,
5757
type SSHValues,
5858
mergeSshConfigValues,
59+
parseCoderSshOptions,
5960
parseSshConfig,
6061
} from "./sshConfig";
6162
import { SshProcessMonitor } from "./sshProcess";
62-
import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport";
63+
import { computeSshProperties, sshSupportsSetEnv } from "./sshSupport";
6364
import { WorkspaceStateMachine } from "./workspaceStateMachine";
6465

6566
export interface RemoteDetails extends vscode.Disposable {
@@ -823,17 +824,6 @@ export class Remote {
823824
}
824825
}
825826

826-
// deploymentConfig is now set from the remote coderd deployment.
827-
// Now override with the user's config.
828-
const userConfigSsh = vscode.workspace
829-
.getConfiguration("coder")
830-
.get<string[]>("sshConfig", []);
831-
const userConfig = parseSshConfig(userConfigSsh);
832-
const sshConfigOverrides = mergeSshConfigValues(
833-
deploymentSSHConfig,
834-
userConfig,
835-
);
836-
837827
let sshConfigFile = vscode.workspace
838828
.getConfiguration()
839829
.get<string>("remote.SSH.configFile");
@@ -849,6 +839,20 @@ export class Remote {
849839
const sshConfig = new SSHConfig(sshConfigFile);
850840
await sshConfig.load();
851841

842+
// Merge SSH config from three sources (lowest to highest priority):
843+
// 1. coder config-ssh --ssh-option flags from the CLI block
844+
// 2. Deployment SSH config from the coderd API
845+
// 3. User's VS Code coder.sshConfig setting
846+
const configSshOptions = parseCoderSshOptions(sshConfig.getRaw());
847+
const userConfigSsh = vscode.workspace
848+
.getConfiguration("coder")
849+
.get<string[]>("sshConfig", []);
850+
const userConfig = parseSshConfig(userConfigSsh);
851+
const sshConfigOverrides = mergeSshConfigValues(
852+
mergeSshConfigValues(configSshOptions, deploymentSSHConfig),
853+
userConfig,
854+
);
855+
852856
const hostPrefix = safeHostname
853857
? `${AuthorityPrefix}.${safeHostname}--`
854858
: `${AuthorityPrefix}--`;
@@ -881,7 +885,7 @@ export class Remote {
881885
// A user can provide a "Host *" entry in their SSH config to add options
882886
// to all hosts. We need to ensure that the options we set are not
883887
// overridden by the user's config.
884-
const computedProperties = computeSSHProperties(
888+
const computedProperties = computeSshProperties(
885889
hostName,
886890
sshConfig.getRaw(),
887891
);

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";
@@ -11,6 +12,8 @@ import {
1112
// and makes mistakes abundantly clear.
1213
const sshFilePath = "/Path/To/UserHomeDir/.sshConfigDir/sshConfigFile";
1314
const sshTempFilePathExpr = `^/Path/To/UserHomeDir/\\.sshConfigDir/\\.sshConfigFile\\.vscode-coder-tmp\\.[a-z0-9]+$`;
15+
const managedHeader = `# This section is managed by the Coder VS Code extension.
16+
# Changes will be overwritten on the next workspace connection.`;
1417

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

4245
const expectedOutput = `# --- START CODER VSCODE ---
46+
${managedHeader}
4347
Host coder-vscode--*
4448
ConnectTimeout 0
4549
LogLevel ERROR
@@ -82,6 +86,7 @@ it("creates a new file and adds the config", async () => {
8286
});
8387

8488
const expectedOutput = `# --- START CODER VSCODE dev.coder.com ---
89+
${managedHeader}
8590
Host coder-vscode.dev.coder.com--*
8691
ConnectTimeout 0
8792
LogLevel ERROR
@@ -133,6 +138,7 @@ it("adds a new coder config in an existent SSH configuration", async () => {
133138
const expectedOutput = `${existentSSHConfig}
134139
135140
# --- START CODER VSCODE dev.coder.com ---
141+
${managedHeader}
136142
Host coder-vscode.dev.coder.com--*
137143
ConnectTimeout 0
138144
LogLevel ERROR
@@ -203,6 +209,7 @@ Host *
203209
const expectedOutput = `${keepSSHConfig}
204210
205211
# --- START CODER VSCODE dev.coder.com ---
212+
${managedHeader}
206213
Host coder-vscode.dev-updated.coder.com--*
207214
ConnectTimeout 1
208215
LogLevel ERROR
@@ -260,6 +267,7 @@ Host coder-vscode--*
260267
const expectedOutput = `${existentSSHConfig}
261268
262269
# --- START CODER VSCODE dev.coder.com ---
270+
${managedHeader}
263271
Host coder-vscode.dev.coder.com--*
264272
ConnectTimeout 0
265273
LogLevel ERROR
@@ -303,6 +311,7 @@ it("it does not remove a user-added block that only matches the host of an old c
303311
ForwardAgent=yes
304312
305313
# --- START CODER VSCODE dev.coder.com ---
314+
${managedHeader}
306315
Host coder-vscode.dev.coder.com--*
307316
ConnectTimeout 0
308317
LogLevel ERROR
@@ -573,6 +582,7 @@ Host donotdelete
573582
User please
574583
575584
# --- START CODER VSCODE dev.coder.com ---
585+
${managedHeader}
576586
Host coder-vscode.dev.coder.com--*
577587
ConnectTimeout 0
578588
LogLevel ERROR
@@ -637,6 +647,7 @@ it("override values", async () => {
637647
);
638648

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

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)