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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions src/common/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
14 changes: 7 additions & 7 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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. */
Expand All @@ -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,
Expand All @@ -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",
]);
}
19 changes: 15 additions & 4 deletions src/settings/cli.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -52,7 +56,13 @@ export function getGlobalShellFlags(
validateDeploymentUrl(auth.url);
return [
escapeCommandArg("--token="),
...buildGlobalFlags(configs, auth, escapeCommandArg, escapeProxyCommandArg),
...buildGlobalFlags(
configs,
auth,
escapeCommandArg,
escapeProxyCommandArg,
escapeProxyCommandPath,
),
];
}

Expand All @@ -61,7 +71,7 @@ export function getGlobalFlags(
configs: Pick<WorkspaceConfiguration, "get">,
auth: CliAuth,
): string[] {
return buildGlobalFlags(configs, auth, identity, identity);
return buildGlobalFlags(configs, auth, identity, identity, identity);
}

const identity = (s: string) => s;
Expand All @@ -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
Expand All @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions test/unit/common/strings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
escapeCommandArg,
escapeProxyCommandArg,
escapeProxyCommandPath,
escapeShellArg,
} from "@/common/strings";

Expand Down Expand Up @@ -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);
});
});
7 changes: 5 additions & 2 deletions test/unit/remote/remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -435,7 +438,7 @@ describe("Remote", () => {
expect(JSON.parse(result.stdout)).toStrictEqual([
"--token=",
"--global-config",
configDir,
configDir.replaceAll("\\", "/"),
"--url",
CLI_AUTH.url,
"--header-command",
Expand Down
Loading