@gotgenes/pi-permission-system 9.2.0 → 10.0.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/CHANGELOG.md +26 -0
- package/README.md +11 -10
- package/package.json +1 -1
- package/src/forwarded-permissions/io.ts +29 -0
- package/src/forwarded-permissions/polling.ts +34 -2
- package/src/index.ts +2 -0
- package/src/permission-event-rpc.ts +7 -0
- package/src/permission-events.ts +89 -8
- package/src/permission-forwarding.ts +23 -0
- package/src/permission-prompter.ts +22 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/service.ts +17 -0
- package/test/composition-root.test.ts +5 -0
- package/test/permission-event-rpc.test.ts +39 -0
- package/test/permission-events.test.ts +78 -10
- package/test/permission-forwarding.test.ts +282 -0
- package/test/permission-prompter.test.ts +120 -0
- package/test/permission-ui-prompt.test.ts +146 -0
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
PERMISSIONS_PROTOCOL_VERSION,
|
|
14
14
|
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
15
15
|
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
16
|
+
PERMISSIONS_UI_PROMPT_CHANNEL,
|
|
16
17
|
} from "#src/permission-events";
|
|
17
18
|
|
|
18
19
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
@@ -317,6 +318,44 @@ describe("registerPermissionRpcHandlers — permissions:rpc:prompt", () => {
|
|
|
317
318
|
}
|
|
318
319
|
});
|
|
319
320
|
|
|
321
|
+
it("emits a UI prompt broadcast before awaiting the UI decision", async () => {
|
|
322
|
+
const bus = createEventBus();
|
|
323
|
+
const ctx = makeCtxWithUi();
|
|
324
|
+
const requestUi = vi
|
|
325
|
+
.fn()
|
|
326
|
+
.mockResolvedValue({ approved: true, state: "approved" as const });
|
|
327
|
+
const deps = makeDeps({
|
|
328
|
+
getRuntimeContext: vi.fn().mockReturnValue(ctx),
|
|
329
|
+
requestPermissionDecisionFromUi: requestUi,
|
|
330
|
+
});
|
|
331
|
+
registerPermissionRpcHandlers(bus, deps);
|
|
332
|
+
|
|
333
|
+
const promptPromise = waitForReply(bus, PERMISSIONS_UI_PROMPT_CHANNEL);
|
|
334
|
+
const replyPromise = waitForReply(
|
|
335
|
+
bus,
|
|
336
|
+
`${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:req-prompt-broadcast`,
|
|
337
|
+
);
|
|
338
|
+
bus.emit(PERMISSIONS_RPC_PROMPT_CHANNEL, {
|
|
339
|
+
requestId: "req-prompt-broadcast",
|
|
340
|
+
surface: "bash",
|
|
341
|
+
value: "git push",
|
|
342
|
+
message: "Allow git push?",
|
|
343
|
+
agentName: "Worker",
|
|
344
|
+
sessionLabel: "Allow git *",
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await expect(promptPromise).resolves.toEqual({
|
|
348
|
+
requestId: "req-prompt-broadcast",
|
|
349
|
+
source: "rpc_prompt",
|
|
350
|
+
surface: "bash",
|
|
351
|
+
value: "git push",
|
|
352
|
+
agentName: "Worker",
|
|
353
|
+
message: "Allow git push?",
|
|
354
|
+
forwarding: null,
|
|
355
|
+
});
|
|
356
|
+
await replyPromise;
|
|
357
|
+
});
|
|
358
|
+
|
|
320
359
|
it("passes the message to requestPermissionDecisionFromUi", async () => {
|
|
321
360
|
const bus = createEventBus();
|
|
322
361
|
const ctx = makeCtxWithUi();
|
|
@@ -14,15 +14,18 @@ import type {
|
|
|
14
14
|
PermissionsPromptRequest,
|
|
15
15
|
PermissionsReadyEvent,
|
|
16
16
|
PermissionsRpcReply,
|
|
17
|
+
PermissionUiPromptEvent,
|
|
17
18
|
} from "#src/permission-events";
|
|
18
19
|
import {
|
|
19
20
|
emitDecisionEvent,
|
|
20
21
|
emitReadyEvent,
|
|
22
|
+
emitUiPromptEvent,
|
|
21
23
|
PERMISSIONS_DECISION_CHANNEL,
|
|
22
24
|
PERMISSIONS_PROTOCOL_VERSION,
|
|
23
25
|
PERMISSIONS_READY_CHANNEL,
|
|
24
26
|
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
25
27
|
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
28
|
+
PERMISSIONS_UI_PROMPT_CHANNEL,
|
|
26
29
|
} from "#src/permission-events";
|
|
27
30
|
|
|
28
31
|
// ── Minimal EventBus stub ──────────────────────────────────────────────────
|
|
@@ -43,6 +46,7 @@ describe("constants", () => {
|
|
|
43
46
|
|
|
44
47
|
it("channel names have the correct values", () => {
|
|
45
48
|
expect(PERMISSIONS_READY_CHANNEL).toBe("permissions:ready");
|
|
49
|
+
expect(PERMISSIONS_UI_PROMPT_CHANNEL).toBe("permissions:ui_prompt");
|
|
46
50
|
expect(PERMISSIONS_DECISION_CHANNEL).toBe("permissions:decision");
|
|
47
51
|
expect(PERMISSIONS_RPC_CHECK_CHANNEL).toBe("permissions:rpc:check");
|
|
48
52
|
expect(PERMISSIONS_RPC_PROMPT_CHANNEL).toBe("permissions:rpc:prompt");
|
|
@@ -52,20 +56,75 @@ describe("constants", () => {
|
|
|
52
56
|
// ── emitReadyEvent ─────────────────────────────────────────────────────────
|
|
53
57
|
|
|
54
58
|
describe("emitReadyEvent", () => {
|
|
55
|
-
it("emits on the permissions:ready channel
|
|
59
|
+
it("emits an empty payload on the permissions:ready channel", () => {
|
|
56
60
|
const bus = makeEventBus();
|
|
57
61
|
emitReadyEvent(bus);
|
|
58
62
|
expect(bus.emit).toHaveBeenCalledOnce();
|
|
59
|
-
expect(bus.emit).toHaveBeenCalledWith("permissions:ready", {
|
|
60
|
-
protocolVersion: 1,
|
|
61
|
-
});
|
|
63
|
+
expect(bus.emit).toHaveBeenCalledWith("permissions:ready", {});
|
|
62
64
|
});
|
|
63
65
|
|
|
64
|
-
it("
|
|
66
|
+
it("carries no protocolVersion (version lives in the RPC envelope)", () => {
|
|
65
67
|
const bus = makeEventBus();
|
|
66
68
|
emitReadyEvent(bus);
|
|
67
69
|
const payload = bus.emit.mock.calls[0][1] as PermissionsReadyEvent;
|
|
68
|
-
expect(
|
|
70
|
+
expect(payload).not.toHaveProperty("protocolVersion");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("swallows event bus errors because broadcasts are best-effort", () => {
|
|
74
|
+
const bus = {
|
|
75
|
+
emit: vi.fn(() => {
|
|
76
|
+
throw new Error("listener failed");
|
|
77
|
+
}),
|
|
78
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
expect(() => emitReadyEvent(bus)).not.toThrow();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── emitUiPromptEvent ──────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("emitUiPromptEvent", () => {
|
|
88
|
+
function makeUiPromptEvent(
|
|
89
|
+
overrides: Partial<PermissionUiPromptEvent> = {},
|
|
90
|
+
): PermissionUiPromptEvent {
|
|
91
|
+
return {
|
|
92
|
+
requestId: "req-123",
|
|
93
|
+
source: "tool_call",
|
|
94
|
+
surface: "bash",
|
|
95
|
+
value: "git status",
|
|
96
|
+
agentName: "Explore",
|
|
97
|
+
message: "Allow git status?",
|
|
98
|
+
forwarding: null,
|
|
99
|
+
...overrides,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
it("emits on the permissions:ui_prompt channel", () => {
|
|
104
|
+
const bus = makeEventBus();
|
|
105
|
+
emitUiPromptEvent(bus, makeUiPromptEvent());
|
|
106
|
+
expect(bus.emit).toHaveBeenCalledOnce();
|
|
107
|
+
expect(bus.emit.mock.calls[0][0]).toBe("permissions:ui_prompt");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("forwards the full payload unchanged", () => {
|
|
111
|
+
const bus = makeEventBus();
|
|
112
|
+
const event = makeUiPromptEvent({
|
|
113
|
+
forwarding: { requesterAgentName: "Worker", requesterSessionId: "child" },
|
|
114
|
+
});
|
|
115
|
+
emitUiPromptEvent(bus, event);
|
|
116
|
+
expect(bus.emit.mock.calls[0][1]).toEqual(event);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("swallows event bus errors because UI prompt broadcasts are observational", () => {
|
|
120
|
+
const bus = {
|
|
121
|
+
emit: vi.fn(() => {
|
|
122
|
+
throw new Error("listener failed");
|
|
123
|
+
}),
|
|
124
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
expect(() => emitUiPromptEvent(bus, makeUiPromptEvent())).not.toThrow();
|
|
69
128
|
});
|
|
70
129
|
});
|
|
71
130
|
|
|
@@ -143,6 +202,17 @@ describe("emitDecisionEvent", () => {
|
|
|
143
202
|
expect(payload.agentName).toBeNull();
|
|
144
203
|
expect(payload.matchedPattern).toBeNull();
|
|
145
204
|
});
|
|
205
|
+
|
|
206
|
+
it("swallows event bus errors because broadcasts are best-effort", () => {
|
|
207
|
+
const bus = {
|
|
208
|
+
emit: vi.fn(() => {
|
|
209
|
+
throw new Error("listener failed");
|
|
210
|
+
}),
|
|
211
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
expect(() => emitDecisionEvent(bus, makeDecisionEvent())).not.toThrow();
|
|
215
|
+
});
|
|
146
216
|
});
|
|
147
217
|
|
|
148
218
|
// ── Type-shape compile-time checks (runtime assertions on literal values) ──
|
|
@@ -279,7 +349,7 @@ describe("piPermissionSystemExtension ready event wiring", () => {
|
|
|
279
349
|
rmSync(baseDir, { recursive: true, force: true });
|
|
280
350
|
});
|
|
281
351
|
|
|
282
|
-
it("emits permissions:ready
|
|
352
|
+
it("emits permissions:ready at session_start", async () => {
|
|
283
353
|
const emitSpy = vi.fn();
|
|
284
354
|
const handlers = new Map<
|
|
285
355
|
string,
|
|
@@ -324,8 +394,6 @@ describe("piPermissionSystemExtension ready event wiring", () => {
|
|
|
324
394
|
([channel]) => channel === PERMISSIONS_READY_CHANNEL,
|
|
325
395
|
);
|
|
326
396
|
expect(readyCalls).toHaveLength(1);
|
|
327
|
-
expect(readyCalls[0][1]).toEqual({
|
|
328
|
-
protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
|
|
329
|
-
});
|
|
397
|
+
expect(readyCalls[0][1]).toEqual({});
|
|
330
398
|
});
|
|
331
399
|
});
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
5
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
6
|
import {
|
|
7
|
+
confirmPermission,
|
|
8
|
+
processForwardedPermissionRequests,
|
|
9
|
+
} from "#src/forwarded-permissions/polling";
|
|
10
|
+
import {
|
|
11
|
+
createPermissionForwardingLocation,
|
|
3
12
|
resolvePermissionForwardingTargetSessionId,
|
|
4
13
|
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
5
14
|
SUBAGENT_PARENT_SESSION_ENV_KEY,
|
|
@@ -240,3 +249,276 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
|
|
|
240
249
|
).toBe("parent-from-env");
|
|
241
250
|
});
|
|
242
251
|
});
|
|
252
|
+
|
|
253
|
+
describe("processForwardedPermissionRequests", () => {
|
|
254
|
+
test("emits a UI prompt event before showing a forwarded permission dialog", async () => {
|
|
255
|
+
const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
|
|
256
|
+
try {
|
|
257
|
+
const forwardingDir = join(root, "forwarding");
|
|
258
|
+
const location = createPermissionForwardingLocation(
|
|
259
|
+
forwardingDir,
|
|
260
|
+
"parent-session",
|
|
261
|
+
);
|
|
262
|
+
mkdirSync(location.requestsDir, { recursive: true });
|
|
263
|
+
mkdirSync(location.responsesDir, { recursive: true });
|
|
264
|
+
writeFileSync(
|
|
265
|
+
join(location.requestsDir, "req-forwarded.json"),
|
|
266
|
+
JSON.stringify({
|
|
267
|
+
id: "req-forwarded",
|
|
268
|
+
createdAt: Date.now(),
|
|
269
|
+
requesterSessionId: "child-session",
|
|
270
|
+
targetSessionId: "parent-session",
|
|
271
|
+
requesterAgentName: "Explore",
|
|
272
|
+
message: "Allow git push?",
|
|
273
|
+
}),
|
|
274
|
+
"utf-8",
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const events = {
|
|
278
|
+
emit: vi.fn(),
|
|
279
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
280
|
+
};
|
|
281
|
+
const requestPermissionDecisionFromUi = vi
|
|
282
|
+
.fn()
|
|
283
|
+
.mockResolvedValue({ approved: true, state: "approved" as const });
|
|
284
|
+
|
|
285
|
+
await processForwardedPermissionRequests(
|
|
286
|
+
{
|
|
287
|
+
hasUI: true,
|
|
288
|
+
ui: { select: vi.fn(), input: vi.fn() },
|
|
289
|
+
sessionManager: {
|
|
290
|
+
getSessionId: vi.fn().mockReturnValue("parent-session"),
|
|
291
|
+
},
|
|
292
|
+
} as unknown as ExtensionContext,
|
|
293
|
+
{
|
|
294
|
+
forwardingDir,
|
|
295
|
+
subagentSessionsDir: join(root, "subagents"),
|
|
296
|
+
events,
|
|
297
|
+
logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
|
|
298
|
+
writeReviewLog: vi.fn(),
|
|
299
|
+
requestPermissionDecisionFromUi,
|
|
300
|
+
shouldAutoApprove: () => false,
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
expect(events.emit).toHaveBeenCalledWith(
|
|
305
|
+
"permissions:ui_prompt",
|
|
306
|
+
expect.objectContaining({
|
|
307
|
+
requestId: "req-forwarded",
|
|
308
|
+
source: "tool_call",
|
|
309
|
+
surface: null,
|
|
310
|
+
value: null,
|
|
311
|
+
agentName: "Explore",
|
|
312
|
+
message: expect.stringContaining("Allow git push?"),
|
|
313
|
+
forwarding: {
|
|
314
|
+
requesterAgentName: "Explore",
|
|
315
|
+
requesterSessionId: "child-session",
|
|
316
|
+
},
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
|
|
320
|
+
} finally {
|
|
321
|
+
rmSync(root, { recursive: true, force: true });
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("emits a non-degraded UI prompt event when the request carries display fields", async () => {
|
|
326
|
+
const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
|
|
327
|
+
try {
|
|
328
|
+
const forwardingDir = join(root, "forwarding");
|
|
329
|
+
const location = createPermissionForwardingLocation(
|
|
330
|
+
forwardingDir,
|
|
331
|
+
"parent-session",
|
|
332
|
+
);
|
|
333
|
+
mkdirSync(location.requestsDir, { recursive: true });
|
|
334
|
+
mkdirSync(location.responsesDir, { recursive: true });
|
|
335
|
+
writeFileSync(
|
|
336
|
+
join(location.requestsDir, "req-forwarded-rich.json"),
|
|
337
|
+
JSON.stringify({
|
|
338
|
+
id: "req-forwarded-rich",
|
|
339
|
+
createdAt: Date.now(),
|
|
340
|
+
requesterSessionId: "child-session",
|
|
341
|
+
targetSessionId: "parent-session",
|
|
342
|
+
requesterAgentName: "Explore",
|
|
343
|
+
message: "Allow git push?",
|
|
344
|
+
source: "tool_call",
|
|
345
|
+
surface: "bash",
|
|
346
|
+
value: "git push",
|
|
347
|
+
}),
|
|
348
|
+
"utf-8",
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const events = {
|
|
352
|
+
emit: vi.fn(),
|
|
353
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
354
|
+
};
|
|
355
|
+
const requestPermissionDecisionFromUi = vi
|
|
356
|
+
.fn()
|
|
357
|
+
.mockResolvedValue({ approved: true, state: "approved" as const });
|
|
358
|
+
|
|
359
|
+
await processForwardedPermissionRequests(
|
|
360
|
+
{
|
|
361
|
+
hasUI: true,
|
|
362
|
+
ui: { select: vi.fn(), input: vi.fn() },
|
|
363
|
+
sessionManager: {
|
|
364
|
+
getSessionId: vi.fn().mockReturnValue("parent-session"),
|
|
365
|
+
},
|
|
366
|
+
} as unknown as ExtensionContext,
|
|
367
|
+
{
|
|
368
|
+
forwardingDir,
|
|
369
|
+
subagentSessionsDir: join(root, "subagents"),
|
|
370
|
+
events,
|
|
371
|
+
logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
|
|
372
|
+
writeReviewLog: vi.fn(),
|
|
373
|
+
requestPermissionDecisionFromUi,
|
|
374
|
+
shouldAutoApprove: () => false,
|
|
375
|
+
},
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
expect(events.emit).toHaveBeenCalledWith(
|
|
379
|
+
"permissions:ui_prompt",
|
|
380
|
+
expect.objectContaining({
|
|
381
|
+
requestId: "req-forwarded-rich",
|
|
382
|
+
source: "tool_call",
|
|
383
|
+
surface: "bash",
|
|
384
|
+
value: "git push",
|
|
385
|
+
agentName: "Explore",
|
|
386
|
+
message: expect.stringContaining("Allow git push?"),
|
|
387
|
+
forwarding: {
|
|
388
|
+
requesterAgentName: "Explore",
|
|
389
|
+
requesterSessionId: "child-session",
|
|
390
|
+
},
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
|
|
394
|
+
} finally {
|
|
395
|
+
rmSync(root, { recursive: true, force: true });
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("does not emit a UI prompt event when forwarded permission auto-approves", async () => {
|
|
400
|
+
const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
|
|
401
|
+
try {
|
|
402
|
+
const forwardingDir = join(root, "forwarding");
|
|
403
|
+
const location = createPermissionForwardingLocation(
|
|
404
|
+
forwardingDir,
|
|
405
|
+
"parent-session",
|
|
406
|
+
);
|
|
407
|
+
mkdirSync(location.requestsDir, { recursive: true });
|
|
408
|
+
mkdirSync(location.responsesDir, { recursive: true });
|
|
409
|
+
writeFileSync(
|
|
410
|
+
join(location.requestsDir, "req-forwarded-auto.json"),
|
|
411
|
+
JSON.stringify({
|
|
412
|
+
id: "req-forwarded-auto",
|
|
413
|
+
createdAt: Date.now(),
|
|
414
|
+
requesterSessionId: "child-session",
|
|
415
|
+
targetSessionId: "parent-session",
|
|
416
|
+
requesterAgentName: "Explore",
|
|
417
|
+
message: "Allow git push?",
|
|
418
|
+
}),
|
|
419
|
+
"utf-8",
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const events = {
|
|
423
|
+
emit: vi.fn(),
|
|
424
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
425
|
+
};
|
|
426
|
+
const requestPermissionDecisionFromUi = vi.fn();
|
|
427
|
+
|
|
428
|
+
await processForwardedPermissionRequests(
|
|
429
|
+
{
|
|
430
|
+
hasUI: true,
|
|
431
|
+
ui: { select: vi.fn(), input: vi.fn() },
|
|
432
|
+
sessionManager: {
|
|
433
|
+
getSessionId: vi.fn().mockReturnValue("parent-session"),
|
|
434
|
+
},
|
|
435
|
+
} as unknown as ExtensionContext,
|
|
436
|
+
{
|
|
437
|
+
forwardingDir,
|
|
438
|
+
subagentSessionsDir: join(root, "subagents"),
|
|
439
|
+
events,
|
|
440
|
+
logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
|
|
441
|
+
writeReviewLog: vi.fn(),
|
|
442
|
+
requestPermissionDecisionFromUi,
|
|
443
|
+
shouldAutoApprove: () => true,
|
|
444
|
+
},
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
expect(events.emit).not.toHaveBeenCalledWith(
|
|
448
|
+
"permissions:ui_prompt",
|
|
449
|
+
expect.anything(),
|
|
450
|
+
);
|
|
451
|
+
expect(requestPermissionDecisionFromUi).not.toHaveBeenCalled();
|
|
452
|
+
} finally {
|
|
453
|
+
rmSync(root, { recursive: true, force: true });
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
describe("confirmPermission", () => {
|
|
459
|
+
test("shows the UI dialog but does not emit a UI prompt event (the prompter does)", async () => {
|
|
460
|
+
const events = {
|
|
461
|
+
emit: vi.fn(),
|
|
462
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
463
|
+
};
|
|
464
|
+
const requestPermissionDecisionFromUi = vi
|
|
465
|
+
.fn()
|
|
466
|
+
.mockResolvedValue({ approved: true, state: "approved" as const });
|
|
467
|
+
|
|
468
|
+
await confirmPermission(
|
|
469
|
+
{
|
|
470
|
+
hasUI: true,
|
|
471
|
+
ui: { select: vi.fn(), input: vi.fn() },
|
|
472
|
+
} as unknown as ExtensionContext,
|
|
473
|
+
"Allow git push?",
|
|
474
|
+
{
|
|
475
|
+
forwardingDir: "/tmp/forwarding",
|
|
476
|
+
subagentSessionsDir: "/tmp/subagents",
|
|
477
|
+
events,
|
|
478
|
+
logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
|
|
479
|
+
writeReviewLog: vi.fn(),
|
|
480
|
+
requestPermissionDecisionFromUi,
|
|
481
|
+
shouldAutoApprove: () => false,
|
|
482
|
+
},
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
|
|
486
|
+
expect(events.emit).not.toHaveBeenCalledWith(
|
|
487
|
+
"permissions:ui_prompt",
|
|
488
|
+
expect.anything(),
|
|
489
|
+
);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("does not show a dialog or emit when there is no active UI", async () => {
|
|
493
|
+
const events = {
|
|
494
|
+
emit: vi.fn(),
|
|
495
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
496
|
+
};
|
|
497
|
+
const requestPermissionDecisionFromUi = vi.fn();
|
|
498
|
+
|
|
499
|
+
await confirmPermission(
|
|
500
|
+
{
|
|
501
|
+
hasUI: false,
|
|
502
|
+
sessionManager: {
|
|
503
|
+
getSessionDir: vi.fn().mockReturnValue(null),
|
|
504
|
+
},
|
|
505
|
+
} as unknown as ExtensionContext,
|
|
506
|
+
"Allow git push?",
|
|
507
|
+
{
|
|
508
|
+
forwardingDir: "/tmp/forwarding",
|
|
509
|
+
subagentSessionsDir: "/tmp/subagents",
|
|
510
|
+
events,
|
|
511
|
+
logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
|
|
512
|
+
writeReviewLog: vi.fn(),
|
|
513
|
+
requestPermissionDecisionFromUi,
|
|
514
|
+
shouldAutoApprove: () => false,
|
|
515
|
+
},
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
expect(events.emit).not.toHaveBeenCalledWith(
|
|
519
|
+
"permissions:ui_prompt",
|
|
520
|
+
expect.anything(),
|
|
521
|
+
);
|
|
522
|
+
expect(requestPermissionDecisionFromUi).not.toHaveBeenCalled();
|
|
523
|
+
});
|
|
524
|
+
});
|
|
@@ -53,6 +53,7 @@ function makeDeps(
|
|
|
53
53
|
writeReviewLog: vi.fn(),
|
|
54
54
|
subagentSessionsDir: "/sessions/subagents",
|
|
55
55
|
forwardingDir: "/sessions/permission-forwarding",
|
|
56
|
+
events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
|
|
56
57
|
requestPermissionDecisionFromUi: vi
|
|
57
58
|
.fn()
|
|
58
59
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
@@ -71,8 +72,13 @@ describe("PermissionPrompter", () => {
|
|
|
71
72
|
|
|
72
73
|
describe("yolo-mode auto-approve", () => {
|
|
73
74
|
it("returns approved without calling confirmPermission when yoloMode is true", async () => {
|
|
75
|
+
const events = {
|
|
76
|
+
emit: vi.fn(),
|
|
77
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
78
|
+
};
|
|
74
79
|
const deps = makeDeps({
|
|
75
80
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
|
|
81
|
+
events,
|
|
76
82
|
});
|
|
77
83
|
const prompter = new PermissionPrompter(deps);
|
|
78
84
|
|
|
@@ -84,6 +90,10 @@ describe("PermissionPrompter", () => {
|
|
|
84
90
|
autoApproved: true,
|
|
85
91
|
});
|
|
86
92
|
expect(mockConfirmPermission).not.toHaveBeenCalled();
|
|
93
|
+
expect(events.emit).not.toHaveBeenCalledWith(
|
|
94
|
+
"permissions:ui_prompt",
|
|
95
|
+
expect.anything(),
|
|
96
|
+
);
|
|
87
97
|
});
|
|
88
98
|
|
|
89
99
|
it("logs permission_request.auto_approved in yolo mode", async () => {
|
|
@@ -154,6 +164,90 @@ describe("PermissionPrompter", () => {
|
|
|
154
164
|
);
|
|
155
165
|
});
|
|
156
166
|
|
|
167
|
+
it("emits a UI prompt event with normalized surface and value when the session has UI", async () => {
|
|
168
|
+
const events = {
|
|
169
|
+
emit: vi.fn(),
|
|
170
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
171
|
+
};
|
|
172
|
+
mockConfirmPermission.mockResolvedValue({
|
|
173
|
+
approved: true,
|
|
174
|
+
state: "approved",
|
|
175
|
+
});
|
|
176
|
+
const deps = makeDeps({ events });
|
|
177
|
+
const prompter = new PermissionPrompter(deps);
|
|
178
|
+
|
|
179
|
+
await prompter.prompt(
|
|
180
|
+
makeCtx(true),
|
|
181
|
+
makeDetails({
|
|
182
|
+
toolName: "bash",
|
|
183
|
+
command: "git push",
|
|
184
|
+
toolInputPreview: "git push",
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", {
|
|
189
|
+
requestId: "req-123",
|
|
190
|
+
source: "tool_call",
|
|
191
|
+
surface: "bash",
|
|
192
|
+
value: "git push",
|
|
193
|
+
agentName: "test-agent",
|
|
194
|
+
message: "Allow read?",
|
|
195
|
+
forwarding: null,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("normalizes skill UI prompt events to the skill surface", async () => {
|
|
200
|
+
const events = {
|
|
201
|
+
emit: vi.fn(),
|
|
202
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
203
|
+
};
|
|
204
|
+
mockConfirmPermission.mockResolvedValue({
|
|
205
|
+
approved: true,
|
|
206
|
+
state: "approved",
|
|
207
|
+
});
|
|
208
|
+
const deps = makeDeps({ events });
|
|
209
|
+
const prompter = new PermissionPrompter(deps);
|
|
210
|
+
|
|
211
|
+
await prompter.prompt(
|
|
212
|
+
makeCtx(true),
|
|
213
|
+
makeDetails({
|
|
214
|
+
source: "skill_input",
|
|
215
|
+
toolName: undefined,
|
|
216
|
+
skillName: "deploy-helper",
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", {
|
|
221
|
+
requestId: "req-123",
|
|
222
|
+
source: "skill_input",
|
|
223
|
+
surface: "skill",
|
|
224
|
+
value: "deploy-helper",
|
|
225
|
+
agentName: "test-agent",
|
|
226
|
+
message: "Allow read?",
|
|
227
|
+
forwarding: null,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("does not emit a UI prompt event when the session has no UI", async () => {
|
|
232
|
+
const events = {
|
|
233
|
+
emit: vi.fn(),
|
|
234
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
235
|
+
};
|
|
236
|
+
mockConfirmPermission.mockResolvedValue({
|
|
237
|
+
approved: true,
|
|
238
|
+
state: "approved",
|
|
239
|
+
});
|
|
240
|
+
const deps = makeDeps({ events });
|
|
241
|
+
const prompter = new PermissionPrompter(deps);
|
|
242
|
+
|
|
243
|
+
await prompter.prompt(makeCtx(false), makeDetails());
|
|
244
|
+
|
|
245
|
+
expect(events.emit).not.toHaveBeenCalledWith(
|
|
246
|
+
"permissions:ui_prompt",
|
|
247
|
+
expect.anything(),
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
157
251
|
it("logs permission_request.approved when confirmPermission returns approved", async () => {
|
|
158
252
|
const writeReviewLog = vi.fn();
|
|
159
253
|
mockConfirmPermission.mockResolvedValue({
|
|
@@ -245,6 +339,30 @@ describe("PermissionPrompter", () => {
|
|
|
245
339
|
expect.any(String),
|
|
246
340
|
expect.anything(),
|
|
247
341
|
{ sessionLabel: "Yes, for 'read' tool" },
|
|
342
|
+
{ source: "tool_call", surface: "read", value: "read" },
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("passes the display fields (source/surface/value) to confirmPermission", async () => {
|
|
347
|
+
mockConfirmPermission.mockResolvedValue({
|
|
348
|
+
approved: true,
|
|
349
|
+
state: "approved",
|
|
350
|
+
});
|
|
351
|
+
const deps = makeDeps();
|
|
352
|
+
const prompter = new PermissionPrompter(deps);
|
|
353
|
+
const details = makeDetails({
|
|
354
|
+
toolName: "bash",
|
|
355
|
+
command: "git push",
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
await prompter.prompt(makeCtx(false), details);
|
|
359
|
+
|
|
360
|
+
expect(mockConfirmPermission).toHaveBeenCalledWith(
|
|
361
|
+
expect.anything(),
|
|
362
|
+
expect.any(String),
|
|
363
|
+
expect.anything(),
|
|
364
|
+
undefined,
|
|
365
|
+
{ source: "tool_call", surface: "bash", value: "git push" },
|
|
248
366
|
);
|
|
249
367
|
});
|
|
250
368
|
|
|
@@ -263,6 +381,7 @@ describe("PermissionPrompter", () => {
|
|
|
263
381
|
expect.any(String),
|
|
264
382
|
expect.anything(),
|
|
265
383
|
undefined,
|
|
384
|
+
{ source: "tool_call", surface: "read", value: "read" },
|
|
266
385
|
);
|
|
267
386
|
});
|
|
268
387
|
|
|
@@ -282,6 +401,7 @@ describe("PermissionPrompter", () => {
|
|
|
282
401
|
"Allow bash: git status?",
|
|
283
402
|
expect.anything(),
|
|
284
403
|
undefined,
|
|
404
|
+
{ source: "tool_call", surface: "read", value: "read" },
|
|
285
405
|
);
|
|
286
406
|
});
|
|
287
407
|
});
|