Skip to content

Commit e647f3b

Browse files
committed
fix: address R4 review feedback
- Handle spawn 'error' events (ENOENT, EACCES) in runCliCommand so missing or unexecutable binaries reject the promise instead of hanging the progress dialog (DEREM-23). - Thread the shell-escape choice through to getHeaderArgs so the value is only quoted in shell:true contexts. Multi-word coder.headerCommand values now reach the CLI verbatim in execFile/no-shell paths (DEREM-24). - Report the terminating signal in the error message when the process is killed (DEREM-25). - Expose stdinEnd from the spawn test harness and assert it in the happy-path test so the DEREM-18 EOF fix is locked in (DEREM-26). Also added regression tests for spawn 'error' and signal-kill paths, and updated cliExec/cliConfig tests for the corrected no-shell header value.
1 parent 16bdd55 commit e647f3b

6 files changed

Lines changed: 58 additions & 31 deletions

File tree

src/api/workspace.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,19 @@ function runCliCommand(ctx: CliContext, args: string[]): Promise<void> {
8080
capturedStderr += text;
8181
});
8282

83-
proc.on("close", (code: number) => {
83+
// Settle on ENOENT/EACCES; later `close` rejects are then no-ops.
84+
proc.on("error", reject);
85+
86+
proc.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
8487
if (code === 0) {
8588
resolve();
86-
} else {
87-
let msg = `"${fullArgs.join(" ")}" exited with code ${code}`;
88-
if (capturedStderr) msg += `: ${capturedStderr}`;
89-
reject(new Error(msg));
89+
return;
9090
}
91+
const exit =
92+
code !== null ? `code ${code}` : `signal ${signal ?? "unknown"}`;
93+
let msg = `"${fullArgs.join(" ")}" exited with ${exit}`;
94+
if (capturedStderr) msg += `: ${capturedStderr}`;
95+
reject(new Error(msg));
9196
});
9297
});
9398
}

src/settings/cli.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { isKeyringSupported } from "../core/cliCredentialManager";
2-
import { escapeCommandArg } from "../util";
2+
import { escapeCommandArg, escapeShellArg } from "../util";
33

44
import { getHeaderArgs } from "./headers";
55

@@ -29,41 +29,38 @@ export function getUserGlobalFlags(
2929
);
3030
}
3131

32-
/**
33-
* Returns global configuration flags for Coder CLI commands with auth values
34-
* escaped for shell use (e.g., `terminal.sendText`, `spawn({ shell: true })`).
35-
*/
32+
/** Flags for shell contexts (`terminal.sendText`, `spawn({ shell: true })`). */
3633
export function getGlobalShellFlags(
3734
configs: Pick<WorkspaceConfiguration, "get">,
3835
auth: CliAuth,
3936
): string[] {
40-
return buildGlobalFlags(configs, auth, escapeCommandArg);
37+
return buildGlobalFlags(configs, auth, escapeCommandArg, escapeShellArg);
4138
}
4239

43-
/**
44-
* Returns global configuration flags for Coder CLI commands with raw auth
45-
* values suitable for `execFile` (no shell escaping).
46-
*/
40+
/** Raw flags for `execFile` or `spawn` without a shell. */
4741
export function getGlobalFlags(
4842
configs: Pick<WorkspaceConfiguration, "get">,
4943
auth: CliAuth,
5044
): string[] {
51-
return buildGlobalFlags(configs, auth, (s) => s);
45+
return buildGlobalFlags(configs, auth, identity, identity);
5246
}
5347

48+
const identity = (s: string) => s;
49+
5450
function buildGlobalFlags(
5551
configs: Pick<WorkspaceConfiguration, "get">,
5652
auth: CliAuth,
57-
esc: (s: string) => string,
53+
escAuth: (s: string) => string,
54+
escHeader: (s: string) => string,
5855
): string[] {
5956
const authFlags =
6057
auth.mode === "url"
61-
? ["--url", esc(auth.url)]
62-
: ["--global-config", esc(auth.configDir)];
58+
? ["--url", escAuth(auth.url)]
59+
: ["--global-config", escAuth(auth.configDir)];
6360

6461
const filtered = stripManagedFlags(getUserGlobalFlags(configs));
6562

66-
return [...filtered, ...authFlags, ...getHeaderArgs(configs)];
63+
return [...filtered, ...authFlags, ...getHeaderArgs(configs, escHeader)];
6764
}
6865

