@gotgenes/pi-permission-system 10.3.1 → 10.5.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/src/config-modal.ts +10 -8
  4. package/src/config-store.ts +6 -11
  5. package/src/forwarded-permissions/io.ts +16 -22
  6. package/src/forwarded-permissions/permission-forwarder.ts +16 -19
  7. package/src/gate-prompter.ts +1 -3
  8. package/src/handlers/gates/bash-command.ts +2 -2
  9. package/src/handlers/gates/bash-external-directory.ts +2 -2
  10. package/src/handlers/gates/bash-path.ts +2 -2
  11. package/src/handlers/gates/path.ts +2 -2
  12. package/src/handlers/gates/runner.ts +3 -3
  13. package/src/handlers/gates/tool-call-gate-pipeline.ts +10 -9
  14. package/src/index.ts +27 -41
  15. package/src/permission-event-rpc.ts +19 -15
  16. package/src/permission-prompter.ts +4 -3
  17. package/src/permission-resolver.ts +69 -2
  18. package/src/permission-session.ts +7 -83
  19. package/src/prompting-gateway.ts +104 -0
  20. package/src/session-logger.ts +17 -3
  21. package/test/config-modal.test.ts +13 -7
  22. package/test/config-store.test.ts +7 -9
  23. package/test/forwarded-permissions/io.test.ts +23 -26
  24. package/test/handlers/external-directory-integration.test.ts +45 -32
  25. package/test/handlers/external-directory-session-dedup.test.ts +47 -57
  26. package/test/handlers/gates/bash-external-directory.test.ts +2 -2
  27. package/test/handlers/gates/bash-path.test.ts +2 -2
  28. package/test/handlers/gates/runner.test.ts +10 -16
  29. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +30 -21
  30. package/test/handlers/input-events.test.ts +19 -4
  31. package/test/handlers/input.test.ts +29 -13
  32. package/test/handlers/tool-call-events.test.ts +23 -5
  33. package/test/helpers/gate-fixtures.ts +11 -15
  34. package/test/helpers/handler-fixtures.ts +31 -50
  35. package/test/permission-event-rpc.test.ts +30 -28
  36. package/test/permission-forwarder.test.ts +6 -5
  37. package/test/permission-prompter.test.ts +28 -28
  38. package/test/permission-resolver.test.ts +194 -0
  39. package/test/permission-session.test.ts +27 -180
  40. package/test/prompting-gateway.test.ts +230 -0
