@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.
@@ -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
+ }