Skip to content

Commit 207d11e

Browse files
authored
fix: refresh CLI credentials before each invocation (#926)
- Refresh the credential file (or keyring on supported systems) via `cliManager.configure` inside `commands.ts:resolveCliEnv` so each CLI call reads a current token. Previously credentials were written only at connection time and could go stale across refreshes. mTLS still works (empty token allowed). - Stop wrapping `AbortError` in `cliError` so cancellations no longer surface as stale CLI stderr to `withCancellableProgress`. - Add `writeStdoutJs` / `writeStderrJs` test helpers that wrap `fs.writeSync`. Fixes intermittent flake where async pipe writes were lost on exit (see https://nodejs.org/api/process.html#a-note-on-process-io).
1 parent 20072d8 commit 207d11e

12 files changed

Lines changed: 149 additions & 120 deletions

src/commands.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,12 @@ export class Commands {
930930
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
931931
const configs = vscode.workspace.getConfiguration();
932932
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
933+
// Same threat model as the connection-time write in remote.ts: token
934+
// goes to the file (or keyring on supported systems), never env, since
935+
// child env is sibling-readable on most platforms.
936+
await this.cliManager.configure(baseUrl, client.getSessionToken() ?? "", {
937+
silent: true,
938+
});
933939
return { binary, configs, auth, featureSet };
934940
}
935941

src/core/cliCredentialManager.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,26 +56,20 @@ export class CliCredentialManager {
5656
* setting is enabled and the CLI supports it; otherwise writes plaintext
5757
* files under --global-config.
5858
*
59-
* Keyring and files are mutually exclusive — never both.
60-
*
61-
* When `keyringOnly` is set, silently returns if the keyring is unavailable
62-
* instead of falling back to file storage.
59+
* Keyring and files are mutually exclusive, never both.
6360
*/
6461
public async storeToken(
6562
url: string,
6663
token: string,
6764
configs: Pick<WorkspaceConfiguration, "get">,
68-
options?: { signal?: AbortSignal; keyringOnly?: boolean },
65+
options?: { signal?: AbortSignal },
6966
): Promise<void> {
7067
const binPath = await this.resolveKeyringBinary(
7168
url,
7269
configs,
7370
"keyringAuth",
7471
);
7572
if (!binPath) {
76-
if (options?.keyringOnly) {
77-
return;
78-
}
7973
await this.writeCredentialFiles(url, token);
8074
return;
8175
}

src/core/cliExec.ts

Lines changed: 46 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type ExecFileException, execFile, spawn } from "node:child_process";
22
import { promisify } from "node:util";
33
import * as vscode from "vscode";
44

5-
import { toError } from "../error/errorUtils";
5+
import { isAbortError, toError } from "../error/errorUtils";
66
import {
77
type CliAuth,
88
getGlobalFlags,
@@ -17,23 +17,6 @@ export interface CliEnv {
1717
configs: Pick<vscode.WorkspaceConfiguration, "get">;
1818
}
1919

20-
const execFileAsync = promisify(execFile);
21-
22-
function isExecFileException(error: unknown): error is ExecFileException {
23-
return error instanceof Error && "code" in error;
24-
}
25-
26-
/** Prefer stderr over the default message which includes the full command line. */
27-
function cliError(error: unknown): Error {
28-
if (isExecFileException(error)) {
29-
const stderr = error.stderr?.trim();
30-
if (stderr) {
31-
return new Error(stderr, { cause: error });
32-
}
33-
}
34-
return toError(error);
35-
}
36-
3720
/**
3821
* Return the version from the binary. Throw if unable to execute the binary or
3922
* find the version for any reason.
@@ -135,14 +118,56 @@ export function ping(env: CliEnv, workspaceName: string): vscode.Terminal {
135118
});
136119
}
137120

121+
/**
122+
* SSH into a workspace and run a command via `terminal.sendText`.
123+
*/
124+
export async function openAppStatusTerminal(
125+
env: CliEnv,
126+
app: {
127+
name?: string;
128+
command?: string;
129+
workspace_name: string;
130+
},
131+
): Promise<void> {
132+
const globalFlags = getGlobalShellFlags(env.configs, env.auth);
133+
const terminal = vscode.window.createTerminal({ name: app.name });
134+
terminal.sendText(
135+
`${escapeCommandArg(env.binary)} ${globalFlags.join(" ")} ssh ${escapeCommandArg(app.workspace_name)}`,
136+
);
137+
await new Promise((resolve) => setTimeout(resolve, 5000));
138+
terminal.sendText(app.command ?? "");
139+
terminal.show(false);
140+
}
141+
142+
const execFileAsync = promisify(execFile);
143+
144+
/** Prefer stderr over the default message which includes the full command line. */
145+
function cliError(error: unknown): Error {
146+
// Pass aborts through; wrapping erases the AbortError name and would surface stale CLI warnings as the failure.
147+
if (isAbortError(error)) {
148+
return error;
149+
}
150+
if (isExecFileException(error)) {
151+
const stderr = error.stderr?.trim();
152+
if (stderr) {
153+
return new Error(stderr, { cause: error });
154+
}
155+
}
156+
return toError(error);
157+
}
158+
159+
function isExecFileException(error: unknown): error is ExecFileException {
160+
return error instanceof Error && "code" in error;
161+
}
162+
138163
/**
139164
* Spawn a CLI command in a PTY terminal with process lifecycle management.
140165
*/
141166
function spawnCliInTerminal(options: {
142167
name: string;
143168
binary: string;
144169
args: string[];
145-
banner?: string[];
170+
banner: string[];
146171
}): vscode.Terminal {
147172
const writeEmitter = new vscode.EventEmitter<string>();
148173
const closeEmitter = new vscode.EventEmitter<number | void>();
@@ -186,10 +211,8 @@ function spawnCliInTerminal(options: {
186211
onDidWrite: writeEmitter.event,
187212
onDidClose: closeEmitter.event,
188213
open: () => {
189-
if (options.banner) {
190-
for (const line of options.banner) {
191-
writeEmitter.fire(line + "\r\n");
192-
}
214+
for (const line of options.banner) {
215+
writeEmitter.fire(line + "\r\n");
193216
}
194217
},
195218
close: () => {
@@ -262,24 +285,3 @@ function spawnCliInTerminal(options: {
262285
terminal.show(false);
263286
return terminal;
264287
}
265-
266-
/**
267-
* SSH into a workspace and run a command via `terminal.sendText`.
268-
*/
269-
export async function openAppStatusTerminal(
270-
env: CliEnv,
271-
app: {
272-
name?: string;
273-
command?: string;
274-
workspace_name: string;
275-
},
276-
): Promise<void> {
277-
const globalFlags = getGlobalShellFlags(env.configs, env.auth);
278-
const terminal = vscode.window.createTerminal(app.name);
279-
terminal.sendText(
280-
`${escapeCommandArg(env.binary)} ${globalFlags.join(" ")} ssh ${escapeCommandArg(app.workspace_name)}`,
281-
);
282-
await new Promise((resolve) => setTimeout(resolve, 5000));
283-
terminal.sendText(app.command ?? "");
284-
terminal.show(false);
285-
}

src/core/cliManager.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,9 @@ export class CliManager {
817817
*
818818
* Both URL and token are required. Empty tokens are allowed (e.g. mTLS
819819
* authentication) but the URL must be a non-empty string.
820+
*
821+
* @param options.silent Suppress the progress notification only; failures
822+
* still surface via {@link handleStoreError} (logged + error toast).
820823
*/
821824
public async configure(
822825
url: string,

src/error/errorUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import util from "node:util";
44
import { toError as baseToError } from "@repo/shared";
55

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

src/login/loginCoordinator.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -153,17 +153,11 @@ export class LoginCoordinator implements vscode.Disposable {
153153
});
154154
await this.mementoManager.addToUrlHistory(url);
155155

156-
// Fire-and-forget: sync token to OS keyring for the CLI.
157156
if (result.token) {
158157
this.cliCredentialManager
159-
.storeToken(url, result.token, vscode.workspace.getConfiguration(), {
160-
keyringOnly: true,
161-
})
158+
.storeToken(url, result.token, vscode.workspace.getConfiguration())
162159
.catch((error) => {
163-
this.logger.warn(
164-
"Failed to store token in keyring at login:",
165-
error,
166-
);
160+
this.logger.warn("Failed to store token at login:", error);
167161
});
168162
}
169163
}

test/unit/core/cliCredentialManager.test.ts

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -269,42 +269,6 @@ describe("CliCredentialManager", () => {
269269
}),
270270
).rejects.toThrow("The operation was aborted");
271271
});
272-
273-
it.each([
274-
{ scenario: "keyring disabled", keyringEnabled: false },
275-
{ scenario: "CLI version too old", keyringEnabled: true },
276-
])(
277-
"never writes files when keyringOnly and $scenario",
278-
async ({ keyringEnabled }) => {
279-
vi.mocked(isKeyringEnabled).mockReturnValue(keyringEnabled);
280-
if (keyringEnabled) {
281-
vi.mocked(cliExec.version).mockResolvedValueOnce("2.28.0");
282-
}
283-
const { manager } = setup();
284-
285-
await manager.storeToken(TEST_URL, "token", configs, {
286-
keyringOnly: true,
287-
});
288-
289-
expect(execFile).not.toHaveBeenCalled();
290-
expect(memfs.existsSync(URL_FILE)).toBe(false);
291-
expect(memfs.existsSync(SESSION_FILE)).toBe(false);
292-
},
293-
);
294-
295-
it("uses keyring without writing files when keyringOnly and keyring available", async () => {
296-
vi.mocked(isKeyringEnabled).mockReturnValue(true);
297-
stubExecFile({ stdout: "" });
298-
const { manager } = setup();
299-
300-
await manager.storeToken(TEST_URL, "my-token", configs, {
301-
keyringOnly: true,
302-
});
303-
304-
expect(execFile).toHaveBeenCalled();
305-
expect(memfs.existsSync(URL_FILE)).toBe(false);
306-
expect(memfs.existsSync(SESSION_FILE)).toBe(false);
307-
});
308272
});
309273

310274
describe("readToken", () => {

test/unit/core/cliExec.test.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import path from "path";
44
import { beforeAll, describe, expect, it, vi } from "vitest";
55

66
import { MockConfigurationProvider } from "../../mocks/testHelpers";
7-
import { isWindows, quoteCommand, writeExecutable } from "../../utils/platform";
7+
import {
8+
isWindows,
9+
quoteCommand,
10+
writeExecutable,
11+
writeStderrJs,
12+
writeStdoutJs,
13+
} from "../../utils/platform";
814

915
import type { CliEnv } from "@/core/cliExec";
1016

@@ -36,7 +42,7 @@ describe("cliExec", () => {
3642

3743
/** JS code for a fake CLI that writes a fixed string to stdout. */
3844
function echoBin(output: string): string {
39-
return `process.stdout.write(${JSON.stringify(output)});`;
45+
return writeStdoutJs(output);
4046
}
4147

4248
/**
@@ -46,10 +52,11 @@ describe("cliExec", () => {
4652
function oldCliBin(stderr: string, stdout: string): string {
4753
return [
4854
`if (process.argv.includes("--output")) {`,
49-
` process.stderr.write(${JSON.stringify(stderr)});`,
50-
` process.exitCode = 1;`,
55+
` ${writeStderrJs(stderr)}`,
56+
` process.exit(1);`,
5157
`} else {`,
52-
` process.stdout.write(${JSON.stringify(stdout)});`,
58+
` ${writeStdoutJs(stdout)}`,
59+
` process.exit(0);`,
5360
`}`,
5461
].join("\n");
5562
}
@@ -159,15 +166,27 @@ describe("cliExec", () => {
159166

160167
it("surfaces stderr instead of full command line on failure", async () => {
161168
const code = [
162-
`process.stderr.write("invalid argument for -t flag\\n");`,
163-
`process.exitCode = 1;`,
169+
writeStderrJs("invalid argument for -t flag\n"),
170+
`process.exit(1);`,
164171
].join("\n");
165172
const bin = await writeExecutable(tmp, "speedtest-err", code);
166173
const { env } = setup({ mode: "global-config", configDir: "/tmp" }, bin);
167174
await expect(
168175
cliExec.speedtest(env, "owner/workspace", "bad"),
169176
).rejects.toThrow("invalid argument for -t flag");
170177
});
178+
179+
it("preserves AbortError name when cancelled via signal", async () => {
180+
// Hangs forever so the only way out is the abort signal.
181+
const code = `setInterval(() => {}, 1000);`;
182+
const bin = await writeExecutable(tmp, "speedtest-hang", code);
183+
const { env } = setup({ mode: "global-config", configDir: "/tmp" }, bin);
184+
const ac = new AbortController();
185+
ac.abort();
186+
await expect(
187+
cliExec.speedtest(env, "owner/workspace", undefined, ac.signal),
188+
).rejects.toMatchObject({ name: "AbortError" });
189+
});
171190
});
172191

173192
describe("supportBundle", () => {
@@ -204,8 +223,8 @@ describe("cliExec", () => {
204223

205224
it("surfaces stderr on failure", async () => {
206225
const code = [
207-
`process.stderr.write("workspace not found\\n");`,
208-
`process.exitCode = 1;`,
226+
writeStderrJs("workspace not found\n"),
227+
`process.exit(1);`,
209228
].join("\n");
210229
const bin = await writeExecutable(tmp, "sb-err", code);
211230
const { env } = setup({ mode: "global-config", configDir: "/tmp" }, bin);

test/unit/error/errorUtils.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,47 @@
11
import { describe, it, expect } from "vitest";
22

3-
import { getErrorDetail, toError } from "@/error/errorUtils";
3+
import { getErrorDetail, isAbortError, toError } from "@/error/errorUtils";
4+
5+
describe("isAbortError", () => {
6+
it("returns true for an Error named AbortError", () => {
7+
const err = new Error("aborted");
8+
err.name = "AbortError";
9+
expect(isAbortError(err)).toBe(true);
10+
});
11+
12+
it("returns true for DOMException-style abort thrown by AbortController", () => {
13+
const ac = new AbortController();
14+
ac.abort();
15+
// `signal.reason` is a DOMException with name "AbortError" in modern Node.
16+
const reason = ac.signal.reason;
17+
expect(isAbortError(reason)).toBe(true);
18+
});
19+
20+
it("returns false for a plain Error", () => {
21+
expect(isAbortError(new Error("nope"))).toBe(false);
22+
});
23+
24+
it.each<[string, unknown]>([
25+
["null", null],
26+
["undefined", undefined],
27+
["string", "AbortError"],
28+
["object with name only", { name: "AbortError" }],
29+
])("returns false for %s", (_name, input) => {
30+
expect(isAbortError(input)).toBe(false);
31+
});
32+
33+
it("narrows the type to Error", () => {
34+
const err: unknown = Object.assign(new Error("aborted"), {
35+
name: "AbortError",
36+
});
37+
if (isAbortError(err)) {
38+
// Type-only assertion: this line must compile without a cast.
39+
expect(err.message).toBe("aborted");
40+
} else {
41+
throw new Error("expected isAbortError to narrow");
42+
}
43+
});
44+
});
445

546
describe("getErrorDetail", () => {
647
it("returns detail from API error", () => {

test/unit/login/loginCoordinator.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ describe("LoginCoordinator", () => {
500500
return { ...ctx, user, login };
501501
}
502502

503-
it("calls storeToken with keyringOnly after successful login", async () => {
503+
it("calls storeToken after successful login", async () => {
504504
const { mockCredentialManager, login } = await loginWithStoredToken();
505505

506506
await login();
@@ -509,7 +509,6 @@ describe("LoginCoordinator", () => {
509509
TEST_URL,
510510
"stored-token",
511511
expect.anything(),
512-
{ keyringOnly: true },
513512
);
514513
});
515514

0 commit comments

Comments
 (0)