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
17 changes: 12 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@

## Unreleased

### Fixed

- Fixed SSH config writes failing on Windows when antivirus, cloud sync software,
or another process briefly locks the file.

### Added

- Automatically set `reconnectionGraceTime`, `serverShutdownTimeout`, and `maxReconnectionAttempts`
Expand All @@ -18,6 +13,18 @@
- SSH options from `coder config-ssh --ssh-option` are now applied to VS Code connections,
with priority order: VS Code setting > `coder config-ssh` options > deployment config.

### Fixed

- Fixed SSH config writes failing on Windows when antivirus, cloud sync software,
or another process briefly locks the file.

### Changed

- `coder.useKeyring` is now opt-in (default: false). Keyring storage requires CLI >= 2.29.0 for
storage and logout sync, and >= 2.31.0 for syncing login from CLI to VS Code.
- Session tokens are now saved to the OS keyring at login time (when enabled and CLI >= 2.29.0),
not only when connecting to a workspace.

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

### Added
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,9 @@
}
},
"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.",
"markdownDescription": "Store session tokens in the OS keyring (macOS Keychain, Windows Credential Manager) instead of plaintext files. Requires CLI >= 2.29.0 (>= 2.31.0 to sync login from CLI to VS Code). This will attempt to sync between the CLI and VS Code since they share the same keyring entry. It will log you out of the CLI if you log out of the IDE, and vice versa. Has no effect on Linux.",
"type": "boolean",
"default": true
"default": false
},
"coder.httpClientLogLevel": {
"markdownDescription": "Controls the verbosity of HTTP client logging. This affects what details are logged for each HTTP request and response.",
Expand Down
4 changes: 3 additions & 1 deletion src/cliConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ function isFlag(item: string, name: string): boolean {
export function isKeyringEnabled(
configs: Pick<WorkspaceConfiguration, "get">,
): boolean {
return isKeyringSupported() && configs.get<boolean>("coder.useKeyring", true);
return (
isKeyringSupported() && configs.get<boolean>("coder.useKeyring", false)
);
}

/**
Expand Down
34 changes: 27 additions & 7 deletions src/core/cliCredentialManager.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import * as semver from "semver";

import { isKeyringEnabled } from "../cliConfig";
import { isAbortError } from "../error/errorUtils";
import { featureSetForVersion } from "../featureSet";
import { getHeaderArgs } from "../headers";
import { renameWithRetry, tempFilePath, toSafeHost } from "../util";
Expand Down Expand Up @@ -34,7 +36,8 @@ export type BinaryResolver = (deploymentUrl: string) => Promise<string>;
* Returns true on platforms where the OS keyring is supported (macOS, Windows).
*/
export function isKeyringSupported(): boolean {
return process.platform === "darwin" || process.platform === "win32";
const platform = os.platform();
return platform === "darwin" || platform === "win32";
}

/**
Expand All @@ -54,19 +57,25 @@ export class CliCredentialManager {
* files under --global-config.
*
* Keyring and files are mutually exclusive — never both.
*
* When `keyringOnly` is set, silently returns if the keyring is unavailable
* instead of falling back to file storage.
*/
public async storeToken(
url: string,
token: string,
configs: Pick<WorkspaceConfiguration, "get">,
signal?: AbortSignal,
options?: { signal?: AbortSignal; keyringOnly?: boolean },
): Promise<void> {
const binPath = await this.resolveKeyringBinary(
url,
configs,
"keyringAuth",
);
if (!binPath) {
if (options?.keyringOnly) {
return;
}
await this.writeCredentialFiles(url, token);
return;
}
Expand All @@ -80,7 +89,7 @@ export class CliCredentialManager {
try {
await this.execWithTimeout(binPath, args, {
env: { ...process.env, CODER_SESSION_TOKEN: token },
signal,
signal: options?.signal,
});
this.logger.info("Stored token via CLI for", url);
} catch (error) {
Expand All @@ -92,10 +101,12 @@ export class CliCredentialManager {
/**
* Read a token via `coder login token --url`. Returns trimmed stdout,
* or undefined on any failure (resolver, CLI, empty output).
* Throws AbortError when the signal is aborted.
*/
public async readToken(
url: string,
configs: Pick<WorkspaceConfiguration, "get">,
options?: { signal?: AbortSignal },
): Promise<string | undefined> {
let binPath: string | undefined;
try {
Expand All @@ -114,27 +125,33 @@ export class CliCredentialManager {

const args = [...getHeaderArgs(configs), "login", "token", "--url", url];
try {
const { stdout } = await this.execWithTimeout(binPath, args);
const { stdout } = await this.execWithTimeout(binPath, args, {
signal: options?.signal,
});
const token = stdout.trim();
return token || undefined;
} catch (error) {
if (isAbortError(error)) {
throw error;
}
this.logger.warn("Failed to read token via CLI:", error);
return undefined;
}
}

/**
* Delete credentials for a deployment. Runs file deletion and keyring
* deletion in parallel, both best-effort (never throws).
* deletion in parallel, both best-effort. Throws AbortError when the
* signal is aborted.
*/
public async deleteToken(
url: string,
configs: Pick<WorkspaceConfiguration, "get">,
signal?: AbortSignal,
options?: { signal?: AbortSignal },
): Promise<void> {
await Promise.all([
this.deleteCredentialFiles(url),
this.deleteKeyringToken(url, configs, signal),
this.deleteKeyringToken(url, configs, options?.signal),
]);
}

Expand Down Expand Up @@ -241,6 +258,9 @@ export class CliCredentialManager {
await this.execWithTimeout(binPath, args, { signal });
this.logger.info("Deleted token via CLI for", url);
} catch (error) {
if (isAbortError(error)) {
throw error;
}
this.logger.warn("Failed to delete token via CLI:", error);
}
}
Expand Down
14 changes: 8 additions & 6 deletions src/core/cliManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import * as semver from "semver";
import * as vscode from "vscode";

import { errToStr } from "../api/api-helper";
import { isKeyringEnabled } from "../cliConfig";
import * as pgp from "../pgp";
import { withCancellableProgress } from "../progress";
import { withCancellableProgress, withOptionalProgress } from "../progress";
import { tempFilePath, toSafeHost } from "../util";
import { vscodeProposed } from "../vscodeProposed";

Expand Down Expand Up @@ -759,13 +760,13 @@ export class CliManager {
}

const result = await withCancellableProgress(
({ signal }) =>
this.cliCredentialManager.storeToken(url, token, configs, { signal }),
{
location: vscode.ProgressLocation.Notification,
title: `Storing credentials for ${url}`,
cancellable: true,
},
({ signal }) =>
this.cliCredentialManager.storeToken(url, token, configs, signal),
);
if (result.ok) {
return;
Expand All @@ -783,14 +784,15 @@ export class CliManager {
*/
public async clearCredentials(url: string): Promise<void> {
const configs = vscode.workspace.getConfiguration();
const result = await withCancellableProgress(
const result = await withOptionalProgress(
({ signal }) =>
this.cliCredentialManager.deleteToken(url, configs, { signal }),
{
enabled: isKeyringEnabled(configs),
location: vscode.ProgressLocation.Notification,
title: `Removing credentials for ${url}`,
cancellable: true,
},
({ signal }) =>
this.cliCredentialManager.deleteToken(url, configs, signal),
);
if (result.ok) {
return;
Expand Down
7 changes: 7 additions & 0 deletions src/error/errorUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors";
import util from "node:util";

/**
* Check whether an unknown thrown value is an AbortError (signal cancellation).
*/
export function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}

// getErrorDetail is copied from coder/site, but changes the default return.
export const getErrorDetail = (error: unknown): string | undefined | null => {
if (isApiError(error)) {
Expand Down
32 changes: 29 additions & 3 deletions src/login/loginCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import * as vscode from "vscode";

import { CoderApi } from "../api/coderApi";
import { needToken } from "../api/utils";
import { isKeyringEnabled } from "../cliConfig";
import { CertificateError } from "../error/certificateError";
import { OAuthAuthorizer } from "../oauth/authorizer";
import { buildOAuthTokenData } from "../oauth/utils";
import { withOptionalProgress } from "../progress";
import { maybeAskAuthMethod, maybeAskUrl } from "../promptUtils";
import { vscodeProposed } from "../vscodeProposed";

Expand Down Expand Up @@ -147,6 +149,20 @@ export class LoginCoordinator implements vscode.Disposable {
oauth: result.oauth, // undefined for non-OAuth logins
});
await this.mementoManager.addToUrlHistory(url);

// Fire-and-forget: sync token to OS keyring for the CLI.
if (result.token) {
this.cliCredentialManager
.storeToken(url, result.token, vscode.workspace.getConfiguration(), {
keyringOnly: true,
})
.catch((error) => {
this.logger.warn(
"Failed to store token in keyring at login:",
error,
);
});
}
}
}

Expand Down Expand Up @@ -243,10 +259,20 @@ export class LoginCoordinator implements vscode.Disposable {
}

// Try keyring token (picks up tokens written by `coder login` in the terminal)
const keyringToken = await this.cliCredentialManager.readToken(
deployment.url,
vscode.workspace.getConfiguration(),
const configs = vscode.workspace.getConfiguration();
const keyringResult = await withOptionalProgress(
({ signal }) =>
this.cliCredentialManager.readToken(deployment.url, configs, {
signal,
}),
{
enabled: isKeyringEnabled(configs),
location: vscode.ProgressLocation.Notification,
title: "Reading token from OS keyring...",
cancellable: true,
},
);
const keyringToken = keyringResult.ok ? keyringResult.value : undefined;
if (
keyringToken &&
keyringToken !== providedToken &&
Expand Down
35 changes: 33 additions & 2 deletions src/progress.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as vscode from "vscode";

import { isAbortError } from "./error/errorUtils";

export type ProgressResult<T> =
| { ok: true; value: T }
| { ok: false; cancelled: true }
Expand All @@ -17,8 +19,8 @@ export interface ProgressContext {
* `{ cancelled: true }`.
*/
export function withCancellableProgress<T>(
options: vscode.ProgressOptions & { cancellable: true },
fn: (ctx: ProgressContext) => Promise<T>,
options: vscode.ProgressOptions & { cancellable: true },
): Thenable<ProgressResult<T>> {
return vscode.window.withProgress(
options,
Expand All @@ -29,7 +31,7 @@ export function withCancellableProgress<T>(
const value = await fn({ progress, signal: ac.signal });
return { ok: true, value };
} catch (error) {
if ((error as Error).name === "AbortError") {
if (isAbortError(error)) {
return { ok: false, cancelled: true };
}
return { ok: false, cancelled: false, error };
Expand All @@ -40,6 +42,35 @@ export function withCancellableProgress<T>(
);
}

/**
* Like withCancellableProgress, but only shows the progress notification when
* `enabled` is true. When false, runs the function directly without UI.
* Returns ProgressResult<T> in both cases for uniform call-site handling.
*/
export async function withOptionalProgress<T>(
fn: (ctx: ProgressContext) => Promise<T>,
options: vscode.ProgressOptions & { cancellable: true; enabled: boolean },
): Promise<ProgressResult<T>> {
if (options.enabled) {
return withCancellableProgress(fn, options);
}
try {
const noop = () => {
// No-op: progress reporting is disabled.
};
const value = await fn({
progress: { report: noop },
signal: new AbortController().signal,
});
return { ok: true, value };
} catch (error) {
if (isAbortError(error)) {
return { ok: false, cancelled: true };
}
return { ok: false, cancelled: false, error };
}
}

/**
* Run a task inside a VS Code progress notification (no cancellation).
* A thin wrapper over `vscode.window.withProgress` that passes only the
Expand Down
Loading