Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
26 changes: 20 additions & 6 deletions src/core/cliCredentialManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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";
Expand Down Expand Up @@ -34,7 +35,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";
Comment thread
EhabY marked this conversation as resolved.
}

/**
Expand All @@ -54,19 +56,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 +88,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 @@ -96,6 +104,7 @@ export class CliCredentialManager {
public async readToken(
url: string,
configs: Pick<WorkspaceConfiguration, "get">,
options?: { signal?: AbortSignal },
): Promise<string | undefined> {
let binPath: string | undefined;
try {
Expand All @@ -114,10 +123,15 @@ 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 ((error as Error).name === "AbortError") {
throw error;
}
Comment thread
EhabY marked this conversation as resolved.
Outdated
this.logger.warn("Failed to read token via CLI:", error);
return undefined;
}
Expand All @@ -130,11 +144,11 @@ export class CliCredentialManager {
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
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
31 changes: 28 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,19 @@ export class LoginCoordinator implements vscode.Disposable {
oauth: result.oauth, // undefined for non-OAuth logins
});
await this.mementoManager.addToUrlHistory(url);

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 +258,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
28 changes: 27 additions & 1 deletion src/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,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 @@ -40,6 +40,32 @@ 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) {
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
21 changes: 13 additions & 8 deletions test/unit/cliConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as os from "node:os";
import * as semver from "semver";
import { afterEach, it, expect, describe, vi } from "vitest";
import { it, expect, describe, vi } from "vitest";

import {
type CliAuth,
Expand All @@ -14,16 +15,14 @@ import { featureSetForVersion } from "@/featureSet";
import { MockConfigurationProvider } from "../mocks/testHelpers";
import { isWindows } from "../utils/platform";

vi.mock("node:os");

const globalConfigAuth: CliAuth = {
mode: "global-config",
configDir: "/config/dir",
};

describe("cliConfig", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

describe("getGlobalFlags", () => {
const urlAuth: CliAuth = { mode: "url", url: "https://dev.coder.com" };

Expand Down Expand Up @@ -224,6 +223,12 @@ describe("cliConfig", () => {
useKeyring: boolean;
expected: boolean;
}
it("returns false on darwin when setting is unset (default)", () => {
vi.mocked(os.platform).mockReturnValue("darwin");
const config = new MockConfigurationProvider();
expect(isKeyringEnabled(config)).toBe(false);
});

it.each<KeyringEnabledCase>([
{ platform: "darwin", useKeyring: true, expected: true },
{ platform: "win32", useKeyring: true, expected: true },
Expand All @@ -232,7 +237,7 @@ describe("cliConfig", () => {
])(
"returns $expected on $platform with useKeyring=$useKeyring",
({ platform, useKeyring, expected }) => {
vi.stubGlobal("process", { ...process, platform });
vi.mocked(os.platform).mockReturnValue(platform);
const config = new MockConfigurationProvider();
config.set("coder.useKeyring", useKeyring);
expect(isKeyringEnabled(config)).toBe(expected);
Expand All @@ -242,7 +247,7 @@ describe("cliConfig", () => {

describe("resolveCliAuth", () => {
it("returns url mode when keyring should be used", () => {
vi.stubGlobal("process", { ...process, platform: "darwin" });
vi.mocked(os.platform).mockReturnValue("darwin");
const config = new MockConfigurationProvider();
config.set("coder.useKeyring", true);
const featureSet = featureSetForVersion(semver.parse("2.29.0"));
Expand All @@ -259,7 +264,7 @@ describe("cliConfig", () => {
});

it("returns global-config mode when keyring should not be used", () => {
vi.stubGlobal("process", { ...process, platform: "linux" });
vi.mocked(os.platform).mockReturnValue("linux");
const config = new MockConfigurationProvider();
const featureSet = featureSetForVersion(semver.parse("2.29.0"));
const auth = resolveCliAuth(
Expand Down
Loading