@@ -0,0 +1,194 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { ScopedPermissionManager } from "#src/permission-manager";
3
+ import { PermissionResolver } from "#src/permission-resolver";
4
+ import type { Ruleset } from "#src/rule";
5
+ import { SessionApproval } from "#src/session-approval";
6
+ import { SessionRules } from "#src/session-rules";
7
+ import type { PermissionCheckResult, PermissionState } from "#src/types";
8
+
9
+ function makePermissionManager() {
10
+ return {
11
+ configureForCwd: vi.fn<(cwd: string | undefined | null) => void>(),
12
+ checkPermission: vi
13
+ .fn<
14
+ (
15
+ toolName: string,
16
+ input: unknown,
17
+ agentName?: string,
18
+ sessionRules?: Ruleset,
19
+ ) => PermissionCheckResult
20
+ >()
21
+ .mockReturnValue({
22
+ state: "allow",
23
+ toolName: "read",
24
+ source: "tool",
25
+ origin: "builtin",
26
+ }),
27
+ getToolPermission: vi
28
+ .fn<(toolName: string, agentName?: string) => PermissionState>()
29
+ .mockReturnValue("allow"),
30
+ getConfigIssues: vi.fn((): string[] => []),
31
+ getPolicyCacheStamp: vi.fn((): string => "stamp-1"),
32
+ };
33
+ }
34
+
35
+ function makeResolver(
36
+ pm?: ScopedPermissionManager,
37
+ sessionRules?: Pick<SessionRules, "getRuleset">,
38
+ ) {
39
+ const permissionManager = pm ?? makePermissionManager();
40
+ const rules = sessionRules ?? new SessionRules();
41
+ return {
42
+ resolver: new PermissionResolver(permissionManager, rules),
43
+ permissionManager,
44
+ };
45
+ }
46
+
47
+ beforeEach(() => {
48
+ // no module-level vi.fn() stubs to reset
49
+ });
50
+
51
+ describe("PermissionResolver", () => {
52
+ describe("resolve", () => {
53
+ it("forwards surface, input, and agentName, applying the empty session ruleset", () => {
54
+ const { resolver, permissionManager } = makeResolver();
55
+
56
+ resolver.resolve("bash", { command: "ls" }, "agent-x");
57
+
58
+ expect(permissionManager.checkPermission).toHaveBeenCalledWith(
59
+ "bash",
60
+ { command: "ls" },
61
+ "agent-x",
62
+ [],
63
+ );
64
+ });
65
+
66
+ it("defaults agentName to undefined when omitted", () => {
67
+ const { resolver, permissionManager } = makeResolver();
68
+
69
+ resolver.resolve("read", { path: ".env" });
70
+
71
+ expect(permissionManager.checkPermission).toHaveBeenCalledWith(
72
+ "read",
73
+ { path: ".env" },
74
+ undefined,
75
+ [],
76
+ );
77
+ });
78
+
79
+ it("applies a recorded session approval on the next resolve", () => {
80
+ const pm = makePermissionManager();
81
+ const sessionRules = new SessionRules();
82
+ const { resolver } = makeResolver(pm, sessionRules);
83
+
84
+ // Record an approval directly into the shared SessionRules instance.
85
+ sessionRules.record(SessionApproval.single("bash", "git *"));
86
+ resolver.resolve("bash", { command: "git status" });
87
+
88
+ const passedRules = vi.mocked(pm.checkPermission).mock.calls[0][3];
89
+ expect(passedRules).toHaveLength(1);
90
+ expect(passedRules?.[0]).toMatchObject({
91
+ surface: "bash",
92
+ pattern: "git *",
93
+ action: "allow",
94
+ });
95
+ });
96
+
97
+ it("returns the PermissionManager's check result", () => {
98
+ const pm = makePermissionManager();
99
+ vi.mocked(pm.checkPermission).mockReturnValue({
100
+ state: "deny",
101
+ toolName: "bash",
102
+ source: "bash",
103
+ origin: "global",
104
+ matchedPattern: "rm *",
105
+ });
106
+ const { resolver } = makeResolver(pm);
107
+
108
+ const result = resolver.resolve("bash", { command: "rm -rf /" });
109
+
110
+ expect(result).toEqual({
111
+ state: "deny",
112
+ toolName: "bash",
113
+ source: "bash",
114
+ origin: "global",
115
+ matchedPattern: "rm *",
116
+ });
117
+ });
118
+ });
119
+
120
+ describe("checkPermission", () => {
121
+ it("delegates to permissionManager.checkPermission with the given args", () => {
122
+ const { resolver, permissionManager } = makeResolver();
123
+
124
+ resolver.checkPermission("bash", { command: "ls" }, "agent-1");
125
+
126
+ expect(permissionManager.checkPermission).toHaveBeenCalledWith(
127
+ "bash",
128
+ { command: "ls" },
129
+ "agent-1",
130
+ undefined,
131
+ );
132
+ });
133
+
134
+ it("passes optional sessionRules through when supplied", () => {
135
+ const { resolver, permissionManager } = makeResolver();
136
+ const extraRules: Ruleset = [
137
+ { surface: "bash", pattern: "*", action: "allow", origin: "session" },
138
+ ];
139
+
140
+ resolver.checkPermission(
141
+ "bash",
142
+ { command: "ls" },
143
+ undefined,
144
+ extraRules,
145
+ );
146
+
147
+ expect(permissionManager.checkPermission).toHaveBeenCalledWith(
148
+ "bash",
149
+ { command: "ls" },
150
+ undefined,
151
+ extraRules,
152
+ );
153
+ });
154
+ });
155
+
156
+ describe("getToolPermission", () => {
157
+ it("delegates to permissionManager.getToolPermission", () => {
158
+ const pm = makePermissionManager();
159
+ vi.mocked(pm.getToolPermission).mockReturnValue("deny");
160
+ const { resolver } = makeResolver(pm);
161
+
162
+ const result = resolver.getToolPermission("write", "my-agent");
163
+
164
+ expect(pm.getToolPermission).toHaveBeenCalledWith("write", "my-agent");
165
+ expect(result).toBe("deny");
166
+ });
167
+ });
168
+
169
+ describe("getConfigIssues", () => {
170
+ it("delegates to permissionManager.getConfigIssues", () => {
171
+ const pm = makePermissionManager();
172
+ vi.mocked(pm.getConfigIssues).mockReturnValue(["issue-1"]);
173
+ const { resolver } = makeResolver(pm);
174
+
175
+ const result = resolver.getConfigIssues("agent-1");
176
+
177
+ expect(pm.getConfigIssues).toHaveBeenCalledWith("agent-1");
178
+ expect(result).toEqual(["issue-1"]);
179
+ });
180
+ });
181
+
182
+ describe("getPolicyCacheStamp", () => {
183
+ it("delegates to permissionManager.getPolicyCacheStamp", () => {
184
+ const pm = makePermissionManager();
185
+ vi.mocked(pm.getPolicyCacheStamp).mockReturnValue("stamp-abc");
186
+ const { resolver } = makeResolver(pm);
187
+
188
+ const result = resolver.getPolicyCacheStamp("agent-1");
189
+
190
+ expect(pm.getPolicyCacheStamp).toHaveBeenCalledWith("agent-1");
191
+ expect(result).toBe("stamp-abc");
192
+ });
193
+ });
194
+ });
@@ -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
 
