@gotgenes/pi-permission-system 9.2.0 → 10.1.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 (73) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +12 -11
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/io.ts +29 -0
  8. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  9. package/src/forwarding-manager.ts +3 -7
  10. package/src/gate-handler-session.ts +13 -0
  11. package/src/gate-prompter.ts +14 -0
  12. package/src/handlers/before-agent-start.ts +2 -3
  13. package/src/handlers/gates/bash-command.ts +4 -18
  14. package/src/handlers/gates/bash-external-directory.ts +3 -15
  15. package/src/handlers/gates/bash-path.ts +3 -16
  16. package/src/handlers/gates/descriptor.ts +0 -28
  17. package/src/handlers/gates/path.ts +3 -15
  18. package/src/handlers/gates/runner.ts +142 -105
  19. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  20. package/src/handlers/gates/skill-input.ts +44 -0
  21. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  22. package/src/handlers/lifecycle.ts +9 -9
  23. package/src/handlers/permission-gate-handler.ts +34 -238
  24. package/src/index.ts +50 -68
  25. package/src/mcp-targets.ts +56 -46
  26. package/src/permission-event-rpc.ts +7 -0
  27. package/src/permission-events.ts +89 -8
  28. package/src/permission-forwarding.ts +23 -0
  29. package/src/permission-prompter.ts +27 -56
  30. package/src/permission-resolver.ts +17 -0
  31. package/src/permission-session.ts +77 -9
  32. package/src/permission-ui-prompt.ts +127 -0
  33. package/src/permissions-service.ts +53 -0
  34. package/src/service-lifecycle.ts +49 -0
  35. package/src/service.ts +17 -0
  36. package/src/session-approval-recorder.ts +6 -0
  37. package/src/session-lifecycle-session.ts +24 -0
  38. package/src/tool-input-preview.ts +0 -62
  39. package/src/tool-input-prompt-formatters.ts +63 -0
  40. package/src/tool-preview-formatter.ts +6 -4
  41. package/test/composition-root.test.ts +5 -0
  42. package/test/decision-reporter.test.ts +112 -0
  43. package/test/denial-messages.test.ts +62 -0
  44. package/test/forwarding-manager.test.ts +26 -44
  45. package/test/handlers/before-agent-start.test.ts +45 -21
  46. package/test/handlers/external-directory-integration.test.ts +86 -22
  47. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  48. package/test/handlers/gates/bash-command.test.ts +49 -90
  49. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  50. package/test/handlers/gates/bash-path.test.ts +63 -148
  51. package/test/handlers/gates/path.test.ts +38 -105
  52. package/test/handlers/gates/runner.test.ts +150 -93
  53. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  54. package/test/handlers/gates/skill-input.test.ts +128 -0
  55. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  56. package/test/handlers/input.test.ts +1 -2
  57. package/test/handlers/lifecycle.test.ts +49 -33
  58. package/test/handlers/tool-call-events.test.ts +1 -1
  59. package/test/helpers/gate-fixtures.ts +147 -16
  60. package/test/helpers/handler-fixtures.ts +143 -27
  61. package/test/mcp-targets.test.ts +55 -0
  62. package/test/permission-event-rpc.test.ts +39 -0
  63. package/test/permission-events.test.ts +78 -10
  64. package/test/permission-forwarder.test.ts +295 -0
  65. package/test/permission-prompter.test.ts +147 -38
  66. package/test/permission-session.test.ts +160 -27
  67. package/test/permission-ui-prompt.test.ts +146 -0
  68. package/test/permissions-service.test.ts +151 -0
  69. package/test/runtime.test.ts +0 -4
  70. package/test/service-lifecycle.test.ts +162 -0
  71. package/test/tool-input-preview.test.ts +0 -111
  72. package/test/tool-input-prompt-formatters.test.ts +115 -0
  73. package/src/forwarded-permissions/polling.ts +0 -379
@@ -217,6 +217,79 @@ describe("PermissionSession", () => {
217
217
  });
218
218
  });
219
219
 
