Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
86 changes: 75 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,76 @@ 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: string[] = [];
for (let i = 0; i < raw.length; i++) {
if (isFlag(raw[i], "--use-keyring")) {
continue;
}
if (isFlag(raw[i], "--global-config")) {
// Skip the next item too when the value is a separate entry.
if (raw[i] === "--global-config") {
i++;
}
continue;
}
filtered.push(raw[i]);
}
Comment thread
EhabY marked this conversation as resolved.

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

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);
}

/**
* Single source of truth: should the extension use the OS keyring for this session?
* Requires CLI >= 2.29.0, macOS or Windows, and the coder.useKeyring setting enabled.
*/
export function shouldUseKeyring(
configs: Pick<WorkspaceConfiguration, "get">,
featureSet: FeatureSet,
): boolean {
return isKeyringEnabled(configs) && featureSet.keyringAuth;
}

/**
* 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 (shouldUseKeyring(configs, featureSet)) {
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
131 changes: 131 additions & 0 deletions src/core/cliCredentialManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";

import { isKeyringEnabled } from "../cliConfig";
import { getHeaderArgs } from "../headers";

import type { WorkspaceConfiguration } from "vscode";

import type { Logger } from "../logging/logger";

const execFileAsync = promisify(execFile);

/**
* Resolves a CLI binary path for a given deployment URL, fetching/downloading
* if needed. Returns the path or throws if unavailable.
*/
export type BinaryResolver = (url: string) => Promise<string>;
Comment thread
EhabY marked this conversation as resolved.
Outdated

/**
* Returns true on platforms where the OS keyring is supported (macOS, Windows).
*/
export function isKeyringSupported(): boolean {
return process.platform === "darwin" || process.platform === "win32";
}

/**
* Delegates credential storage to the Coder CLI. All operations resolve the
* binary via the injected BinaryResolver before invoking it.
*/
export class CliCredentialManager {
constructor(
private readonly logger: Logger,
private readonly resolveBinary: BinaryResolver,
) {}

/**
* Store a token via `coder login --use-token-as-session`.
* Token is passed via CODER_SESSION_TOKEN env var, never in args.
*/
public async storeToken(
Comment thread
EhabY marked this conversation as resolved.
url: string,
token: string,
configs: Pick<WorkspaceConfiguration, "get">,
): Promise<void> {
let binPath: string;
try {
binPath = await this.resolveBinary(url);
} catch (error) {
this.logger.debug("Could not resolve CLI binary for token store:", error);
throw error;
}

const args = [
...getHeaderArgs(configs),
"login",
"--use-token-as-session",
url,
];
try {
await execFileAsync(binPath, args, {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we define timeout for execFileAsync operations, just in case it hangs?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a timeout of 60s with logging every 5 seconds (also added a progress monitor)

env: { ...process.env, CODER_SESSION_TOKEN: token },
});
this.logger.info("Stored token via CLI for", url);
} catch (error) {
this.logger.debug("Failed to store token via CLI:", error);
throw error;
}
}

/**
* Read a token via `coder login token --url`. Returns trimmed stdout,
* or undefined on any failure (resolver, CLI, empty output).
*/
public async readToken(
url: string,
configs: Pick<WorkspaceConfiguration, "get">,
): Promise<string | undefined> {
if (!isKeyringEnabled(configs)) {
return undefined;
}

let binPath: string;
try {
binPath = await this.resolveBinary(url);
Comment thread
EhabY marked this conversation as resolved.
} catch (error) {
this.logger.debug("Could not resolve CLI binary for token read:", error);
return undefined;
}

const args = [...getHeaderArgs(configs), "login", "token", "--url", url];
try {
const { stdout } = await execFileAsync(binPath, args);
const token = stdout.trim();
return token || undefined;
} catch (error) {
this.logger.debug("Failed to read token via CLI:", error);
return undefined;
}
}

/**
* Delete a token via `coder logout --url`. Best-effort: never throws.
*/
public async deleteToken(
url: string,
configs: Pick<WorkspaceConfiguration, "get">,
): Promise<void> {
if (!isKeyringEnabled(configs)) {
return;
}

let binPath: string;
try {
binPath = await this.resolveBinary(url);
Comment thread
EhabY marked this conversation as resolved.
Outdated
} catch (error) {
this.logger.debug(
Comment thread
EhabY marked this conversation as resolved.
Outdated
"Could not resolve CLI binary for token delete:",
error,
);
return;
}

const args = [...getHeaderArgs(configs), "logout", "--url", url, "--yes"];
try {
await execFileAsync(binPath, args);
this.logger.info("Deleted token via CLI for", url);
} catch (error) {
this.logger.debug("Failed to delete token via CLI:", error);
}
}
}
Loading