@pi-archimedes/sudo 2.7.2 → 2.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/sudo",
3
- "version": "2.7.2",
3
+ "version": "2.8.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -19,7 +19,7 @@
19
19
  "./guard": "./src/guard.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@pi-archimedes/core": "2.7.2"
22
+ "@pi-archimedes/core": "2.8.0"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@earendil-works/pi-coding-agent": ">=0.1.0",
@@ -1,7 +1,23 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { confirmCommand, maskLine, promptForPassword } from "./prompt.js";
4
4
 
5
+ // Mock the bridge BEFORE the prompt module loads — the real module is
6
+ // env-gated and would be inactive, but the tests need per-test control of
7
+ // `active` (same pattern as ask/tool.test.ts). Default: inactive, so the
8
+ // pre-existing headless/TUI tests run against the mock with active=false.
9
+ const bridgeState = vi.hoisted(() => ({
10
+ active: false,
11
+ ask: vi.fn(),
12
+ confirm: vi.fn(),
13
+ password: vi.fn(),
14
+ state: vi.fn(() => "idle"),
15
+ }));
16
+
17
+ vi.mock("@pi-archimedes/core/bridge", () => ({
18
+ getBridge: () => bridgeState,
19
+ }));
20
+
5
21
  // ── minimal ui.custom fakes ──────────────────────────────────────────────────
6
22
 
7
23
  interface MaskedComponent {
@@ -147,3 +163,79 @@ describe("promptForPassword — masked component", () => {
147
163
  await expect(promise).resolves.toBe("");
148
164
  });
149
165
  });
166
+
167
+ // ── bridge routing (Task 3) ────────────────────────────────────────────────
168
+
169
+ function bridgeRpcCtx() {
170
+ const confirm = vi.fn(async () => true);
171
+ const custom = vi.fn(async () => "");
172
+ const ui = { confirm, custom } as unknown as ExtensionContext["ui"];
173
+ const ctx = { mode: "rpc" as const, hasUI: true, ui } as unknown as ExtensionContext;
174
+ return { ctx, confirm, custom };
175
+ }
176
+
177
+ describe("promptForPassword — bridge routing", () => {
178
+ afterEach(() => {
179
+ bridgeState.active = false;
180
+ bridgeState.password.mockReset();
181
+ });
182
+
183
+ it("routes through the bridge in bridge mode (rpc ctx) and never touches ui.custom", async () => {
184
+ bridgeState.active = true;
185
+ bridgeState.password.mockResolvedValue("s3cret");
186
+ const { ctx, custom } = bridgeRpcCtx();
187
+
188
+ await expect(promptForPassword(ctx, "apt install ripgrep", "install ripgrep")).resolves.toBe("s3cret");
189
+ expect(bridgeState.password).toHaveBeenCalledWith({ command: "apt install ripgrep", reason: "install ripgrep" });
190
+ expect(custom).not.toHaveBeenCalled(); // the Client's modal, not the TUI field
191
+ });
192
+
193
+ it("resolves '' on bridge cancellation (the caller treats '' as cancellation — unchanged)", async () => {
194
+ bridgeState.active = true;
195
+ bridgeState.password.mockResolvedValue("");
196
+ const { ctx } = bridgeRpcCtx();
197
+
198
+ await expect(promptForPassword(ctx)).resolves.toBe("");
199
+ });
200
+
201
+ it("still rejects in headless mode when the bridge is inactive (the 0010 gate is unchanged)", async () => {
202
+ bridgeState.active = false;
203
+ await expect(promptForPassword(headlessCtx("json"))).rejects.toThrow();
204
+ expect(bridgeState.password).not.toHaveBeenCalled();
205
+ });
206
+ });
207
+
208
+ describe("confirmCommand — bridge routing", () => {
209
+ afterEach(() => {
210
+ bridgeState.active = false;
211
+ bridgeState.confirm.mockReset();
212
+ });
213
+
214
+ it("routes through the bridge in bridge mode (rpc ctx) and never touches ui.confirm", async () => {
215
+ bridgeState.active = true;
216
+ bridgeState.confirm.mockResolvedValue(true);
217
+ const { ctx, confirm, custom } = bridgeRpcCtx();
218
+
219
+ await expect(confirmCommand(ctx, "apt install ripgrep", "install ripgrep")).resolves.toBe(true);
220
+ expect(bridgeState.confirm).toHaveBeenCalledWith({ command: "apt install ripgrep", reason: "install ripgrep" });
221
+ expect(confirm).not.toHaveBeenCalled(); // the Client's modal, not the TUI confirm
222
+ });
223
+
224
+ it("resolves false on bridge decline", async () => {
225
+ bridgeState.active = true;
226
+ bridgeState.confirm.mockResolvedValue(false);
227
+ const { ctx } = bridgeRpcCtx();
228
+
229
+ await expect(confirmCommand(ctx, "reboot", "retry service")).resolves.toBe(false);
230
+ });
231
+
232
+ it("still uses ui.confirm in TUI when the bridge is inactive (unchanged)", async () => {
233
+ bridgeState.active = false;
234
+ const confirm = vi.fn(async (_title: string, _message: string) => true);
235
+ const ctx = { mode: "tui" as const, hasUI: true, ui: { confirm } } as unknown as ExtensionContext;
236
+
237
+ await expect(confirmCommand(ctx, "apt install ripgrep", "install ripgrep")).resolves.toBe(true);
238
+ expect(confirm).toHaveBeenCalledTimes(1);
239
+ expect(bridgeState.confirm).not.toHaveBeenCalled();
240
+ });
241
+ });
package/src/prompt.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { getBridge } from "@pi-archimedes/core/bridge";
3
4
 
