Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
- Proxy log directory now defaults to the extension's global storage when `coder.proxyLogDirectory`
is not set, so SSH connection logs are always captured without manual configuration. Also respects
the `CODER_SSH_LOG_DIR` environment variable as a fallback.
- SSH options from `coder config-ssh --ssh-option` are now applied to VS Code connections,
with priority order: VS Code setting > `coder config-ssh` options > deployment config.

## [v1.14.0-pre](https://github.com/coder/vscode-coder/releases/tag/v1.14.0-pre) 2026-03-06

Expand Down
30 changes: 17 additions & 13 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,11 @@ import {
SSHConfig,
type SSHValues,
mergeSshConfigValues,
parseCoderSshOptions,
parseSshConfig,
} from "./sshConfig";
import { SshProcessMonitor } from "./sshProcess";
import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport";
import { computeSshProperties, sshSupportsSetEnv } from "./sshSupport";
import { WorkspaceStateMachine } from "./workspaceStateMachine";

export interface RemoteDetails extends vscode.Disposable {
Expand Down Expand Up @@ -816,17 +817,6 @@ export class Remote {
}
}

// deploymentConfig is now set from the remote coderd deployment.
// Now override with the user's config.
const userConfigSsh = vscode.workspace
.getConfiguration("coder")
.get<string[]>("sshConfig", []);
const userConfig = parseSshConfig(userConfigSsh);
const sshConfigOverrides = mergeSshConfigValues(
deploymentSSHConfig,
userConfig,
);

