From 3254e10139a21485e87e5919432b7bc7fbd09e6a Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 15 Oct 2026 09:40:19 +0000 Subject: [PATCH] fix(ssh): write Windows ProxyCommand paths with forward slashes --- CHANGELOG.md | 3 +++ src/common/strings.ts | 12 ++++++++++++ src/remote/remote.ts | 14 +++++++------- src/settings/cli.ts | 19 +++++++++++++++---- test/unit/common/strings.test.ts | 20 ++++++++++++++++++++ test/unit/remote/remote.test.ts | 7 +++++-- 6 files changed, 62 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a86404f09..f4c1dc83b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ a backtick, `\`, `%` or a line break. - On Windows, a `coder.headerCommand` containing `"` was split into several arguments before it reached the CLI. +- On Windows, the open source Remote - SSH extension 0.4.0 dropped every `\` + from the paths in the SSH `ProxyCommand`. Drive-letter paths are now written + with `/`. ## [v1.17.0](https://github.com/coder/vscode-coder/releases/tag/v1.17.0) 2026-10-13 diff --git a/src/common/strings.ts b/src/common/strings.ts index 78ee991046..5d5c7e824a 100644 --- a/src/common/strings.ts +++ b/src/common/strings.ts @@ -57,3 +57,15 @@ export function escapeShellArg(arg: string): string { export function escapeProxyCommandArg(arg: string): string { return /[$`\\%\r\n]/.test(arg) ? escapeShellArg(arg) : escapeCommandArg(arg); } + +/** + * {@link escapeProxyCommandArg} for a local path. Windows drive paths use `/`, + * which every runner accepts; open-remote-ssh 0.4.0 strips `\` as an escape. + */ +export function escapeProxyCommandPath(filePath: string): string { + const portable = + os.platform() === "win32" && /^[a-z]:\\/i.test(filePath) + ? filePath.replaceAll("\\", "/") + : filePath; + return escapeProxyCommandArg(portable); +} diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 5559abdabc..79b8cc975d 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -18,7 +18,7 @@ import { HttpStatusCode } from "../api/httpStatusCode"; import { extractAgents } from "../api/models"; import { version as cliVersion } from "../cli/cliExec"; import { expandPath } from "../common/fs"; -import { escapeCommandArg } from "../common/strings"; +import { escapeCommandArg, escapeProxyCommandPath } from "../common/strings"; import { CONFIG_CHANGE_DEBOUNCE_MS, watchConfigurationChanges, @@ -865,7 +865,7 @@ export class Remote { } await fs.mkdir(logDir, { recursive: true }); this.logger.info("SSH proxy diagnostics are being written to", logDir); - return ["--log-dir", escapeCommandArg(logDir), "-v"]; + return ["--log-dir", escapeProxyCommandPath(logDir), "-v"]; } private getMainSshConfigPath(): string { @@ -1114,7 +1114,7 @@ function coderCommand( vscode.workspace.getConfiguration(), options.cliAuth, ); - return `${escapeCommandArg(options.binaryPath)} ${globalFlags.join(" ")} ${subcommand} ${flags.join(" ")}`; + return `${escapeProxyCommandPath(options.binaryPath)} ${globalFlags.join(" ")} ${subcommand} ${flags.join(" ")}`; } /** ProxyCommand for CLIs that support wildcard hosts. */ @@ -1126,7 +1126,7 @@ export function buildSshProxyCommand( "--stdio", `--usage-app=${escapeCommandArg(options.sessionApp)}`, "--network-info-dir", - escapeCommandArg(options.pathResolver.getNetworkInfoPath()), + escapeProxyCommandPath(options.pathResolver.getNetworkInfoPath()), ...options.logArgs, "--ssh-host-prefix", options.hostPrefix, @@ -1145,12 +1145,12 @@ function buildVscodeSshProxyCommand( const { pathResolver, label } = options; return coderCommand(options, "vscodessh", [ "--network-info-dir", - escapeCommandArg(pathResolver.getNetworkInfoPath()), + escapeProxyCommandPath(pathResolver.getNetworkInfoPath()), ...options.logArgs, "--session-token-file", - escapeCommandArg(pathResolver.getSessionTokenPath(label)), + escapeProxyCommandPath(pathResolver.getSessionTokenPath(label)), "--url-file", - escapeCommandArg(pathResolver.getUrlPath(label)), + escapeProxyCommandPath(pathResolver.getUrlPath(label)), "%h", ]); } diff --git a/src/settings/cli.ts b/src/settings/cli.ts index 9cfe766596..3c68057fb2 100644 --- a/src/settings/cli.ts +++ b/src/settings/cli.ts @@ -1,7 +1,11 @@ import os from "node:os"; import { expandPath } from "../common/fs"; -import { escapeCommandArg, escapeProxyCommandArg } from "../common/strings"; +import { + escapeCommandArg, + escapeProxyCommandArg, + escapeProxyCommandPath, +} from "../common/strings"; import { validateDeploymentUrl } from "../common/validation"; import { getHeaderArgs } from "./headers"; @@ -52,7 +56,13 @@ export function getGlobalShellFlags( validateDeploymentUrl(auth.url); return [ escapeCommandArg("--token="), - ...buildGlobalFlags(configs, auth, escapeCommandArg, escapeProxyCommandArg), + ...buildGlobalFlags( + configs, + auth, + escapeCommandArg, + escapeProxyCommandArg, + escapeProxyCommandPath, + ), ]; } @@ -61,7 +71,7 @@ export function getGlobalFlags( configs: Pick, auth: CliAuth, ): string[] { - return buildGlobalFlags(configs, auth, identity, identity); + return buildGlobalFlags(configs, auth, identity, identity, identity); } const identity = (s: string) => s; @@ -88,6 +98,7 @@ function buildGlobalFlags( auth: CliAuth, escUserFlag: (s: string) => string, escValue: (s: string) => string, + escPath: (s: string) => string, ): string[] { // Escape after stripping so expansion whitespace stays in one shell token. // User flags keep shell expansion (e.g. `$VAR`); the auth values and @@ -97,7 +108,7 @@ function buildGlobalFlags( auth.store === "extension", ).map(escUserFlag); if (auth.store === "extension") { - flags.push("--global-config", escValue(auth.configDir)); + flags.push("--global-config", escPath(auth.configDir)); } flags.push("--url", escValue(auth.url)); if (auth.useKeyring !== undefined) { diff --git a/test/unit/common/strings.test.ts b/test/unit/common/strings.test.ts index 735cfc81ec..40ac2b80e5 100644 --- a/test/unit/common/strings.test.ts +++ b/test/unit/common/strings.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeCommandArg, escapeProxyCommandArg, + escapeProxyCommandPath, escapeShellArg, } from "@/common/strings"; @@ -123,3 +124,22 @@ describe("escapeProxyCommandArg", () => { expect(escapeProxyCommandArg(value)).toBe(escape(value)); }); }); + +describe("escapeProxyCommandPath", () => { + const platformSpy = vi.spyOn(os, "platform"); + afterEach(() => platformSpy.mockReset()); + + it.each([ + [ + "win32", + String.raw`C:\Users\John Doe\coder.exe`, + '"C:/Users/John Doe/coder.exe"', + ], + ["win32", String.raw`d:\a\b`, "d:/a/b"], + ["win32", String.raw`\\server\share\x`, String.raw`"\\server\share\x"`], + ["linux", String.raw`C:\x`, String.raw`'C:\x'`], + ] as const)("on %s writes %j as %s", (platform, value, expected) => { + platformSpy.mockReturnValue(platform); + expect(escapeProxyCommandPath(value)).toBe(expected); + }); +}); diff --git a/test/unit/remote/remote.test.ts b/test/unit/remote/remote.test.ts index ab21ab784a..fdd41bb57f 100644 --- a/test/unit/remote/remote.test.ts +++ b/test/unit/remote/remote.test.ts @@ -403,7 +403,10 @@ describe("Remote", () => { // OpenSSH runs the command with `sh -c` on Unix; Win32-OpenSSH passes it // to CreateProcessW, so the program's argv parser splits it. it("reaches the CLI unchanged through this platform's OpenSSH", () => { - const configDir = "/Users/me/Library/Application Support/coder"; + // A drive path on Windows reaches the CLI with `/`. + const configDir = isWindows() + ? String.raw`C:\Users\John Doe\AppData\coder` + : "/Users/me/Library/Application Support/coder"; const headerCommand = 'echo "X: $TOKEN" "C:\\a b\\" `id`'; const pathResolver = new PathResolver("/mock/global", "/mock/log"); new MockConfigurationProvider().set("coder.headerCommand", headerCommand); @@ -435,7 +438,7 @@ describe("Remote", () => { expect(JSON.parse(result.stdout)).toStrictEqual([ "--token=", "--global-config", - configDir, + configDir.replaceAll("\\", "/"), "--url", CLI_AUTH.url, "--header-command",