@@ -244,74 +240,6 @@ describe("PermissionSession", () => {
244
240
  });
245
241
  });
246
242
 
247
- describe("resolve", () => {
248
- it("forwards surface, input, and agentName, applying the empty session ruleset", () => {
249
- const pm = makePermissionManager();
250
- const { session } = createSession({ permissionManager: pm });
251
-
252
- session.resolve("bash", { command: "ls" }, "agent-x");
253
-
254
- expect(pm.checkPermission).toHaveBeenCalledWith(
255
- "bash",
256
- { command: "ls" },
257
- "agent-x",
258
- [],
259
- );
260
- });
261
-
262
- it("defaults agentName to undefined when omitted", () => {
263
- const pm = makePermissionManager();
264
- const { session } = createSession({ permissionManager: pm });
265
-
266
- session.resolve("read", { path: ".env" });
267
-
268
- expect(pm.checkPermission).toHaveBeenCalledWith(
269
- "read",
270
- { path: ".env" },
271
- undefined,
272
- [],
273
- );
274
- });
275
-
276
- it("applies a recorded session approval on the next resolve", () => {
277
- const pm = makePermissionManager();
278
- const { session } = createSession({ permissionManager: pm });
279
-
280
- session.recordSessionApproval(SessionApproval.single("bash", "git *"));
281
- session.resolve("bash", { command: "git status" });
282
-
283
- const sessionRules = vi.mocked(pm.checkPermission).mock.calls[0][3];
284
- expect(sessionRules).toHaveLength(1);
285
- expect(sessionRules?.[0]).toMatchObject({
286
- surface: "bash",
287
- pattern: "git *",
288
- action: "allow",
289
- });
290
- });
291
-
292
- it("returns the PermissionManager's check result", () => {
293
- const pm = makePermissionManager();
294
- vi.mocked(pm.checkPermission).mockReturnValue({
295
- state: "deny",
296
- toolName: "bash",
297
- source: "bash",
298
- origin: "global",
299
- matchedPattern: "rm *",
300
- });
301
- const { session } = createSession({ permissionManager: pm });
302
-
303
- const result = session.resolve("bash", { command: "rm -rf /" });
304
-
305
- expect(result).toEqual({
306
- state: "deny",
307
- toolName: "bash",
308
- source: "bash",
309
- origin: "global",
310
- matchedPattern: "rm *",
311
- });
312
- });
313
- });
314
-
315
243
  describe("activate and deactivate", () => {
316
244
  it("stores the context on activate", () => {
317
245
  const { session, forwarding } = createSession();
@@ -329,6 +257,23 @@ describe("PermissionSession", () => {
329
257
 
330
258
  expect(forwarding.stop).toHaveBeenCalled();
331
259
  });
260
+
261
+ it("forwards activate to the gateway", () => {
262
+ const { session, gateway } = createSession();
263
+ const ctx = makeCtx();
264
+
265
+ session.activate(ctx);
266
+
267
+ expect(gateway.activate).toHaveBeenCalledWith(ctx);
268
+ });
269
+
270
+ it("forwards deactivate to the gateway", () => {
271
+ const { session, gateway } = createSession();
272
+ session.activate(makeCtx());
273
+ session.deactivate();
274
+
275
+ expect(gateway.deactivate).toHaveBeenCalled();
276
+ });
332
277
  });
333
278
 
334
279
  describe("resetForNewSession", () => {
@@ -627,102 +572,4 @@ describe("PermissionSession", () => {
627
572
  expect(session.getRuntimeContext()).toBeNull();
628
573
  });
629
574
  });
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
575
  });
@@ -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
+ });