Skip to content

Commit b8c1b22

Browse files
clawSeansteipete
andauthored
fix(node-host): clarify unusable node exec cwd failures (#97105)
* fix(node-host): clarify unusable exec cwd failures * fix(node-host): tighten cwd failure diagnostics * refactor(node-host): keep cwd handling localized * chore: leave release changelog ownership centralized --------- Co-authored-by: clawSean <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent e595a8c commit b8c1b22

2 files changed

Lines changed: 150 additions & 7 deletions

File tree

src/node-host/invoke.run-command.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { EventEmitter } from "node:events";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
25
import { afterEach, describe, expect, it, vi } from "vitest";
36
import { testing } from "./invoke.js";
47

@@ -124,4 +127,93 @@ describe("runCommand", () => {
124127
error: null,
125128
});
126129
});
130+
131+
describe("working directory failures", () => {
132+
const enoent = (message: string) =>
133+
Object.assign(new Error(message), { code: "ENOENT" }) as NodeJS.ErrnoException;
134+
135+
it("blames a missing working directory instead of the shell", () => {
136+
const cwd = path.join(os.tmpdir(), `node-exec-missing-${process.pid}-${Date.now()}`);
137+
expect(testing.clarifyNodeExecCwdSpawnError(enoent("spawn /bin/sh ENOENT"), cwd)).toBe(
138+
`node exec working directory does not exist on the node host: ${cwd} (os reported: spawn /bin/sh ENOENT)`,
139+
);
140+
});
141+
142+
it("flags a cwd that exists but is not a directory", () => {
143+
const file = path.join(os.tmpdir(), `node-exec-file-${process.pid}-${Date.now()}.txt`);
144+
fs.writeFileSync(file, "x");
145+
try {
146+
const error = Object.assign(new Error("spawn ENOTDIR"), {
147+
code: "ENOTDIR",
148+
}) as NodeJS.ErrnoException;
149+
expect(testing.clarifyNodeExecCwdSpawnError(error, file)).toBe(
150+
`node exec working directory is not a directory on the node host: ${file} (os reported: spawn ENOTDIR)`,
151+
);
152+
} finally {
153+
fs.rmSync(file, { force: true });
154+
}
155+
});
156+
157+
it("clarifies an asynchronous spawn error for a missing cwd", async () => {
158+
const child = createMockChild();
159+
mockNextSpawn(child);
160+
const cwd = path.join(os.tmpdir(), `node-exec-run-missing-${process.pid}-${Date.now()}`);
161+
162+
const resultPromise = testing.runCommand(["/bin/sh"], cwd, undefined, undefined);
163+
child.emit("error", enoent("spawn /bin/sh ENOENT"));
164+
165+
await expect(resultPromise).resolves.toMatchObject({
166+
success: false,
167+
error: expect.stringContaining(
168+
`node exec working directory does not exist on the node host: ${cwd}`,
169+
),
170+
});
171+
});
172+
173+
it("clarifies a synchronous spawn throw for a cwd that is a file", async () => {
174+
const file = path.join(os.tmpdir(), `node-exec-run-file-${process.pid}-${Date.now()}.txt`);
175+
fs.writeFileSync(file, "x");
176+
vi.mocked(spawn).mockImplementationOnce(() => {
177+
throw Object.assign(new Error("spawn ENOTDIR"), { code: "ENOTDIR" });
178+
});
179+
try {
180+
await expect(
181+
testing.runCommand(["/bin/sh"], file, undefined, undefined),
182+
).resolves.toMatchObject({
183+
success: false,
184+
error: expect.stringContaining(
185+
`node exec working directory is not a directory on the node host: ${file}`,
186+
),
187+
});
188+
} finally {
189+
fs.rmSync(file, { force: true });
190+
}
191+
});
192+
193+
it("preserves genuine executable and unrelated spawn errors", () => {
194+
const missingExecutable = "spawn /usr/bin/does-not-exist ENOENT";
195+
expect(testing.clarifyNodeExecCwdSpawnError(enoent(missingExecutable), os.tmpdir())).toBe(
196+
missingExecutable,
197+
);
198+
expect(testing.clarifyNodeExecCwdSpawnError(enoent("spawn /bin/sh ENOENT"), undefined)).toBe(
199+
"spawn /bin/sh ENOENT",
200+
);
201+
const denied = Object.assign(new Error("spawn EACCES"), {
202+
code: "EACCES",
203+
}) as NodeJS.ErrnoException;
204+
expect(testing.clarifyNodeExecCwdSpawnError(denied, "/missing")).toBe("spawn EACCES");
205+
});
206+
207+
it("preserves the spawn error when the cwd cannot be inspected", () => {
208+
const message = "spawn /bin/sh ENOENT";
209+
const stat = vi.spyOn(fs, "statSync").mockImplementationOnce(() => {
210+
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
211+
});
212+
try {
213+
expect(testing.clarifyNodeExecCwdSpawnError(enoent(message), "/unreadable")).toBe(message);
214+
} finally {
215+
stat.mockRestore();
216+
}
217+
});
218+
});
127219
});

