@pi-archimedes/sudo 2.7.3 → 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 +2 -2
- package/src/prompt.test.ts +93 -1
- package/src/prompt.ts +13 -0
- package/src/tool.test.ts +97 -0
- package/src/tool.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/sudo",
|
|
3
|
-
"version": "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.
|
|
22
|
+
"@pi-archimedes/core": "2.8.0"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
package/src/prompt.test.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|