@gotgenes/pi-permission-system 10.3.0 → 10.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/config-modal.ts +10 -8
- package/src/config-store.ts +13 -34
- package/src/forwarded-permissions/io.ts +16 -22
- package/src/forwarded-permissions/permission-forwarder.ts +16 -19
- package/src/gate-prompter.ts +1 -3
- package/src/handlers/gates/runner.ts +1 -1
- package/src/index.ts +68 -51
- package/src/permission-event-rpc.ts +19 -15
- package/src/permission-prompter.ts +4 -3
- package/src/permission-session.ts +10 -67
- package/src/permissions-service.ts +3 -5
- package/src/prompting-gateway.ts +104 -0
- package/src/session-logger.ts +63 -12
- package/test/composition-root.test.ts +85 -1
- package/test/config-modal.test.ts +13 -7
- package/test/config-store.test.ts +23 -49
- package/test/forwarded-permissions/io.test.ts +23 -26
- package/test/handlers/external-directory-integration.test.ts +45 -32
- package/test/handlers/external-directory-session-dedup.test.ts +36 -46
- package/test/handlers/gates/runner.test.ts +10 -16
- package/test/handlers/input-events.test.ts +19 -4
- package/test/handlers/input.test.ts +29 -13
- package/test/handlers/tool-call-events.test.ts +23 -5
- package/test/helpers/gate-fixtures.ts +6 -6
- package/test/helpers/handler-fixtures.ts +24 -39
- package/test/permission-event-rpc.test.ts +30 -28
- package/test/permission-forwarder.test.ts +6 -5
- package/test/permission-prompter.test.ts +28 -28
- package/test/permission-session.test.ts +40 -112
- package/test/prompting-gateway.test.ts +230 -0
- package/test/session-logger.test.ts +151 -64
- package/src/runtime.ts +0 -147
- package/test/runtime.test.ts +0 -303
|
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
6
|
-
|
|
6
|
+
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
7
7
|
import {
|
|
8
8
|
PermissionForwarder,
|
|
9
9
|
type PermissionForwarderDeps,
|
|
@@ -18,12 +18,11 @@ function makeDeps(
|
|
|
18
18
|
return {
|
|
19
19
|
forwardingDir: "/tmp/forwarding",
|
|
20
20
|
subagentSessionsDir: "/tmp/subagents",
|
|
21
|
-
logger: {
|
|
22
|
-
writeReviewLog: vi.fn(),
|
|
21
|
+
logger: { review: vi.fn(), debug: vi.fn() },
|
|
23
22
|
requestPermissionDecisionFromUi: vi
|
|
24
23
|
.fn()
|
|
25
24
|
.mockResolvedValue({ approved: true, state: "approved" as const }),
|
|
26
|
-
|
|
25
|
+
config: { current: () => ({ ...DEFAULT_EXTENSION_CONFIG }) },
|
|
27
26
|
...overrides,
|
|
28
27
|
};
|
|
29
28
|
}
|
|
@@ -271,7 +270,9 @@ describe("processInbox", () => {
|
|
|
271
270
|
forwardingDir,
|
|
272
271
|
events,
|
|
273
272
|
requestPermissionDecisionFromUi,
|
|
274
|
-
|
|
273
|
+
config: {
|
|
274
|
+
current: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
|
|
275
|
+
},
|
|
275
276
|
}),
|
|
276
277
|
);
|
|
277
278
|
|
|
@@ -50,7 +50,7 @@ function makeDeps(
|
|
|
50
50
|
): PermissionPrompterDeps {
|
|
51
51
|
return {
|
|
52
52
|
config: makeConfigReader(),
|
|
53
|
-
|
|
53
|
+
logger: { review: vi.fn() },
|
|
54
54
|
events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
|
|
55
55
|
forwarder: { requestApproval: mockRequestApproval },
|
|
56
56
|
...overrides,
|
|
@@ -97,32 +97,32 @@ describe("PermissionPrompter", () => {
|
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
it("logs permission_request.auto_approved in yolo mode", async () => {
|
|
100
|
-
const
|
|
100
|
+
const logger = { review: vi.fn() };
|
|
101
101
|
const deps = makeDeps({
|
|
102
102
|
config: makeConfigReader({ yoloMode: true }),
|
|
103
|
-
|
|
103
|
+
logger,
|
|
104
104
|
});
|
|
105
105
|
const prompter = new PermissionPrompter(deps);
|
|
106
106
|
|
|
107
107
|
await prompter.prompt(makeCtx(false), makeDetails());
|
|
108
108
|
|
|
109
|
-
expect(
|
|
109
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
110
110
|
"permission_request.auto_approved",
|
|
111
111
|
expect.objectContaining({ requestId: "req-123" }),
|
|
112
112
|
);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
it("does not log permission_request.waiting in yolo mode", async () => {
|
|
116
|
-
const
|
|
116
|
+
const logger = { review: vi.fn() };
|
|
117
117
|
const deps = makeDeps({
|
|
118
118
|
config: makeConfigReader({ yoloMode: true }),
|
|
119
|
-
|
|
119
|
+
logger,
|
|
120
120
|
});
|
|
121
121
|
const prompter = new PermissionPrompter(deps);
|
|
122
122
|
|
|
123
123
|
await prompter.prompt(makeCtx(false), makeDetails());
|
|
124
124
|
|
|
125
|
-
expect(
|
|
125
|
+
expect(logger.review).not.toHaveBeenCalledWith(
|
|
126
126
|
"permission_request.waiting",
|
|
127
127
|
expect.anything(),
|
|
128
128
|
);
|
|
@@ -144,18 +144,18 @@ describe("PermissionPrompter", () => {
|
|
|
144
144
|
|
|
145
145
|
describe("non-yolo path (UI present)", () => {
|
|
146
146
|
it("logs permission_request.waiting before calling confirmPermission", async () => {
|
|
147
|
-
const
|
|
147
|
+
const logger = { review: vi.fn() };
|
|
148
148
|
const approved: PermissionPromptDecision = {
|
|
149
149
|
approved: true,
|
|
150
150
|
state: "approved",
|
|
151
151
|
};
|
|
152
152
|
mockRequestApproval.mockResolvedValue(approved);
|
|
153
|
-
const deps = makeDeps({
|
|
153
|
+
const deps = makeDeps({ logger });
|
|
154
154
|
const prompter = new PermissionPrompter(deps);
|
|
155
155
|
|
|
156
156
|
await prompter.prompt(makeCtx(true), makeDetails());
|
|
157
157
|
|
|
158
|
-
const calls =
|
|
158
|
+
const calls = logger.review.mock.calls.map((c) => c[0] as string);
|
|
159
159
|
expect(
|
|
160
160
|
calls.indexOf("permission_request.waiting"),
|
|
161
161
|
).toBeGreaterThanOrEqual(0);
|
|
@@ -249,17 +249,17 @@ describe("PermissionPrompter", () => {
|
|
|
249
249
|
});
|
|
250
250
|
|
|
251
251
|
it("logs permission_request.approved when confirmPermission returns approved", async () => {
|
|
252
|
-
const
|
|
252
|
+
const logger = { review: vi.fn() };
|
|
253
253
|
mockRequestApproval.mockResolvedValue({
|
|
254
254
|
approved: true,
|
|
255
255
|
state: "approved",
|
|
256
256
|
});
|
|
257
|
-
const deps = makeDeps({
|
|
257
|
+
const deps = makeDeps({ logger });
|
|
258
258
|
const prompter = new PermissionPrompter(deps);
|
|
259
259
|
|
|
260
260
|
await prompter.prompt(makeCtx(true), makeDetails());
|
|
261
261
|
|
|
262
|
-
expect(
|
|
262
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
263
263
|
"permission_request.approved",
|
|
264
264
|
expect.objectContaining({
|
|
265
265
|
requestId: "req-123",
|
|
@@ -269,17 +269,17 @@ describe("PermissionPrompter", () => {
|
|
|
269
269
|
});
|
|
270
270
|
|
|
271
271
|
it("logs permission_request.denied when confirmPermission returns denied", async () => {
|
|
272
|
-
const
|
|
272
|
+
const logger = { review: vi.fn() };
|
|
273
273
|
mockRequestApproval.mockResolvedValue({
|
|
274
274
|
approved: false,
|
|
275
275
|
state: "denied",
|
|
276
276
|
});
|
|
277
|
-
const deps = makeDeps({
|
|
277
|
+
const deps = makeDeps({ logger });
|
|
278
278
|
const prompter = new PermissionPrompter(deps);
|
|
279
279
|
|
|
280
280
|
await prompter.prompt(makeCtx(true), makeDetails());
|
|
281
281
|
|
|
282
|
-
expect(
|
|
282
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
283
283
|
"permission_request.denied",
|
|
284
284
|
expect.objectContaining({
|
|
285
285
|
requestId: "req-123",
|
|
@@ -289,18 +289,18 @@ describe("PermissionPrompter", () => {
|
|
|
289
289
|
});
|
|
290
290
|
|
|
291
291
|
it("logs permission_request.denied with denialReason when present", async () => {
|
|
292
|
-
const
|
|
292
|
+
const logger = { review: vi.fn() };
|
|
293
293
|
mockRequestApproval.mockResolvedValue({
|
|
294
294
|
approved: false,
|
|
295
295
|
state: "denied_with_reason",
|
|
296
296
|
denialReason: "too sensitive",
|
|
297
297
|
});
|
|
298
|
-
const deps = makeDeps({
|
|
298
|
+
const deps = makeDeps({ logger });
|
|
299
299
|
const prompter = new PermissionPrompter(deps);
|
|
300
300
|
|
|
301
301
|
await prompter.prompt(makeCtx(true), makeDetails());
|
|
302
302
|
|
|
303
|
-
expect(
|
|
303
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
304
304
|
"permission_request.denied",
|
|
305
305
|
expect.objectContaining({
|
|
306
306
|
denialReason: "too sensitive",
|
|
@@ -406,12 +406,12 @@ describe("PermissionPrompter", () => {
|
|
|
406
406
|
|
|
407
407
|
describe("review log fields", () => {
|
|
408
408
|
it("includes all standard fields in the waiting log entry", async () => {
|
|
409
|
-
const
|
|
409
|
+
const logger = { review: vi.fn() };
|
|
410
410
|
mockRequestApproval.mockResolvedValue({
|
|
411
411
|
approved: true,
|
|
412
412
|
state: "approved",
|
|
413
413
|
});
|
|
414
|
-
const deps = makeDeps({
|
|
414
|
+
const deps = makeDeps({ logger });
|
|
415
415
|
const prompter = new PermissionPrompter(deps);
|
|
416
416
|
const details = makeDetails({
|
|
417
417
|
toolCallId: "tc-1",
|
|
@@ -424,7 +424,7 @@ describe("PermissionPrompter", () => {
|
|
|
424
424
|
|
|
425
425
|
await prompter.prompt(makeCtx(true), details);
|
|
426
426
|
|
|
427
|
-
expect(
|
|
427
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
428
428
|
"permission_request.waiting",
|
|
429
429
|
expect.objectContaining({
|
|
430
430
|
requestId: "req-123",
|
|
@@ -443,17 +443,17 @@ describe("PermissionPrompter", () => {
|
|
|
443
443
|
});
|
|
444
444
|
|
|
445
445
|
it("uses null for optional fields not present in details", async () => {
|
|
446
|
-
const
|
|
446
|
+
const logger = { review: vi.fn() };
|
|
447
447
|
mockRequestApproval.mockResolvedValue({
|
|
448
448
|
approved: true,
|
|
449
449
|
state: "approved",
|
|
450
450
|
});
|
|
451
|
-
const deps = makeDeps({
|
|
451
|
+
const deps = makeDeps({ logger });
|
|
452
452
|
const prompter = new PermissionPrompter(deps);
|
|
453
453
|
|
|
454
454
|
await prompter.prompt(makeCtx(true), makeDetails());
|
|
455
455
|
|
|
456
|
-
expect(
|
|
456
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
457
457
|
"permission_request.waiting",
|
|
458
458
|
expect.objectContaining({
|
|
459
459
|
toolCallId: null,
|
|
@@ -499,17 +499,17 @@ describe("PermissionPrompter", () => {
|
|
|
499
499
|
});
|
|
500
500
|
|
|
501
501
|
it("logs the outcome when confirmPermission resolves via forwarding", async () => {
|
|
502
|
-
const
|
|
502
|
+
const logger = { review: vi.fn() };
|
|
503
503
|
mockRequestApproval.mockResolvedValue({
|
|
504
504
|
approved: true,
|
|
505
505
|
state: "approved",
|
|
506
506
|
});
|
|
507
|
-
const deps = makeDeps({
|
|
507
|
+
const deps = makeDeps({ logger });
|
|
508
508
|
const prompter = new PermissionPrompter(deps);
|
|
509
509
|
|
|
510
510
|
await prompter.prompt(makeCtx(false), makeDetails());
|
|
511
511
|
|
|
512
|
-
expect(
|
|
512
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
513
513
|
"permission_request.approved",
|
|
514
514
|
expect.objectContaining({ requestId: "req-123" }),
|
|
515
515
|
);
|
|
@@ -22,13 +22,12 @@ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
|
22
22
|
import type { ExtensionPaths } from "#src/extension-paths";
|
|
23
23
|
import type { ForwardingController } from "#src/forwarding-manager";
|
|
24
24
|
import type { ScopedPermissionManager } from "#src/permission-manager";
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
type PermissionSessionRuntimeDeps,
|
|
28
|
-
} from "#src/permission-session";
|
|
25
|
+
import { PermissionSession } from "#src/permission-session";
|
|
26
|
+
import type { PromptingGatewayLifecycle } from "#src/prompting-gateway";
|
|
29
27
|
import type { Ruleset } from "#src/rule";
|
|
30
28
|
import { SessionApproval } from "#src/session-approval";
|
|
31
29
|
import type { SessionLogger } from "#src/session-logger";
|
|
30
|
+
import { SessionRules } from "#src/session-rules";
|
|
32
31
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
33
32
|
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
34
33
|
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
@@ -82,12 +81,10 @@ function makeConfigStore(
|
|
|
82
81
|
};
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
function
|
|
84
|
+
function makeGateway(): PromptingGatewayLifecycle {
|
|
86
85
|
return {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
.fn()
|
|
90
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
86
|
+
activate: vi.fn<PromptingGatewayLifecycle["activate"]>(),
|
|
87
|
+
deactivate: vi.fn<PromptingGatewayLifecycle["deactivate"]>(),
|
|
91
88
|
};
|
|
92
89
|
}
|
|
93
90
|
|
|
@@ -129,32 +126,44 @@ function createSession(overrides?: {
|
|
|
129
126
|
logger?: SessionLogger;
|
|
130
127
|
forwarding?: ForwardingController;
|
|
131
128
|
permissionManager?: ScopedPermissionManager;
|
|
129
|
+
sessionRules?: SessionRules;
|
|
132
130
|
configStore?: SessionConfigStore;
|
|
133
|
-
|
|
131
|
+
gateway?: PromptingGatewayLifecycle;
|
|
134
132
|
}): {
|
|
135
133
|
session: PermissionSession;
|
|
136
134
|
paths: ExtensionPaths;
|
|
137
135
|
logger: SessionLogger;
|
|
138
136
|
forwarding: ForwardingController;
|
|
137
|
+
sessionRules: SessionRules;
|
|
139
138
|
configStore: SessionConfigStore;
|
|
140
|
-
|
|
139
|
+
gateway: PromptingGatewayLifecycle;
|
|
141
140
|
} {
|
|
142
141
|
const paths = makePaths(overrides?.paths);
|
|
143
142
|
const logger = overrides?.logger ?? makeLogger();
|
|
144
143
|
const forwarding = overrides?.forwarding ?? makeForwarding();
|
|
145
144
|
const permissionManager =
|
|
146
145
|
overrides?.permissionManager ?? makePermissionManager();
|
|
146
|
+
const sessionRules = overrides?.sessionRules ?? new SessionRules();
|
|
147
147
|
const configStore = overrides?.configStore ?? makeConfigStore();
|
|
148
|
-
const
|
|
148
|
+
const gateway = overrides?.gateway ?? makeGateway();
|
|
149
149
|
const session = new PermissionSession(
|
|
150
150
|
paths,
|
|
151
151
|
logger,
|
|
152
152
|
forwarding,
|
|
153
153
|
permissionManager,
|
|
154
|
+
sessionRules,
|
|
154
155
|
configStore,
|
|
155
|
-
|
|
156
|
+
gateway,
|
|
156
157
|
);
|
|
157
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
session,
|
|
160
|
+
paths,
|
|
161
|
+
logger,
|
|
162
|
+
forwarding,
|
|
163
|
+
sessionRules,
|
|
164
|
+
configStore,
|
|
165
|
+
gateway,
|
|
166
|
+
};
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
@@ -316,6 +325,23 @@ describe("PermissionSession", () => {
|
|
|
316
325
|
|
|
317
326
|
expect(forwarding.stop).toHaveBeenCalled();
|
|
318
327
|
});
|
|
328
|
+
|
|
329
|
+
it("forwards activate to the gateway", () => {
|
|
330
|
+
const { session, gateway } = createSession();
|
|
331
|
+
const ctx = makeCtx();
|
|
332
|
+
|
|
333
|
+
session.activate(ctx);
|
|
334
|
+
|
|
335
|
+
expect(gateway.activate).toHaveBeenCalledWith(ctx);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("forwards deactivate to the gateway", () => {
|
|
339
|
+
const { session, gateway } = createSession();
|
|
340
|
+
session.activate(makeCtx());
|
|
341
|
+
session.deactivate();
|
|
342
|
+
|
|
343
|
+
expect(gateway.deactivate).toHaveBeenCalled();
|
|
344
|
+
});
|
|
319
345
|
});
|
|
320
346
|
|
|
321
347
|
describe("resetForNewSession", () => {
|
|
@@ -614,102 +640,4 @@ describe("PermissionSession", () => {
|
|
|
614
640
|
expect(session.getRuntimeContext()).toBeNull();
|
|
615
641
|
});
|
|
616
642
|
});
|
|
617
|
-
|
|
618
|
-
describe("canConfirm", () => {
|
|
619
|
-
it("returns true when context is active and canPrompt returns true", () => {
|
|
620
|
-
const { session } = createSession();
|
|
621
|
-
session.activate(makeCtx());
|
|
622
|
-
expect(session.canConfirm()).toBe(true);
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
it("returns false when no context is active", () => {
|
|
626
|
-
const { session } = createSession();
|
|
627
|
-
expect(session.canConfirm()).toBe(false);
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
it("returns false when canPrompt returns false", () => {
|
|
631
|
-
const runtimeDeps = makeRuntimeDeps();
|
|
632
|
-
(
|
|
633
|
-
runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
|
|
634
|
-
).mockReturnValue(false);
|
|
635
|
-
const { session } = createSession({ runtimeDeps });
|
|
636
|
-
session.activate(makeCtx());
|
|
637
|
-
expect(session.canConfirm()).toBe(false);
|
|
638
|
-
});
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
describe("promptPermission", () => {
|
|
642
|
-
it("delegates to prompt with stored context", async () => {
|
|
643
|
-
const { session, runtimeDeps } = createSession();
|
|
644
|
-
const ctx = makeCtx();
|
|
645
|
-
session.activate(ctx);
|
|
646
|
-
const details = {
|
|
647
|
-
requestId: "req-1",
|
|
648
|
-
source: "tool_call" as const,
|
|
649
|
-
agentName: null,
|
|
650
|
-
message: "Allow?",
|
|
651
|
-
};
|
|
652
|
-
|
|
653
|
-
const result = await session.promptPermission(details);
|
|
654
|
-
|
|
655
|
-
expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
|
|
656
|
-
expect(result).toEqual({ approved: true, state: "approved" });
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
it("throws when no context is active", async () => {
|
|
660
|
-
const { session } = createSession();
|
|
661
|
-
const details = {
|
|
662
|
-
requestId: "req-1",
|
|
663
|
-
source: "tool_call" as const,
|
|
664
|
-
agentName: null,
|
|
665
|
-
message: "Allow?",
|
|
666
|
-
};
|
|
667
|
-
|
|
668
|
-
await expect(session.promptPermission(details)).rejects.toThrow(
|
|
669
|
-
"promptPermission called before the session was activated",
|
|
670
|
-
);
|
|
671
|
-
});
|
|
672
|
-
});
|
|
673
|
-
|
|
674
|
-
describe("canPrompt", () => {
|
|
675
|
-
it("delegates to runtimeDeps.canRequestPermissionConfirmation", () => {
|
|
676
|
-
const { session, runtimeDeps } = createSession();
|
|
677
|
-
const ctx = makeCtx();
|
|
678
|
-
|
|
679
|
-
const result = session.canPrompt(ctx);
|
|
680
|
-
|
|
681
|
-
expect(runtimeDeps.canRequestPermissionConfirmation).toHaveBeenCalledWith(
|
|
682
|
-
ctx,
|
|
683
|
-
);
|
|
684
|
-
expect(result).toBe(true);
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
it("returns false when runtimeDeps says no", () => {
|
|
688
|
-
const runtimeDeps = makeRuntimeDeps();
|
|
689
|
-
(
|
|
690
|
-
runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
|
|
691
|
-
).mockReturnValue(false);
|
|
692
|
-
const { session } = createSession({ runtimeDeps });
|
|
693
|
-
|
|
694
|
-
expect(session.canPrompt(makeCtx())).toBe(false);
|
|
695
|
-
});
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
describe("prompt", () => {
|
|
699
|
-
it("delegates to runtimeDeps.promptPermission", async () => {
|
|
700
|
-
const { session, runtimeDeps } = createSession();
|
|
701
|
-
const ctx = makeCtx();
|
|
702
|
-
const details = {
|
|
703
|
-
requestId: "req-1",
|
|
704
|
-
source: "tool_call" as const,
|
|
705
|
-
agentName: null,
|
|
706
|
-
message: "Allow?",
|
|
707
|
-
};
|
|
708
|
-
|
|
709
|
-
const result = await session.prompt(ctx, details);
|
|
710
|
-
|
|
711
|
-
expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
|
|
712
|
-
expect(result).toEqual({ approved: true, state: "approved" });
|
|
713
|
-
});
|
|
714
|
-
});
|
|
715
643
|
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for PromptingGateway.
|
|
3
|
+
*
|
|
4
|
+
* The gateway owns the stored ExtensionContext and is the sole implementation
|
|
5
|
+
* of the GatePrompter role. These tests exercise canConfirm() across all
|
|
6
|
+
* policy permutations and verify the prompt/reject contract for promptPermission().
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
import type { ConfigReader } from "#src/config-store";
|
|
12
|
+
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
13
|
+
import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
14
|
+
import type {
|
|
15
|
+
PermissionPrompterApi,
|
|
16
|
+
PromptPermissionDetails,
|
|
17
|
+
} from "#src/permission-prompter";
|
|
18
|
+
import {
|
|
19
|
+
PromptingGateway,
|
|
20
|
+
type PromptingGatewayDeps,
|
|
21
|
+
} from "#src/prompting-gateway";
|
|
22
|
+
|
|
23
|
+
// ── Test helpers ──────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
26
|
+
return {
|
|
27
|
+
cwd: "/test/project",
|
|
28
|
+
hasUI: true,
|
|
29
|
+
ui: {
|
|
30
|
+
setStatus: vi.fn(),
|
|
31
|
+
notify: vi.fn(),
|
|
32
|
+
select: vi.fn(),
|
|
33
|
+
input: vi.fn(),
|
|
34
|
+
},
|
|
35
|
+
sessionManager: {
|
|
36
|
+
getEntries: vi.fn().mockReturnValue([]),
|
|
37
|
+
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
|
|
38
|
+
getSessionId: vi.fn().mockReturnValue(null),
|
|
39
|
+
addEntry: vi.fn(),
|
|
40
|
+
},
|
|
41
|
+
...overrides,
|
|
42
|
+
} as unknown as ExtensionContext;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeConfigReader(
|
|
46
|
+
overrides: Partial<typeof DEFAULT_EXTENSION_CONFIG> = {},
|
|
47
|
+
): ConfigReader {
|
|
48
|
+
return {
|
|
49
|
+
current: vi
|
|
50
|
+
.fn<() => typeof DEFAULT_EXTENSION_CONFIG>()
|
|
51
|
+
.mockReturnValue({ ...DEFAULT_EXTENSION_CONFIG, ...overrides }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makePrompterApi(): PermissionPrompterApi & {
|
|
56
|
+
prompt: ReturnType<typeof vi.fn>;
|
|
57
|
+
} {
|
|
58
|
+
return {
|
|
59
|
+
prompt: vi
|
|
60
|
+
.fn<PermissionPrompterApi["prompt"]>()
|
|
61
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeDetails(): PromptPermissionDetails {
|
|
66
|
+
return {
|
|
67
|
+
requestId: "req-1",
|
|
68
|
+
source: "tool_call",
|
|
69
|
+
agentName: null,
|
|
70
|
+
message: "Allow this?",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeDeps(
|
|
75
|
+
overrides: Partial<PromptingGatewayDeps> = {},
|
|
76
|
+
): PromptingGatewayDeps {
|
|
77
|
+
return {
|
|
78
|
+
config: overrides.config ?? makeConfigReader(),
|
|
79
|
+
subagentSessionsDir:
|
|
80
|
+
overrides.subagentSessionsDir ?? "/test/agent/subagent-sessions",
|
|
81
|
+
registry: overrides.registry,
|
|
82
|
+
prompter: overrides.prompter ?? makePrompterApi(),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe("PromptingGateway", () => {
|
|
89
|
+
describe("canConfirm", () => {
|
|
90
|
+
it("returns false before activate", () => {
|
|
91
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
92
|
+
expect(gateway.canConfirm()).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns true after activate when context has UI", () => {
|
|
96
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
97
|
+
gateway.activate(makeCtx({ hasUI: true }));
|
|
98
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns false when context has no UI, is not a subagent, and yolo mode is off", () => {
|
|
102
|
+
const gateway = new PromptingGateway(
|
|
103
|
+
makeDeps({ config: makeConfigReader({ yoloMode: false }) }),
|
|
104
|
+
);
|
|
105
|
+
gateway.activate(makeCtx({ hasUI: false }));
|
|
106
|
+
expect(gateway.canConfirm()).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns true when yolo mode is enabled (no UI, not subagent)", () => {
|
|
110
|
+
const gateway = new PromptingGateway(
|
|
111
|
+
makeDeps({ config: makeConfigReader({ yoloMode: true }) }),
|
|
112
|
+
);
|
|
113
|
+
gateway.activate(makeCtx({ hasUI: false }));
|
|
114
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns true when running as a subagent (env hint)", () => {
|
|
118
|
+
vi.stubEnv("PI_IS_SUBAGENT", "1");
|
|
119
|
+
const gateway = new PromptingGateway(
|
|
120
|
+
makeDeps({ config: makeConfigReader({ yoloMode: false }) }),
|
|
121
|
+
);
|
|
122
|
+
gateway.activate(makeCtx({ hasUI: false }));
|
|
123
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
124
|
+
vi.unstubAllEnvs();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns false after deactivate", () => {
|
|
128
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
129
|
+
gateway.activate(makeCtx({ hasUI: true }));
|
|
130
|
+
gateway.deactivate();
|
|
131
|
+
expect(gateway.canConfirm()).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns true after re-activate following deactivate", () => {
|
|
135
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
136
|
+
gateway.activate(makeCtx({ hasUI: true }));
|
|
137
|
+
gateway.deactivate();
|
|
138
|
+
gateway.activate(makeCtx({ hasUI: true }));
|
|
139
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("prompt", () => {
|
|
144
|
+
it("rejects before activate", async () => {
|
|
145
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
146
|
+
await expect(gateway.prompt(makeDetails())).rejects.toThrow(
|
|
147
|
+
"prompt called before the session was activated",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("delegates to deps.prompter.prompt with the stored context", async () => {
|
|
152
|
+
const prompter = makePrompterApi();
|
|
153
|
+
const gateway = new PromptingGateway(makeDeps({ prompter }));
|
|
154
|
+
const ctx = makeCtx();
|
|
155
|
+
gateway.activate(ctx);
|
|
156
|
+
const details = makeDetails();
|
|
157
|
+
|
|
158
|
+
const result = await gateway.prompt(details);
|
|
159
|
+
|
|
160
|
+
expect(prompter.prompt).toHaveBeenCalledWith(ctx, details);
|
|
161
|
+
expect(result).toEqual({ approved: true, state: "approved" });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("uses the most recently activated context", async () => {
|
|
165
|
+
const prompter = makePrompterApi();
|
|
166
|
+
const gateway = new PromptingGateway(makeDeps({ prompter }));
|
|
167
|
+
const firstCtx = makeCtx({ cwd: "/first" });
|
|
168
|
+
const secondCtx = makeCtx({ cwd: "/second" });
|
|
169
|
+
|
|
170
|
+
gateway.activate(firstCtx);
|
|
171
|
+
gateway.activate(secondCtx);
|
|
172
|
+
|
|
173
|
+
await gateway.prompt(makeDetails());
|
|
174
|
+
|
|
175
|
+
expect(prompter.prompt).toHaveBeenCalledWith(
|
|
176
|
+
secondCtx,
|
|
177
|
+
expect.anything(),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("rejects after deactivate", async () => {
|
|
182
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
183
|
+
gateway.activate(makeCtx());
|
|
184
|
+
gateway.deactivate();
|
|
185
|
+
await expect(gateway.prompt(makeDetails())).rejects.toThrow(
|
|
186
|
+
"prompt called before the session was activated",
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("returns the prompter decision", async () => {
|
|
191
|
+
const decision: PermissionPromptDecision = {
|
|
192
|
+
approved: false,
|
|
193
|
+
state: "denied",
|
|
194
|
+
denialReason: "user declined",
|
|
195
|
+
};
|
|
196
|
+
const prompter = makePrompterApi();
|
|
197
|
+
prompter.prompt.mockResolvedValue(decision);
|
|
198
|
+
const gateway = new PromptingGateway(makeDeps({ prompter }));
|
|
199
|
+
gateway.activate(makeCtx());
|
|
200
|
+
|
|
201
|
+
const result = await gateway.prompt(makeDetails());
|
|
202
|
+
|
|
203
|
+
expect(result).toEqual(decision);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("lifecycle", () => {
|
|
208
|
+
afterEach(() => {
|
|
209
|
+
vi.unstubAllEnvs();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("activate then deactivate clears the stored context", () => {
|
|
213
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
214
|
+
gateway.activate(makeCtx());
|
|
215
|
+
gateway.deactivate();
|
|
216
|
+
expect(gateway.canConfirm()).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("multiple activate calls update the stored context", () => {
|
|
220
|
+
const prompter = makePrompterApi();
|
|
221
|
+
const gateway = new PromptingGateway(makeDeps({ prompter }));
|
|
222
|
+
const ctx2 = makeCtx({ cwd: "/new" });
|
|
223
|
+
gateway.activate(makeCtx({ cwd: "/old" }));
|
|
224
|
+
gateway.activate(ctx2);
|
|
225
|
+
|
|
226
|
+
// canConfirm still works (context set)
|
|
227
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|