Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

### Added

- New `coder.globalConfig` setting to override the Coder CLI `--global-config`
directory, with `CODER_CONFIG_DIR` fallback, so file-backed CLI login/auth can
be shared with the VS Code extension when keyring auth is not active.
- New **Shared Workspaces** view in the Coder sidebar that lists workspaces
other users have shared with you, with search and refresh actions, so you
can find and open them just like your own.
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,14 @@
],
"scope": "machine"
},
"coder.globalConfig": {
"markdownDescription": "Path to the global Coder CLI config directory passed with `--global-config`. Defaults to `CODER_CONFIG_DIR` if not set, otherwise the extension's per-deployment global storage directory. Set this to a shared CLI config directory such as `~/.config/coderv2` to share login/auth with the Coder CLI. Ignored when `#coder.useKeyring#` is active and supported.\n\nSupports `${env:VAR}`, `${userHome}`, and a leading `~`.",
"type": "string",
"default": "",
"scope": "machine"
},
"coder.globalFlags": {
"markdownDescription": "Global flags to pass to every Coder CLI invocation. Enter each flag as a separate array item, in order. Do **not** include the `coder` command itself. See the [CLI reference](https://coder.com/docs/reference/cli) for available global flags.\n\nSupports `${env:VAR}`, `${userHome}`, and a leading `~`. For `--flag=value` items the expansion applies to the value half, so `--cfg=~/coder` works.\n\nFor `--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#`.",
"markdownDescription": "Global flags to pass to every Coder CLI invocation. Enter each flag as a separate array item, in order. Do **not** include the `coder` command itself. See the [CLI reference](https://coder.com/docs/reference/cli) for available global flags.\n\nSupports `${env:VAR}`, `${userHome}`, and a leading `~`. For `--flag=value` items the expansion applies to the value half, so `--cfg=~/coder` works.\n\nFor `--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; use `#coder.globalConfig#` and `#coder.useKeyring#` instead.",
"type": "array",
"items": {
"type": "string"
Expand Down
2 changes: 1 addition & 1 deletion src/api/authInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type AxiosError, isAxiosError } from "axios";

import { AuthTelemetry } from "../instrumentation/auth";
import { OAuthError } from "../oauth/errors";
import { toSafeHost } from "../util";
import { toSafeHost } from "../util/uri";

import type * as vscode from "vscode";

Expand Down
3 changes: 2 additions & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ import {
import { resolveCliAuth } from "./settings/cli";
import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs";
import { runExportTelemetryCommand } from "./telemetry/export/command";
import { openInBrowser, toRemoteAuthority, toSafeHost } from "./util";
import { toRemoteAuthority } from "./util/authority";
import { openInBrowser, toSafeHost } from "./util/uri";
import { vscodeProposed } from "./vscodeProposed";
import { parseSpeedtestResult } from "./webviews/speedtest/types";
import {
Expand Down
122 changes: 101 additions & 21 deletions src/core/cliCredentialManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { promisify } from "node:util";
import * as semver from "semver";

import { isAbortError } from "../error/errorUtils";
import { featureSetForVersion } from "../featureSet";
import { featureSetForVersion, type FeatureSet } from "../featureSet";
import {
CredentialCliError,
CredentialFileError,
Expand All @@ -15,8 +15,8 @@ import {
import { isKeyringEnabled } from "../settings/cli";
import { getHeaderArgs } from "../settings/headers";
import { type TelemetryReporter } from "../telemetry/reporter";
import { toSafeHost } from "../util";
import { writeAtomically } from "../util/fs";
import { normalizeUrl, toSafeHost } from "../util/uri";

import { version } from "./cliExec";

Expand All @@ -30,6 +30,15 @@ import type { PathResolver } from "./pathResolver";
const execFileAsync = promisify(execFile);

type KeyringFeature = "keyringAuth" | "keyringTokenRead";
type TokenReadSource =
| { mode: "files" }
| { mode: "keyring"; binPath: string }
| { mode: "none" };

export interface CliCredential {
token: string;
source: "keyring" | "files";
}

const EXEC_TIMEOUT_MS = 60_000;
const EXEC_LOG_INTERVAL_MS = 5_000;
Expand Down Expand Up @@ -122,37 +131,46 @@ 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.
* Read a token from CLI-managed credentials. Uses `coder login token --url`
* when keyring auth is active, otherwise reads the file credentials under
* --global-config. Returns the token and the source it came from, 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 {
binPath = await this.resolveKeyringBinary(
url,
configs,
"keyringTokenRead",
);
} catch (error) {
this.logger.warn("Could not resolve CLI binary for token read:", error);
return undefined;
): Promise<CliCredential | undefined> {
const source = await this.resolveTokenReadSource(url, configs);
if (source.mode === "files") {
const token = await this.readCredentialFiles(url);
return token ? { token, source: "files" } : undefined;
}
if (!binPath) {
if (source.mode === "none") {
return undefined;
}
const token = await this.readKeyringToken(
source.binPath,
url,
configs,
options,
);
return token ? { token, source: "keyring" } : undefined;
}

private async readKeyringToken(
binPath: string,
url: string,
configs: Pick<WorkspaceConfiguration, "get">,
options?: { signal?: AbortSignal },
): Promise<string | undefined> {
const args = [...getHeaderArgs(configs), "login", "token", "--url", url];
try {
const { stdout } = await this.execWithTimeout(binPath, args, {
signal: options?.signal,
});
const token = stdout.trim();
return token || undefined;
return nonEmpty(stdout);
} catch (error) {
if (isAbortError(error)) {
throw error;
Expand Down Expand Up @@ -200,8 +218,33 @@ export class CliCredentialManager {
return undefined;
}
const binPath = await this.resolveBinary(url);
const cliVersion = semver.parse(await version(binPath));
return featureSetForVersion(cliVersion)[feature] ? binPath : undefined;
return (await this.getFeatureSet(binPath))[feature] ? binPath : undefined;
}

private async resolveTokenReadSource(
url: string,
configs: Pick<WorkspaceConfiguration, "get">,
): Promise<TokenReadSource> {
if (!isKeyringEnabled(configs)) {
return { mode: "files" };
}
try {
const binPath = await this.resolveBinary(url);
const featureSet = await this.getFeatureSet(binPath);
if (!featureSet.keyringAuth) {
return { mode: "files" };
}
return featureSet.keyringTokenRead
? { mode: "keyring", binPath }
: { mode: "none" };
} catch (error) {
this.logger.warn("Could not resolve CLI binary for token read:", error);
return { mode: "none" };
}
}

private async getFeatureSet(binPath: string): Promise<FeatureSet> {
return featureSetForVersion(semver.parse(await version(binPath)));
}

/**
Expand Down Expand Up @@ -248,6 +291,34 @@ export class CliCredentialManager {
}
}

/**
* Read URL and token files under --global-config.
*/
private async readCredentialFiles(url: string): Promise<string | undefined> {
try {
const files = await this.readCredentialFilePair(url);
return sameNormalizedUrl(files.url, url)
? nonEmpty(files.token)
: undefined;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
this.logger.warn("Failed to read credential files:", error);
}
return undefined;
}
}

private async readCredentialFilePair(
url: string,
): Promise<{ url: string; token: string }> {
const safeHostname = toSafeHost(url);
const [storedUrl, token] = await Promise.all([
fs.readFile(this.pathResolver.getUrlPath(safeHostname), "utf8"),
fs.readFile(this.pathResolver.getSessionTokenPath(safeHostname), "utf8"),
]);
return { url: storedUrl, token };
}

/**
* Delete URL and token files. Best-effort: never throws.
*/
Expand Down Expand Up @@ -317,3 +388,12 @@ export class CliCredentialManager {
);
}
}

function sameNormalizedUrl(storedUrl: string, expectedUrl: string): boolean {
return normalizeUrl(storedUrl) === normalizeUrl(expectedUrl);
}

function nonEmpty(value: string): string | undefined {
const trimmed = value.trim();
return trimmed || undefined;
}
2 changes: 1 addition & 1 deletion src/core/cliManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import {
import * as pgp from "../pgp";
import { withCancellableProgress, withOptionalProgress } from "../progress";
import { isKeyringEnabled } from "../settings/cli";
import { toSafeHost } from "../util";
import { tempFilePath } from "../util/fs";
import { toSafeHost } from "../util/uri";
import { vscodeProposed } from "../vscodeProposed";

import { BinaryLock } from "./binaryLock";
Expand Down
8 changes: 5 additions & 3 deletions src/core/pathResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ export class PathResolver {
) {}

/**
* Return the directory for the deployment with the provided hostname to
* where the global Coder configs are stored.
* Return the directory where the global Coder configs are stored.
*
* The caller must ensure this directory exists before use.
*/
public getGlobalConfigDir(safeHostname: string): string {
return path.join(this.basePath, safeHostname);
return (
PathResolver.resolveOverride("coder.globalConfig", "CODER_CONFIG_DIR") ||

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.

Instead of separate variables could we just have users add the flag to coder.globalFlags? Then we change the order so theirs takes precedence.

If we combine this with always running coder login token then we never need to actually know where the global dir is ourselves.

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.

In that case all deployments would share the same location by default which is why we introduced this per-deployment storage and the internal use of the global-config

@code-asher code-asher Jun 22, 2026

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.

I am not sure I follow, we should keep our current default. The simplest solution would be to just prepend our default so the user's takes precedence when the CLI parses the flags (at least, I am pretty sure when the flag is duplicated the last takes precedence).

We could also check for the flag and avoid adding our default if we wanted the flags to be a bit "cleaner" without repeats.

That does make me think though, I wonder if we should provide a variable so users can configure their own multi-deployment setup with different paths to avoid collisions. So they could do something like --global-config /path/to/my/global/configs/${env:CODER_URL} or something like that.

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.

In file mode the extension reads/writes the url+session files itself (and defaults the binary cache under this dir), so it needs the path directly, a flag in coder.globalFlags only reaches CLI invocations, not our own fs calls, which is exactly why --global-config is stripped from user flags today.

We could potentially extract the global-config from the globalFlags but this means we need to parse arguments and deal with that headache. Also, we do actually support ${env:XXX} format here anyway!

@code-asher code-asher Jun 23, 2026

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.

Good point, I forgot we do that and thought we stored them with coder login like the Toolbox plugin (we should switch though).

and defaults the binary cache under this dir

Oh wait this is a really good point, IMO the user setting the --global-config flag should not affect the binary path, it should keep going into the plugin's data directory (or the binary dir if they configured that of course). --global-config should be for the CLI only, so it makes no sense to store the plugin's data there too (currently they are the same by coincidence basically).

I just feel like we are going down a path of adding more and more global settings when we already have the one, but not gonna block over it.

Also, we do actually support ${env:XXX} format here anyway!

True true, I meant to make sure we are setting CODER_URL since not sure we are doing that right now.

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.

I can make it something like this (remove the setting, use the CLI whenever possible):

Global config / credential changes

Custom CLI config dir: was the coder.globalConfig setting; now --global-config in coder.globalFlags (2.31+ only, flag-only, never touches the extension's own files).

Behavioral diff (only what changed)

Case Old New
File write, 2.31+ extension writes the files coder login --global-config
File read, 2.31+ extension reads the files coder login token --global-config
File read, <2.31 extension reads the files returns nothing (no CLI read path)
Keyring read, <2.29 extension reads the files returns nothing
Custom config dir redirected our fs + flag + binary cache flag only, file mode, 2.31+
Binary cache dir followed the override always plugin dir (coder.binaryDestination still applies)

Unchanged: keyring write/read on supported versions, all writes/reads on <2.31 default dir, and delete (fs.rm + coder logout).

Residual: with an override, deleteToken still cleans only the default dir.

path.join(this.basePath, safeHostname)
);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/core/secretsManager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod";

import { DeploymentSchema, type Deployment } from "../deployment/types";
import { toSafeHost } from "../util";
import { toSafeHost } from "../util/uri";

import type { OAuth2ClientRegistrationResponse } from "coder/site/src/api/typesGenerated";
import type { Memento, SecretStorage, Disposable } from "vscode";
Expand Down
33 changes: 22 additions & 11 deletions src/login/loginCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { buildOAuthTokenData } from "../oauth/utils";
import { withOptionalProgress } from "../progress";
import { maybeAskAuthMethod, maybeAskUrl } from "../promptUtils";
import { isKeyringEnabled } from "../settings/cli";
import { openInBrowser } from "../util";
import { openInBrowser } from "../util/uri";
import { vscodeProposed } from "../vscodeProposed";

import type { User } from "coder/site/src/api/typesGenerated";
Expand Down Expand Up @@ -314,30 +314,41 @@ export class LoginCoordinator implements vscode.Disposable {
}
}

// Try keyring token (picks up tokens written by `coder login` in the terminal)
// Try CLI-managed credentials. This reads from the OS keyring when
// enabled, otherwise from the resolved global config directory.
const configs = vscode.workspace.getConfiguration();
const keyringResult = await withOptionalProgress(
const keyringEnabled = isKeyringEnabled(configs);
const cliCredentialResult = await withOptionalProgress(
({ signal }) =>
this.cliCredentialManager.readToken(deployment.url, configs, {
signal,
}),
{
enabled: isKeyringEnabled(configs),
enabled: keyringEnabled,
location: vscode.ProgressLocation.Notification,
title: "Reading token from OS keyring...",
cancellable: true,
},
);
const keyringToken = keyringResult.ok ? keyringResult.value : undefined;
const cliCredential = cliCredentialResult.ok
? cliCredentialResult.value
: undefined;
if (
keyringToken &&
keyringToken !== providedToken &&
keyringToken !== auth?.token
cliCredential &&
cliCredential.token !== providedToken &&
cliCredential.token !== auth?.token
) {
this.logger.debug("Trying token from OS keyring");
const result = await this.tryTokenAuth(client, keyringToken, isAutoLogin);
this.logger.debug("Trying token from CLI credentials");
const result = await this.tryTokenAuth(
client,
cliCredential.token,
isAutoLogin,
);
if (result !== "unauthorized") {
return withLoginMethod("keyring_token", result);
return withLoginMethod(
cliCredential.source === "keyring" ? "keyring_token" : "cli_token",
result,
);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/oauth/authorizer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as vscode from "vscode";

import { CoderApi } from "../api/coderApi";
import { resolveUiUrl } from "../util";
import { resolveUiUrl } from "../util/uri";

import {
AUTH_GRANT_TYPE,
Expand Down
11 changes: 8 additions & 3 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,12 @@ import {
resolveCliAuth,
} from "../settings/cli";
import { getHeaderCommand } from "../settings/headers";
import { escapeCommandArg, expandPath } from "../util";
import {
AuthorityPrefix,
type AuthorityParts,
escapeCommandArg,
expandPath,
parseRemoteAuthority,
} from "../util";
} from "../util/authority";
import { vscodeProposed } from "../vscodeProposed";
import { WorkspaceMonitor } from "../workspace/workspaceMonitor";

Expand Down Expand Up @@ -437,6 +436,12 @@ export class Remote {
title: string;
getValue: () => unknown;
}> = [
{
setting: "coder.globalConfig",
title: "Global Config",
getValue: () =>
this.pathResolver.getGlobalConfigDir(parts.safeHostname),
},
{
setting: "coder.globalFlags",
title: "Global Flags",
Expand Down
2 changes: 1 addition & 1 deletion src/remote/workspaceStateMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import type { StartupMode } from "../core/mementoManager";
import type { FeatureSet } from "../featureSet";
import type { Logger } from "../logging/logger";
import type { CliAuth } from "../settings/cli";
import type { AuthorityParts } from "../util";
import type { AuthorityParts } from "../util/authority";

/**
* Manages workspace and agent state transitions until ready for SSH connection.
Expand Down
1 change: 1 addition & 0 deletions src/supportBundle/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const COLLECTED_SETTINGS: readonly string[] = [
"coder.disableUpdateNotifications",
"coder.enableDownloads",
"coder.experimental.oauth",
"coder.globalConfig",
"coder.httpClientLogLevel",
"coder.insecure",
"coder.networkThreshold.latencyMs",
Expand Down
2 changes: 1 addition & 1 deletion src/uri/uriHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { errToStr } from "../api/api-helper";
import { AuthTelemetry } from "../instrumentation/auth";
import { CALLBACK_PATH } from "../oauth/utils";
import { maybeAskUrl } from "../promptUtils";
import { toSafeHost } from "../util";
import { toSafeHost } from "../util/uri";
import { vscodeProposed } from "../vscodeProposed";

import type { Commands } from "../commands";
Expand Down
Loading