Skip to content

Commit 697351f

Browse files
committed
feat: store session tokens in the OS keyring
On macOS and Windows with CLI >= 2.29.0, write session tokens to the OS keyring (Keychain / Credential Manager) instead of plaintext files. The CLI reads from the keyring when invoked with --url instead of --global-config. Falls back to file storage on Linux, older CLIs, or if the keyring write fails. Key changes: - Add KeyringStore wrapping @napi-rs/keyring with the CLI's credential format (JSON map keyed by host, base64 on macOS, raw bytes on Windows) - Add CliAuth discriminated union ("global-config" | "url") threaded through proxy command building and workspace state machine - Add shouldUseKeyring() as single source of truth gating on CLI version, platform, and coder.useKeyring setting - Restructure remote.ts setup() to call configure() after featureSet is known, so the keyring decision can be made - Add keyring read fallback in LoginCoordinator for tokens written by `coder login` from the terminal - Add vendor-keyring.mjs build script to copy native binaries into dist/node_modules/ for VSIX packaging (vsce can't follow pnpm symlinks) - Harden file fallback with mode 0o600
1 parent faa9542 commit 697351f

23 files changed

+1128
-67
lines changed

esbuild.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const buildOptions = {
3232
// undefined when bundled to CJS, causing runtime errors.
3333
openpgp: "./node_modules/openpgp/dist/node/openpgp.min.cjs",
3434
},
35-
external: ["vscode"],
35+
external: ["vscode", "@napi-rs/keyring"],
3636
sourcemap: production ? "external" : true,
3737
minify: production,
3838
plugins: watch ? [logRebuildPlugin] : [],