6966
function stripManagedFlags(flags: string[]): string[] {

src/settings/headers.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { escapeShellArg } from "../util";
2-
31
import type { WorkspaceConfiguration } from "vscode";
42

53
/** Returns the header command from settings or the CODER_HEADER_COMMAND env var. */
@@ -13,13 +11,14 @@ export function getHeaderCommand(
1311
return cmd || undefined;
1412
}
1513

16-
/** Returns `--header-command` CLI args, escaped for the current platform. */
14+
/** Returns `--header-command` args; shell callers pass `escapeShellArg` as `esc`. */
1715
export function getHeaderArgs(
1816
config: Pick<WorkspaceConfiguration, "get">,
17+
esc: (s: string) => string = (s) => s,
1918
): string[] {
2019
const command = getHeaderCommand(config);
2120
if (!command) {
2221
return [];
2322
}
24-
return ["--header-command", escapeShellArg(command)];
23+
return ["--header-command", esc(command)];
2524
}

test/unit/api/workspace.test.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,17 @@ function controlSpawn() {
119119
});
120120
return {
121121
spawned,
122+
stdinEnd: proc.stdin.end,
122123
stderr(data: string) {
123124
proc.stderr.emit("data", Buffer.from(data));
124125
},
125-
async close(exitCode: number) {
126+
async close(exitCode: number | null, signal?: NodeJS.Signals) {
126127
await spawned;
127-
proc.emit("close", exitCode);
128+
proc.emit("close", exitCode, signal ?? null);
129+
},
130+
async error(err: Error) {
131+
await spawned;
132+
proc.emit("error", err);
128133
},
129134
};
130135
}
@@ -207,6 +212,7 @@ describe("updateWorkspace", () => {
207212
"update",
208213
"testuser/test-workspace",
209214
]);
215+
expect(sp.stdinEnd).toHaveBeenCalled();
210216
expect(restClient.getWorkspace).toHaveBeenCalledWith(ctx.workspace.id);
211217
});
212218

@@ -223,6 +229,27 @@ describe("updateWorkspace", () => {
223229
expect(restClient.getWorkspace).not.toHaveBeenCalled();
224230
});
225231

232+
it("rejects when spawn emits an error (e.g. missing binary)", async () => {
233+
const { ctx, restClient } = createUpdateCtx();
234+
const sp = controlSpawn();
235+
236+
const result = updateWorkspace(ctx);
237+
await sp.error(new Error("spawn /usr/bin/coder ENOENT"));
238+
239+
await expect(result).rejects.toThrow(/ENOENT/);
240+
expect(restClient.getWorkspace).not.toHaveBeenCalled();
241+
});
242+
243+
it("reports the terminating signal when the process is killed", async () => {
244+
const { ctx } = createUpdateCtx();
245+
const sp = controlSpawn();
246+
247+
const result = updateWorkspace(ctx);
248+
await sp.close(null, "SIGTERM");
249+
250+
await expect(result).rejects.toThrow(/signal SIGTERM/);
251+
});
252+
226253
it("falls back to the API update path when coder update is unsupported", async () => {
227254
const { ctx, restClient, finalWorkspace } = createUpdateCtx({
228255
featureSet: { cliUpdate: false },

test/unit/cliConfig.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,14 @@ describe("cliConfig", () => {
190190
]);
191191
});
192192

193-
it("should still escape header-command flags", () => {
193+
it("passes header-command value through verbatim (no shell)", () => {
194194
const config = new MockConfigurationProvider();
195195
config.set("coder.headerCommand", "echo test");
196196
expect(getGlobalFlags(config, globalConfigAuth)).toStrictEqual([
197197
"--global-config",
198198
"/config/dir",
199199
"--header-command",
200-
quoteCommand("echo test"),
200+
"echo test",
201201
]);
202202
});
203203

test/unit/core/cliExec.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
66
import { MockConfigurationProvider } from "../../mocks/testHelpers";
77
import {
88
isWindows,
9-
quoteCommand,
109
writeExecutable,
1110
writeStderrJs,
1211
writeStdoutJs,
@@ -154,7 +153,7 @@ describe("cliExec", () => {
154153
"--url",
155154
"http://localhost:3000",
156155
"--header-command",
157-
quoteCommand("my-header-cmd"),
156+
"my-header-cmd",
158157
"speedtest",
159158
"owner/workspace",
160159
"--output",
@@ -211,7 +210,7 @@ describe("cliExec", () => {
211210
"--url",
212211
"http://localhost:3000",
213212
"--header-command",
214-
quoteCommand("my-header-cmd"),
213+
"my-header-cmd",
215214
"support",
216215
"bundle",
217216
"owner/workspace",

0 commit comments

Comments
 (0)