From befd9ae92e0f3e0f1d95b1b06724a0cb8f0e4441 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 2 Sep 2025 17:16:02 +0300 Subject: [PATCH 1/4] Added support for CLI global flag configurations through coder.globalFlags --- CHANGELOG.md | 4 ++++ package.json | 7 +++++++ src/api.ts | 6 ++---- src/commands.ts | 17 +++++++++++------ src/globalFlags.ts | 29 +++++++++++++++++++++++++++++ src/remote.ts | 28 +++++++++++++++++++--------- 6 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 src/globalFlags.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ed103a..875be8ef 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. + ## [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..cdfadb5b 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 the `#coder.headerCommand#` setting **takes precedence** and will override any `--header-command` value specified here.", + "type": "array", + "items": { + "type": "string" + } } } }, diff --git a/src/api.ts b/src/api.ts index dc66335d..2211d2a9 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, diff --git a/src/commands.ts b/src/commands.ts index 11ecf8b7..9561672b 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.length === 0 ? "" : ` ${globalFlags.join(" ")}`} ssh ${app.workspace_name}`, ); await new Promise((resolve) => setTimeout(resolve, 5000)); terminal.sendText(app.command ?? ""); diff --git a/src/globalFlags.ts b/src/globalFlags.ts new file mode 100644 index 00000000..037ef0c5 --- /dev/null +++ b/src/globalFlags.ts @@ -0,0 +1,29 @@ +import { WorkspaceConfiguration } from "vscode"; +import { getHeaderArgs } from "./headers"; +import { escapeCommandArg } from "./util"; + +export function getGlobalFlags( + configs: WorkspaceConfiguration, + configDir?: string, +): string[] { + const globalFlags = configs.get("coder.globalFlags") || []; + const headerArgs = getHeaderArgs(configs); + const globalConfigArgs = configDir + ? ["--global-config", escapeCommandArg(configDir)] + : []; + + // Precedence of "coder.headerCommand" is higher than "coder.globalConfig" with the "--header-command" flag + let filteredGlobalFlags = globalFlags; + if (headerArgs.length > 0) { + filteredGlobalFlags = globalFlags.filter( + (flag) => !flag.startsWith("--header-command"), + ); + } + + if (globalConfigArgs.length > 0) { + filteredGlobalFlags = globalFlags.filter( + (flag) => !flag.startsWith("--global-config"), + ); + } + return [...filteredGlobalFlags, ...headerArgs, ...globalConfigArgs]; +} diff --git a/src/remote.ts b/src/remote.ts index 9dbb1503..27d6295a 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(featureSet, 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,20 @@ export class Remote { return sshConfig.getRaw(); } + private globalConfigs(featureSet: FeatureSet, label: string): string { + const vscodeConfig = vscode.workspace.getConfiguration(); + let args: string[]; + if (featureSet.wildcardSSH) { + args = getGlobalFlags( + vscodeConfig, + path.dirname(this.storage.getSessionTokenPath(label)), + ); + } else { + args = getGlobalFlags(vscodeConfig); + } + return args.length === 0 ? "" : ` ${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 { From 08a2dc3daf4f59175d7f3ed49255281da4506a47 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 4 Sep 2025 12:48:18 +0300 Subject: [PATCH 2/4] Address review comments + Add some tests --- CHANGELOG.md | 2 +- package.json | 2 +- src/globalFlags.test.ts | 78 +++++++++++++++++++++++++++++++++++++++++ src/globalFlags.ts | 28 ++++----------- src/remote.ts | 17 ++++----- 5 files changed, 93 insertions(+), 34 deletions(-) create mode 100644 src/globalFlags.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 875be8ef..67957fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Added -- Add support for CLI global flag configurations. +- 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 diff --git a/package.json b/package.json index cdfadb5b..c3743cd4 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "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 the `#coder.headerCommand#` setting **takes precedence** and will override any `--header-command` value specified here.", + "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/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 index 037ef0c5..3c89c4b2 100644 --- a/src/globalFlags.ts +++ b/src/globalFlags.ts @@ -4,26 +4,12 @@ import { escapeCommandArg } from "./util"; export function getGlobalFlags( configs: WorkspaceConfiguration, - configDir?: string, + configDir: string, ): string[] { - const globalFlags = configs.get("coder.globalFlags") || []; - const headerArgs = getHeaderArgs(configs); - const globalConfigArgs = configDir - ? ["--global-config", escapeCommandArg(configDir)] - : []; - - // Precedence of "coder.headerCommand" is higher than "coder.globalConfig" with the "--header-command" flag - let filteredGlobalFlags = globalFlags; - if (headerArgs.length > 0) { - filteredGlobalFlags = globalFlags.filter( - (flag) => !flag.startsWith("--header-command"), - ); - } - - if (globalConfigArgs.length > 0) { - filteredGlobalFlags = globalFlags.filter( - (flag) => !flag.startsWith("--global-config"), - ); - } - return [...filteredGlobalFlags, ...headerArgs, ...globalConfigArgs]; + // 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 27d6295a..271e8225 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -762,7 +762,7 @@ export class Remote { ? `${AuthorityPrefix}.${label}--` : `${AuthorityPrefix}--`; - const globalConfigs = this.globalConfigs(featureSet, label); + const globalConfigs = this.globalConfigs(label); const proxyCommand = featureSet.wildcardSSH ? `${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` @@ -824,17 +824,12 @@ export class Remote { return sshConfig.getRaw(); } - private globalConfigs(featureSet: FeatureSet, label: string): string { + private globalConfigs(label: string): string { const vscodeConfig = vscode.workspace.getConfiguration(); - let args: string[]; - if (featureSet.wildcardSSH) { - args = getGlobalFlags( - vscodeConfig, - path.dirname(this.storage.getSessionTokenPath(label)), - ); - } else { - args = getGlobalFlags(vscodeConfig); - } + const args: string[] = getGlobalFlags( + vscodeConfig, + path.dirname(this.storage.getSessionTokenPath(label)), + ); return args.length === 0 ? "" : ` ${args.join(" ")}`; } From 0547fdaa9f61b0f2a23ecdcbf5bb9d6d55485bd1 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 8 Sep 2025 12:35:41 +0300 Subject: [PATCH 3/4] Address last smol comments --- src/commands.ts | 2 +- src/globalFlags.ts | 4 ++++ src/remote.ts | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 9561672b..2e4ba705 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -513,7 +513,7 @@ export class Commands { configDir, ); terminal.sendText( - `${escapeCommandArg(binary)}${globalFlags.length === 0 ? "" : ` ${globalFlags.join(" ")}`} ssh ${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.ts b/src/globalFlags.ts index 3c89c4b2..851e41c7 100644 --- a/src/globalFlags.ts +++ b/src/globalFlags.ts @@ -2,6 +2,10 @@ 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, diff --git a/src/remote.ts b/src/remote.ts index 271e8225..b5165b4a 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -826,11 +826,11 @@ export class Remote { private globalConfigs(label: string): string { const vscodeConfig = vscode.workspace.getConfiguration(); - const args: string[] = getGlobalFlags( + const args = getGlobalFlags( vscodeConfig, path.dirname(this.storage.getSessionTokenPath(label)), ); - return args.length === 0 ? "" : ` ${args.join(" ")}`; + return ` ${args.join(" ")}`; } // showNetworkUpdates finds the SSH process ID that is being used by this From ae8223d7618af963d1ff69d7835c4fa39a5d2d1b Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 8 Sep 2025 15:24:11 +0300 Subject: [PATCH 4/4] Fix cold start of workspace from VS Code --- src/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.ts b/src/api.ts index 2211d2a9..9c0022f0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -195,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