Skip to content

Commit 26e8363

Browse files
authored
refactor: improve keyring support reliability and reduce duplication (#831)
- Separate keyringTokenRead (>= 2.31) from keyringAuth (>= 2.29) feature flags - Extract tempFilePath() utility with crypto.randomUUID, replacing 4 inline patterns - Add withProgress() wrapper, adopt in commands.ts and promptUtils.ts - Type-safe circular dependency guard in ServiceContainer - Fix stubExecFileAbortable to handle non-pre-aborted signals - Replace regex test matchers with structural assertions
1 parent e70dca1 commit 26e8363

File tree

14 files changed

+221
-85
lines changed

14 files changed

+221
-85
lines changed

src/commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { toError } from "./error/errorUtils";
2222
import { featureSetForVersion } from "./featureSet";
2323
import { type Logger } from "./logging/logger";
2424
import { type LoginCoordinator } from "./login/loginCoordinator";
25+
import { withProgress } from "./progress";
2526
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
2627
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
2728
import { vscodeProposed } from "./vscodeProposed";
@@ -446,11 +447,10 @@ export class Commands {
446447
}): Promise<void> {
447448
// Launch and run command in terminal if command is provided
448449
if (app.command) {
449-
return vscode.window.withProgress(
450+
return withProgress(
450451
{
451452
location: vscode.ProgressLocation.Notification,
452453
title: `Connecting to AI Agent...`,
453-
cancellable: false,
454454
},
455455
async () => {
456456
const terminal = vscode.window.createTerminal(app.name);

src/core/cliCredentialManager.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as semver from "semver";
77
import { isKeyringEnabled } from "../cliConfig";
88
import { featureSetForVersion } from "../featureSet";
99
import { getHeaderArgs } from "../headers";
10-
import { toSafeHost } from "../util";
10+
import { tempFilePath, toSafeHost } from "../util";
1111

1212
import * as cliUtils from "./cliUtils";
1313

@@ -19,6 +19,8 @@ import type { PathResolver } from "./pathResolver";
1919

2020
const execFileAsync = promisify(execFile);
2121

22+
type KeyringFeature = "keyringAuth" | "keyringTokenRead";
23+
2224
const EXEC_TIMEOUT_MS = 60_000;
2325
const EXEC_LOG_INTERVAL_MS = 5_000;
2426

@@ -59,7 +61,11 @@ export class CliCredentialManager {
5961
configs: Pick<WorkspaceConfiguration, "get">,
6062
signal?: AbortSignal,
6163
): Promise<void> {
62-
const binPath = await this.resolveKeyringBinary(url, configs);
64+
const binPath = await this.resolveKeyringBinary(
65+
url,
66+
configs,
67+
"keyringAuth",
68+
);
6369
if (!binPath) {
6470
await this.writeCredentialFiles(url, token);
6571
return;
@@ -91,17 +97,20 @@ export class CliCredentialManager {
9197
url: string,
9298
configs: Pick<WorkspaceConfiguration, "get">,
9399
): Promise<string | undefined> {
94-
if (!isKeyringEnabled(configs)) {
95-
return undefined;
96-
}
97-
98-
let binPath: string;
100+
let binPath: string | undefined;
99101
try {
100-
binPath = await this.resolveBinary(url);
102+
binPath = await this.resolveKeyringBinary(
103+
url,
104+
configs,
105+
"keyringTokenRead",
106+
);
101107
} catch (error) {
102108
this.logger.warn("Could not resolve CLI binary for token read:", error);
103109
return undefined;
104110
}
111+
if (!binPath) {
112+
return undefined;
113+
}
105114

106115
const args = [...getHeaderArgs(configs), "login", "token", "--url", url];
107116
try {
@@ -131,22 +140,23 @@ export class CliCredentialManager {
131140

132141
/**
133142
* Resolve a CLI binary for keyring operations. Returns the binary path
134-
* when keyring is enabled in settings and the CLI version supports it,
135-
* or undefined to fall back to file-based storage.
143+
* when keyring is enabled in settings and the CLI version supports the
144+
* requested feature, or undefined to fall back to file-based storage.
136145
*
137146
* Throws on binary resolution or version-check failure (caller decides
138147
* whether to catch or propagate).
139148
*/
140149
private async resolveKeyringBinary(
141150
url: string,
142151
configs: Pick<WorkspaceConfiguration, "get">,
152+
feature: KeyringFeature,
143153
): Promise<string | undefined> {
144154
if (!isKeyringEnabled(configs)) {
145155
return undefined;
146156
}
147157
const binPath = await this.resolveBinary(url);
148158
const version = semver.parse(await cliUtils.version(binPath));
149-
return featureSetForVersion(version).keyringAuth ? binPath : undefined;
159+
return featureSetForVersion(version)[feature] ? binPath : undefined;
150160
}
151161

152162
/**
@@ -217,7 +227,7 @@ export class CliCredentialManager {
217227
): Promise<void> {
218228
let binPath: string | undefined;
219229
try {
220-
binPath = await this.resolveKeyringBinary(url, configs);
230+
binPath = await this.resolveKeyringBinary(url, configs, "keyringAuth");
221231
} catch (error) {
222232
this.logger.warn("Could not resolve keyring binary for delete:", error);
223233
return;
@@ -243,8 +253,7 @@ export class CliCredentialManager {
243253
content: string,
244254
): Promise<void> {
245255
await fs.mkdir(path.dirname(filePath), { recursive: true });
246-
const tempPath =
247-
filePath + ".temp-" + Math.random().toString(36).substring(8);
256+
const tempPath = tempFilePath(filePath, "temp");
248257
try {
249258
await fs.writeFile(tempPath, content, { mode: 0o600 });
250259
await fs.rename(tempPath, filePath);

src/core/cliManager.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import * as vscode from "vscode";
1212
import { errToStr } from "../api/api-helper";
1313
import * as pgp from "../pgp";
1414
import { withCancellableProgress } from "../progress";
15-
import { toSafeHost } from "../util";
15+
import { tempFilePath, toSafeHost } from "../util";
1616
import { vscodeProposed } from "../vscodeProposed";
1717

1818
import { BinaryLock } from "./binaryLock";
@@ -40,7 +40,7 @@ export class CliManager {
4040

4141
/**
4242
* Return the path to a cached CLI binary for a deployment URL.
43-
* Stat check only no network, no subprocess. Throws if absent.
43+
* Stat check only, no network, no subprocess. Throws if absent.
4444
*/
4545
public async locateBinary(url: string): Promise<string> {
4646
const safeHostname = toSafeHost(url);
@@ -244,8 +244,7 @@ export class CliManager {
244244
binPath: string,
245245
tempFile: string,
246246
): Promise<void> {
247-
const oldBinPath =
248-
binPath + ".old-" + Math.random().toString(36).substring(8);
247+
const oldBinPath = tempFilePath(binPath, "old");
249248

250249
try {
251250
// Step 1: Move existing binary to backup (if it exists)
@@ -336,8 +335,7 @@ export class CliManager {
336335
progressLogPath: string,
337336
): Promise<string> {
338337
const cfg = vscode.workspace.getConfiguration("coder");
339-
const tempFile =
340-
binPath + ".temp-" + Math.random().toString(36).substring(8);
338+
const tempFile = tempFilePath(binPath, "temp");
341339

342340
try {
343341
const removed = await cliUtils.rmOld(binPath);

src/core/container.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,17 @@ export class ServiceContainer implements vscode.Disposable {
3737
context.globalState,
3838
this.logger,
3939
);
40-
// Circular ref: cliCredentialManager ↔ cliManager. Safe because
41-
// the resolver is only called after construction.
40+
// Circular ref: cliCredentialManager ↔ cliManager. The resolver
41+
// closure captures `this` by reference, so `this.cliManager` is
42+
// available when the closure is called (after construction).
4243
this.cliCredentialManager = new CliCredentialManager(
4344
this.logger,
4445
async (url) => {
46+
if (!this.cliManager) {
47+
throw new Error(
48+
"BinaryResolver called before CliManager was initialised",
49+
);
50+
}
4551
try {
4652
return await this.cliManager.locateBinary(url);
4753
} catch {

src/featureSet.ts

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@ export interface FeatureSet {
66
wildcardSSH: boolean;
77
buildReason: boolean;
88
keyringAuth: boolean;
9+
keyringTokenRead: boolean;
10+
}
11+
12+
/**
13+
* True when the CLI version is at least `minVersion`, or is a dev build.
14+
* Returns false for null (unknown) versions.
15+
*/
16+
function versionAtLeast(
17+
version: semver.SemVer | null,
18+
minVersion: string,
19+
): boolean {
20+
if (!version) {
21+
return false;
22+
}
23+
return version.compare(minVersion) >= 0 || version.prerelease[0] === "devel";
924
}
1025

1126
/**
@@ -22,24 +37,15 @@ export function featureSetForVersion(
2237
version?.prerelease.length === 0
2338
),
2439

25-
// CLI versions before 2.3.3 don't support the --log-dir flag!
26-
// If this check didn't exist, VS Code connections would fail on
27-
// older versions because of an unknown CLI argument.
28-
proxyLogDirectory:
29-
(version?.compare("2.3.3") ?? 0) > 0 ||
30-
version?.prerelease[0] === "devel",
31-
wildcardSSH:
32-
(version ? version.compare("2.19.0") : -1) >= 0 ||
33-
version?.prerelease[0] === "devel",
34-
35-
// The --reason flag was added to `coder start` in 2.25.0
36-
buildReason:
37-
(version?.compare("2.25.0") ?? 0) >= 0 ||
38-
version?.prerelease[0] === "devel",
39-
40-
// Keyring-backed token storage was added in CLI 2.29.0
41-
keyringAuth:
42-
(version?.compare("2.29.0") ?? 0) >= 0 ||
43-
version?.prerelease[0] === "devel",
40+
// --log-dir flag for proxy logs; vscodessh fails if unsupported
41+
proxyLogDirectory: versionAtLeast(version, "2.4.0"),
42+
// Wildcard SSH host matching
43+
wildcardSSH: versionAtLeast(version, "2.19.0"),
44+
// --reason flag for `coder start`
45+
buildReason: versionAtLeast(version, "2.25.0"),
46+
// Keyring-backed token storage via `coder login`
47+
keyringAuth: versionAtLeast(version, "2.29.0"),
48+
// `coder login token` for reading tokens from the keyring
49+
keyringTokenRead: versionAtLeast(version, "2.31.0"),
4450
};
4551
}

src/progress.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,17 @@ export function withCancellableProgress<T>(
3939
},
4040
);
4141
}
42+
43+
/**
44+
* Run a task inside a VS Code progress notification (no cancellation).
45+
* A thin wrapper over `vscode.window.withProgress` that passes only the
46+
* progress reporter, hiding the unused cancellation token.
47+
*/
48+
export function withProgress<T>(
49+
options: vscode.ProgressOptions,
50+
fn: (
51+
progress: vscode.Progress<{ message?: string; increment?: number }>,
52+
) => Promise<T>,
53+
): Thenable<T> {
54+
return vscode.window.withProgress(options, (progress) => fn(progress));
55+
}

src/promptUtils.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as vscode from "vscode";
44
import { type CoderApi } from "./api/coderApi";
55
import { type MementoManager } from "./core/mementoManager";
66
import { OAuthMetadataClient } from "./oauth/metadataClient";
7+
import { withProgress } from "./progress";
78

89
type AuthMethod = "oauth" | "legacy";
910

@@ -147,17 +148,12 @@ export async function maybeAskAuthMethod(
147148
}
148149

149150
// Check if server supports OAuth with progress indication
150-
const supportsOAuth = await vscode.window.withProgress(
151+
const supportsOAuth = await withProgress(
151152
{
152153
location: vscode.ProgressLocation.Notification,
153154
title: "Checking authentication methods",
154-
cancellable: false,
155-
},
156-
async () => {
157-
return await OAuthMetadataClient.checkOAuthSupport(
158-
client.getAxiosInstance(),
159-
);
160155
},
156+
() => OAuthMetadataClient.checkOAuthSupport(client.getAxiosInstance()),
161157
);
162158

163159
if (supportsOAuth) {

src/remote/sshConfig.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
22
import path from "node:path";
33

4-
import { countSubstring } from "../util";
4+
import { countSubstring, tempFilePath } from "../util";
55

66
class SSHConfigBadFormat extends Error {}
77

@@ -308,28 +308,30 @@ export class SSHConfig {
308308
mode: 0o700, // only owner has rwx permission, not group or everyone.
309309
recursive: true,
310310
});
311-
const randSuffix = Math.random().toString(36).substring(8);
312311
const fileName = path.basename(this.filePath);
313312
const dirName = path.dirname(this.filePath);
314-
const tempFilePath = `${dirName}/.${fileName}.vscode-coder-tmp.${randSuffix}`;
313+
const tempPath = tempFilePath(
314+
`${dirName}/.${fileName}`,
315+
"vscode-coder-tmp",
316+
);
315317
try {
316-
await this.fileSystem.writeFile(tempFilePath, this.getRaw(), {
318+
await this.fileSystem.writeFile(tempPath, this.getRaw(), {
317319
mode: existingMode,
318320
encoding: "utf-8",
319321
});
320322
} catch (err) {
321323
throw new Error(
322-
`Failed to write temporary SSH config file at ${tempFilePath}: ${err instanceof Error ? err.message : String(err)}. ` +
324+
`Failed to write temporary SSH config file at ${tempPath}: ${err instanceof Error ? err.message : String(err)}. ` +
323325
`Please check your disk space, permissions, and that the directory exists.`,
324326
{ cause: err },
325327
);
326328
}
327329

328330
try {
329-
await this.fileSystem.rename(tempFilePath, this.filePath);
331+
await this.fileSystem.rename(tempPath, this.filePath);
330332
} catch (err) {
331333
throw new Error(
332-
`Failed to rename temporary SSH config file at ${tempFilePath} to ${this.filePath}: ${
334+
`Failed to rename temporary SSH config file at ${tempPath} to ${this.filePath}: ${
333335
err instanceof Error ? err.message : String(err)
334336
}. Please check your disk space, permissions, and that the directory exists.`,
335337
{ cause: err },

src/util.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,12 @@ export function escapeCommandArg(arg: string): string {
154154
const escapedString = arg.replaceAll('"', String.raw`\"`);
155155
return `"${escapedString}"`;
156156
}
157+
158+
/**
159+
* Generate a temporary file path by appending a suffix with a random component.
160+
* The suffix describes the purpose of the temp file (e.g. "temp", "old").
161+
* Example: tempFilePath("/a/b", "temp") → "/a/b.temp-k7x3f9qw"
162+
*/
163+
export function tempFilePath(basePath: string, suffix: string): string {
164+
return `${basePath}.${suffix}-${crypto.randomUUID().substring(0, 8)}`;
165+
}

test/unit/core/cliCredentialManager.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,14 @@ function stubExecFileAbortable() {
7676
_bin: string,
7777
_args: string[],
7878
opts: ExecFileOptions,
79+
cb: ExecFileCallback,
7980
) => {
81+
const err = new Error("The operation was aborted");
82+
err.name = "AbortError";
8083
if (opts.signal?.aborted) {
81-
const err = new Error("The operation was aborted");
82-
err.name = "AbortError";
83-
throw err;
84+
cb(err);
85+
} else {
86+
opts.signal?.addEventListener("abort", () => cb(err));
8487
}
8588
}) as unknown as typeof execFile);
8689
}
@@ -163,7 +166,7 @@ describe("CliCredentialManager", () => {
163166
vi.clearAllMocks();
164167
vol.reset();
165168
vi.mocked(isKeyringEnabled).mockReturnValue(false);
166-
vi.mocked(cliUtils.version).mockResolvedValue("2.29.0");
169+
vi.mocked(cliUtils.version).mockResolvedValue("2.31.0");
167170
});
168171

169172
describe("storeToken", () => {
@@ -315,6 +318,17 @@ describe("CliCredentialManager", () => {
315318
expect(execFile).not.toHaveBeenCalled();
316319
});
317320

321+
it("returns undefined when CLI version too old for token read", async () => {
322+
vi.mocked(isKeyringEnabled).mockReturnValue(true);
323+
// 2.30 supports keyringAuth but not keyringTokenRead (requires 2.31+)
324+
vi.mocked(cliUtils.version).mockResolvedValueOnce("2.30.0");
325+
stubExecFile({ stdout: "my-token" });
326+
const { manager } = setup();
327+
328+
expect(await manager.readToken(TEST_URL, configs)).toBeUndefined();
329+
expect(execFile).not.toHaveBeenCalled();
330+
});
331+
318332
it("passes timeout to execFile", async () => {
319333
vi.mocked(isKeyringEnabled).mockReturnValue(true);
320334
stubExecFile({ stdout: "token" });

0 commit comments

Comments
 (0)