let sshConfigFile = vscode.workspace
.getConfiguration()
.get<string>("remote.SSH.configFile");
Expand All @@ -842,6 +832,20 @@ export class Remote {
const sshConfig = new SSHConfig(sshConfigFile);
await sshConfig.load();

// Merge SSH config from three sources (highest to lowest priority):
// 1. User's VS Code coder.sshConfig setting
// 2. coder config-ssh --ssh-option flags from the CLI block
// 3. Deployment SSH config from the coderd API
const configSshOptions = parseCoderSshOptions(sshConfig.getRaw());
const userConfigSsh = vscode.workspace
.getConfiguration("coder")
.get<string[]>("sshConfig", []);
const userConfig = parseSshConfig(userConfigSsh);
const sshConfigOverrides = mergeSshConfigValues(
mergeSshConfigValues(deploymentSSHConfig, configSshOptions),
userConfig,
);

const hostPrefix = safeHostname
? `${AuthorityPrefix}.${safeHostname}--`
: `${AuthorityPrefix}--`;
Expand Down Expand Up @@ -874,7 +878,7 @@ export class Remote {
// A user can provide a "Host *" entry in their SSH config to add options
// to all hosts. We need to ensure that the options we set are not
// overridden by the user's config.
const computedProperties = computeSSHProperties(
const computedProperties = computeSshProperties(
hostName,
sshConfig.getRaw(),
);
Expand Down
35 changes: 32 additions & 3 deletions src/remote/sshConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ const defaultFileSystem: FileSystem = {
writeFile,
};

// Matches an SSH config key at the start of a line (e.g. "ConnectTimeout", "LogLevel").
const sshKeyRegex = /^[a-zA-Z0-9-]+/;

// Matches the Coder CLI's START-CODER / END-CODER block, flexible on dash count.
const coderBlockRegex = /^# -+START-CODER-+$(.*?)^# -+END-CODER-+$/ms;

/**
* Parse an array of SSH config lines into a Record.
* Handles both "Key value" and "Key=value" formats.
Expand All @@ -44,8 +50,7 @@ const defaultFileSystem: FileSystem = {
export function parseSshConfig(lines: string[]): Record<string, string> {
return lines.reduce(
(acc, line) => {
// Match key pattern (same as VS Code settings: ^[a-zA-Z0-9-]+)
const keyMatch = /^[a-zA-Z0-9-]+/.exec(line);
const keyMatch = sshKeyRegex.exec(line);
if (!keyMatch) {
return acc; // Malformed line
}
Expand Down Expand Up @@ -74,6 +79,25 @@ export function parseSshConfig(lines: string[]): Record<string, string> {
);
}

/**
* Extract `# :ssh-option=` values from the Coder CLI's config block.
* Returns `{}` if no CLI block is found.
*/
export function parseCoderSshOptions(raw: string): Record<string, string> {
const blockMatch = coderBlockRegex.exec(raw);
const block = blockMatch?.[1];
if (!block) {
return {};
}
const prefix = "# :ssh-option=";
const sshOptionLines = block
.split(/\r?\n/)
.filter((line) => line.startsWith(prefix))
.map((line) => line.slice(prefix.length));

return parseSshConfig(sshOptionLines);
}

// mergeSSHConfigValues will take a given ssh config and merge it with the overrides
// provided. The merge handles key case insensitivity, so casing in the "key" does
// not matter.
Expand Down Expand Up @@ -255,7 +279,12 @@ export class SSHConfig {
overrides?: Record<string, string>,
) {
const { Host, ...otherValues } = values;
const lines = [this.startBlockComment(safeHostname), `Host ${Host}`];
const lines = [
this.startBlockComment(safeHostname),
"# This section is managed by the Coder VS Code extension.",
"# Changes will be overwritten on the next workspace connection.",
`Host ${Host}`,
];

// configValues is the merged values of the defaults and the overrides.
const configValues = mergeSshConfigValues(otherValues, overrides ?? {});
Expand Down
23 changes: 15 additions & 8 deletions src/remote/sshSupport.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import * as childProcess from "child_process";

// Matches the OpenSSH version number from `ssh -V` output.
// [^,]* prevents greedy matching across comma-separated components
const openSSHVersionRegex = /OpenSSH[^,]*_([\d.]+)/;

/** Check if the local SSH installation supports the `SetEnv` directive. */
export function sshSupportsSetEnv(): boolean {
try {
// Run `ssh -V` to get the version string.
Expand All @@ -11,12 +16,12 @@ export function sshSupportsSetEnv(): boolean {
}
}

// sshVersionSupportsSetEnv ensures that the version string from the SSH
// command line supports the `SetEnv` directive.
//
// It was introduced in SSH 7.8 and not all versions support it.
/**
* Check if an SSH version string supports the `SetEnv` directive.
* Requires OpenSSH 7.8 or later.
*/
export function sshVersionSupportsSetEnv(sshVersionString: string): boolean {
const match = /OpenSSH.*_([\d.]+)[^,]*/.exec(sshVersionString);
const match = openSSHVersionRegex.exec(sshVersionString);
if (match?.[1]) {
const installedVersion = match[1];
const parts = installedVersion.split(".");
Expand All @@ -37,9 +42,11 @@ export function sshVersionSupportsSetEnv(sshVersionString: string): boolean {
return false;
}

// computeSSHProperties accepts an SSH config and a host name and returns
// the properties that should be set for that host.
export function computeSSHProperties(
/**
* Compute the effective SSH properties for a given host by evaluating
* all matching Host blocks in the provided SSH config.
*/
export function computeSshProperties(
host: string,
config: string,
): Record<string, string> {
Expand Down
106 changes: 106 additions & 0 deletions test/unit/remote/sshConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { it, afterEach, vi, expect, describe } from "vitest";

import {
SSHConfig,
parseCoderSshOptions,
parseSshConfig,
mergeSshConfigValues,
} from "@/remote/sshConfig";
Expand All @@ -12,6 +13,8 @@ import {
const sshFilePath = "/Path/To/UserHomeDir/.sshConfigDir/sshConfigFile";
const sshTempFilePrefix =
"/Path/To/UserHomeDir/.sshConfigDir/.sshConfigFile.vscode-coder-tmp-";
const managedHeader = `# This section is managed by the Coder VS Code extension.
# Changes will be overwritten on the next workspace connection.`;

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

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

const expectedOutput = `# --- START CODER VSCODE dev.coder.com ---
${managedHeader}
Host coder-vscode.dev.coder.com--*
ConnectTimeout 0
LogLevel ERROR
Expand Down Expand Up @@ -134,6 +139,7 @@ it("adds a new coder config in an existent SSH configuration", async () => {
const expectedOutput = `${existentSSHConfig}

# --- START CODER VSCODE dev.coder.com ---
${managedHeader}
Host coder-vscode.dev.coder.com--*
ConnectTimeout 0
LogLevel ERROR
Expand Down Expand Up @@ -204,6 +210,7 @@ Host *
const expectedOutput = `${keepSSHConfig}

# --- START CODER VSCODE dev.coder.com ---
${managedHeader}
Host coder-vscode.dev-updated.coder.com--*
ConnectTimeout 1
LogLevel ERROR
Expand Down Expand Up @@ -261,6 +268,7 @@ Host coder-vscode--*
const expectedOutput = `${existentSSHConfig}

# --- START CODER VSCODE dev.coder.com ---
${managedHeader}
Host coder-vscode.dev.coder.com--*
ConnectTimeout 0
LogLevel ERROR
Expand Down Expand Up @@ -304,6 +312,7 @@ it("it does not remove a user-added block that only matches the host of an old c
ForwardAgent=yes

# --- START CODER VSCODE dev.coder.com ---
${managedHeader}
Host coder-vscode.dev.coder.com--*
ConnectTimeout 0
LogLevel ERROR
Expand Down Expand Up @@ -574,6 +583,7 @@ Host donotdelete
User please

# --- START CODER VSCODE dev.coder.com ---
${managedHeader}
Host coder-vscode.dev.coder.com--*
ConnectTimeout 0
LogLevel ERROR
Expand Down Expand Up @@ -638,6 +648,7 @@ it("override values", async () => {
);

const expectedOutput = `# --- START CODER VSCODE dev.coder.com ---
${managedHeader}
Host coder-vscode.dev.coder.com--*
Buzz baz
ConnectTimeout 500
Expand Down Expand Up @@ -854,3 +865,98 @@ describe("mergeSshConfigValues", () => {
expect(mergeSshConfigValues(config, overrides)).toEqual(expected);
});
});

describe("parseCoderSshOptions", () => {
const coderBlock = (...lines: string[]) =>
`# ------------START-CODER-----------\n${lines.join("\n")}\n# ------------END-CODER------------`;

interface SshOptionTestCase {
name: string;
raw: string;
expected: Record<string, string>;
}
it.each<SshOptionTestCase>([
{
name: "empty string",
raw: "",
expected: {},
},
{
name: "no CLI block",
raw: "Host myhost\n HostName example.com",
expected: {},
},
{
name: "single option",
raw: coderBlock("# :ssh-option=ForwardX11=yes"),
expected: { ForwardX11: "yes" },
},
{
name: "multiple options",
raw: coderBlock(
"# :ssh-option=ForwardX11=yes",
"# :ssh-option=ForwardX11Trusted=yes",
),
expected: { ForwardX11: "yes", ForwardX11Trusted: "yes" },
},
{
name: "ignores non-ssh-option keys",
raw: coderBlock(
"# :wait=yes",
"# :disable-autostart=true",
"# :ssh-option=ForwardX11=yes",
),
expected: { ForwardX11: "yes" },
},
{
name: "accumulates SetEnv across lines",
raw: coderBlock(
"# :ssh-option=SetEnv=FOO=1",
"# :ssh-option=SetEnv=BAR=2",
),
expected: { SetEnv: "FOO=1 BAR=2" },
},
{
name: "tolerates different dash counts in markers",
raw: `# ---START-CODER---\n# :ssh-option=ForwardX11=yes\n# ---END-CODER---`,
expected: { ForwardX11: "yes" },
},
])("$name", ({ raw, expected }) => {
expect(parseCoderSshOptions(raw)).toEqual(expected);
});

it("extracts only ssh-options from a full config", () => {
const raw = `Host personal-server
HostName 10.0.0.1
User admin

# ------------START-CODER-----------
# This file is managed by coder. DO NOT EDIT.
#
# You should not hand-edit this file, changes may be overwritten.
# For more information, see https://coder.com/docs
#
# :wait=yes
# :disable-autostart=true
# :ssh-option=ForwardX11=yes
# :ssh-option=ForwardX11Trusted=yes

Host coder.mydeployment--*
ConnectTimeout 0
ForwardX11 yes
ForwardX11Trusted yes
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
ProxyCommand /usr/bin/coder ssh --stdio --ssh-host-prefix coder.mydeployment-- %h
# ------------END-CODER------------

Host work-server
HostName 10.0.0.2
User work`;
expect(parseCoderSshOptions(raw)).toEqual({
ForwardX11: "yes",
ForwardX11Trusted: "yes",
});
});
});
Loading
Loading