@gotgenes/pi-permission-system 10.3.1 → 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.
@@ -22,10 +22,8 @@ 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
- PermissionSession,
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";
@@ -83,12 +81,10 @@ function makeConfigStore(
83
81
  };
84
82
  }
85
83
 
86
- function makeRuntimeDeps(): PermissionSessionRuntimeDeps {
84
+ function makeGateway(): PromptingGatewayLifecycle {
87
85
  return {
88
- canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
89
- promptPermission: vi
90
- .fn()
91
- .mockResolvedValue({ approved: true, state: "approved" }),
86
+ activate: vi.fn<PromptingGatewayLifecycle["activate"]>(),
87
+ deactivate: vi.fn<PromptingGatewayLifecycle["deactivate"]>(),
92
88
  };
93
89
  }
94
90
 
@@ -132,7 +128,7 @@ function createSession(overrides?: {
132
128
  permissionManager?: ScopedPermissionManager;
133
129
  sessionRules?: SessionRules;
134
130
  configStore?: SessionConfigStore;
135
- runtimeDeps?: PermissionSessionRuntimeDeps;
131
+ gateway?: PromptingGatewayLifecycle;
136
132
  }): {
137
133
  session: PermissionSession;
138
134
  paths: ExtensionPaths;
@@ -140,7 +136,7 @@ function createSession(overrides?: {
140
136
  forwarding: ForwardingController;
141
137
  sessionRules: SessionRules;
142
138
  configStore: SessionConfigStore;
143
- runtimeDeps: PermissionSessionRuntimeDeps;
139
+ gateway: PromptingGatewayLifecycle;
144
140
  } {
145
141
  const paths = makePaths(overrides?.paths);
146
142
  const logger = overrides?.logger ?? makeLogger();
@@ -149,7 +145,7 @@ function createSession(overrides?: {
149
145
  overrides?.permissionManager ?? makePermissionManager();
150
146
  const sessionRules = overrides?.sessionRules ?? new SessionRules();
151
147
  const configStore = overrides?.configStore ?? makeConfigStore();
152
- const runtimeDeps = overrides?.runtimeDeps ?? makeRuntimeDeps();
148
+ const gateway = overrides?.gateway ?? makeGateway();
153
149
  const session = new PermissionSession(
154
150
  paths,
155
151
  logger,
@@ -157,7 +153,7 @@ function createSession(overrides?: {
157
153
  permissionManager,
158
154
  sessionRules,
159
155
  configStore,
160
- runtimeDeps,
156
+ gateway,
161
157
  );
162
158
  return {
163
159
  session,
@@ -166,7 +162,7 @@ function createSession(overrides?: {
166
162
  forwarding,
167
163
  sessionRules,
168
164
  configStore,
169
- runtimeDeps,
165
+ gateway,
170
166
  };
171
167
  }
172
168
 
@@ -329,6 +325,23 @@ describe("PermissionSession", () => {
329
325
 
330
326
  expect(forwarding.stop).toHaveBeenCalled();
331
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
+ });
332
345
  });
333
346
 
334
347
  describe("resetForNewSession", () => {
@@ -627,102 +640,4 @@ describe("PermissionSession", () => {
627
640
  expect(session.getRuntimeContext()).toBeNull();
628
641
  });
629
642
  });
630
-
631
- describe("canConfirm", () => {
632
- it("returns true when context is active and canPrompt returns true", () => {
633
- const { session } = createSession();
634
- session.activate(makeCtx());
635
- expect(session.canConfirm()).toBe(true);
636
- });
637
-
638
- it("returns false when no context is active", () => {
639
- const { session } = createSession();
640
- expect(session.canConfirm()).toBe(false);
641
- });
642
-
643
- it("returns false when canPrompt returns false", () => {
644
- const runtimeDeps = makeRuntimeDeps();
645
- (
646
- runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
647
- ).mockReturnValue(false);
648
- const { session } = createSession({ runtimeDeps });
649
- session.activate(makeCtx());
650
- expect(session.canConfirm()).toBe(false);
651
- });
652
- });
653
-
654
- describe("promptPermission", () => {
655
- it("delegates to prompt with stored context", async () => {
656
- const { session, runtimeDeps } = createSession();
657
- const ctx = makeCtx();
658
- session.activate(ctx);
659
- const details = {
660
- requestId: "req-1",
661
- source: "tool_call" as const,
662
- agentName: null,
663
- message: "Allow?",
664
- };
665
-
666
- const result = await session.promptPermission(details);
667
-
668
- expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
669
- expect(result).toEqual({ approved: true, state: "approved" });
670
- });
671
-
672
- it("throws when no context is active", async () => {
673
- const { session } = createSession();
674
- const details = {
675
- requestId: "req-1",
676
- source: "tool_call" as const,
677
- agentName: null,
678
- message: "Allow?",
679
- };
680
-
681
- await expect(session.promptPermission(details)).rejects.toThrow(
682
- "promptPermission called before the session was activated",
683
- );
684
- });
685
- });
686
-
687
- describe("canPrompt", () => {
688
- it("delegates to runtimeDeps.canRequestPermissionConfirmation", () => {
689
- const { session, runtimeDeps } = createSession();
690
- const ctx = makeCtx();
691
-
692
- const result = session.canPrompt(ctx);
693
-
694
- expect(runtimeDeps.canRequestPermissionConfirmation).toHaveBeenCalledWith(
695
- ctx,
696
- );
697
- expect(result).toBe(true);
698
- });
699
-
700
- it("returns false when runtimeDeps says no", () => {
701
- const runtimeDeps = makeRuntimeDeps();
702
- (
703
- runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
704
- ).mockReturnValue(false);
705
- const { session } = createSession({ runtimeDeps });
706
-
707
- expect(session.canPrompt(makeCtx())).toBe(false);
708
- });
709
- });
710
-
711
- describe("prompt", () => {
712
- it("delegates to runtimeDeps.promptPermission", async () => {
713
- const { session, runtimeDeps } = createSession();
714
- const ctx = makeCtx();
715
- const details = {
716
- requestId: "req-1",
717
- source: "tool_call" as const,
718
- agentName: null,
719
- message: "Allow?",
720
- };
721
-
722
- const result = await session.prompt(ctx, details);
723
-
724
- expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
725
- expect(result).toEqual({ approved: true, state: "approved" });
726
- });
727
- });
728
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
+ });