diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ed103a..67957fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Always enable verbose (`-v`) flag when a log directory is configured (`coder.proxyLogDir`). +### Added + +- Add support for CLI global flag configurations through the `coder.globalFlags` setting. + ## [1.10.1](https://github.com/coder/vscode-coder/releases/tag/v1.10.1) 2025-08-13 ### Fixed diff --git a/package.json b/package.json index 57995339..c3743cd4 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,13 @@ "markdownDescription": "Disable Coder CLI signature verification, which can be useful if you run an unsigned fork of the binary.", "type": "boolean", "default": false + }, + "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", + "items": { + "type": "string" + } } } }, diff --git a/src/api.ts b/src/api.ts index dc66335d..9c0022f0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -13,7 +13,7 @@ import * as ws from "ws"; import { errToStr } from "./api-helper"; import { CertificateError } from "./error"; import { FeatureSet } from "./featureSet"; -import { getHeaderArgs } from "./headers"; +import { getGlobalFlags } from "./globalFlags"; import { getProxyForUrl } from "./proxy"; import { Storage } from "./storage"; import { expandPath } from "./util"; @@ -186,9 +186,7 @@ export async function startWorkspaceIfStoppedOrFailed( return new Promise((resolve, reject) => { const startArgs = [ - "--global-config", - globalConfigDir, - ...getHeaderArgs(vscode.workspace.getConfiguration()), + ...getGlobalFlags(vscode.workspace.getConfiguration(), globalConfigDir), "start", "--yes", workspace.owner_name + "/" + workspace.name, @@ -197,7 +195,7 @@ export async function startWorkspaceIfStoppedOrFailed( startArgs.push(...["--reason", "vscode_connection"]); } - const startProcess = spawn(binPath, startArgs); + const startProcess = spawn(binPath, startArgs, { shell: true }); startProcess.stdout.on("data", (data: Buffer) => { data diff --git a/src/commands.ts b/src/commands.ts index 11ecf8b7..2e4ba705 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -10,8 +10,9 @@ import * as vscode from "vscode"; import { makeCoderSdk, needToken } from "./api"; import { extractAgents } from "./api-helper"; import { CertificateError } from "./error"; +import { getGlobalFlags } from "./globalFlags"; import { Storage } from "./storage"; -import { toRemoteAuthority, toSafeHost } from "./util"; +import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; import { AgentTreeItem, WorkspaceTreeItem, @@ -503,12 +504,16 @@ export class Commands { this.restClient, toSafeHost(url), ); - const escape = (str: string): string => - `"${str.replace(/"/g, '\\"')}"`; + + const configDir = path.dirname( + this.storage.getSessionTokenPath(toSafeHost(url)), + ); + const globalFlags = getGlobalFlags( + vscode.workspace.getConfiguration(), + configDir, + ); terminal.sendText( - `${escape(binary)} ssh --global-config ${escape( - path.dirname(this.storage.getSessionTokenPath(toSafeHost(url))), - )} ${app.workspace_name}`, + `${escapeCommandArg(binary)}${` ${globalFlags.join(" ")}`} ssh ${app.workspace_name}`, ); await new Promise((resolve) => setTimeout(resolve, 5000)); terminal.sendText(app.command ?? ""); diff --git a/src/globalFlags.test.ts b/src/globalFlags.test.ts new file mode 100644 index 00000000..307500e7 --- /dev/null +++ b/src/globalFlags.test.ts @@ -0,0 +1,78 @@ +import { it, expect, describe } from "vitest"; +import { WorkspaceConfiguration } from "vscode"; +import { getGlobalFlags } from "./globalFlags"; + +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 config = { + get: (key: string) => { + if (key === "coder.headerCommand") { + return "echo test"; + } + 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", + "'echo test'", + ]); + }); +}); diff --git a/src/globalFlags.ts b/src/globalFlags.ts new file mode 100644 index 00000000..851e41c7 --- /dev/null +++ b/src/globalFlags.ts @@ -0,0 +1,19 @@ +import { WorkspaceConfiguration } from "vscode"; +import { getHeaderArgs } from "./headers"; +import { escapeCommandArg } from "./util"; + +/** + * Returns global configuration flags for Coder CLI commands. + * Always includes the `--global-config` argument with the specified config directory. + */ +export function getGlobalFlags( + configs: WorkspaceConfiguration, + configDir: string, +): string[] { + // Last takes precedence/overrides previous ones + return [ + ...(configs.get("coder.globalFlags") || []), + ...["--global-config", escapeCommandArg(configDir)], + ...getHeaderArgs(configs), + ]; +} diff --git a/src/remote.ts b/src/remote.ts index 9dbb1503..b5165b4a 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -26,7 +26,7 @@ import { extractAgents } from "./api-helper"; import * as cli from "./cliManager"; import { Commands } from "./commands"; import { featureSetForVersion, FeatureSet } from "./featureSet"; -import { getHeaderArgs } from "./headers"; +import { getGlobalFlags } from "./globalFlags"; import { Inbox } from "./inbox"; import { SSHConfig, SSHValues, mergeSSHConfigValues } from "./sshConfig"; import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; @@ -758,19 +758,15 @@ export class Remote { const sshConfig = new SSHConfig(sshConfigFile); await sshConfig.load(); - const headerArgs = getHeaderArgs(vscode.workspace.getConfiguration()); - const headerArgList = - headerArgs.length > 0 ? ` ${headerArgs.join(" ")}` : ""; - const hostPrefix = label ? `${AuthorityPrefix}.${label}--` : `${AuthorityPrefix}--`; + const globalConfigs = this.globalConfigs(label); + const proxyCommand = featureSet.wildcardSSH - ? `${escapeCommandArg(binaryPath)}${headerArgList} --global-config ${escapeCommandArg( - path.dirname(this.storage.getSessionTokenPath(label)), - )} ssh --stdio --usage-app=vscode --disable-autostart --network-info-dir ${escapeCommandArg(this.storage.getNetworkInfoPath())}${await this.formatLogArg(logDir)} --ssh-host-prefix ${hostPrefix} %h` - : `${escapeCommandArg(binaryPath)}${headerArgList} vscodessh --network-info-dir ${escapeCommandArg( + ? `${escapeCommandArg(binaryPath)}${globalConfigs} ssh --stdio --usage-app=vscode --disable-autostart --network-info-dir ${escapeCommandArg(this.storage.getNetworkInfoPath())}${await this.formatLogArg(logDir)} --ssh-host-prefix ${hostPrefix} %h` + : `${escapeCommandArg(binaryPath)}${globalConfigs} vscodessh --network-info-dir ${escapeCommandArg( this.storage.getNetworkInfoPath(), )}${await this.formatLogArg(logDir)} --session-token-file ${escapeCommandArg(this.storage.getSessionTokenPath(label))} --url-file ${escapeCommandArg( this.storage.getUrlPath(label), @@ -828,6 +824,15 @@ export class Remote { return sshConfig.getRaw(); } + private globalConfigs(label: string): string { + const vscodeConfig = vscode.workspace.getConfiguration(); + const args = getGlobalFlags( + vscodeConfig, + path.dirname(this.storage.getSessionTokenPath(label)), + ); + return ` ${args.join(" ")}`; + } + // showNetworkUpdates finds the SSH process ID that is being used by this // workspace and reads the file being created by the Coder CLI. private showNetworkUpdates(sshPid: number): vscode.Disposable {