220
+ describe("resolve", () => {
221
+ it("forwards surface, input, and agentName, applying the empty session ruleset", () => {
222
+ const pm = makePermissionManager();
223
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm);
224
+ const { session } = createSession();
225
+
226
+ session.resolve("bash", { command: "ls" }, "agent-x");
227
+
228
+ expect(pm.checkPermission).toHaveBeenCalledWith(
229
+ "bash",
230
+ { command: "ls" },
231
+ "agent-x",
232
+ [],
233
+ );
234
+ });
235
+
236
+ it("defaults agentName to undefined when omitted", () => {
237
+ const pm = makePermissionManager();
238
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm);
239
+ const { session } = createSession();
240
+
241
+ session.resolve("read", { path: ".env" });
242
+
243
+ expect(pm.checkPermission).toHaveBeenCalledWith(
244
+ "read",
245
+ { path: ".env" },
246
+ undefined,
247
+ [],
248
+ );
249
+ });
250
+
251
+ it("applies a recorded session approval on the next resolve", () => {
252
+ const pm = makePermissionManager();
253
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm);
254
+ const { session } = createSession();
255
+
256
+ session.recordSessionApproval(SessionApproval.single("bash", "git *"));
257
+ session.resolve("bash", { command: "git status" });
258
+
259
+ const sessionRules = vi.mocked(pm.checkPermission).mock.calls[0][3];
260
+ expect(sessionRules).toHaveLength(1);
261
+ expect(sessionRules?.[0]).toMatchObject({
262
+ surface: "bash",
263
+ pattern: "git *",
264
+ action: "allow",
265
+ });
266
+ });
267
+
268
+ it("returns the PermissionManager's check result", () => {
269
+ const pm = makePermissionManager({
270
+ checkPermission: vi.fn().mockReturnValue({
271
+ state: "deny",
272
+ toolName: "bash",
273
+ source: "bash",
274
+ origin: "global",
275
+ matchedPattern: "rm *",
276
+ }),
277
+ });
278
+ mockCreatePermissionManagerForCwd.mockReturnValue(pm);
279
+ const { session } = createSession();
280
+
281
+ const result = session.resolve("bash", { command: "rm -rf /" });
282
+
283
+ expect(result).toEqual({
284
+ state: "deny",
285
+ toolName: "bash",
286
+ source: "bash",
287
+ origin: "global",
288
+ matchedPattern: "rm *",
289
+ });
290
+ });
291
+ });
292
+
220
293
  describe("activate and deactivate", () => {
221
294
  it("stores the context on activate", () => {
222
295
  const { session, forwarding } = createSession();
@@ -432,26 +505,25 @@ describe("PermissionSession", () => {
432
505
  });
433
506
 
434
507
  describe("infrastructure paths", () => {
435
- it("getInfrastructureDirs returns paths from ExtensionPaths", () => {
436
- const { session } = createSession();
437
- expect(session.getInfrastructureDirs()).toEqual([
438
- "/test/agent",
439
- "/test/agent/git",
440
- ]);
441
- });
442
-
443
- it("getInfrastructureReadPaths returns config paths", () => {
508
+ it("getInfrastructureReadDirs combines piInfrastructureDirs and piInfrastructureReadPaths", () => {
444
509
  const runtimeDeps = makeRuntimeDeps();
445
510
  (runtimeDeps.getConfig as ReturnType<typeof vi.fn>).mockReturnValue({
446
511
  piInfrastructureReadPaths: ["/extra/path"],
447
512
  });
448
513
  const { session } = createSession({ runtimeDeps });
449
- expect(session.getInfrastructureReadPaths()).toEqual(["/extra/path"]);
514
+ expect(session.getInfrastructureReadDirs()).toEqual([
515
+ "/test/agent",
516
+ "/test/agent/git",
517
+ "/extra/path",
518
+ ]);
450
519
  });
451
520
 
452
- it("getInfrastructureReadPaths returns empty when config omits the field", () => {
521
+ it("getInfrastructureReadDirs returns only piInfrastructureDirs when config omits the field", () => {
453
522
  const { session } = createSession();
454
- expect(session.getInfrastructureReadPaths()).toEqual([]);
523
+ expect(session.getInfrastructureReadDirs()).toEqual([
524
+ "/test/agent",
525
+ "/test/agent/git",
526
+ ]);
455
527
  });
456
528
  });
457
529
 
@@ -478,6 +550,26 @@ describe("PermissionSession", () => {
478
550
  const { session } = createSession({ runtimeDeps });
479
551
  expect(session.config).toBe(fakeConfig);
480
552
  });
553
+
554
+ it("getToolPreviewLimits returns resolved preview limits from config", () => {
555
+ const runtimeDeps = makeRuntimeDeps();
556
+ (runtimeDeps.getConfig as ReturnType<typeof vi.fn>).mockReturnValue({
557
+ toolInputPreviewMaxLength: 400,
558
+ toolTextSummaryMaxLength: 120,
559
+ });
560
+ const { session } = createSession({ runtimeDeps });
561
+ const limits = session.getToolPreviewLimits();
562
+ expect(limits.toolInputPreviewMaxLength).toBe(400);
563
+ expect(limits.toolTextSummaryMaxLength).toBe(120);
564
+ });
565
+
566
+ it("getToolPreviewLimits falls back to built-in defaults when config omits fields", () => {
567
+ const { session } = createSession();
568
+ const limits = session.getToolPreviewLimits();
569
+ expect(limits.toolInputPreviewMaxLength).toBeGreaterThan(0);
570
+ expect(limits.toolTextSummaryMaxLength).toBeGreaterThan(0);
571
+ expect(limits.toolInputLogPreviewMaxLength).toBeGreaterThan(0);
572
+ });
481
573
  });
482
574
 
483
575
  describe("reload", () => {
@@ -532,6 +624,62 @@ describe("PermissionSession", () => {
532
624
  });
533
625
  });
534
626
 
627
+ describe("canConfirm", () => {
628
+ it("returns true when context is active and canPrompt returns true", () => {
629
+ const { session } = createSession();
630
+ session.activate(makeCtx());
631
+ expect(session.canConfirm()).toBe(true);
632
+ });
633
+
634
+ it("returns false when no context is active", () => {
635
+ const { session } = createSession();
636
+ expect(session.canConfirm()).toBe(false);
637
+ });
638
+
639
+ it("returns false when canPrompt returns false", () => {
640
+ const runtimeDeps = makeRuntimeDeps();
641
+ (
642
+ runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
643
+ ).mockReturnValue(false);
644
+ const { session } = createSession({ runtimeDeps });
645
+ session.activate(makeCtx());
646
+ expect(session.canConfirm()).toBe(false);
647
+ });
648
+ });
649
+
650
+ describe("promptPermission", () => {
651
+ it("delegates to prompt with stored context", async () => {
652
+ const { session, runtimeDeps } = createSession();
653
+ const ctx = makeCtx();
654
+ session.activate(ctx);
655
+ const details = {
656
+ requestId: "req-1",
657
+ source: "tool_call" as const,
658
+ agentName: null,
659
+ message: "Allow?",
660
+ };
661
+
662
+ const result = await session.promptPermission(details);
663
+
664
+ expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
665
+ expect(result).toEqual({ approved: true, state: "approved" });
666
+ });
667
+
668
+ it("throws when no context is active", async () => {
669
+ const { session } = createSession();
670
+ const details = {
671
+ requestId: "req-1",
672
+ source: "tool_call" as const,
673
+ agentName: null,
674
+ message: "Allow?",
675
+ };
676
+
677
+ await expect(session.promptPermission(details)).rejects.toThrow(
678
+ "promptPermission called before the session was activated",
679
+ );
680
+ });
681
+ });
682
+
535
683
  describe("canPrompt", () => {
536
684
  it("delegates to runtimeDeps.canRequestPermissionConfirmation", () => {
537
685
  const { session, runtimeDeps } = createSession();
@@ -573,19 +721,4 @@ describe("PermissionSession", () => {
573
721
  expect(result).toEqual({ approved: true, state: "approved" });
574
722
  });
575
723
  });
576
-
577
- describe("createPermissionRequestId", () => {
578
- it("starts with the given prefix", () => {
579
- const { session } = createSession();
580
- const id = session.createPermissionRequestId("skill-input");
581
- expect(id.startsWith("skill-input-")).toBe(true);
582
- });
583
-
584
- it("generates unique IDs on repeated calls", () => {
585
- const { session } = createSession();
586
- const id1 = session.createPermissionRequestId("test");
587
- const id2 = session.createPermissionRequestId("test");
588
- expect(id1).not.toBe(id2);
589
- });
590
- });
591
724
  });
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ buildDirectUiPrompt,
5
+ buildForwardedUiPrompt,
6
+ buildRpcUiPrompt,
7
+ } from "#src/permission-ui-prompt";
8
+
9
+ describe("buildDirectUiPrompt", () => {
10
+ it("maps a tool_call prompt to the tool surface and command value", () => {
11
+ expect(
12
+ buildDirectUiPrompt({
13
+ requestId: "req-1",
14
+ source: "tool_call",
15
+ agentName: "Explore",
16
+ message: "Allow git push?",
17
+ toolName: "bash",
18
+ command: "git push",
19
+ }),
20
+ ).toEqual({
21
+ requestId: "req-1",
22
+ source: "tool_call",
23
+ surface: "bash",
24
+ value: "git push",
25
+ agentName: "Explore",
26
+ message: "Allow git push?",
27
+ forwarding: null,
28
+ });
29
+ });
30
+
31
+ it("normalizes a skill prompt to the skill surface and skill-name value", () => {
32
+ expect(
33
+ buildDirectUiPrompt({
34
+ requestId: "req-2",
35
+ source: "skill_input",
36
+ agentName: null,
37
+ message: "Allow skill?",
38
+ skillName: "deploy-helper",
39
+ }),
40
+ ).toEqual({
41
+ requestId: "req-2",
42
+ source: "skill_input",
43
+ surface: "skill",
44
+ value: "deploy-helper",
45
+ agentName: null,
46
+ message: "Allow skill?",
47
+ forwarding: null,
48
+ });
49
+ });
50
+
51
+ it("derives value with command > path > target > skillName > toolName precedence", () => {
52
+ expect(
53
+ buildDirectUiPrompt({
54
+ requestId: "req-3",
55
+ source: "tool_call",
56
+ agentName: null,
57
+ message: "m",
58
+ toolName: "read",
59
+ path: "/etc/hosts",
60
+ target: "ignored",
61
+ }).value,
62
+ ).toBe("/etc/hosts");
63
+ });
64
+ });
65
+
66
+ describe("buildRpcUiPrompt", () => {
67
+ it("maps an RPC prompt to the rpc_prompt source with passthrough surface/value", () => {
68
+ expect(
69
+ buildRpcUiPrompt({
70
+ requestId: "req-rpc",
71
+ surface: "bash",
72
+ value: "git push",
73
+ agentName: "Worker",
74
+ message: "Allow git push?",
75
+ }),
76
+ ).toEqual({
77
+ requestId: "req-rpc",
78
+ source: "rpc_prompt",
79
+ surface: "bash",
80
+ value: "git push",
81
+ agentName: "Worker",
82
+ message: "Allow git push?",
83
+ forwarding: null,
84
+ });
85
+ });
86
+
87
+ it("defaults missing surface, value, and agentName to null", () => {
88
+ expect(
89
+ buildRpcUiPrompt({ requestId: "req-rpc-2", message: "Allow?" }),
90
+ ).toEqual({
91
+ requestId: "req-rpc-2",
92
+ source: "rpc_prompt",
93
+ surface: null,
94
+ value: null,
95
+ agentName: null,
96
+ message: "Allow?",
97
+ forwarding: null,
98
+ });
99
+ });
100
+ });
101
+
102
+ describe("buildForwardedUiPrompt", () => {
103
+ it("populates forwarding context and carries the original source/surface/value", () => {
104
+ expect(
105
+ buildForwardedUiPrompt({
106
+ requestId: "req-fwd",
107
+ message: "Subagent 'Explore' requested permission.\n\nAllow git push?",
108
+ requesterAgentName: "Explore",
109
+ requesterSessionId: "child-session",
110
+ source: "tool_call",
111
+ surface: "bash",
112
+ value: "git push",
113
+ }),
114
+ ).toEqual({
115
+ requestId: "req-fwd",
116
+ source: "tool_call",
117
+ surface: "bash",
118
+ value: "git push",
119
+ agentName: "Explore",
120
+ message: "Subagent 'Explore' requested permission.\n\nAllow git push?",
121
+ forwarding: {
122
+ requesterAgentName: "Explore",
123
+ requesterSessionId: "child-session",
124
+ },
125
+ });
126
+ });
127
+
128
+ it("falls back to source tool_call with null surface/value when the request omits them", () => {
129
+ expect(
130
+ buildForwardedUiPrompt({
131
+ requestId: "req-fwd-old",
132
+ message: "Allow?",
133
+ requesterAgentName: null,
134
+ requesterSessionId: null,
135
+ }),
136
+ ).toEqual({
137
+ requestId: "req-fwd-old",
138
+ source: "tool_call",
139
+ surface: null,
140
+ value: null,
141
+ agentName: null,
142
+ message: "Allow?",
143
+ forwarding: { requesterAgentName: null, requesterSessionId: null },
144
+ });
145
+ });
146
+ });
@@ -0,0 +1,151 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { PermissionManager } from "#src/permission-manager";
3
+ import { LocalPermissionsService } from "#src/permissions-service";
4
+ import type { Ruleset } from "#src/rule";
5
+ import type { SessionRules } from "#src/session-rules";
6
+ import type {
7
+ ToolInputFormatter,
8
+ ToolInputFormatterRegistry,
9
+ } from "#src/tool-input-formatter-registry";
10
+
11
+ import { makeCheckResult } from "#test/helpers/handler-fixtures";
12
+
13
+ // ── input-normalizer stub ──────────────────────────────────────────────────
14
+
15
+ const mockBuildInputForSurface = vi.hoisted(() =>
16
+ vi.fn<(surface: string, value?: string) => unknown>(),
17
+ );
18
+
19
+ vi.mock("#src/input-normalizer", () => ({
20
+ buildInputForSurface: mockBuildInputForSurface,
21
+ }));
22
+
23
+ // ── helpers ────────────────────────────────────────────────────────────────
24
+
25
+ function makePermissionManager(): PermissionManager {
26
+ return {
27
+ checkPermission: vi
28
+ .fn<PermissionManager["checkPermission"]>()
29
+ .mockReturnValue(makeCheckResult()),
30
+ getToolPermission: vi
31
+ .fn<PermissionManager["getToolPermission"]>()
32
+ .mockReturnValue("allow"),
33
+ } as unknown as PermissionManager;
34
+ }
35
+
36
+ function makeSessionRules(rules: Ruleset = []): SessionRules {
37
+ return {
38
+ getRuleset: vi.fn<SessionRules["getRuleset"]>().mockReturnValue(rules),
39
+ } as unknown as SessionRules;
40
+ }
41
+
42
+ function makeFormatterRegistry(): ToolInputFormatterRegistry {
43
+ return {
44
+ register: vi
45
+ .fn<ToolInputFormatterRegistry["register"]>()
46
+ .mockReturnValue(vi.fn()),
47
+ } as unknown as ToolInputFormatterRegistry;
48
+ }
49
+
50
+ function makeService(overrides?: {
51
+ permissionManager?: PermissionManager;
52
+ sessionRules?: SessionRules;
53
+ formatterRegistry?: ToolInputFormatterRegistry;
54
+ }) {
55
+ const permissionManager =
56
+ overrides?.permissionManager ?? makePermissionManager();
57
+ const sessionRules = overrides?.sessionRules ?? makeSessionRules();
58
+ const formatterRegistry =
59
+ overrides?.formatterRegistry ?? makeFormatterRegistry();
60
+ const service = new LocalPermissionsService(
61
+ permissionManager,
62
+ sessionRules,
63
+ formatterRegistry,
64
+ );
65
+ return { service, permissionManager, sessionRules, formatterRegistry };
66
+ }
67
+
68
+ // ── tests ──────────────────────────────────────────────────────────────────
69
+
70
+ beforeEach(() => {
71
+ mockBuildInputForSurface.mockReset();
72
+ mockBuildInputForSurface.mockReturnValue({ type: "tool-input" });
73
+ });
74
+
75
+ describe("checkPermission", () => {
76
+ it("builds the surface input from surface and value", () => {
77
+ const { service } = makeService();
78
+ service.checkPermission("bash", "echo hi");
79
+ expect(mockBuildInputForSurface).toHaveBeenCalledWith("bash", "echo hi");
80
+ });
81
+
82
+ it("builds the surface input with undefined value when value is omitted", () => {
83
+ const { service } = makeService();
84
+ service.checkPermission("read");
85
+ expect(mockBuildInputForSurface).toHaveBeenCalledWith("read", undefined);
86
+ });
87
+
88
+ it("calls permissionManager.checkPermission with surface, built input, agentName, and current ruleset", () => {
89
+ const ruleset: Ruleset = [
90
+ { surface: "bash", pattern: "*", action: "allow", origin: "global" },
91
+ ];
92
+ const builtInput = { type: "bash-input" };
93
+ mockBuildInputForSurface.mockReturnValue(builtInput);
94
+ const { service, permissionManager, sessionRules } = makeService({
95
+ sessionRules: makeSessionRules(ruleset),
96
+ });
97
+ service.checkPermission("bash", "echo hi", "my-agent");
98
+ expect(permissionManager.checkPermission).toHaveBeenCalledWith(
99
+ "bash",
100
+ builtInput,
101
+ "my-agent",
102
+ ruleset,
103
+ );
104
+ void sessionRules; // used indirectly
105
+ });
106
+
107
+ it("returns the result from permissionManager.checkPermission", () => {
108
+ const expected = makeCheckResult({ state: "deny", toolName: "bash" });
109
+ const { service, permissionManager } = makeService();
110
+ vi.mocked(permissionManager.checkPermission).mockReturnValue(expected);
111
+ const result = service.checkPermission("bash", "rm -rf /");
112
+ expect(result).toBe(expected);
113
+ });
114
+ });
115
+
116
+ describe("getToolPermission", () => {
117
+ it("delegates to permissionManager.getToolPermission", () => {
118
+ const { service, permissionManager } = makeService();
119
+ vi.mocked(permissionManager.getToolPermission).mockReturnValue("deny");
120
+ const result = service.getToolPermission("write", "my-agent");
121
+ expect(permissionManager.getToolPermission).toHaveBeenCalledWith(
122
+ "write",
123
+ "my-agent",
124
+ );
125
+ expect(result).toBe("deny");
126
+ });
127
+
128
+ it("omits agentName when not provided", () => {
129
+ const { service, permissionManager } = makeService();
130
+ service.getToolPermission("read");
131
+ expect(permissionManager.getToolPermission).toHaveBeenCalledWith(
132
+ "read",
133
+ undefined,
134
+ );
135
+ });
136
+ });
137
+
138
+ describe("registerToolInputFormatter", () => {
139
+ it("delegates to formatterRegistry.register and returns the unsubscribe function", () => {
140
+ const unsub = vi.fn();
141
+ const { service, formatterRegistry } = makeService();
142
+ vi.mocked(formatterRegistry.register).mockReturnValue(unsub);
143
+ const formatter: ToolInputFormatter = vi.fn();
144
+ const result = service.registerToolInputFormatter("my-tool", formatter);
145
+ expect(formatterRegistry.register).toHaveBeenCalledWith(
146
+ "my-tool",
147
+ formatter,
148
+ );
149
+ expect(result).toBe(unsub);
150
+ });
151
+ });
@@ -49,10 +49,6 @@ vi.mock("../src/config-reporter", () => ({
49
49
  buildResolvedConfigLogEntry: mockBuildResolvedConfigLogEntry,
50
50
  }));
51
51
 
52
- vi.mock("../src/forwarded-permissions/polling", () => ({
53
- processForwardedPermissionRequests: vi.fn().mockResolvedValue(undefined),
54
- }));
55
-
56
52
  vi.mock("../src/subagent-context", () => ({
57
53
  isSubagentExecutionContext: vi.fn().mockReturnValue(false),
58
54
  }));
@@ -0,0 +1,162 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { PermissionsService } from "#src/service";
3
+ import {
4
+ PermissionServiceLifecycle,
5
+ type ServiceLifecycle,
6
+ } from "#src/service-lifecycle";
7
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
8
+
9
+ import { makeCtx } from "#test/helpers/handler-fixtures";
10
+
11
+ // ── module stubs ───────────────────────────────────────────────────────────
12
+
13
+ const mockIsRegisteredSubagentChild = vi.hoisted(() =>
14
+ vi.fn<(ctx: unknown, registry: unknown) => boolean>().mockReturnValue(false),
15
+ );
16
+ const mockPublishPermissionsService = vi.hoisted(() => vi.fn<() => void>());
17
+ const mockUnpublishPermissionsService = vi.hoisted(() => vi.fn<() => void>());
18
+ const mockEmitReadyEvent = vi.hoisted(() => vi.fn<() => void>());
19
+
20
+ vi.mock("#src/subagent-context", () => ({
21
+ isRegisteredSubagentChild: mockIsRegisteredSubagentChild,
22
+ }));
23
+ vi.mock("#src/service", () => ({
24
+ publishPermissionsService: mockPublishPermissionsService,
25
+ unpublishPermissionsService: mockUnpublishPermissionsService,
26
+ }));
27
+ vi.mock("#src/permission-events", () => ({
28
+ emitReadyEvent: mockEmitReadyEvent,
29
+ }));
30
+
31
+ // ── helpers ────────────────────────────────────────────────────────────────
32
+
33
+ function makeService(): PermissionsService {
34
+ return {
35
+ checkPermission: vi.fn(),
36
+ getToolPermission: vi.fn(),
37
+ registerToolInputFormatter: vi.fn(),
38
+ };
39
+ }
40
+
41
+ function makeRegistry(): SubagentSessionRegistry {
42
+ return {
43
+ has: vi.fn().mockReturnValue(false),
44
+ } as unknown as SubagentSessionRegistry;
45
+ }
46
+
47
+ function makeLifecycle(overrides?: { subscriptions?: (() => void)[] }) {
48
+ const service = makeService();
49
+ const registry = makeRegistry();
50
+ const events = { emit: vi.fn(), on: vi.fn() };
51
+ const subscriptions = overrides?.subscriptions ?? [];
52
+ const lifecycle = new PermissionServiceLifecycle(
53
+ service,
54
+ registry,
55
+ events,
56
+ subscriptions,
57
+ );
58
+ return { lifecycle, service, registry, events, subscriptions };
59
+ }
60
+
61
+ beforeEach(() => {
62
+ mockIsRegisteredSubagentChild.mockReset();
63
+ mockIsRegisteredSubagentChild.mockReturnValue(false);
64
+ mockPublishPermissionsService.mockReset();
65
+ mockUnpublishPermissionsService.mockReset();
66
+ mockEmitReadyEvent.mockReset();
67
+ });
68
+
69
+ // ── ServiceLifecycle interface shape ──────────────────────────────────────
70
+
71
+ it("PermissionServiceLifecycle satisfies ServiceLifecycle", () => {
72
+ const { lifecycle } = makeLifecycle();
73
+ const _: ServiceLifecycle = lifecycle;
74
+ expect(_).toBeDefined();
75
+ });
76
+
77
+ // ── activate ──────────────────────────────────────────────────────────────
78
+
79
+ describe("activate", () => {
80
+ it("publishes the service for a non-child session", () => {
81
+ const ctx = makeCtx();
82
+ const { lifecycle, service } = makeLifecycle();
83
+ mockIsRegisteredSubagentChild.mockReturnValue(false);
84
+ lifecycle.activate(ctx);
85
+ expect(mockPublishPermissionsService).toHaveBeenCalledWith(service);
86
+ });
87
+
88
+ it("skips publishing for a registered child session", () => {
89
+ const ctx = makeCtx();
90
+ const { lifecycle } = makeLifecycle();
91
+ mockIsRegisteredSubagentChild.mockReturnValue(true);
92
+ lifecycle.activate(ctx);
93
+ expect(mockPublishPermissionsService).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("always emits the ready event, even for a child session", () => {
97
+ const ctx = makeCtx();
98
+ const { lifecycle, events } = makeLifecycle();
99
+ mockIsRegisteredSubagentChild.mockReturnValue(true);
100
+ lifecycle.activate(ctx);
101
+ expect(mockEmitReadyEvent).toHaveBeenCalledWith(events);
102
+ });
103
+
104
+ it("emits ready after publishing the service", () => {
105
+ const ctx = makeCtx();
106
+ const order: string[] = [];
107
+ mockPublishPermissionsService.mockImplementation(() =>
108
+ order.push("publish"),
109
+ );
110
+ mockEmitReadyEvent.mockImplementation(() => order.push("ready"));
111
+ const { lifecycle } = makeLifecycle();
112
+ lifecycle.activate(ctx);
113
+ expect(order).toEqual(["publish", "ready"]);
114
+ });
115
+
116
+ it("passes ctx and registry to isRegisteredSubagentChild", () => {
117
+ const ctx = makeCtx();
118
+ const { lifecycle, registry } = makeLifecycle();
119
+ lifecycle.activate(ctx);
120
+ expect(mockIsRegisteredSubagentChild).toHaveBeenCalledWith(ctx, registry);
121
+ });
122
+ });
123
+
124
+ // ── teardown ──────────────────────────────────────────────────────────────
125
+
126
+ describe("teardown", () => {
127
+ it("calls each subscription unsubscribe function", () => {
128
+ const unsub1 = vi.fn();
129
+ const unsub2 = vi.fn();
130
+ const unsub3 = vi.fn();
131
+ const { lifecycle } = makeLifecycle({
132
+ subscriptions: [unsub1, unsub2, unsub3],
133
+ });
134
+ lifecycle.teardown();
135
+ expect(unsub1).toHaveBeenCalledOnce();
136
+ expect(unsub2).toHaveBeenCalledOnce();
137
+ expect(unsub3).toHaveBeenCalledOnce();
138
+ });
139
+
140
+ it("unpublishes the service after running subscriptions", () => {
141
+ const order: string[] = [];
142
+ const unsub = vi.fn(() => order.push("unsub"));
143
+ mockUnpublishPermissionsService.mockImplementation(() =>
144
+ order.push("unpublish"),
145
+ );
146
+ const { lifecycle } = makeLifecycle({ subscriptions: [unsub] });
147
+ lifecycle.teardown();
148
+ expect(order).toEqual(["unsub", "unpublish"]);
149
+ });
150
+
151
+ it("passes the service to unpublishPermissionsService", () => {
152
+ const { lifecycle, service } = makeLifecycle();
153
+ lifecycle.teardown();
154
+ expect(mockUnpublishPermissionsService).toHaveBeenCalledWith(service);
155
+ });
156
+
157
+ it("works with no subscriptions", () => {
158
+ const { lifecycle } = makeLifecycle({ subscriptions: [] });
159
+ expect(() => lifecycle.teardown()).not.toThrow();
160
+ expect(mockUnpublishPermissionsService).toHaveBeenCalledOnce();
161
+ });
162
+ });