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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
- Fixed CLI binary downloads failing when servers or proxies compress responses unexpectedly.
- Clarified CLI download progress notification wording.

### Added

- Session tokens are now stored in the OS keyring (Keychain on macOS, Credential Manager on
Windows) instead of plaintext files, when using CLI >= 2.29.0. Falls back to file storage on
Linux, older CLIs, or if the keyring write fails. Controlled via the `coder.useKeyring` setting.

## [v1.13.0](https://github.com/coder/vscode-coder/releases/tag/v1.13.0) 2026-03-03

### Added
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export default defineConfig(

// Build config - ESM with Node globals
{
files: ["esbuild.mjs"],
files: ["esbuild.mjs", "scripts/*.mjs"],
languageOptions: {
globals: {
...globals.node,
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,17 @@
]
},
"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.",
"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` and `--use-keyring` flags are silently ignored as the extension manages them via `#coder.useKeyring#`.",
"type": "array",
"items": {
"type": "string"
}
},
"coder.useKeyring": {
"markdownDescription": "Store session tokens in the OS keyring (macOS Keychain, Windows Credential Manager) instead of plaintext files. Requires CLI >= 2.29.0. Has no effect on Linux.",
"type": "boolean",
"default": true
},
"coder.httpClientLogLevel": {
"markdownDescription": "Controls the verbosity of HTTP client logging. This affects what details are logged for each HTTP request and response.",
"type": "string",
Expand Down
6 changes: 3 additions & 3 deletions src/api/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import { spawn } from "node:child_process";
import * as vscode from "vscode";

import { getGlobalFlags } from "../cliConfig";
import { type CliAuth, getGlobalFlags } from "../cliConfig";
import { type FeatureSet } from "../featureSet";
import { escapeCommandArg } from "../util";
import { type UnidirectionalStream } from "../websocket/eventStreamConnection";
Expand Down Expand Up @@ -50,7 +50,7 @@ export class LazyStream<T> {
*/
export async function startWorkspaceIfStoppedOrFailed(
restClient: Api,
globalConfigDir: string,
auth: CliAuth,
binPath: string,
workspace: Workspace,
writeEmitter: vscode.EventEmitter<string>,
Expand All @@ -65,7 +65,7 @@ export async function startWorkspaceIfStoppedOrFailed(

return new Promise((resolve, reject) => {
const startArgs = [
...getGlobalFlags(vscode.workspace.getConfiguration(), globalConfigDir),
...getGlobalFlags(vscode.workspace.getConfiguration(), auth),
"start",
"--yes",
createWorkspaceIdentifier(workspace),
Expand Down
80 changes: 69 additions & 11 deletions src/cliConfig.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { type WorkspaceConfiguration } from "vscode";

import { isKeyringSupported } from "./core/cliCredentialManager";
import { getHeaderArgs } from "./headers";
import { escapeCommandArg } from "./util";

import type { WorkspaceConfiguration } from "vscode";

import type { FeatureSet } from "./featureSet";

export type CliAuth =
| { mode: "global-config"; configDir: string }
| { mode: "url"; url: string };

/**
* Returns the raw global flags from user configuration.
*/
Expand All @@ -14,19 +21,70 @@ export function getGlobalFlagsRaw(

/**
* Returns global configuration flags for Coder CLI commands.
* Always includes the `--global-config` argument with the specified config directory.
* Includes either `--global-config` or `--url` depending on the auth mode.
*/
export function getGlobalFlags(
configs: Pick<WorkspaceConfiguration, "get">,
configDir: string,
auth: CliAuth,
): string[] {
// Last takes precedence/overrides previous ones
return [
...getGlobalFlagsRaw(configs),
"--global-config",
escapeCommandArg(configDir),
...getHeaderArgs(configs),
];
const authFlags =
auth.mode === "url"
? ["--url", escapeCommandArg(auth.url)]
: ["--global-config", escapeCommandArg(auth.configDir)];

const raw = getGlobalFlagsRaw(configs);
const filtered = stripManagedFlags(raw);

return [...filtered, ...authFlags, ...getHeaderArgs(configs)];
}

function stripManagedFlags(rawFlags: string[]): string[] {
const filtered: string[] = [];
for (let i = 0; i < rawFlags.length; i++) {
if (isFlag(rawFlags[i], "--use-keyring")) {
continue;
}
if (isFlag(rawFlags[i], "--global-config")) {
// Skip the next item too when the value is a separate entry.
if (rawFlags[i] === "--global-config") {
i++;
}
continue;
}
filtered.push(rawFlags[i]);
}
return filtered;
}

function isFlag(item: string, name: string): boolean {
return (
item === name || item.startsWith(`${name}=`) || item.startsWith(`${name} `)
);
}

/**
* Returns true when the user has keyring enabled and the platform supports it.
*/
export function isKeyringEnabled(
configs: Pick<WorkspaceConfiguration, "get">,
): boolean {
return isKeyringSupported() && configs.get<boolean>("coder.useKeyring", true);
}

/**
* Resolves how the CLI should authenticate: via the keyring (`--url`) or via
* the global config directory (`--global-config`).
*/
export function resolveCliAuth(
configs: Pick<WorkspaceConfiguration, "get">,
featureSet: FeatureSet,
deploymentUrl: string,
configDir: string,
): CliAuth {
if (isKeyringEnabled(configs) && featureSet.keyringAuth) {
return { mode: "url", url: deploymentUrl };
}
return { mode: "global-config", configDir };
}

/**
Expand Down
37 changes: 24 additions & 13 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@ import {
} from "coder/site/src/api/typesGenerated";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as semver from "semver";
import * as vscode from "vscode";

import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper";
import { type CoderApi } from "./api/coderApi";
import { getGlobalFlags } from "./cliConfig";
import { getGlobalFlags, resolveCliAuth } from "./cliConfig";
import { type CliManager } from "./core/cliManager";
import * as cliUtils from "./core/cliUtils";
import { type ServiceContainer } from "./core/container";
import { type MementoManager } from "./core/mementoManager";
import { type PathResolver } from "./core/pathResolver";
import { type SecretsManager } from "./core/secretsManager";
import { type DeploymentManager } from "./deployment/deploymentManager";
import { CertificateError } from "./error/certificateError";
import { toError } from "./error/errorUtils";
import { featureSetForVersion } from "./featureSet";
import { type Logger } from "./logging/logger";
import { type LoginCoordinator } from "./login/loginCoordinator";
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
Expand Down Expand Up @@ -210,13 +213,13 @@ export class Commands {

this.logger.debug("Logging out");

const safeHostname =
this.deploymentManager.getCurrentDeployment()?.safeHostname;
const deployment = this.deploymentManager.getCurrentDeployment();

await this.deploymentManager.clearDeployment();

if (safeHostname) {
await this.secretsManager.clearAllAuthData(safeHostname);
if (deployment) {
await this.cliManager.clearCredentials(deployment.url);
await this.secretsManager.clearAllAuthData(deployment.safeHostname);
}

vscode.window
Expand Down Expand Up @@ -283,6 +286,10 @@ export class Commands {

if (selected.hostnames.length === 1) {
const selectedHostname = selected.hostnames[0];
const auth = await this.secretsManager.getSessionAuth(selectedHostname);
if (auth?.url) {
await this.cliManager.clearCredentials(auth.url);
}
await this.secretsManager.clearAllAuthData(selectedHostname);
this.logger.info("Removed credentials for", selectedHostname);
vscode.window.showInformationMessage(
Expand All @@ -300,9 +307,13 @@ export class Commands {
);
if (confirm === "Remove All") {
await Promise.all(
selected.hostnames.map((h) =>
this.secretsManager.clearAllAuthData(h),
),
selected.hostnames.map(async (h) => {
const auth = await this.secretsManager.getSessionAuth(h);
if (auth?.url) {
await this.cliManager.clearCredentials(auth.url);
}
await this.secretsManager.clearAllAuthData(h);
}),
);
this.logger.info(
"Removed credentials for all deployments:",
Expand Down Expand Up @@ -449,14 +460,14 @@ export class Commands {
const safeHost = toSafeHost(baseUrl);
const binary = await this.cliManager.fetchBinary(
this.extensionClient,
safeHost,
);

const version = semver.parse(await cliUtils.version(binary));
const featureSet = featureSetForVersion(version);
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
const globalFlags = getGlobalFlags(
vscode.workspace.getConfiguration(),
configDir,
);
const configs = vscode.workspace.getConfiguration();
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
const globalFlags = getGlobalFlags(configs, auth);
terminal.sendText(
`${escapeCommandArg(binary)} ${globalFlags.join(" ")} ssh ${app.workspace_name}`,
);
Expand Down
Loading