eslint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export default defineConfig(
154154

155155
// Build config - ESM with Node globals
156156
{
157-
files: ["esbuild.mjs"],
157+
files: ["esbuild.mjs", "scripts/*.mjs"],
158158
languageOptions: {
159159
globals: {
160160
...globals.node,

package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"test:integration": "tsc -p test --outDir out --noCheck && node esbuild.mjs && vscode-test",
3333
"test:webview": "vitest --project webview",
3434
"typecheck": "concurrently -g \"tsc --noEmit\" \"tsc --noEmit -p test\"",
35-
"vscode:prepublish": "pnpm build:production",
35+
"vscode:prepublish": "pnpm build:production && node scripts/vendor-keyring.mjs",
3636
"watch": "concurrently -n extension,webviews \"pnpm watch:extension\" \"pnpm watch:webviews\"",
3737
"watch:extension": "node esbuild.mjs --watch",
3838
"watch:webviews": "pnpm -r --filter \"./packages/*\" --parallel dev"
@@ -156,6 +156,11 @@
156156
"type": "string"
157157
}
158158
},
159+
"coder.useKeyring": {
160+
"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.",
161+
"type": "boolean",
162+
"default": true
163+
},
159164
"coder.httpClientLogLevel": {
160165
"markdownDescription": "Controls the verbosity of HTTP client logging. This affects what details are logged for each HTTP request and response.",
161166
"type": "string",
@@ -463,6 +468,7 @@
463468
"word-wrap": "1.2.5"
464469
},
465470
"dependencies": {
471+
"@napi-rs/keyring": "^1.2.0",
466472
"@peculiar/x509": "^1.14.3",
467473
"@repo/shared": "workspace:*",
468474
"axios": "1.13.6",

pnpm-lock.yaml

Lines changed: 135 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,16 @@ onlyBuiltDependencies:
2727
- keytar
2828
- unrs-resolver
2929
- utf-8-validate
30+
31+
# Install @napi-rs/keyring native binaries for macOS and Windows so they're
32+
# available when building the universal VSIX (even on Linux CI).
33+
# Only macOS and Windows use the keyring; Linux falls back to file storage.
34+
supportedArchitectures:
35+
os:
36+
- current
37+
- darwin
38+
- win32
39+
cpu:
40+
- current
41+
- x64
42+
- arm64

scripts/vendor-keyring.mjs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Vendor @napi-rs/keyring into dist/node_modules/ for VSIX packaging.
3+
*
4+
* pnpm uses symlinks that vsce can't follow. This script resolves them and
5+
* copies the JS wrapper plus macOS/Windows .node binaries into dist/, where
6+
* Node's require() resolution finds them from dist/extension.js.
7+
*/
8+
import { cpSync, existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
9+
import { join, resolve, basename } from "node:path";
10+
11+
const outputDir = resolve("dist/node_modules/@napi-rs/keyring");
12+
const keyringPkg = resolve("node_modules/@napi-rs/keyring");
13+
14+
if (!existsSync(keyringPkg)) {
15+
console.log("@napi-rs/keyring not found, skipping");
16+
process.exit(0);
17+
}
18+
19+
const resolvedPkg = realpathSync(keyringPkg);
20+
21+
rmSync(outputDir, { recursive: true, force: true });
22+
mkdirSync(outputDir, { recursive: true });
23+
cpSync(resolvedPkg, outputDir, { recursive: true });
24+
25+
// Platform packages are siblings of the resolved keyring package in pnpm's layout.
26+
// Exact file names so the build fails loudly if the native module renames anything.
27+
const siblingsDir = resolve(resolvedPkg, "..");
28+
const binaries = [
29+
"keyring-darwin-arm64/keyring.darwin-arm64.node",
30+
"keyring-darwin-x64/keyring.darwin-x64.node",
31+
"keyring-win32-arm64-msvc/keyring.win32-arm64-msvc.node",
32+
"keyring-win32-x64-msvc/keyring.win32-x64-msvc.node",
33+
];
34+
35+
for (const binary of binaries) {
36+
const symlink = join(siblingsDir, binary);
37+
if (!existsSync(symlink)) {
38+
console.error(
39+
`Missing native binary: ${binary}\n` +
40+
"Ensure .npmrc includes supportedArchitectures for all target OS/CPU combinations.",
41+
);
42+
process.exit(1);
43+
}
44+
const src = realpathSync(symlink);
45+
const filename = basename(binary);
46+
const dest = join(outputDir, filename);
47+
cpSync(src, dest);
48+
}
49+
50+
console.log(
51+
`Vendored @napi-rs/keyring with ${binaries.length} platform binaries into dist/`,
52+
);

src/api/workspace.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import { spawn } from "node:child_process";
88
import * as vscode from "vscode";
99

10-
import { getGlobalFlags } from "../cliConfig";
10+
import { type CliAuth, getGlobalFlags } from "../cliConfig";
1111
import { type FeatureSet } from "../featureSet";
1212
import { escapeCommandArg } from "../util";
1313
import { type UnidirectionalStream } from "../websocket/eventStreamConnection";
@@ -50,7 +50,7 @@ export class LazyStream<T> {
5050
*/
5151
export async function startWorkspaceIfStoppedOrFailed(
5252
restClient: Api,
53-
globalConfigDir: string,
53+
auth: CliAuth,
5454
binPath: string,
5555
workspace: Workspace,
5656
writeEmitter: vscode.EventEmitter<string>,
@@ -65,7 +65,7 @@ export async function startWorkspaceIfStoppedOrFailed(
6565

6666
return new Promise((resolve, reject) => {
6767
const startArgs = [
68-
...getGlobalFlags(vscode.workspace.getConfiguration(), globalConfigDir),
68+
...getGlobalFlags(vscode.workspace.getConfiguration(), auth),
6969
"start",
7070
"--yes",
7171
createWorkspaceIdentifier(workspace),

src/cliConfig.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,76 @@
1-
import { type WorkspaceConfiguration } from "vscode";
1+
import * as vscode from "vscode";
22

3+
import { type FeatureSet } from "./featureSet";
34
import { getHeaderArgs } from "./headers";
5+
import { isKeyringSupported } from "./keyringStore";
46
import { escapeCommandArg } from "./util";
57

8+
export type CliAuth =
9+
| { mode: "global-config"; configDir: string }
10+
| { mode: "url"; url: string };
11+
612
/**
713
* Returns the raw global flags from user configuration.
814
*/
915
export function getGlobalFlagsRaw(
10-
configs: Pick<WorkspaceConfiguration, "get">,
16+
configs: Pick<vscode.WorkspaceConfiguration, "get">,
1117
): string[] {
1218
return configs.get<string[]>("coder.globalFlags", []);
1319
}
1420

1521
/**
1622
* Returns global configuration flags for Coder CLI commands.
17-
* Always includes the `--global-config` argument with the specified config directory.
23+
* Includes either `--global-config` or `--url` depending on the auth mode.
1824
*/
1925
export function getGlobalFlags(
20-
configs: Pick<WorkspaceConfiguration, "get">,
21-
configDir: string,
26+
configs: Pick<vscode.WorkspaceConfiguration, "get">,
27+
auth: CliAuth,
2228
): string[] {
29+
const authFlags =
30+
auth.mode === "url"
31+
? ["--url", escapeCommandArg(auth.url)]
32+
: ["--global-config", escapeCommandArg(auth.configDir)];
33+
2334
// Last takes precedence/overrides previous ones
2435
return [
2536
...getGlobalFlagsRaw(configs),
26-
"--global-config",
27-
escapeCommandArg(configDir),
37+
...authFlags,
2838
...getHeaderArgs(configs),
2939
];
3040
}
3141

42+
/**
43+
* Single source of truth: should the extension use the OS keyring for this session?
44+
* Requires CLI >= 2.29.0, macOS or Windows, and the coder.useKeyring setting enabled.
45+
*/
46+
export function shouldUseKeyring(featureSet: FeatureSet): boolean {
47+
return (
48+
featureSet.keyringAuth &&
49+
isKeyringSupported() &&
50+
vscode.workspace.getConfiguration().get<boolean>("coder.useKeyring", true)
51+
);
52+
}
53+
54+
/**
55+
* Resolves how the CLI should authenticate: via the keyring (`--url`) or via
56+
* the global config directory (`--global-config`).
57+
*/
58+
export function resolveCliAuth(
59+
featureSet: FeatureSet,
60+
deploymentUrl: string | undefined,
61+
configDir: string,
62+
): CliAuth {
63+
if (shouldUseKeyring(featureSet) && deploymentUrl) {
64+
return { mode: "url", url: deploymentUrl };
65+
}
66+
return { mode: "global-config", configDir };
67+
}
68+
3269
/**
3370
* Returns SSH flags for the `coder ssh` command from user configuration.
3471
*/
3572
export function getSshFlags(
36-
configs: Pick<WorkspaceConfiguration, "get">,
73+
configs: Pick<vscode.WorkspaceConfiguration, "get">,
3774
): string[] {
3875
// Make sure to match this default with the one in the package.json
3976
return configs.get<string[]>("coder.sshFlags", ["--disable-autostart"]);

0 commit comments

Comments
 (0)