src/node-host/invoke.ts

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,39 @@ function requireExecApprovalsBaseHash(
270270
}
271271
}
272272

273+
// libuv reports a failed pre-exec `chdir(cwd)` as `spawn <argv0> ENOENT`, which
274+
// blames the shell/command instead of the missing working directory (#85202).
275+
// When the spawn cwd is set but is not a usable directory, name the real cause.
276+
// Diagnostic only: the run still fails closed — the cwd is never dropped to fall
277+
// back to the node's default directory.
278+
function clarifyNodeExecCwdSpawnError(
279+
error: NodeJS.ErrnoException,
280+
cwd: string | undefined,
281+
): string {
282+
const message = error.message;
283+
if (!cwd || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
284+
return message;
285+
}
286+
let reason: "does not exist" | "is not a directory";
287+
try {
288+
const stats = fs.statSync(cwd);
289+
// An existing directory means the cwd is fine and the ENOENT is about the
290+
// executable itself; leave the original message untouched.
291+
if (stats.isDirectory()) {
292+
return message;
293+
}
294+
reason = "is not a directory";
295+
} catch (statError) {
296+
const statCode = (statError as NodeJS.ErrnoException).code;
297+
if (statCode !== "ENOENT" && statCode !== "ENOTDIR") {
298+
return message;
299+
}
300+
reason =
301+
statCode === "ENOTDIR" || error.code === "ENOTDIR" ? "is not a directory" : "does not exist";
302+
}
303+
return `node exec working directory ${reason} on the node host: ${cwd} (os reported: ${message})`;
304+
}
305+
273306
async function runCommand(
274307
argv: string[],
275308
cwd: string | undefined,
@@ -285,12 +318,29 @@ async function runCommand(
285318
let settled = false;
286319
const windowsEncoding = resolveWindowsConsoleEncoding();
287320

288-
const child = spawn(argv[0], argv.slice(1), {
289-
cwd,
290-
env,
291-
stdio: ["ignore", "pipe", "pipe"],
292-
windowsHide: true,
293-
});
321+
// A cwd that exists but is not a directory makes `spawn` throw ENOTDIR
322+
// synchronously instead of emitting `error`. Keep that failure inside the
323+
// node result because runner.ts intentionally dispatches invokes with `void`.
324+
let child: ReturnType<typeof spawn>;
325+
try {
326+
child = spawn(argv[0], argv.slice(1), {
327+
cwd,
328+
env,
329+
stdio: ["ignore", "pipe", "pipe"],
330+
windowsHide: true,
331+
});
332+
} catch (err) {
333+
resolve({
334+
exitCode: undefined,
335+
timedOut: false,
336+
success: false,
337+
stdout: "",
338+
stderr: "",
339+
error: clarifyNodeExecCwdSpawnError(err as NodeJS.ErrnoException, cwd),
340+
truncated: false,
341+
});
342+
return;
343+
}
294344

295345
const onChunk = (chunk: Buffer, target: "stdout" | "stderr") => {
296346
if (outputLen >= OUTPUT_CAP) {
@@ -383,7 +433,7 @@ async function runCommand(
383433
child.stderr?.on("error", onStreamError);
384434
child.on("error", (err) => {
385435
if (!streamError) {
386-
finalize(undefined, err.message);
436+
finalize(undefined, clarifyNodeExecCwdSpawnError(err, cwd));
387437
}
388438
});
389439
child.on("exit", (code) => {
@@ -818,5 +868,6 @@ async function sendNodeEvent(client: GatewayClient, event: string, payload: unkn
818868

819869
export const testing = {
820870
STREAM_ERROR_KILL_GRACE_MS,
871+
clarifyNodeExecCwdSpawnError,
821872
runCommand,
822873
} as const;

0 commit comments

Comments
 (0)