diff --git a/CHANGELOG.md b/CHANGELOG.md index 425ed11a..1166d82c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Support for paths that begin with a tilde (`~`). +- Support for `coder ssh` flag configurations through the `coder.sshFlags` setting. ### Fixed diff --git a/package.json b/package.json index bd60a54c..b06f2a76 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,16 @@ "type": "boolean", "default": false }, + "coder.sshFlags": { + "markdownDescription": "Additional flags to pass to the `coder ssh` command when establishing SSH connections. Enter each flag as a separate array item; values are passed verbatim and in order. See the [CLI ssh reference](https://coder.com/docs/reference/cli/ssh) for available flags.\n\nNote: `--network-info-dir` and `--ssh-host-prefix` are ignored (managed internally). Prefer `#coder.proxyLogDirectory#` over `--log-dir`/`-l` for full functionality.", + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "--disable-autostart" + ] + }, "coder.globalFlags": { "markdownDescription": "Global flags to pass to every Coder CLI invocation. Enter each flag as a separate array item; values are passed verbatim and in order. Do **not** include the `coder` command itself. See the [CLI reference](https://coder.com/docs/reference/cli) for available global flags.\n\nNote that for `--header-command`, precedence is: `#coder.headerCommand#` setting, then `CODER_HEADER_COMMAND` environment variable, then the value specified here. The `--global-config` flag is explicitly ignored.", "type": "array", diff --git a/src/api/workspace.ts b/src/api/workspace.ts index 1d3b7a4e..93319337 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -8,8 +8,8 @@ import { import { spawn } from "node:child_process"; import * as vscode from "vscode"; +import { getGlobalFlags } from "../cliConfig"; import { type FeatureSet } from "../featureSet"; -import { getGlobalFlags } from "../globalFlags"; import { escapeCommandArg } from "../util"; import { type UnidirectionalStream } from "../websocket/eventStreamConnection"; diff --git a/src/globalFlags.ts b/src/cliConfig.ts similarity index 60% rename from src/globalFlags.ts rename to src/cliConfig.ts index 8e75ce8d..0ae0080f 100644 --- a/src/globalFlags.ts +++ b/src/cliConfig.ts @@ -14,7 +14,16 @@ export function getGlobalFlags( // Last takes precedence/overrides previous ones return [ ...(configs.get("coder.globalFlags") || []), - ...["--global-config", escapeCommandArg(configDir)], + "--global-config", + escapeCommandArg(configDir), ...getHeaderArgs(configs), ]; } + +/** + * Returns SSH flags for the `coder ssh` command from user configuration. + */ +export function getSshFlags(configs: WorkspaceConfiguration): string[] { + // Make sure to match this default with the one in the package.json + return configs.get("coder.sshFlags", ["--disable-autostart"]); +} diff --git a/src/commands.ts b/src/commands.ts index 384b4d79..9bb2ed54 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -10,6 +10,7 @@ import * as vscode from "vscode"; import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; +import { getGlobalFlags } from "./cliConfig"; import { type CliManager } from "./core/cliManager"; import { type ServiceContainer } from "./core/container"; import { type ContextManager } from "./core/contextManager"; @@ -17,7 +18,6 @@ import { type MementoManager } from "./core/mementoManager"; import { type PathResolver } from "./core/pathResolver"; import { type SecretsManager } from "./core/secretsManager"; import { CertificateError } from "./error"; -import { getGlobalFlags } from "./globalFlags"; import { type Logger } from "./logging/logger"; import { maybeAskAgent, maybeAskUrl } from "./promptUtils"; import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 4193e46d..27a0477e 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -20,6 +20,7 @@ import { import { extractAgents } from "../api/api-helper"; import { CoderApi } from "../api/coderApi"; import { needToken } from "../api/utils"; +import { getGlobalFlags, getSshFlags } from "../cliConfig"; import { type Commands } from "../commands"; import { type CliManager } from "../core/cliManager"; import * as cliUtils from "../core/cliUtils"; @@ -27,7 +28,6 @@ import { type ServiceContainer } from "../core/container"; import { type ContextManager } from "../core/contextManager"; import { type PathResolver } from "../core/pathResolver"; import { featureSetForVersion, type FeatureSet } from "../featureSet"; -import { getGlobalFlags } from "../globalFlags"; import { Inbox } from "../inbox"; import { type Logger } from "../logging/logger"; import { @@ -501,8 +501,6 @@ export class Remote { sshMonitor.onLogFilePathChange((newPath) => { this.commands.workspaceLogPath = newPath; }), - // Watch for logDir configuration changes - this.watchLogDirSetting(logDir, featureSet), // Register the label formatter again because SSH overrides it! vscode.extensions.onDidChange(() => { // Dispose previous label formatter @@ -516,6 +514,18 @@ export class Remote { }), ...(await this.createAgentMetadataStatusBar(agent, workspaceClient)), ); + + const settingsToWatch = [ + { setting: "coder.globalFlags", title: "Global flags" }, + { setting: "coder.sshFlags", title: "SSH flags" }, + ]; + if (featureSet.proxyLogDirectory) { + settingsToWatch.push({ + setting: "coder.proxyLogDirectory", + title: "Proxy log directory", + }); + } + disposables.push(this.watchSettings(settingsToWatch)); } catch (ex) { // Whatever error happens, make sure we clean up the disposables in case of failure disposables.forEach((d) => d.dispose()); @@ -554,8 +564,10 @@ export class Remote { } /** - * Return the --log-dir argument value for the ProxyCommand. It may be an + * Return the --log-dir argument value for the ProxyCommand. It may be an * empty string if the setting is not set or the cli does not support it. + * + * Value defined in the "coder.sshFlags" setting is not considered. */ private getLogDir(featureSet: FeatureSet): string { if (!featureSet.proxyLogDirectory) { @@ -571,16 +583,79 @@ export class Remote { } /** - * Formats the --log-dir argument for the ProxyCommand after making sure it + * Builds the ProxyCommand for SSH connections to Coder workspaces. + * Uses `coder ssh` for modern deployments with wildcard support, + * or falls back to `coder vscodessh` for older deployments. + */ + private async buildProxyCommand( + binaryPath: string, + label: string, + hostPrefix: string, + logDir: string, + useWildcardSSH: boolean, + ): Promise { + const vscodeConfig = vscode.workspace.getConfiguration(); + + const escapedBinaryPath = escapeCommandArg(binaryPath); + const globalConfig = getGlobalFlags( + vscodeConfig, + this.pathResolver.getGlobalConfigDir(label), + ); + const logArgs = await this.getLogArgs(logDir); + + if (useWildcardSSH) { + // User SSH flags are included first; internally-managed flags + // are appended last so they take precedence. + const userSshFlags = getSshFlags(vscodeConfig); + // Make sure to update the `coder.sshFlags` description if we add more internal flags here! + const internalFlags = [ + "--stdio", + "--usage-app=vscode", + "--network-info-dir", + escapeCommandArg(this.pathResolver.getNetworkInfoPath()), + ...logArgs, + "--ssh-host-prefix", + hostPrefix, + "%h", + ]; + + const allFlags = [...userSshFlags, ...internalFlags]; + return `${escapedBinaryPath} ${globalConfig.join(" ")} ssh ${allFlags.join(" ")}`; + } else { + const networkInfoDir = escapeCommandArg( + this.pathResolver.getNetworkInfoPath(), + ); + const sessionTokenFile = escapeCommandArg( + this.pathResolver.getSessionTokenPath(label), + ); + const urlFile = escapeCommandArg(this.pathResolver.getUrlPath(label)); + + const sshFlags = [ + "--network-info-dir", + networkInfoDir, + ...logArgs, + "--session-token-file", + sessionTokenFile, + "--url-file", + urlFile, + "%h", + ]; + + return `${escapedBinaryPath} ${globalConfig.join(" ")} vscodessh ${sshFlags.join(" ")}`; + } + } + + /** + * Returns the --log-dir argument for the ProxyCommand after making sure it * has been created. */ - private async formatLogArg(logDir: string): Promise { + private async getLogArgs(logDir: string): Promise { if (!logDir) { - return ""; + return []; } 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", escapeCommandArg(logDir), "-v"]; } // updateSSHConfig updates the SSH configuration with a wildcard that handles @@ -666,15 +741,13 @@ export class Remote { ? `${AuthorityPrefix}.${label}--` : `${AuthorityPrefix}--`; - const globalConfigs = this.globalConfigs(label); - - const proxyCommand = featureSet.wildcardSSH - ? `${escapeCommandArg(binaryPath)}${globalConfigs} ssh --stdio --usage-app=vscode --disable-autostart --network-info-dir ${escapeCommandArg(this.pathResolver.getNetworkInfoPath())}${await this.formatLogArg(logDir)} --ssh-host-prefix ${hostPrefix} %h` - : `${escapeCommandArg(binaryPath)}${globalConfigs} vscodessh --network-info-dir ${escapeCommandArg( - this.pathResolver.getNetworkInfoPath(), - )}${await this.formatLogArg(logDir)} --session-token-file ${escapeCommandArg(this.pathResolver.getSessionTokenPath(label))} --url-file ${escapeCommandArg( - this.pathResolver.getUrlPath(label), - )} %h`; + const proxyCommand = await this.buildProxyCommand( + binaryPath, + label, + hostPrefix, + logDir, + featureSet.wildcardSSH, + ); const sshValues: SSHValues = { Host: hostPrefix + `*`, @@ -727,38 +800,26 @@ export class Remote { return sshConfig.getRaw(); } - private globalConfigs(label: string): string { - const vscodeConfig = vscode.workspace.getConfiguration(); - const args = getGlobalFlags( - vscodeConfig, - this.pathResolver.getGlobalConfigDir(label), - ); - return ` ${args.join(" ")}`; - } - - private watchLogDirSetting( - currentLogDir: string, - featureSet: FeatureSet, + private watchSettings( + settings: Array<{ setting: string; title: string }>, ): vscode.Disposable { return vscode.workspace.onDidChangeConfiguration((e) => { - if (!e.affectsConfiguration("coder.proxyLogDirectory")) { - return; - } - const newLogDir = this.getLogDir(featureSet); - if (newLogDir === currentLogDir) { - return; + for (const { setting, title } of settings) { + if (!e.affectsConfiguration(setting)) { + continue; + } + vscode.window + .showInformationMessage( + `${title} setting changed. Reload window to apply.`, + "Reload", + ) + .then((action) => { + if (action === "Reload") { + vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + }); + break; } - - vscode.window - .showInformationMessage( - "Log directory configuration changed. Reload window to apply.", - "Reload", - ) - .then((action) => { - if (action === "Reload") { - vscode.commands.executeCommand("workbench.action.reloadWindow"); - } - }); }); } diff --git a/test/unit/cliConfig.test.ts b/test/unit/cliConfig.test.ts new file mode 100644 index 00000000..d350dcbd --- /dev/null +++ b/test/unit/cliConfig.test.ts @@ -0,0 +1,116 @@ +import { it, expect, describe } from "vitest"; +import { type WorkspaceConfiguration } from "vscode"; + +import { getGlobalFlags, getSshFlags } from "@/cliConfig"; + +import { isWindows } from "../utils/platform"; + +describe("cliConfig", () => { + describe("getGlobalFlags", () => { + it("should return global-config and header args when no global flags configured", () => { + const config = { + get: () => undefined, + } as unknown as WorkspaceConfiguration; + + expect(getGlobalFlags(config, "/config/dir")).toStrictEqual([ + "--global-config", + '"/config/dir"', + ]); + }); + + it("should return global flags from config with global-config appended", () => { + const config = { + get: (key: string) => + key === "coder.globalFlags" + ? ["--verbose", "--disable-direct-connections"] + : undefined, + } as unknown as WorkspaceConfiguration; + + expect(getGlobalFlags(config, "/config/dir")).toStrictEqual([ + "--verbose", + "--disable-direct-connections", + "--global-config", + '"/config/dir"', + ]); + }); + + it("should not filter duplicate global-config flags, last takes precedence", () => { + const config = { + get: (key: string) => + key === "coder.globalFlags" + ? [ + "-v", + "--global-config /path/to/ignored", + "--disable-direct-connections", + ] + : undefined, + } as unknown as WorkspaceConfiguration; + + expect(getGlobalFlags(config, "/config/dir")).toStrictEqual([ + "-v", + "--global-config /path/to/ignored", + "--disable-direct-connections", + "--global-config", + '"/config/dir"', + ]); + }); + + it("should not filter header-command flags, header args appended at end", () => { + const headerCommand = "echo test"; + const config = { + get: (key: string) => { + if (key === "coder.headerCommand") { + return headerCommand; + } + if (key === "coder.globalFlags") { + return ["-v", "--header-command custom", "--no-feature-warning"]; + } + return undefined; + }, + } as unknown as WorkspaceConfiguration; + + const result = getGlobalFlags(config, "/config/dir"); + expect(result).toStrictEqual([ + "-v", + "--header-command custom", // ignored by CLI + "--no-feature-warning", + "--global-config", + '"/config/dir"', + "--header-command", + quoteCommand(headerCommand), + ]); + }); + }); + + describe("getSshFlags", () => { + it("returns default flags when no SSH flags configured", () => { + const config = { + get: (_key: string, defaultValue: unknown) => defaultValue, + } as unknown as WorkspaceConfiguration; + + expect(getSshFlags(config)).toStrictEqual(["--disable-autostart"]); + }); + + it("returns SSH flags from config", () => { + const config = { + get: (key: string) => + key === "coder.sshFlags" + ? ["--disable-autostart", "--wait=yes", "--ssh-host-prefix=custom"] + : undefined, + } as unknown as WorkspaceConfiguration; + + expect(getSshFlags(config)).toStrictEqual([ + "--disable-autostart", + "--wait=yes", + // No filtering and returned as-is (even though it'll be overridden later) + "--ssh-host-prefix=custom", + ]); + }); + }); +}); + +function quoteCommand(value: string): string { + // Used to escape environment variables in commands. See `getHeaderArgs` in src/headers.ts + const quote = isWindows() ? '"' : "'"; + return `${quote}${value}${quote}`; +} diff --git a/test/unit/globalFlags.test.ts b/test/unit/globalFlags.test.ts deleted file mode 100644 index 94c89dba..00000000 --- a/test/unit/globalFlags.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { it, expect, describe } from "vitest"; -import { type WorkspaceConfiguration } from "vscode"; - -import { getGlobalFlags } from "@/globalFlags"; - -import { isWindows } from "../utils/platform"; - -describe("Global flags suite", () => { - it("should return global-config and header args when no global flags configured", () => { - const config = { - get: () => undefined, - } as unknown as WorkspaceConfiguration; - - expect(getGlobalFlags(config, "/config/dir")).toStrictEqual([ - "--global-config", - '"/config/dir"', - ]); - }); - - it("should return global flags from config with global-config appended", () => { - const config = { - get: (key: string) => - key === "coder.globalFlags" - ? ["--verbose", "--disable-direct-connections"] - : undefined, - } as unknown as WorkspaceConfiguration; - - expect(getGlobalFlags(config, "/config/dir")).toStrictEqual([ - "--verbose", - "--disable-direct-connections", - "--global-config", - '"/config/dir"', - ]); - }); - - it("should not filter duplicate global-config flags, last takes precedence", () => { - const config = { - get: (key: string) => - key === "coder.globalFlags" - ? [ - "-v", - "--global-config /path/to/ignored", - "--disable-direct-connections", - ] - : undefined, - } as unknown as WorkspaceConfiguration; - - expect(getGlobalFlags(config, "/config/dir")).toStrictEqual([ - "-v", - "--global-config /path/to/ignored", - "--disable-direct-connections", - "--global-config", - '"/config/dir"', - ]); - }); - - it("should not filter header-command flags, header args appended at end", () => { - const headerCommand = "echo test"; - const config = { - get: (key: string) => { - if (key === "coder.headerCommand") { - return headerCommand; - } - if (key === "coder.globalFlags") { - return ["-v", "--header-command custom", "--no-feature-warning"]; - } - return undefined; - }, - } as unknown as WorkspaceConfiguration; - - const result = getGlobalFlags(config, "/config/dir"); - expect(result).toStrictEqual([ - "-v", - "--header-command custom", // ignored by CLI - "--no-feature-warning", - "--global-config", - '"/config/dir"', - "--header-command", - quoteCommand(headerCommand), - ]); - }); -}); - -function quoteCommand(value: string): string { - // Used to escape environment variables in commands. See `getHeaderArgs` in src/headers.ts - const quote = isWindows() ? '"' : "'"; - return `${quote}${value}${quote}`; -}