@pi-archimedes/sudo 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/package.json +36 -0
- package/src/argv-split.test.ts +59 -0
- package/src/argv-split.ts +82 -0
- package/src/cache.test.ts +91 -0
- package/src/cache.ts +58 -0
- package/src/config.test.ts +47 -0
- package/src/config.ts +27 -0
- package/src/guard.test.ts +427 -0
- package/src/guard.ts +759 -0
- package/src/index.ts +81 -0
- package/src/lifecycle.test.ts +165 -0
- package/src/prompt.test.ts +149 -0
- package/src/prompt.ts +99 -0
- package/src/tool.test.ts +1364 -0
- package/src/tool.ts +587 -0
package/src/tool.test.ts
ADDED
|
@@ -0,0 +1,1364 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
// Mock core/settings-io BEFORE importing the tool — the real module builds
|
|
6
|
+
// its settings path at module load and would read the user's real settings.
|
|
7
|
+
const store = vi.hoisted(() => ({
|
|
8
|
+
settings: {} as Record<string, Record<string, unknown>>,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("@pi-archimedes/core/settings-io", () => ({
|
|
12
|
+
loadConfig: (namespace: string, defaults: Record<string, unknown>) => ({
|
|
13
|
+
...defaults,
|
|
14
|
+
...store.settings[namespace],
|
|
15
|
+
}),
|
|
16
|
+
saveConfig: (namespace: string, config: Record<string, unknown>) => {
|
|
17
|
+
store.settings[namespace] = config;
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { buildSudoArgv, createSudoExecTool, probeSudoTicket, scrubSecret } from "./tool.js";
|
|
22
|
+
import type { SudoChild, SudoSpawner } from "./tool.js";
|
|
23
|
+
import { credentialCache } from "./cache.js";
|
|
24
|
+
|
|
25
|
+
// ── fakes ────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
interface FakeChild {
|
|
28
|
+
stdin: { written: string[]; write(chunk: string): void; end(): void };
|
|
29
|
+
stdout: { on(evt: string, cb: (data: Buffer) => void): unknown };
|
|
30
|
+
stderr: { on(evt: string, cb: (data: Buffer) => void): unknown };
|
|
31
|
+
pid: number;
|
|
32
|
+
killed: boolean;
|
|
33
|
+
/** signals passed to kill(), in order. */
|
|
34
|
+
killSignals: Array<NodeJS.Signals | undefined>;
|
|
35
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
36
|
+
/** 'exit'/'close' pass an exit callback, 'error' passes an Error callback. */
|
|
37
|
+
on(evt: string, cb: ((code: number | null, signal?: NodeJS.Signals | null) => void) | ((err: Error) => void)): unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeFakeSpawner(opts: { stderr?: string; stdout?: string; code: number | null; signal?: NodeJS.Signals; exitOn?: "register" | "kill" }): {
|
|
41
|
+
spawner: (command: string, args: string[]) => FakeChild;
|
|
42
|
+
last: { command: string; args: string[]; child: FakeChild };
|
|
43
|
+
} {
|
|
44
|
+
const last: { command: string; args: string[]; child: FakeChild } = { command: "", args: [], child: undefined as unknown as FakeChild };
|
|
45
|
+
const registeredExits = new Map<string, (code: number | null, signal?: NodeJS.Signals | null) => void>();
|
|
46
|
+
const fireExit = (signal?: NodeJS.Signals): void => {
|
|
47
|
+
for (const cb of registeredExits.values()) queueMicrotask(() => cb(opts.code ?? null, signal ?? opts.signal ?? null));
|
|
48
|
+
};
|
|
49
|
+
const spawner = (command: string, args: string[]): FakeChild => {
|
|
50
|
+
last.command = command;
|
|
51
|
+
last.args = args;
|
|
52
|
+
const stdout = {
|
|
53
|
+
on(_evt: string, cb: (data: Buffer) => void) {
|
|
54
|
+
if (opts.stdout) queueMicrotask(() => cb(Buffer.from(opts.stdout as string)));
|
|
55
|
+
return stdout;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const stderr = {
|
|
59
|
+
on(_evt: string, cb: (data: Buffer) => void) {
|
|
60
|
+
queueMicrotask(() => cb(Buffer.from(opts.stderr ?? "")));
|
|
61
|
+
return stderr;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const child: FakeChild = {
|
|
65
|
+
stdin: {
|
|
66
|
+
written: [],
|
|
67
|
+
write(chunk: string) {
|
|
68
|
+
this.written.push(chunk);
|
|
69
|
+
},
|
|
70
|
+
end() {},
|
|
71
|
+
},
|
|
72
|
+
stdout,
|
|
73
|
+
stderr,
|
|
74
|
+
pid: 4242,
|
|
75
|
+
killed: false,
|
|
76
|
+
killSignals: [],
|
|
77
|
+
kill(signal?: NodeJS.Signals) {
|
|
78
|
+
this.killed = true;
|
|
79
|
+
this.killSignals.push(signal);
|
|
80
|
+
if (opts.exitOn === "kill") fireExit(signal); // async, like a real child
|
|
81
|
+
return true;
|
|
82
|
+
},
|
|
83
|
+
on(evt: string, cb: ((code: number | null, signal?: NodeJS.Signals | null) => void) | ((err: Error) => void)): unknown {
|
|
84
|
+
if (evt === "exit" || evt === "close") {
|
|
85
|
+
const exitCb = cb as (code: number | null, signal?: NodeJS.Signals | null) => void;
|
|
86
|
+
if (opts.exitOn === "kill") registeredExits.set(evt, exitCb);
|
|
87
|
+
else queueMicrotask(() => exitCb(opts.code ?? null, opts.signal ?? null));
|
|
88
|
+
}
|
|
89
|
+
// 'error' is intentionally not fired by the base fake — the
|
|
90
|
+
// process-failure tests build their own error-capable children.
|
|
91
|
+
return child;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
last.child = child;
|
|
95
|
+
return child;
|
|
96
|
+
};
|
|
97
|
+
return { spawner, last };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Locale fixtures for credential failures the tool cannot signature-recognize
|
|
101
|
+
// (shared by the probe-gale and two-strike describes).
|
|
102
|
+
const NON_ENGLISH_STDERR = '[sudo] password for daniel: \nFalsches Passwort "daniel".\n';
|
|
103
|
+
const LOCALIZED_ONLY_STDERR = 'Falsches Passwort "daniel".\n';
|
|
104
|
+
|
|
105
|
+
/** Spawner yielding the ambiguous (prompt + localized text, exit 1) failure. */
|
|
106
|
+
function ambiguousSpawner(): SudoSpawner {
|
|
107
|
+
const { spawner } = makeFakeSpawner({ stderr: NON_ENGLISH_STDERR, code: 1 });
|
|
108
|
+
return spawner;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Spawner piping consecutive fake runs through, one fixture step each. */
|
|
112
|
+
function stepSpawner(steps: Array<{ stderr?: string; stdout?: string; code?: number | null }>): SudoSpawner {
|
|
113
|
+
const instances = steps.map((s) => makeFakeSpawner({ stderr: "", stdout: "", code: 0, ...s }));
|
|
114
|
+
let i = 0;
|
|
115
|
+
return (command, args) => {
|
|
116
|
+
const cur = instances[Math.min(i, instances.length - 1)]!;
|
|
117
|
+
i += 1;
|
|
118
|
+
return cur.spawner(command, args);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Child whose 'error' event fires asynchronously — a spawn that never produced a usable process. */
|
|
123
|
+
function erroredChild(): SudoChild {
|
|
124
|
+
const err = Object.assign(new Error("spawn sudo ENOENT"), { code: "ENOENT" });
|
|
125
|
+
return {
|
|
126
|
+
stdin: { write() {}, end() {} },
|
|
127
|
+
stdout: { on: () => null },
|
|
128
|
+
stderr: { on: () => null },
|
|
129
|
+
kill: () => true,
|
|
130
|
+
on(evt: string, cb: unknown) {
|
|
131
|
+
if (evt === "error") queueMicrotask(() => (cb as (e: Error) => void)(err));
|
|
132
|
+
return null;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function headlessCtx(mode: string): ExtensionContext {
|
|
138
|
+
return ({ mode, hasUI: mode === "rpc", ui: {} }) as unknown as ExtensionContext;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface RunResult {
|
|
142
|
+
content: Array<{ type: string; text: string }>;
|
|
143
|
+
details: Record<string, unknown>;
|
|
144
|
+
isError?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function run(
|
|
148
|
+
tool: ReturnType<typeof createSudoExecTool>,
|
|
149
|
+
ctx: ExtensionContext,
|
|
150
|
+
params: Record<string, unknown>,
|
|
151
|
+
signal: AbortSignal | undefined = undefined,
|
|
152
|
+
): Promise<RunResult> {
|
|
153
|
+
return tool.execute("call-1", params as never, signal, undefined, ctx) as unknown as Promise<RunResult>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe("sudo_exec tool", () => {
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
credentialCache.clear();
|
|
159
|
+
for (const key of Object.keys(store.settings)) delete store.settings[key];
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("schema", () => {
|
|
163
|
+
const tool = createSudoExecTool();
|
|
164
|
+
|
|
165
|
+
it("registers as sudo_exec", () => {
|
|
166
|
+
expect(tool.name).toBe("sudo_exec");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("exposes exactly command, reason, timeoutMs — no password, no host", () => {
|
|
170
|
+
const params = tool.parameters as unknown as { properties: Record<string, unknown>; required?: string[] };
|
|
171
|
+
expect(Object.keys(params.properties).sort()).toEqual(["command", "reason", "timeoutMs"]);
|
|
172
|
+
expect("password" in params.properties).toBe(false);
|
|
173
|
+
expect("host" in params.properties).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("requires command and reason; timeoutMs is optional", () => {
|
|
177
|
+
const params = tool.parameters as unknown as { required?: string[] };
|
|
178
|
+
expect(params.required?.sort()).toEqual(["command", "reason"]);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("headless block", () => {
|
|
183
|
+
it("fails with a clear error when ctx.mode is not tui, without prompting", async () => {
|
|
184
|
+
const tool = createSudoExecTool();
|
|
185
|
+
const spies = { confirmCalls: [] as unknown[], customCalls: [] as unknown[] };
|
|
186
|
+
const ui = { confirm: (...args: unknown[]) => spies.confirmCalls.push(args), custom: (...args: unknown[]) => spies.customCalls.push(args) } as unknown as ExtensionContext["ui"];
|
|
187
|
+
const ctx = { mode: "json", hasUI: false, ui } as unknown as ExtensionContext;
|
|
188
|
+
|
|
189
|
+
const result = await run(tool, ctx, { command: "apt install ripgrep", reason: "install tool" });
|
|
190
|
+
|
|
191
|
+
expect(result.isError).toBe(true);
|
|
192
|
+
expect((result.content[0] as { text: string }).text).toMatch(/interactive/i);
|
|
193
|
+
// no UI was touched at all
|
|
194
|
+
expect(spies.confirmCalls).toHaveLength(0);
|
|
195
|
+
expect(spies.customCalls).toHaveLength(0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("fails in rpc mode too (custom components need the TUI)", async () => {
|
|
199
|
+
const tool = createSudoExecTool();
|
|
200
|
+
const result = await run(tool, headlessCtx("rpc"), { command: "apt install ripgrep", reason: "install tool" });
|
|
201
|
+
expect(result.isError).toBe(true);
|
|
202
|
+
expect((result.content[0] as { text: string }).text).toMatch(/interactive/i);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("carries the reason in details", async () => {
|
|
206
|
+
const tool = createSudoExecTool();
|
|
207
|
+
const result = await run(tool, headlessCtx("json"), { command: "apt install ripgrep", reason: "install tool" });
|
|
208
|
+
expect((result.details as { reason: string }).reason).toBe("install tool");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("validation", () => {
|
|
213
|
+
it("rejects an empty command", async () => {
|
|
214
|
+
const tool = createSudoExecTool();
|
|
215
|
+
const spies = spiesNoPrompt();
|
|
216
|
+
const result = await run(tool, tuiCtxWithSpies(true, spies), { command: " ", reason: "x" });
|
|
217
|
+
expect(result.isError).toBe(true);
|
|
218
|
+
expect((result.content[0] as { text: string }).text).toMatch(/command/i);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("confirmation gate", () => {
|
|
223
|
+
it("fails with 'not confirmed' when the user declines, without prompting for a password", async () => {
|
|
224
|
+
const tool = createSudoExecTool();
|
|
225
|
+
const spies = { confirmCalls: [] as Array<{ title: string; message: string }>, customCalls: [] as unknown[] };
|
|
226
|
+
const ctx = tuiCtxWithSpies(false, spies);
|
|
227
|
+
const result = await run(tool, ctx, { command: "apt install ripgrep", reason: "install ripgrep" });
|
|
228
|
+
|
|
229
|
+
expect(result.isError).toBe(true);
|
|
230
|
+
expect((result.content[0] as { text: string }).text).toMatch(/not confirmed/i);
|
|
231
|
+
expect(spies.customCalls).toHaveLength(0); // never prompted
|
|
232
|
+
// the confirmation displayed the exact command and reason
|
|
233
|
+
expect(spies.confirmCalls).toHaveLength(1);
|
|
234
|
+
expect(spies.confirmCalls[0]?.message).toContain("apt install ripgrep");
|
|
235
|
+
expect(spies.confirmCalls[0]?.message).toContain("install ripgrep");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("buildSudoArgv", () => {
|
|
240
|
+
it("builds sudo -S argv for a plain command", () => {
|
|
241
|
+
expect(buildSudoArgv("apt install ripgrep")).toEqual(["sudo", "-S", "apt", "install", "ripgrep"]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("splits quoted arguments into single argv entries", () => {
|
|
245
|
+
expect(buildSudoArgv(`systemctl restart "open rest api"`)).toEqual(["sudo", "-S", "systemctl", "restart", "open rest api"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("strips a stray leading sudo", () => {
|
|
249
|
+
expect(buildSudoArgv("sudo apt update")).toEqual(["sudo", "-S", "apt", "update"]);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("execution", () => {
|
|
254
|
+
it("runs the command and returns stdout with scrubbed details on success", async () => {
|
|
255
|
+
const { spawner, last } = makeFakeSpawner({ stdout: "hello\n", stderr: "[sudo] password for daniel: ", code: 0 });
|
|
256
|
+
const { authProbe, calls } = probeSpy(true);
|
|
257
|
+
const tool = createSudoExecTool({ spawner, authProbe });
|
|
258
|
+
credentialCache.set("cachedpw", 60_000);
|
|
259
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
260
|
+
|
|
261
|
+
const result = await run(tool, ctx, { command: "echo hello", reason: "test" });
|
|
262
|
+
|
|
263
|
+
expect(last.command).toBe("sudo");
|
|
264
|
+
expect(last.args).toEqual(["-S", "echo", "hello"]);
|
|
265
|
+
expect(result.isError).toBeFalsy();
|
|
266
|
+
const details = result.details as { command: string; reason: string; exitCode: number; stdout: string; stderr: string };
|
|
267
|
+
expect(details.exitCode).toBe(0);
|
|
268
|
+
expect(details.stdout).toBe("hello\n");
|
|
269
|
+
expect((result.content[0] as { text: string }).text).toBe("hello\n");
|
|
270
|
+
expect(details.command).toBe("echo hello");
|
|
271
|
+
expect(details.reason).toBe("test");
|
|
272
|
+
expect(calls()).toBe(0); // success path never probes
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("clears the credential cache on auth failure and reports an error", async () => {
|
|
276
|
+
const { spawner } = makeFakeSpawner({
|
|
277
|
+
stderr: "[sudo] password for daniel: \nsorry, try again.\n3 incorrect password attempts\n",
|
|
278
|
+
code: 1,
|
|
279
|
+
});
|
|
280
|
+
const tool = createSudoExecTool({ spawner });
|
|
281
|
+
credentialCache.set("badpw", 60_000);
|
|
282
|
+
const spies = spiesNoPrompt();
|
|
283
|
+
const ctx = tuiCtxWithSpies(true, spies);
|
|
284
|
+
|
|
285
|
+
const result = await run(tool, ctx, { command: "reboot", reason: "retry service" });
|
|
286
|
+
|
|
287
|
+
expect(result.isError).toBe(true);
|
|
288
|
+
expect((result.content[0] as { text: string }).text).toMatch(/authentication|incorrect password/i);
|
|
289
|
+
expect(credentialCache.get()).toBeNull(); // cache cleared
|
|
290
|
+
// and the cached (now-bad) password was never retained for retry
|
|
291
|
+
expect(spies.customCalls).toHaveLength(0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("writes the password plus newline to sudo stdin (never argv)", async () => {
|
|
295
|
+
const { spawner, last } = makeFakeSpawner({ stderr: "", code: 0 });
|
|
296
|
+
const tool = createSudoExecTool({ spawner });
|
|
297
|
+
credentialCache.set("hunter2", 60_000);
|
|
298
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
299
|
+
|
|
300
|
+
await run(tool, ctx, { command: "apt update", reason: "maintain" });
|
|
301
|
+
|
|
302
|
+
expect(JSON.stringify(last.args)).not.toContain("hunter2");
|
|
303
|
+
expect(last.child.stdin.written.join("")).toBe("hunter2\n");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("reports non-zero exit codes as errors", async () => {
|
|
307
|
+
const { spawner } = makeFakeSpawner({ stderr: "E: not found", code: 100 });
|
|
308
|
+
const { authProbe, calls } = probeSpy(true);
|
|
309
|
+
const tool = createSudoExecTool({ spawner, authProbe });
|
|
310
|
+
credentialCache.set("pw", 60_000);
|
|
311
|
+
const spies = spiesNoPrompt();
|
|
312
|
+
const ctx = tuiCtxWithSpies(true, spies);
|
|
313
|
+
|
|
314
|
+
const result = await run(tool, ctx, { command: "apt install nope", reason: "test" });
|
|
315
|
+
expect(result.isError).toBe(true);
|
|
316
|
+
expect((result.details as { exitCode: number }).exitCode).toBe(100);
|
|
317
|
+
// a plain command failure (no English auth signature) now ticket-probes;
|
|
318
|
+
// the valid ticket keeps it a normal command failure, not an auth failure
|
|
319
|
+
expect(calls()).toBe(1);
|
|
320
|
+
expect((result.content[0] as { text: string }).text).not.toMatch(/authentication/i);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("never lets the raw password leak into the result details (scrub)", async () => {
|
|
324
|
+
const { spawner } = makeFakeSpawner({ stderr: "weird echo: secret-leak-value\n", code: 0 });
|
|
325
|
+
const tool = createSudoExecTool({ spawner });
|
|
326
|
+
credentialCache.set("secret-leak-value", 60_000);
|
|
327
|
+
const spies = spiesNoPrompt();
|
|
328
|
+
const ctx = tuiCtxWithSpies(true, spies);
|
|
329
|
+
|
|
330
|
+
const result = await run(tool, ctx, { command: "true", reason: "test" });
|
|
331
|
+
const whole = JSON.stringify({ details: result.details, content: result.content });
|
|
332
|
+
expect(whole).not.toContain("secret-leak-value");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("maps a timeout SIGKILL exit to exit code 137 and reports the timeout error (runSudo contract: code null + SIGKILL → 137)", async () => {
|
|
336
|
+
// exitOn:'kill' — the child only exits when runSudo's timeout kills it.
|
|
337
|
+
const { spawner, last } = makeFakeSpawner({ stderr: "", code: null, exitOn: "kill" });
|
|
338
|
+
// model the group SIGKILL landing: -pid delivered successfully → the fake
|
|
339
|
+
// child reports a real SIGKILL exit (null code)
|
|
340
|
+
vi.spyOn(process, "kill").mockImplementation((pid?: number) => {
|
|
341
|
+
if (pid === -4242) last.child.kill("SIGKILL");
|
|
342
|
+
return true;
|
|
343
|
+
});
|
|
344
|
+
const tool = createSudoExecTool({ spawner });
|
|
345
|
+
credentialCache.set("pw", 60_000);
|
|
346
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
347
|
+
|
|
348
|
+
const result = await run(tool, ctx, { command: "sleep", reason: "test", timeoutMs: 10 });
|
|
349
|
+
|
|
350
|
+
expect(result.isError).toBe(true);
|
|
351
|
+
const details = result.details as { exitCode: number; error?: string };
|
|
352
|
+
expect(details.exitCode).toBe(137);
|
|
353
|
+
expect(details.error).toMatch(/timed out after 10ms/i);
|
|
354
|
+
expect(last.child.killed).toBe(true);
|
|
355
|
+
expect(last.child.killSignals).toContain("SIGKILL");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("maps an external abort to exit code 130, kills the process group with SIGKILL, and resolves exactly once (settle-guard)", async () => {
|
|
359
|
+
// No automatic exit: only abort's kill ever settles the child.
|
|
360
|
+
const { spawner, last } = makeFakeSpawner({ stderr: "starting…", code: null, exitOn: "kill" });
|
|
361
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); // group SIGKILL delivered, no exit echoed here — the settle-guard is the point
|
|
362
|
+
const { authProbe, calls } = probeSpy(true);
|
|
363
|
+
const tool = createSudoExecTool({ spawner, authProbe });
|
|
364
|
+
credentialCache.set("pw", 60_000);
|
|
365
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
366
|
+
const controller = new AbortController();
|
|
367
|
+
|
|
368
|
+
const promise = run(tool, ctx, { command: "long job", reason: "test", timeoutMs: 60_000 }, controller.signal);
|
|
369
|
+
let rejected = false;
|
|
370
|
+
promise.catch(() => {
|
|
371
|
+
rejected = true;
|
|
372
|
+
});
|
|
373
|
+
// let execute pass the gate and spawn
|
|
374
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
375
|
+
expect(last.command).toBe("sudo");
|
|
376
|
+
|
|
377
|
+
controller.abort(); // runSudo: child.kill(SIGKILL) + settle(130), before any exit event
|
|
378
|
+
|
|
379
|
+
const result = await promise;
|
|
380
|
+
// the fake's kill() also fires BOTH registered 'exit' and 'close' handlers
|
|
381
|
+
// after the abort — the settled guard must swallow them, keeping 130 stable
|
|
382
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
383
|
+
|
|
384
|
+
expect(result.isError).toBe(true);
|
|
385
|
+
const details = result.details as { exitCode: number; error?: string };
|
|
386
|
+
expect(details.exitCode).toBe(130);
|
|
387
|
+
expect(details.error).toMatch(/exit code 130/);
|
|
388
|
+
// the whole process group received SIGKILL (delivered to -pid, not just the direct child)
|
|
389
|
+
expect(killSpy).toHaveBeenCalledWith(-4242, "SIGKILL");
|
|
390
|
+
expect(last.child.killSignals).toHaveLength(0); // group kill succeeded — single-process fallback not needed
|
|
391
|
+
expect(rejected).toBe(false);
|
|
392
|
+
expect(calls()).toBe(0); // abort (130) never probes
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("fails with 'password entry cancelled' when the masked prompt is cancelled — no spawn, nothing cached", async () => {
|
|
396
|
+
let spawned = false;
|
|
397
|
+
const tool = createSudoExecTool({
|
|
398
|
+
spawner: () => {
|
|
399
|
+
spawned = true;
|
|
400
|
+
throw new Error("spawner must not be called");
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
// spiesNoPrompt's ui.custom resolves "" = the user cancelled the
|
|
404
|
+
// masked password prompt (Esc / Enter on an empty buffer).
|
|
405
|
+
const spies = spiesNoPrompt();
|
|
406
|
+
const ctx = tuiCtxWithSpies(true, spies);
|
|
407
|
+
|
|
408
|
+
const result = await run(tool, ctx, { command: "apt update", reason: "maintain" });
|
|
409
|
+
|
|
410
|
+
expect(result.isError).toBe(true);
|
|
411
|
+
expect((result.content[0] as { text: string }).text).toMatch(/password entry cancelled/i);
|
|
412
|
+
expect(spies.confirmCalls).toHaveLength(1); // confirmed, THEN prompted
|
|
413
|
+
expect(spies.customCalls).toHaveLength(1);
|
|
414
|
+
expect(spawned).toBe(false);
|
|
415
|
+
expect(credentialCache.get()).toBeNull(); // nothing cached on cancel
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("scrubSecret", () => {
|
|
420
|
+
it("replaces a whole line containing the secret with [redacted]", () => {
|
|
421
|
+
expect(scrubSecret("line one\nsudo: secret-leak-value here\nline three", "secret-leak-value")).toBe(
|
|
422
|
+
"line one\n[redacted]\nline three",
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("in multi-line text only the offending line is affected", () => {
|
|
427
|
+
expect(scrubSecret("keep a\nhas the p@ssword in it\nkeep c", "p@ssword")).toBe("keep a\n[redacted]\nkeep c");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("does NOT scrub a secret split across a newline (line-level matching limit — accepted residual)", () => {
|
|
431
|
+
expect(scrubSecret("pa\nss\nsurrounding", "pa\nss")).toBe("pa\nss\nsurrounding");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("returns empty text unchanged", () => {
|
|
435
|
+
expect(scrubSecret("", "secret")).toBe("");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("returns the input unchanged for an empty secret (guard against wiping everything)", () => {
|
|
439
|
+
const text = "a\nb\nc";
|
|
440
|
+
expect(scrubSecret(text, "")).toBe(text);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe("process-level failures (no crash, no hang, no false auth-failure)", () => {
|
|
445
|
+
/**
|
|
446
|
+
* Fake child whose 'error' event fires asynchronously (spawn failed —
|
|
447
|
+
* either the process never started, or it died hard). No 'exit'/'close'
|
|
448
|
+
* is ever fired: a dead-spawn child has no exit to report.
|
|
449
|
+
*/
|
|
450
|
+
function errorChild(err: Error): SudoChild {
|
|
451
|
+
const errorCbs: Array<(e: Error) => void> = [];
|
|
452
|
+
const child: SudoChild = {
|
|
453
|
+
stdin: { write() {}, end() {} },
|
|
454
|
+
stdout: { on: () => null },
|
|
455
|
+
stderr: { on: () => null },
|
|
456
|
+
kill: () => true,
|
|
457
|
+
on(evt: string, cb: unknown) {
|
|
458
|
+
if (evt === "error") errorCbs.push(cb as (e: Error) => void);
|
|
459
|
+
return null;
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
// fires AFTER the executor has synchronously registered listeners —
|
|
463
|
+
// like real Node emitting 'error' on a later tick for a failed spawn.
|
|
464
|
+
queueMicrotask(() => void errorCbs.forEach((cb) => cb(err)));
|
|
465
|
+
return child;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
it("resolves a clean tool failure (exit -1) when the child 'error' event fires (e.g. spawn ENOENT) — no uncaught crash, cache retained", async () => {
|
|
469
|
+
// 3s test timeout is the hang guard: without an 'error' listener the
|
|
470
|
+
// promise would never settle (there is no exit event from a spawn that
|
|
471
|
+
// never produced a process) → the review's crash/hang finding.
|
|
472
|
+
credentialCache.set("pw", 60_000);
|
|
473
|
+
const err = Object.assign(new Error("spawn sudo ENOENT"), { code: "ENOENT" });
|
|
474
|
+
const { authProbe, calls } = probeSpy(true);
|
|
475
|
+
const tool = createSudoExecTool({ spawner: () => errorChild(err), authProbe });
|
|
476
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
477
|
+
|
|
478
|
+
const result = await run(tool, ctx, { command: "apt update", reason: "maintain" });
|
|
479
|
+
|
|
480
|
+
expect(result.isError).toBe(true);
|
|
481
|
+
const text = (result.content[0] as { text: string }).text;
|
|
482
|
+
expect(text).toMatch(/failed to run privileged command/i);
|
|
483
|
+
expect(text).not.toMatch(/authentication failed|incorrect password/i); // must not look like an auth failure
|
|
484
|
+
const details = result.details as { exitCode: number; error?: string };
|
|
485
|
+
expect(details.exitCode).toBe(-1);
|
|
486
|
+
expect(details.error).toBeTruthy();
|
|
487
|
+
// The password STAYS cached: a spawn/transport failure says nothing
|
|
488
|
+
// about the credential — it may be a transient fs/permission hiccup.
|
|
489
|
+
expect(credentialCache.get()?.password).toBe("pw");
|
|
490
|
+
expect(calls()).toBe(0); // the -1 error path never probes
|
|
491
|
+
}, 3000);
|
|
492
|
+
|
|
493
|
+
it("resolves a clean tool failure when the spawner itself throws synchronously — no unhandled rejection, cache retained", async () => {
|
|
494
|
+
credentialCache.set("pw", 60_000);
|
|
495
|
+
const tool = createSudoExecTool({
|
|
496
|
+
spawner: () => {
|
|
497
|
+
throw Object.assign(new Error("spawn sudo EACCES"), { code: "EACCES" });
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
501
|
+
|
|
502
|
+
const result = await run(tool, ctx, { command: "apt update", reason: "maintain" });
|
|
503
|
+
|
|
504
|
+
expect(result.isError).toBe(true);
|
|
505
|
+
const text = (result.content[0] as { text: string }).text;
|
|
506
|
+
expect(text).toMatch(/failed to run privileged command/i);
|
|
507
|
+
expect(text).not.toMatch(/authentication failed|incorrect password/i);
|
|
508
|
+
expect((result.details as { exitCode: number }).exitCode).toBe(-1);
|
|
509
|
+
// cache retained — a spawning throw is about the transport, not the password
|
|
510
|
+
expect(credentialCache.get()?.password).toBe("pw");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("resolves a clean tool failure when writing the password to stdin is fatal (child died before reading it) — cache retained", async () => {
|
|
514
|
+
credentialCache.set("pw", 60_000);
|
|
515
|
+
const child: SudoChild = {
|
|
516
|
+
stdin: {
|
|
517
|
+
write() {
|
|
518
|
+
throw new Error("write EPIPE");
|
|
519
|
+
},
|
|
520
|
+
end() {},
|
|
521
|
+
},
|
|
522
|
+
stdout: { on: () => null },
|
|
523
|
+
stderr: { on: () => null },
|
|
524
|
+
kill: () => true,
|
|
525
|
+
on: () => null,
|
|
526
|
+
};
|
|
527
|
+
const tool = createSudoExecTool({ spawner: () => child });
|
|
528
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
529
|
+
|
|
530
|
+
const result = await run(tool, ctx, { command: "apt update", reason: "maintain" });
|
|
531
|
+
|
|
532
|
+
expect(result.isError).toBe(true);
|
|
533
|
+
const text = (result.content[0] as { text: string }).text;
|
|
534
|
+
expect(text).toMatch(/failed to run privileged command/i);
|
|
535
|
+
expect(text).not.toMatch(/authentication failed|incorrect password/i);
|
|
536
|
+
expect((result.details as { exitCode: number }).exitCode).toBe(-1);
|
|
537
|
+
// the child dying needs no re-auth of the password — keep it cached
|
|
538
|
+
expect(credentialCache.get()?.password).toBe("pw");
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
describe("async stream errors on the child's stdin (no unhandled 'error', no false fail)", () => {
|
|
543
|
+
/**
|
|
544
|
+
* Fake child whose stdin is a REAL EventEmitter: `emit("error")` with
|
|
545
|
+
* no listener throws — if runSudo didn't attach an 'error' listener
|
|
546
|
+
* before the async emit, the queueMicrotask'd throw is an uncaught
|
|
547
|
+
* exception and this entire test run fails (the crash-safety net for
|
|
548
|
+
* the "no unhandled 'error'" requirement). Stdout/stderr are inert
|
|
549
|
+
* sinks — these tests are about the stdin error path only.
|
|
550
|
+
*/
|
|
551
|
+
function streamingChild() {
|
|
552
|
+
const ee = new EventEmitter();
|
|
553
|
+
const written: string[] = [];
|
|
554
|
+
const exitCbs: Array<(code: number | null, signal?: NodeJS.Signals | null) => void> = [];
|
|
555
|
+
const child: SudoChild = {
|
|
556
|
+
stdin: Object.assign(ee, {
|
|
557
|
+
written,
|
|
558
|
+
write(chunk: string) {
|
|
559
|
+
written.push(chunk);
|
|
560
|
+
},
|
|
561
|
+
end() {},
|
|
562
|
+
}),
|
|
563
|
+
stdout: { on: () => null },
|
|
564
|
+
stderr: { on: () => null },
|
|
565
|
+
// no pid on purpose: a kill here is a no-op (killProcessTree
|
|
566
|
+
// skips pid-less children), so the 2s exit fallback is the only
|
|
567
|
+
// thing that settles a "child never reports exit" scenario.
|
|
568
|
+
kill: () => true,
|
|
569
|
+
on(
|
|
570
|
+
evt: string,
|
|
571
|
+
cb: ((code: number | null, signal?: NodeJS.Signals | null) => void) | ((err: Error) => void),
|
|
572
|
+
): unknown {
|
|
573
|
+
if (evt === "exit" || evt === "close") {
|
|
574
|
+
exitCbs.push(cb as (code: number | null, signal?: NodeJS.Signals | null) => void);
|
|
575
|
+
}
|
|
576
|
+
return null;
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
return {
|
|
580
|
+
child,
|
|
581
|
+
written,
|
|
582
|
+
emitStdinError(err: Error): void {
|
|
583
|
+
// async, like a real stream — a dead child's EPIPE can land
|
|
584
|
+
// on a later tick after the write already completed.
|
|
585
|
+
queueMicrotask(() => {
|
|
586
|
+
ee.emit("error", err);
|
|
587
|
+
});
|
|
588
|
+
},
|
|
589
|
+
/** fire BOTH 'exit' and 'close' (real children echo either). */
|
|
590
|
+
exit(code: number | null): void {
|
|
591
|
+
for (const cb of exitCbs) queueMicrotask(() => cb(code, null));
|
|
592
|
+
},
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
it("EPIPE on stdin (reader went away) followed by exit(0) settles as SUCCESS — no false failure for the NOPASSWD fast-exit path", async () => {
|
|
597
|
+
// NOPASSWD narrative: the host's sudoers grants NOPASSWD, the command
|
|
598
|
+
// finishes almost immediately and never reads the password from stdin
|
|
599
|
+
// (`sudo -S` ignores it), so the stdin write pipe breaks with EPIPE —
|
|
600
|
+
// asynchronously, after our write already completed. The read side
|
|
601
|
+
// dying says NOTHING about the credential or the command: the child's
|
|
602
|
+
// real exit code (0, here) must win. Treating the EPIPE as fatal would
|
|
603
|
+
// false-fail this perfectly legitimate fast-exit success.
|
|
604
|
+
credentialCache.set("nopw", 60_000);
|
|
605
|
+
const { child, emitStdinError, exit } = streamingChild();
|
|
606
|
+
const { authProbe, calls } = probeSpy(true);
|
|
607
|
+
const tool = createSudoExecTool({ spawner: () => child, authProbe });
|
|
608
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
609
|
+
|
|
610
|
+
const promise = run(tool, ctx, { command: "hostname", reason: "test" });
|
|
611
|
+
await new Promise((r) => setTimeout(r, 1)); // spawn + stdin listener attached
|
|
612
|
+
emitStdinError(Object.assign(new Error("read EPIPE"), { code: "EPIPE" }));
|
|
613
|
+
exit(0); // 'exit' then 'close' — the settled guard must swallow the echo
|
|
614
|
+
|
|
615
|
+
const result = await promise;
|
|
616
|
+
expect(result.isError).toBeFalsy();
|
|
617
|
+
const details = result.details as { exitCode: number; error?: string };
|
|
618
|
+
expect(details.exitCode).toBe(0);
|
|
619
|
+
expect(details.error).toBeUndefined();
|
|
620
|
+
expect(calls()).toBe(0); // success never probes
|
|
621
|
+
}, 3000);
|
|
622
|
+
|
|
623
|
+
it("EPIPE on stdin followed by exit(1) settles as a normal command failure (exit 1), no crash", async () => {
|
|
624
|
+
credentialCache.set("pw", 60_000);
|
|
625
|
+
const { child, emitStdinError, exit } = streamingChild();
|
|
626
|
+
const { authProbe, calls } = probeSpy(true); // gate probes; valid ticket → command failure, not auth
|
|
627
|
+
const tool = createSudoExecTool({ spawner: () => child, authProbe });
|
|
628
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
629
|
+
|
|
630
|
+
const promise = run(tool, ctx, { command: "systemctl stop dnsmasq", reason: "test" });
|
|
631
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
632
|
+
emitStdinError(Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }));
|
|
633
|
+
exit(1);
|
|
634
|
+
|
|
635
|
+
const result = await promise;
|
|
636
|
+
expect(result.isError).toBe(true);
|
|
637
|
+
const details = result.details as { exitCode: number; error?: string };
|
|
638
|
+
expect(details.exitCode).toBe(1);
|
|
639
|
+
expect(details.error).toMatch(/command failed with exit code 1/i);
|
|
640
|
+
expect((result.content[0] as { text: string }).text).not.toMatch(/authentication/i);
|
|
641
|
+
expect(calls()).toBe(1);
|
|
642
|
+
}, 3000);
|
|
643
|
+
|
|
644
|
+
it("anomalous stdin error while the child is still running (no exit) settles a clean transport failure — cache RETAINED, no probe", async () => {
|
|
645
|
+
credentialCache.set("pw", 60_000);
|
|
646
|
+
const { child, emitStdinError } = streamingChild();
|
|
647
|
+
const { authProbe, calls } = probeSpy(true);
|
|
648
|
+
const tool = createSudoExecTool({ spawner: () => child, authProbe });
|
|
649
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
650
|
+
|
|
651
|
+
const promise = run(tool, ctx, { command: "apt update", reason: "maintain" });
|
|
652
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
653
|
+
emitStdinError(Object.assign(new Error("boom"), { code: "ERR_STREAM_FAKE" }));
|
|
654
|
+
// no exit is fired at all — the handler itself must settle the run
|
|
655
|
+
// (a dead stdin with a live child is a broken transport).
|
|
656
|
+
const result = await promise;
|
|
657
|
+
|
|
658
|
+
expect(result.isError).toBe(true);
|
|
659
|
+
const text = (result.content[0] as { text: string }).text;
|
|
660
|
+
expect(text).toMatch(/failed to run privileged command/i);
|
|
661
|
+
expect(text).toMatch(/stdin failed: ERR_STREAM_FAKE/i);
|
|
662
|
+
expect(text).not.toMatch(/authentication failed|incorrect password/i);
|
|
663
|
+
const details = result.details as { exitCode: number; error?: string };
|
|
664
|
+
expect(details.exitCode).toBe(-1);
|
|
665
|
+
expect(details.error).toBe("stdin failed: ERR_STREAM_FAKE");
|
|
666
|
+
// transport failure says nothing about the credential — keep it cached
|
|
667
|
+
expect(credentialCache.get()?.password).toBe("pw");
|
|
668
|
+
expect(calls()).toBe(0); // the -1 error path never probes
|
|
669
|
+
}, 3000);
|
|
670
|
+
|
|
671
|
+
it("anomalous stdin error AFTER exit(0) already settled success is ignored — result stays success (no double-settle)", async () => {
|
|
672
|
+
credentialCache.set("pw", 60_000);
|
|
673
|
+
const { child, emitStdinError, exit } = streamingChild();
|
|
674
|
+
const { authProbe } = probeSpy(true);
|
|
675
|
+
const tool = createSudoExecTool({ spawner: () => child, authProbe });
|
|
676
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
677
|
+
|
|
678
|
+
const promise = run(tool, ctx, { command: "true", reason: "test" });
|
|
679
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
680
|
+
exit(0); // settles first — 'exit' wins
|
|
681
|
+
await new Promise((r) => setTimeout(r, 1)); // let the settle land
|
|
682
|
+
emitStdinError(Object.assign(new Error("late stream error"), { code: "ERR_STREAM_DESTROYED" }));
|
|
683
|
+
await new Promise((r) => setTimeout(r, 1)); // survive the (ignored) late error
|
|
684
|
+
|
|
685
|
+
const result = await promise;
|
|
686
|
+
expect(result.isError).toBeFalsy();
|
|
687
|
+
const details = result.details as { exitCode: number; error?: string };
|
|
688
|
+
expect(details.exitCode).toBe(0);
|
|
689
|
+
expect(details.error).toBeUndefined();
|
|
690
|
+
}, 3000);
|
|
691
|
+
|
|
692
|
+
it("EPIPE with a child that never reports exit — no crash, the timeout fallback settles the run (safety harness)", async () => {
|
|
693
|
+
// The EPIPE policy defers to 'exit'; when the registerable process is
|
|
694
|
+
// dead-and-silent, the timeout + 2s exit fallback is what settles the
|
|
695
|
+
// run. Also the crash net: the stdin is a real EventEmitter, so an
|
|
696
|
+
// unattached 'error' listener would take down the run.
|
|
697
|
+
credentialCache.set("pw", 60_000);
|
|
698
|
+
const { child, emitStdinError, written } = streamingChild();
|
|
699
|
+
const tool = createSudoExecTool({ spawner: () => child, authProbe: () => Promise.resolve(true) });
|
|
700
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
701
|
+
|
|
702
|
+
const promise = run(tool, ctx, { command: "sleep-forever-in-spirit", reason: "test", timeoutMs: 10 });
|
|
703
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
704
|
+
expect(written.join("")).toBe("pw\n"); // the password write happened before the error
|
|
705
|
+
emitStdinError(Object.assign(new Error("read EPIPE"), { code: "EPIPE" }));
|
|
706
|
+
// no exit ever comes — the exit fallback settles 124 ~2s after the timeout
|
|
707
|
+
|
|
708
|
+
const result = await promise;
|
|
709
|
+
expect(result.isError).toBe(true);
|
|
710
|
+
const details = result.details as { exitCode: number; error?: string };
|
|
711
|
+
expect(details.exitCode).toBe(124);
|
|
712
|
+
expect(details.error).toMatch(/timed out after 10ms/i);
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
describe("probeSudoTicket argv (password-free credential verification)", () => {
|
|
717
|
+
it("the probe argv is EXACTLY ['sudo','-n','-v'] — no program argument (that is the point of the fix)", async () => {
|
|
718
|
+
const calls: Array<{ command: string; args: string[] }> = [];
|
|
719
|
+
const { spawner } = makeFakeSpawner({ stderr: "", code: 0 });
|
|
720
|
+
const capturing: SudoSpawner = (command, args) => {
|
|
721
|
+
calls.push({ command, args });
|
|
722
|
+
return spawner(command, args);
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const valid = await probeSudoTicket(5000, undefined, capturing);
|
|
726
|
+
|
|
727
|
+
expect(calls).toHaveLength(1);
|
|
728
|
+
expect(calls[0]).toEqual({ command: "sudo", args: ["-n", "-v"] });
|
|
729
|
+
expect(calls[0]!.args).not.toContain("true"); // never executes a program
|
|
730
|
+
expect(valid).toBe(true);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("returns false when the credential check exits non-zero (non-zero = no valid credential)", async () => {
|
|
734
|
+
const calls: Array<{ command: string; args: string[] }> = [];
|
|
735
|
+
const { spawner } = makeFakeSpawner({ stderr: "sudo: a password is required\n", code: 1 });
|
|
736
|
+
const capturing: SudoSpawner = (command, args) => {
|
|
737
|
+
calls.push({ command, args });
|
|
738
|
+
return spawner(command, args);
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const valid = await probeSudoTicket(5000, undefined, capturing);
|
|
742
|
+
|
|
743
|
+
expect(calls[0]).toEqual({ command: "sudo", args: ["-n", "-v"] });
|
|
744
|
+
expect(valid).toBe(false);
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
describe("locale-immune auth-failure invalidation (credential ticket probe)", () => {
|
|
749
|
+
// The probe gate no longer requires the English prompt: it fires on any
|
|
750
|
+
// command-level failure (normal non-zero exit) whose stderr did not
|
|
751
|
+
// already match the English "incorrect password" fast-path signature.
|
|
752
|
+
// NON_ENGLISH_STDERR (English prompt + localized failure text) is the
|
|
753
|
+
// case (a) fixture: prompt present — the probe must still run.
|
|
754
|
+
// LOCALIZED_ONLY_STDERR: NO English text at all — promptSeen is false,
|
|
755
|
+
// and the probe must STILL run (the localized-auth regression).
|
|
756
|
+
|
|
757
|
+
function ambiguousRunSpawner() {
|
|
758
|
+
return ambiguousSpawner();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function localizedOnlyRunSpawner() {
|
|
762
|
+
const { spawner } = makeFakeSpawner({ stderr: LOCALIZED_ONLY_STDERR, code: 1 });
|
|
763
|
+
return spawner;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
it("keeps the cache on the FIRST ambiguous failure with a non-terminal probe (strike 1 of two) — with the one-more-attempt warning", async () => {
|
|
767
|
+
const { authProbe, calls } = probeSpy(false);
|
|
768
|
+
const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe });
|
|
769
|
+
credentialCache.set("badpw-de", 60_000);
|
|
770
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
771
|
+
|
|
772
|
+
const result = await run(tool, ctx, { command: "reboot", reason: "retry service" });
|
|
773
|
+
|
|
774
|
+
expect(result.isError).toBe(true);
|
|
775
|
+
const text = (result.content[0] as { text: string }).text;
|
|
776
|
+
// the failure came from the probe, not an English signature — say so
|
|
777
|
+
expect(text).not.toMatch(/authentication failed/i); // not yet invalidated
|
|
778
|
+
expect(text).toMatch(/one more attempt/i); // the honest strike-1 warning
|
|
779
|
+
expect(credentialCache.get()?.password).toBe("badpw-de"); // kept on strike 1 (no-reuse sudoers may probe-fail with a CORRECT password)
|
|
780
|
+
expect(credentialCache.get()?.failStreak).toBe(1);
|
|
781
|
+
expect(calls()).toBe(1);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("keeps the cache (and resets a carried streak) when the probe succeeds — valid credential ticket means the command itself failed, NOT the auth", async () => {
|
|
785
|
+
const { authProbe, calls } = probeSpy(true);
|
|
786
|
+
const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe });
|
|
787
|
+
credentialCache.set("goodpw", 60_000);
|
|
788
|
+
credentialCache.bumpFailStreak(); // carried streak from an earlier ambiguous failure
|
|
789
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
790
|
+
|
|
791
|
+
const result = await run(tool, ctx, { command: "systemctl restart dnsmasq", reason: "flip" });
|
|
792
|
+
|
|
793
|
+
// a normal command failure, not an auth failure
|
|
794
|
+
expect(result.isError).toBe(true);
|
|
795
|
+
const text = (result.content[0] as { text: string }).text;
|
|
796
|
+
expect(text).not.toMatch(/authentication/i);
|
|
797
|
+
expect((result.details as { error?: string }).error).toMatch(/command failed with exit code 1/i);
|
|
798
|
+
// the good credential must NOT have been thrown away
|
|
799
|
+
expect(credentialCache.get()?.password).toBe("goodpw");
|
|
800
|
+
// ticket-terminal probe clears the carried streak — the next ambiguous failure starts at 1
|
|
801
|
+
expect(credentialCache.get()?.failStreak).toBe(0);
|
|
802
|
+
expect(calls()).toBe(1);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("keeps the cache on the first ambiguous failure when the probe itself errors — strike 1, not an immediate clear", async () => {
|
|
806
|
+
const { authProbe, calls } = probeSpy(false, true);
|
|
807
|
+
const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe });
|
|
808
|
+
credentialCache.set("pw", 60_000);
|
|
809
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
810
|
+
|
|
811
|
+
const result = await run(tool, ctx, { command: "wake lock", reason: "test" });
|
|
812
|
+
|
|
813
|
+
// a probe that blew up must NOT escape as a tool error — it is a non-terminal probe
|
|
814
|
+
expect(result.isError).toBe(true);
|
|
815
|
+
const text = (result.content[0] as { text: string }).text;
|
|
816
|
+
expect(text).not.toMatch(/authentication failed/i); // not yet invalidated
|
|
817
|
+
expect(text).toMatch(/one more attempt/i);
|
|
818
|
+
expect(credentialCache.get()?.password).toBe("pw"); // strike 1: keep
|
|
819
|
+
expect(credentialCache.get()?.failStreak).toBe(1);
|
|
820
|
+
expect(calls()).toBe(1);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it("does NOT probe on the English-signature fast path (backwards-compatible: one spawn, no probe)", async () => {
|
|
824
|
+
const { spawner, last } = makeFakeSpawner({
|
|
825
|
+
stderr: "[sudo] password for daniel: \nsorry, try again.\n3 incorrect password attempts\n",
|
|
826
|
+
code: 1,
|
|
827
|
+
});
|
|
828
|
+
const { authProbe, calls } = probeSpy(true);
|
|
829
|
+
const tool = createSudoExecTool({ spawner, authProbe });
|
|
830
|
+
credentialCache.set("badpw", 60_000);
|
|
831
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
832
|
+
|
|
833
|
+
const result = await run(tool, ctx, { command: "reboot", reason: "retry service" });
|
|
834
|
+
|
|
835
|
+
expect(result.isError).toBe(true);
|
|
836
|
+
expect((result.content[0] as { text: string }).text).toMatch(/authentication|incorrect password/i);
|
|
837
|
+
expect(credentialCache.get()).toBeNull();
|
|
838
|
+
expect(calls()).toBe(0); // signature matched — no probe
|
|
839
|
+
expect(last.command).toBe("sudo");
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it("never probes a timed-out run, even when the prompt was seen (124/137 branches stay untouched)", async () => {
|
|
843
|
+
const { spawner } = makeFakeSpawner({ stderr: "[sudo] password for daniel: ", code: null, exitOn: "kill" });
|
|
844
|
+
const { authProbe, calls } = probeSpy(true);
|
|
845
|
+
const tool = createSudoExecTool({ spawner, authProbe });
|
|
846
|
+
credentialCache.set("pw", 60_000);
|
|
847
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
848
|
+
|
|
849
|
+
const result = await run(tool, ctx, { command: "sleep", reason: "test", timeoutMs: 10 });
|
|
850
|
+
|
|
851
|
+
expect(result.isError).toBe(true);
|
|
852
|
+
expect((result.details as { error?: string }).error).toMatch(/timed out after 10ms/i);
|
|
853
|
+
expect(calls()).toBe(0);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("verifies the probe still runs on a localized-sudo failure with NO English prompt text at all (promptSeen=false — the key regression) — and keeps the cache on strike 1", async () => {
|
|
857
|
+
const { authProbe, calls } = probeSpy(false);
|
|
858
|
+
const tool = createSudoExecTool({ spawner: localizedOnlyRunSpawner(), authProbe });
|
|
859
|
+
credentialCache.set("badpw-de", 60_000);
|
|
860
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
861
|
+
|
|
862
|
+
const result = await run(tool, ctx, { command: "reboot", reason: "retry service" });
|
|
863
|
+
|
|
864
|
+
expect(result.isError).toBe(true);
|
|
865
|
+
const text = (result.content[0] as { text: string }).text;
|
|
866
|
+
expect(text).not.toMatch(/authentication failed/i); // strike 1: not yet invalidated
|
|
867
|
+
expect(text).toMatch(/one more attempt/i);
|
|
868
|
+
expect(credentialCache.get()?.password).toBe("badpw-de");
|
|
869
|
+
expect(credentialCache.get()?.failStreak).toBe(1);
|
|
870
|
+
expect(calls()).toBe(1); // the probe ran despite promptSeen=false
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it("keeps the cache on a no-English-prompt failure when the ticket is valid (probe disambiguates auth from command failure)", async () => {
|
|
874
|
+
const { authProbe, calls } = probeSpy(true);
|
|
875
|
+
const tool = createSudoExecTool({ spawner: localizedOnlyRunSpawner(), authProbe });
|
|
876
|
+
credentialCache.set("goodpw", 60_000);
|
|
877
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
878
|
+
|
|
879
|
+
const result = await run(tool, ctx, { command: "systemctl restart dnsmasq", reason: "flip" });
|
|
880
|
+
|
|
881
|
+
expect(result.isError).toBe(true);
|
|
882
|
+
expect((result.content[0] as { text: string }).text).not.toMatch(/authentication/i);
|
|
883
|
+
expect((result.details as { error?: string }).error).toMatch(/command failed with exit code 1/i);
|
|
884
|
+
expect(credentialCache.get()?.password).toBe("goodpw");
|
|
885
|
+
expect(calls()).toBe(1);
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
describe("two-strike rule on ambiguous auth failure (fail streak on the cached credential)", () => {
|
|
890
|
+
it("a second consecutive ambiguous failure (probe non-terminal) on the same cached entry clears the cache and re-prompts, auth-failed-flavored", async () => {
|
|
891
|
+
const spawner = stepSpawner([
|
|
892
|
+
{ stderr: NON_ENGLISH_STDERR, code: 1 }, // strike 1
|
|
893
|
+
{ stderr: NON_ENGLISH_STDERR, code: 1 }, // strike 2
|
|
894
|
+
]);
|
|
895
|
+
const { authProbe, calls } = probeSpy(false);
|
|
896
|
+
const tool = createSudoExecTool({ spawner, authProbe });
|
|
897
|
+
credentialCache.set("badpw-de", 60_000);
|
|
898
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
899
|
+
|
|
900
|
+
const first = await run(tool, ctx, { command: "reboot", reason: "first run" });
|
|
901
|
+
expect(first.isError).toBe(true);
|
|
902
|
+
expect((first.content[0] as { text: string }).text).toMatch(/one more attempt/i);
|
|
903
|
+
expect(credentialCache.get()?.failStreak).toBe(1);
|
|
904
|
+
|
|
905
|
+
const second = await run(tool, ctx, { command: "reboot", reason: "second run" });
|
|
906
|
+
expect(second.isError).toBe(true);
|
|
907
|
+
const text = (second.content[0] as { text: string }).text;
|
|
908
|
+
expect(text).toMatch(/authentication failed/i);
|
|
909
|
+
// cause-NEUTRAL wording: states the consequence, never a cross-checked cause
|
|
910
|
+
expect(text).toMatch(/two consecutive failures/i);
|
|
911
|
+
expect(text).toMatch(/without a verifiable credential/i);
|
|
912
|
+
expect(text).toMatch(/re-prompt/i);
|
|
913
|
+
expect(credentialCache.get()).toBeNull(); // bad-password reuse is finally cut off
|
|
914
|
+
expect(calls()).toBe(2); // both runs probed
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it("a successful run (exit 0) resets a carried streak and the cache stays alive — the next ambiguous failure starts at 1 again", async () => {
|
|
918
|
+
const spawner = stepSpawner([
|
|
919
|
+
{ stderr: NON_ENGLISH_STDERR, code: 1 }, // ambiguous → strike 1
|
|
920
|
+
{ stdout: "ok\n", code: 0 }, // success → reset
|
|
921
|
+
{ stderr: NON_ENGLISH_STDERR, code: 1 }, // ambiguous again → must be strike 1, not a clear
|
|
922
|
+
]);
|
|
923
|
+
const { authProbe, calls } = probeSpy(false);
|
|
924
|
+
const tool = createSudoExecTool({ spawner, authProbe });
|
|
925
|
+
credentialCache.set("pw", 60_000);
|
|
926
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
927
|
+
|
|
928
|
+
await run(tool, ctx, { command: "svc a", reason: "1" });
|
|
929
|
+
expect(credentialCache.get()?.failStreak).toBe(1);
|
|
930
|
+
|
|
931
|
+
const success = await run(tool, ctx, { command: "svc b", reason: "2" });
|
|
932
|
+
expect(success.isError).toBeFalsy();
|
|
933
|
+
expect(credentialCache.get()?.password).toBe("pw");
|
|
934
|
+
expect(credentialCache.get()?.failStreak).toBe(0); // the reset — pinned
|
|
935
|
+
|
|
936
|
+
const third = await run(tool, ctx, { command: "svc c", reason: "3" });
|
|
937
|
+
expect(third.isError).toBe(true);
|
|
938
|
+
expect((third.content[0] as { text: string }).text).toMatch(/one more attempt/i);
|
|
939
|
+
expect(credentialCache.get()?.password).toBe("pw"); // kept AGAIN — a fresh strike #1
|
|
940
|
+
expect(credentialCache.get()?.failStreak).toBe(1);
|
|
941
|
+
expect(calls()).toBe(2); // runs 1 and 3 probed; the success never does
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it("the English fast path clears the cache immediately regardless of streak (unchanged behavior, no probe on strike 2)", async () => {
|
|
945
|
+
const spawner = stepSpawner([
|
|
946
|
+
{ stderr: NON_ENGLISH_STDERR, code: 1 }, // ambiguous → streak 1
|
|
947
|
+
{ stderr: "[sudo] password for daniel: \nsorry, try again.\n3 incorrect password attempts\n", code: 1 }, // fast path
|
|
948
|
+
]);
|
|
949
|
+
const { authProbe, calls } = probeSpy(false); // non-terminal probe → strike 1, not a ticket-valid reset
|
|
950
|
+
const tool = createSudoExecTool({ spawner, authProbe });
|
|
951
|
+
credentialCache.set("badpw", 60_000);
|
|
952
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
953
|
+
|
|
954
|
+
const first = await run(tool, ctx, { command: "reboot", reason: "1" });
|
|
955
|
+
expect(first.isError).toBe(true);
|
|
956
|
+
expect(credentialCache.get()?.failStreak).toBe(1);
|
|
957
|
+
|
|
958
|
+
const second = await run(tool, ctx, { command: "reboot", reason: "2" });
|
|
959
|
+
expect(second.isError).toBe(true);
|
|
960
|
+
expect((second.content[0] as { text: string }).text).toMatch(/authentication failed \(incorrect password\)/i);
|
|
961
|
+
expect(credentialCache.get()).toBeNull(); // cleared immediately — fast path never waits for strike 2
|
|
962
|
+
expect(calls()).toBe(1); // run 1 probed (ambiguous); run 2's signature match skipped the probe
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("a transport failure (-1) mid streak 1 leaves the streak UNCHANGED — the next ambiguous failure is still strike #2", async () => {
|
|
966
|
+
let i = 0;
|
|
967
|
+
const ambient = ambiguousSpawner();
|
|
968
|
+
const spawner: SudoSpawner = (command, args) => {
|
|
969
|
+
i += 1;
|
|
970
|
+
if (i === 2) return erroredChild(); // transport failure says nothing about the credential
|
|
971
|
+
return ambient(command, args);
|
|
972
|
+
};
|
|
973
|
+
const { authProbe } = probeSpy(false);
|
|
974
|
+
const tool = createSudoExecTool({ spawner, authProbe });
|
|
975
|
+
credentialCache.set("pw", 60_000);
|
|
976
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
977
|
+
|
|
978
|
+
const r1 = await run(tool, ctx, { command: "apt update", reason: "1" });
|
|
979
|
+
expect(r1.isError).toBe(true);
|
|
980
|
+
expect(credentialCache.get()?.failStreak).toBe(1);
|
|
981
|
+
|
|
982
|
+
const r2 = await run(tool, ctx, { command: "apt update", reason: "2" });
|
|
983
|
+
expect(r2.isError).toBe(true);
|
|
984
|
+
expect((r2.content[0] as { text: string }).text).toMatch(/failed to run privileged command/i);
|
|
985
|
+
expect((r2.content[0] as { text: string }).text).not.toMatch(/authentication failed/i);
|
|
986
|
+
expect(credentialCache.get()?.password).toBe("pw");
|
|
987
|
+
expect(credentialCache.get()?.failStreak).toBe(1); // transport failure is not a strike
|
|
988
|
+
|
|
989
|
+
const r3 = await run(tool, ctx, { command: "apt update", reason: "3" });
|
|
990
|
+
expect(r3.isError).toBe(true);
|
|
991
|
+
expect(credentialCache.get()).toBeNull(); // the next ambiguous failure IS strike #2 → clear
|
|
992
|
+
});
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
describe("process-group kill on timeout/abort (elevated descendants)", () => {
|
|
996
|
+
afterEach(() => {
|
|
997
|
+
vi.restoreAllMocks();
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it("spawns with { detached: true } so the command owns its own process group", async () => {
|
|
1001
|
+
const { spawner } = makeFakeSpawner({ stderr: "", code: 0 });
|
|
1002
|
+
const captured: Array<{ detached: boolean } | undefined> = [];
|
|
1003
|
+
const wrapping = (command: string, args: string[], options?: { detached: boolean }) => {
|
|
1004
|
+
captured.push(options);
|
|
1005
|
+
return spawner(command, args);
|
|
1006
|
+
};
|
|
1007
|
+
const tool = createSudoExecTool({ spawner: wrapping, authProbe: () => Promise.resolve(true) });
|
|
1008
|
+
credentialCache.set("pw", 60_000);
|
|
1009
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
1010
|
+
|
|
1011
|
+
await run(tool, ctx, { command: "echo hi", reason: "test" });
|
|
1012
|
+
|
|
1013
|
+
expect(captured).toEqual([{ detached: true }]);
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("timeout kill hits the whole process group first (-pid SIGKILL), so root descendants cannot survive", async () => {
|
|
1017
|
+
const { spawner } = makeFakeSpawner({ stderr: "", code: null, exitOn: "kill" });
|
|
1018
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation((pid?: number) => {
|
|
1019
|
+
if (pid !== -4242) {
|
|
1020
|
+
throw Object.assign(new Error("unexpected kill target"), { code: "ESRCH" });
|
|
1021
|
+
}
|
|
1022
|
+
return true; // pretend the group kill succeeds — the fake child never exits on its own
|
|
1023
|
+
});
|
|
1024
|
+
const tool = createSudoExecTool({ spawner, authProbe: () => Promise.resolve(true) });
|
|
1025
|
+
credentialCache.set("pw", 60_000);
|
|
1026
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
1027
|
+
|
|
1028
|
+
// settles via runSudo's 2s exit fallback (the faked group kill leaves the
|
|
1029
|
+
// fake child unexited) → exercises the 124 branch, which must never probe
|
|
1030
|
+
const result = await run(tool, ctx, { command: "sleep", reason: "test", timeoutMs: 10 });
|
|
1031
|
+
|
|
1032
|
+
expect(result.isError).toBe(true);
|
|
1033
|
+
expect((result.details as { error?: string }).error).toMatch(/timed out after 10ms/i);
|
|
1034
|
+
expect(killSpy).toHaveBeenLastCalledWith(-4242, "SIGKILL");
|
|
1035
|
+
}, 10_000);
|
|
1036
|
+
|
|
1037
|
+
it("abort kill hits the whole process group first (-pid SIGKILL)", async () => {
|
|
1038
|
+
const { spawner, last } = makeFakeSpawner({ stderr: "starting…", code: null, exitOn: "kill" });
|
|
1039
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
1040
|
+
const tool = createSudoExecTool({ spawner, authProbe: () => Promise.resolve(true) });
|
|
1041
|
+
credentialCache.set("pw", 60_000);
|
|
1042
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
1043
|
+
const controller = new AbortController();
|
|
1044
|
+
|
|
1045
|
+
const promise = run(tool, ctx, { command: "long job", reason: "test", timeoutMs: 60_000 }, controller.signal);
|
|
1046
|
+
await new Promise((r) => setTimeout(r, 1)); // let execute pass the gate and spawn
|
|
1047
|
+
expect(last.command).toBe("sudo");
|
|
1048
|
+
|
|
1049
|
+
controller.abort();
|
|
1050
|
+
|
|
1051
|
+
const result = await promise;
|
|
1052
|
+
expect((result.details as { exitCode: number }).exitCode).toBe(130);
|
|
1053
|
+
expect(killSpy).toHaveBeenCalledWith(-4242, "SIGKILL");
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it("treats an ESRCH group kill as COMPLETE — one group attempt, no retry, no single-process fallback (the group is already dead)", async () => {
|
|
1057
|
+
const { spawner, last } = makeFakeSpawner({ stderr: "starting…", code: null, exitOn: "kill" });
|
|
1058
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
|
1059
|
+
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
|
|
1060
|
+
});
|
|
1061
|
+
const tool = createSudoExecTool({ spawner, authProbe: () => Promise.resolve(true) });
|
|
1062
|
+
credentialCache.set("pw", 60_000);
|
|
1063
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
1064
|
+
const controller = new AbortController();
|
|
1065
|
+
|
|
1066
|
+
const promise = run(tool, ctx, { command: "long job", reason: "test", timeoutMs: 60_000 }, controller.signal);
|
|
1067
|
+
await new Promise((r) => setTimeout(r, 1)); // let execute pass the gate and spawn
|
|
1068
|
+
|
|
1069
|
+
controller.abort();
|
|
1070
|
+
|
|
1071
|
+
const result = await promise;
|
|
1072
|
+
expect((result.details as { exitCode: number }).exitCode).toBe(130);
|
|
1073
|
+
expect(killSpy).toHaveBeenCalledTimes(1); // ESRCH short-circuits — definitively dead, no retry
|
|
1074
|
+
expect(killSpy).toHaveBeenCalledWith(-4242, "SIGKILL");
|
|
1075
|
+
expect(last.child.killed).toBe(false); // the group is already gone — no single-process fallback needed
|
|
1076
|
+
});
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
describe("hardened process-group kill retry (single-process fallback only after non-ESRCH group attempts are exhausted)", () => {
|
|
1080
|
+
afterEach(() => {
|
|
1081
|
+
vi.restoreAllMocks();
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
// A pid'd child that NEVER exits on its own and fires no exit events:
|
|
1085
|
+
// kill() is recorded only, so the abort settles deterministically on the
|
|
1086
|
+
// finish(130) path and the assertions are purely about the kill attempts.
|
|
1087
|
+
function silentChild() {
|
|
1088
|
+
const killSignals: Array<NodeJS.Signals | undefined> = [];
|
|
1089
|
+
const child: SudoChild = {
|
|
1090
|
+
stdin: { write() {}, end() {} },
|
|
1091
|
+
stdout: { on: () => null },
|
|
1092
|
+
stderr: { on: () => null },
|
|
1093
|
+
pid: 4242,
|
|
1094
|
+
kill(signal?: NodeJS.Signals) {
|
|
1095
|
+
killSignals.push(signal);
|
|
1096
|
+
return true;
|
|
1097
|
+
},
|
|
1098
|
+
on: () => null,
|
|
1099
|
+
};
|
|
1100
|
+
return { child, killSignals, spawner: (() => child) as SudoSpawner };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
it("a transient (non-ESRCH) group-signal failure gets ONE retry after a short delay; retry-ESRCH = complete — exactly 2 group attempts, NO single-process fallback", async () => {
|
|
1104
|
+
let groupAttempts = 0;
|
|
1105
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation((pid?: number) => {
|
|
1106
|
+
groupAttempts += 1;
|
|
1107
|
+
if (groupAttempts === 1) {
|
|
1108
|
+
// attempt 1: transient — the group state is uncertain, not confirmed dead
|
|
1109
|
+
throw Object.assign(new Error("operation not permitted"), { code: "EACCES" });
|
|
1110
|
+
}
|
|
1111
|
+
// attempt 2: definitive — the (entire) group no longer exists
|
|
1112
|
+
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
|
|
1113
|
+
});
|
|
1114
|
+
const realSetTimeout = setTimeout;
|
|
1115
|
+
const delays: number[] = [];
|
|
1116
|
+
// the retry sleeps via a short REAL timer (no fake-timer entanglement);
|
|
1117
|
+
// record the delay and delegate to the real timer.
|
|
1118
|
+
vi.spyOn(globalThis, "setTimeout").mockImplementation(((fn: (...args: unknown[]) => void, ms?: number): unknown => {
|
|
1119
|
+
if (ms !== undefined) delays.push(ms);
|
|
1120
|
+
return (realSetTimeout as (fn: unknown, ms?: number) => unknown)(fn, ms);
|
|
1121
|
+
}) as unknown as typeof setTimeout);
|
|
1122
|
+
const { spawner, killSignals } = silentChild();
|
|
1123
|
+
const tool = createSudoExecTool({ spawner, authProbe: () => Promise.resolve(true) });
|
|
1124
|
+
credentialCache.set("pw", 60_000);
|
|
1125
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
1126
|
+
const controller = new AbortController();
|
|
1127
|
+
|
|
1128
|
+
const promise = run(tool, ctx, { command: "long job", reason: "test", timeoutMs: 60_000 }, controller.signal);
|
|
1129
|
+
await new Promise<void>((r) => { void realSetTimeout(r, 1); }); // let execute pass the gate and spawn
|
|
1130
|
+
|
|
1131
|
+
controller.abort(); // the retry delay is the ONLY armed timer now
|
|
1132
|
+
|
|
1133
|
+
const result = await promise;
|
|
1134
|
+
expect((result.details as { exitCode: number }).exitCode).toBe(130);
|
|
1135
|
+
expect(killSpy).toHaveBeenCalledTimes(2); // exactly one retry after the transient failure
|
|
1136
|
+
expect(killSpy).toHaveBeenNthCalledWith(1, -4242, "SIGKILL");
|
|
1137
|
+
expect(killSpy).toHaveBeenNthCalledWith(2, -4242, "SIGKILL");
|
|
1138
|
+
expect(delays).toContain(50); // short real delay before the retry (the 60_000 run timer may also be recorded)
|
|
1139
|
+
expect(killSignals).toHaveLength(0); // group definitively dead → fallback NOT called
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
it("a definitive ESRCH group-signal failure settles immediately — exactly 1 group attempt, no retry, no single-process fallback", async () => {
|
|
1143
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
|
1144
|
+
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
|
|
1145
|
+
});
|
|
1146
|
+
const { spawner, killSignals } = silentChild();
|
|
1147
|
+
const tool = createSudoExecTool({ spawner, authProbe: () => Promise.resolve(true) });
|
|
1148
|
+
credentialCache.set("pw", 60_000);
|
|
1149
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
1150
|
+
const controller = new AbortController();
|
|
1151
|
+
|
|
1152
|
+
const promise = run(tool, ctx, { command: "long job", reason: "test", timeoutMs: 60_000 }, controller.signal);
|
|
1153
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
1154
|
+
|
|
1155
|
+
controller.abort();
|
|
1156
|
+
|
|
1157
|
+
const result = await promise;
|
|
1158
|
+
expect((result.details as { exitCode: number }).exitCode).toBe(130);
|
|
1159
|
+
expect(killSpy).toHaveBeenCalledTimes(1);
|
|
1160
|
+
expect(killSpy).toHaveBeenCalledWith(-4242, "SIGKILL");
|
|
1161
|
+
expect(killSignals).toHaveLength(0);
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
it("two consecutive transient group-signal failures exhaust the retries → then (and only then) the single-process SIGKILL fallback", async () => {
|
|
1165
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
|
1166
|
+
throw Object.assign(new Error("operation not permitted"), { code: "EACCES" });
|
|
1167
|
+
});
|
|
1168
|
+
const { spawner, killSignals } = silentChild();
|
|
1169
|
+
const tool = createSudoExecTool({ spawner, authProbe: () => Promise.resolve(true) });
|
|
1170
|
+
credentialCache.set("pw", 60_000);
|
|
1171
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
1172
|
+
const controller = new AbortController();
|
|
1173
|
+
|
|
1174
|
+
const promise = run(tool, ctx, { command: "long job", reason: "test", timeoutMs: 60_000 }, controller.signal);
|
|
1175
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
1176
|
+
|
|
1177
|
+
controller.abort();
|
|
1178
|
+
|
|
1179
|
+
const result = await promise;
|
|
1180
|
+
expect((result.details as { exitCode: number }).exitCode).toBe(130);
|
|
1181
|
+
expect(killSpy).toHaveBeenCalledTimes(2); // group attempted first, retried once
|
|
1182
|
+
expect(killSpy).toHaveBeenNthCalledWith(1, -4242, "SIGKILL");
|
|
1183
|
+
expect(killSpy).toHaveBeenNthCalledWith(2, -4242, "SIGKILL");
|
|
1184
|
+
expect(killSignals).toEqual(["SIGKILL"]); // single fallback ran only after group attempts were exhausted
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it("a throwing single-process fallback is swallowed — the abort still settles (130) and no error escapes", async () => {
|
|
1188
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
|
1189
|
+
throw Object.assign(new Error("process already dead"), { code: "EPERM" });
|
|
1190
|
+
});
|
|
1191
|
+
const silent = silentChild();
|
|
1192
|
+
const throwingSpawner: SudoSpawner = () => ({ ...silent.child, kill: () => (() => { throw Object.assign(new Error("child gone"), { code: "ESRCH" }); })() });
|
|
1193
|
+
const tool = createSudoExecTool({ spawner: throwingSpawner, authProbe: () => Promise.resolve(true) });
|
|
1194
|
+
credentialCache.set("pw", 60_000);
|
|
1195
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
1196
|
+
const controller = new AbortController();
|
|
1197
|
+
|
|
1198
|
+
const promise = run(tool, ctx, { command: "long job", reason: "test", timeoutMs: 60_000 }, controller.signal);
|
|
1199
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
1200
|
+
|
|
1201
|
+
controller.abort();
|
|
1202
|
+
|
|
1203
|
+
const result = await promise; // must resolve, not reject — the child is gone anyway
|
|
1204
|
+
expect((result.details as { exitCode: number }).exitCode).toBe(130);
|
|
1205
|
+
expect(killSpy).toHaveBeenCalledTimes(2);
|
|
1206
|
+
});
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
describe("abort-vs-exit race (cancellation never touches credential bookkeeping)", () => {
|
|
1210
|
+
afterEach(() => {
|
|
1211
|
+
vi.restoreAllMocks();
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
// Pid'd child with collector-based 'exit'/'close' registration and a
|
|
1215
|
+
// synchronous fire helper — the race pin needs control over EXACTLY
|
|
1216
|
+
// when the exit event is dispatched relative to the abort's kill.
|
|
1217
|
+
function racingChild() {
|
|
1218
|
+
const exitCbs: Array<(code: number | null, sig?: NodeJS.Signals | null) => void> = [];
|
|
1219
|
+
const child: SudoChild = {
|
|
1220
|
+
stdin: { write() {}, end() {} },
|
|
1221
|
+
stdout: { on: () => null },
|
|
1222
|
+
stderr: { on: () => null },
|
|
1223
|
+
pid: 4242,
|
|
1224
|
+
kill: () => true,
|
|
1225
|
+
on(evt: string, cb: unknown) {
|
|
1226
|
+
if (evt === "exit" || evt === "close") exitCbs.push(cb as (code: number | null, sig?: NodeJS.Signals | null) => void);
|
|
1227
|
+
return null;
|
|
1228
|
+
},
|
|
1229
|
+
};
|
|
1230
|
+
/** fire BOTH registered 'exit' and 'close' synchronously — the worst-case interleaving. */
|
|
1231
|
+
const exit = (code: number): void => {
|
|
1232
|
+
for (const cb of exitCbs) cb(code, null);
|
|
1233
|
+
};
|
|
1234
|
+
return { child, exit, spawner: (() => child) as SudoSpawner };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
it("a real 1–123 exit that fires inside the abort kill's async retry window does NOT win the settle — the run resolves as the abort (130), streak untouched, probe never called (race pin)", async () => {
|
|
1238
|
+
credentialCache.set("pw", 60_000);
|
|
1239
|
+
// Throws EACCES ONCE → killProcessTree's 50ms retry tick is in
|
|
1240
|
+
// flight; afterwards the group kill 'succeeds' silently (no exit
|
|
1241
|
+
// echo — the settle must come from the abort path alone).
|
|
1242
|
+
let killAttempts = 0;
|
|
1243
|
+
vi.spyOn(process, "kill").mockImplementation(() => {
|
|
1244
|
+
killAttempts += 1;
|
|
1245
|
+
if (killAttempts === 1) throw Object.assign(new Error("operation not permitted"), { code: "EACCES" });
|
|
1246
|
+
return true;
|
|
1247
|
+
});
|
|
1248
|
+
const { authProbe, calls } = probeSpy(false); // non-terminal probe — any bad entry would bump the streak, VIEWABLE
|
|
1249
|
+
const racing = racingChild();
|
|
1250
|
+
const tool = createSudoExecTool({ spawner: racing.spawner, authProbe });
|
|
1251
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
1252
|
+
const controller = new AbortController();
|
|
1253
|
+
|
|
1254
|
+
const promise = run(tool, ctx, { command: "long job", reason: "test", timeoutMs: 60_000 }, controller.signal);
|
|
1255
|
+
await new Promise((r) => setTimeout(r, 1)); // spawn done — onAbort is registered
|
|
1256
|
+
// Listener ORDER: runSudo's onAbort (registered first) runs first —
|
|
1257
|
+
// it kicks off the async kill, and THIS exit(3) lands while that
|
|
1258
|
+
// kill is still awaiting its 50ms retry tick: fire exit(3).
|
|
1259
|
+
controller.signal.addEventListener("abort", () => racing.exit(3));
|
|
1260
|
+
controller.abort();
|
|
1261
|
+
|
|
1262
|
+
const result = await promise;
|
|
1263
|
+
expect(result.isError).toBe(true);
|
|
1264
|
+
const details = result.details as { exitCode: number; error?: string };
|
|
1265
|
+
expect(details.exitCode).toBe(130); // the abort outcome — NOT the ordinary failure code 3
|
|
1266
|
+
expect(details.error).toMatch(/exit code 130/);
|
|
1267
|
+
// Cancellation never touches credential bookkeeping:
|
|
1268
|
+
expect(credentialCache.get()?.password).toBe("pw");
|
|
1269
|
+
expect(credentialCache.get()?.failStreak).toBe(0); // neither bumped nor cleared
|
|
1270
|
+
expect(calls()).toBe(0); // the ticket probe was NOT invoked
|
|
1271
|
+
// The settle the full kill sequence completed (both attempts happened)
|
|
1272
|
+
// — the exit(3) that landed during the retry window was swallowed, not honored.
|
|
1273
|
+
expect(killAttempts).toBe(2); // attempt 1 EACCES → 50ms tick → attempt 2 → finish(130)
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
it("cancellation never accumulates across runs — a second run + abort on the same cached entry leaves the fail-streak at 0", async () => {
|
|
1277
|
+
credentialCache.set("pw2", 60_000);
|
|
1278
|
+
let killAttempts = 0;
|
|
1279
|
+
vi.spyOn(process, "kill").mockImplementation(() => {
|
|
1280
|
+
killAttempts += 1;
|
|
1281
|
+
if (killAttempts % 2 === 1) throw Object.assign(new Error("operation not permitted"), { code: "EACCES" });
|
|
1282
|
+
return true; // every odd attempt opens the 50ms retry window; the even one confirms
|
|
1283
|
+
});
|
|
1284
|
+
const { authProbe, calls } = probeSpy(false);
|
|
1285
|
+
const races = [racingChild(), racingChild()]; // fresh child per run
|
|
1286
|
+
let next = 0;
|
|
1287
|
+
const tool = createSudoExecTool({ spawner: () => races[next++]!.child, authProbe });
|
|
1288
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
1289
|
+
|
|
1290
|
+
const abortRun = async (reason: string) => {
|
|
1291
|
+
const controller = new AbortController();
|
|
1292
|
+
const race = races[next]!;
|
|
1293
|
+
const promise = run(tool, ctx, { command: "long job", reason, timeoutMs: 60_000 }, controller.signal);
|
|
1294
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
1295
|
+
controller.signal.addEventListener("abort", () => race.exit(3));
|
|
1296
|
+
controller.abort();
|
|
1297
|
+
return promise;
|
|
1298
|
+
};
|
|
1299
|
+
|
|
1300
|
+
const first = await abortRun("run 1");
|
|
1301
|
+
expect((first.details as { exitCode: number }).exitCode).toBe(130);
|
|
1302
|
+
expect(credentialCache.get()?.failStreak).toBe(0);
|
|
1303
|
+
|
|
1304
|
+
const second = await abortRun("run 2");
|
|
1305
|
+
expect((second.details as { exitCode: number }).exitCode).toBe(130);
|
|
1306
|
+
expect(credentialCache.get()?.password).toBe("pw2"); // still the same entry
|
|
1307
|
+
expect(credentialCache.get()?.failStreak).toBe(0); // pinned: cancellation does not accumulate
|
|
1308
|
+
expect(calls()).toBe(0); // neither aborted run entered the ticket-probe path
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
it("a normal (non-aborted) 1–123 exit still probes and bumps exactly as before — the swallow only applies post-abort", async () => {
|
|
1312
|
+
credentialCache.set("pw", 60_000);
|
|
1313
|
+
const { authProbe, calls } = probeSpy(false); // non-terminal probe → strike 1
|
|
1314
|
+
const racing = racingChild();
|
|
1315
|
+
const tool = createSudoExecTool({ spawner: racing.spawner, authProbe });
|
|
1316
|
+
const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
|
|
1317
|
+
|
|
1318
|
+
const promise = run(tool, ctx, { command: "apt update", reason: "maintain" });
|
|
1319
|
+
await new Promise((r) => setTimeout(r, 1)); // spawn done — no abort anywhere
|
|
1320
|
+
racing.exit(1); // ordinary command failure; 'exit' then 'close'
|
|
1321
|
+
|
|
1322
|
+
const result = await promise;
|
|
1323
|
+
expect(result.isError).toBe(true);
|
|
1324
|
+
expect((result.details as { exitCode: number }).exitCode).toBe(1);
|
|
1325
|
+
expect((result.details as { error?: string }).error).toMatch(/command failed with exit code 1/i);
|
|
1326
|
+
expect((result.content[0] as { text: string }).text).toMatch(/one more attempt/i);
|
|
1327
|
+
expect(calls()).toBe(1); // the probe WAS invoked — baseline preserved
|
|
1328
|
+
expect(credentialCache.get()?.failStreak).toBe(1); // bumped exactly once, strike 1 of two
|
|
1329
|
+
});
|
|
1330
|
+
});
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
// The 'spiesNoPrompt' helper: confirmation says yes, and any accidental
|
|
1334
|
+
// password prompt resolves empty (custom is expected to be never called).
|
|
1335
|
+
function spiesNoPrompt() {
|
|
1336
|
+
return {
|
|
1337
|
+
confirmCalls: [] as Array<{ title: string; message: string }>,
|
|
1338
|
+
customCalls: [] as unknown[],
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function probeSpy(ok: boolean, throws = false) {
|
|
1343
|
+
let calls = 0;
|
|
1344
|
+
const authProbe: (timeoutMs: number, signal: AbortSignal | undefined) => Promise<boolean> = () => {
|
|
1345
|
+
calls += 1;
|
|
1346
|
+
if (throws) return Promise.reject(new Error("probe transport error"));
|
|
1347
|
+
return Promise.resolve(ok);
|
|
1348
|
+
};
|
|
1349
|
+
return { authProbe, calls: () => calls };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function tuiCtxWithSpies(confirmAnswer: boolean, spies: { confirmCalls: Array<{ title: string; message: string }>; customCalls: unknown[] }): ExtensionContext {
|
|
1353
|
+
const ui = {
|
|
1354
|
+
confirm: (title: string, message: string) => {
|
|
1355
|
+
spies.confirmCalls.push({ title, message });
|
|
1356
|
+
return Promise.resolve(confirmAnswer);
|
|
1357
|
+
},
|
|
1358
|
+
custom: (factory: unknown): Promise<string> => {
|
|
1359
|
+
spies.customCalls.push(factory);
|
|
1360
|
+
return Promise.resolve("");
|
|
1361
|
+
},
|
|
1362
|
+
};
|
|
1363
|
+
return { mode: "tui", hasUI: true, cwd: "/tmp", ui: ui as unknown as ExtensionContext["ui"] } as unknown as ExtensionContext;
|
|
1364
|
+
}
|