4
5
  /**
5
6
  * Render one `•` per typed character — pure, so the masking itself is
@@ -25,6 +26,13 @@ export function maskLine(buffer: string): string {
25
26
  * or confirms with an empty buffer — callers treat `""` as cancellation.
26
27
  */
27
28
  export function promptForPassword(ctx: ExtensionContext, command?: string, reason?: string): Promise<string> {
29
+ // Bridge mode: the password comes from the Client's masked modal over the
30
+ // bridge channel — user-paced, 0010-compliant (never a tool parameter). The
31
+ // 0010 headless gate below is intentionally UNCHANGED: it sits after the
32
+ // bridge branch, so non-bridge headless sessions still reject verbatim.
33
+ if (getBridge().active) {
34
+ return getBridge().password({ command: command ?? "", reason: reason ?? "" });
35
+ }
28
36
  if (ctx.mode !== "tui") {
29
37
  return Promise.reject(
30
38
  new Error(
@@ -94,6 +102,11 @@ export function promptForPassword(ctx: ExtensionContext, command?: string, reaso
94
102
  * prompting for a password.
95
103
  */
96
104
  export async function confirmCommand(ctx: ExtensionContext, command: string, reason: string): Promise<boolean> {
105
+ // Bridge mode: the Client's confirm modal (user-paced) replaces the TUI
106
+ // confirm; the ctx.ui.confirm path below is otherwise unchanged.
107
+ if (getBridge().active) {
108
+ return getBridge().confirm({ command, reason });
109
+ }
97
110
  const message = ["This will run with elevated privileges (password required until cached):", "", `$ ${command}`, "", `Reason: ${reason}`].join("\n");
98
111
  return ctx.ui.confirm("Confirm privileged command", message);
99
112
  }
package/src/tool.test.ts CHANGED
@@ -18,6 +18,20 @@ vi.mock("@pi-archimedes/core/settings-io", () => ({
18
18
  },
19
19
  }));
20
20
 
21
+ // Mock the bridge — per-test control of `active` (same pattern as ask/tool.test.ts).
22
+ // Default: inactive, so the pre-existing headless/TUI tests are unaffected.
23
+ const bridgeState = vi.hoisted(() => ({
24
+ active: false,
25
+ ask: vi.fn(),
26
+ confirm: vi.fn(),
27
+ password: vi.fn(),
28
+ state: vi.fn(() => "idle"),
29
+ }));
30
+
31
+ vi.mock("@pi-archimedes/core/bridge", () => ({
32
+ getBridge: () => bridgeState,
33
+ }));
34
+
21
35
  import { buildSudoArgv, createSudoExecTool, probeSudoTicket, scrubSecret } from "./tool.js";
22
36
  import type { SudoChild, SudoSpawner } from "./tool.js";
23
37
  import { credentialCache } from "./cache.js";
@@ -209,6 +223,89 @@ describe("sudo_exec tool", () => {
209
223
  });
210
224
  });
211
225
 
226
+ describe("bridge mode (Task 3)", () => {
227
+ afterEach(() => {
228
+ bridgeState.active = false;
229
+ bridgeState.confirm.mockReset();
230
+ bridgeState.password.mockReset();
231
+ });
232
+
233
+ it("bypasses the headless gate in bridge mode: confirm + password route through the bridge and the command runs", async () => {
234
+ bridgeState.active = true;
235
+ bridgeState.confirm.mockResolvedValue(true);
236
+ bridgeState.password.mockResolvedValue("s3cret");
237
+ const { spawner, last } = makeFakeSpawner({ stdout: "ok\n", stderr: "", code: 0 });
238
+ const tool = createSudoExecTool({ spawner, authProbe: () => Promise.resolve(true) });
239
+ const ui = { confirm: vi.fn(async () => true), custom: vi.fn(async () => "") } as unknown as ExtensionContext["ui"];
240
+ const ctx = { mode: "rpc", hasUI: true, ui } as unknown as ExtensionContext;
241
+
242
+ const result = await run(tool, ctx, { command: "apt update", reason: "maintain" });
243
+
244
+ expect(result.isError).toBeFalsy();
245
+ expect(last.command).toBe("sudo");
246
+ expect(last.args).toEqual(["-S", "apt", "update"]);
247
+ expect(last.child.stdin.written.join("")).toBe("s3cret\n"); // bridge password → sudo -S stdin
248
+ expect(ui.confirm).not.toHaveBeenCalled(); // the Client's modal, not the TUI
249
+ expect(ui.custom).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it("declines via the bridge: 'not confirmed', no password prompt, no spawn", async () => {
253
+ bridgeState.active = true;
254
+ bridgeState.confirm.mockResolvedValue(false);
255
+ bridgeState.password.mockResolvedValue("should-not-be-used");
256
+ let spawned = false;
257
+ const tool = createSudoExecTool({
258
+ spawner: () => {
259
+ spawned = true;
260
+ throw new Error("spawner must not be called");
261
+ },
262
+ });
263
+ const ctx = headlessCtx("rpc");
264
+
265
+ const result = await run(tool, ctx, { command: "reboot", reason: "x" });
266
+
267
+ expect(result.isError).toBe(true);
268
+ expect((result.content[0] as { text: string }).text).toMatch(/not confirmed/i);
269
+ expect(spawned).toBe(false);
270
+ expect(bridgeState.password).not.toHaveBeenCalled();
271
+ });
272
+
273
+ it("bridge password cancel ('' → 'password entry cancelled', nothing cached, no spawn)", async () => {
274
+ bridgeState.active = true;
275
+ bridgeState.confirm.mockResolvedValue(true);
276
+ bridgeState.password.mockResolvedValue("");
277
+ let spawned = false;
278
+ const tool = createSudoExecTool({
279
+ spawner: () => {
280
+ spawned = true;
281
+ throw new Error("spawner must not be called");
282
+ },
283
+ });
284
+ const ctx = headlessCtx("rpc");
285
+
286
+ const result = await run(tool, ctx, { command: "apt update", reason: "maintain" });
287
+
288
+ expect(result.isError).toBe(true);
289
+ expect((result.content[0] as { text: string }).text).toMatch(/password entry cancelled/i);
290
+ expect(spawned).toBe(false);
291
+ expect(credentialCache.get()).toBeNull();
292
+ });
293
+
294
+ it("still rejects in headless mode when the bridge is inactive (the 0010 gate is unchanged)", async () => {
295
+ bridgeState.active = false;
296
+ const tool = createSudoExecTool();
297
+ const ui = { confirm: vi.fn(), custom: vi.fn() } as unknown as ExtensionContext["ui"];
298
+ const ctx = { mode: "json", hasUI: false, ui } as unknown as ExtensionContext;
299
+
300
+ const result = await run(tool, ctx, { command: "apt install ripgrep", reason: "install tool" });
301
+
302
+ expect(result.isError).toBe(true);
303
+ expect((result.content[0] as { text: string }).text).toMatch(/interactive/i);
304
+ expect(ui.confirm).not.toHaveBeenCalled();
305
+ expect(ui.custom).not.toHaveBeenCalled();
306
+ });
307
+ });
308
+
212
309
  describe("validation", () => {
213
310
  it("rejects an empty command", async () => {
214
311
  const tool = createSudoExecTool();
package/src/tool.ts CHANGED
@@ -6,6 +6,7 @@ import { credentialCache } from "./cache.js";
6
6
  import { splitCommandIntoArgv } from "./argv-split.js";
7
7
  import { loadSudoConfig } from "./config.js";
8
8
  import { confirmCommand, promptForPassword } from "./prompt.js";
9
+ import { getBridge } from "@pi-archimedes/core/bridge";
9
10
 
10
11
  // ── Schemas ──────────────────────────────────────────────────────────────────
11
12
 
@@ -410,7 +411,10 @@ export function createSudoExecTool(options: {
410
411
  });
411
412
  }
412
413
 
413
- if (ctx.mode !== "tui") {
414
+ // Bridge mode bypasses the 0010 gate: the bridge confirm/password are
415
+ // user-paced Client modals (0010-compliant). TUI and RPC-no-env behave
416
+ // exactly as before.
417
+ if (ctx.mode !== "tui" && !getBridge().active) {
414
418
  return fail("sudo_exec requires an interactive session — run privileged commands from the main session.", {
415
419
  command,
416
420
  reason,