@gotgenes/pi-permission-system 10.2.0 → 10.3.1

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.
@@ -7,6 +7,7 @@ const mockRequestApproval = vi.fn();
7
7
  // ── Imports ─────────────────────────────────────────────────────────────────
8
8
 
9
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type { ConfigReader } from "#src/config-store";
10
11
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
11
12
  import type { PermissionPromptDecision } from "#src/permission-dialog";
12
13
  import type { PromptPermissionDetails } from "#src/permission-prompter";
@@ -38,11 +39,17 @@ function makeDetails(
38
39
  };
39
40
  }
40
41
 
42
+ function makeConfigReader(
43
+ config: Partial<typeof DEFAULT_EXTENSION_CONFIG> = {},
44
+ ): ConfigReader {
45
+ return { current: () => ({ ...DEFAULT_EXTENSION_CONFIG, ...config }) };
46
+ }
47
+
41
48
  function makeDeps(
42
49
  overrides?: Partial<PermissionPrompterDeps>,
43
50
  ): PermissionPrompterDeps {
44
51
  return {
45
- getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: false }),
52
+ config: makeConfigReader(),
46
53
  writeReviewLog: vi.fn(),
47
54
  events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
48
55
  forwarder: { requestApproval: mockRequestApproval },
@@ -70,7 +77,7 @@ describe("PermissionPrompter", () => {
70
77
  on: vi.fn().mockReturnValue(() => undefined),
71
78
  };
72
79
  const deps = makeDeps({
73
- getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
80
+ config: makeConfigReader({ yoloMode: true }),
74
81
  events,
75
82
  });
76
83
  const prompter = new PermissionPrompter(deps);
@@ -92,7 +99,7 @@ describe("PermissionPrompter", () => {
92
99
  it("logs permission_request.auto_approved in yolo mode", async () => {
93
100
  const writeReviewLog = vi.fn();
94
101
  const deps = makeDeps({
95
- getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
102
+ config: makeConfigReader({ yoloMode: true }),
96
103
  writeReviewLog,
97
104
  });
98
105
  const prompter = new PermissionPrompter(deps);
@@ -108,7 +115,7 @@ describe("PermissionPrompter", () => {
108
115
  it("does not log permission_request.waiting in yolo mode", async () => {
109
116
  const writeReviewLog = vi.fn();
110
117
  const deps = makeDeps({
111
- getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
118
+ config: makeConfigReader({ yoloMode: true }),
112
119
  writeReviewLog,
113
120
  });
114
121
  const prompter = new PermissionPrompter(deps);
@@ -123,7 +130,7 @@ describe("PermissionPrompter", () => {
123
130
 
124
131
  it("does not call confirmPermission with yoloMode even when ctx has UI", async () => {
125
132
  const deps = makeDeps({
126
- getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
133
+ config: makeConfigReader({ yoloMode: true }),
127
134
  });
128
135
  const prompter = new PermissionPrompter(deps);
129
136
 
@@ -17,6 +17,8 @@ vi.mock("../src/active-agent", () => ({
17
17
 
18
18
  // ── Test helpers ───────────────────────────────────────────────────────────
19
19
 
20
+ import type { SessionConfigStore } from "#src/config-store";
21
+ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
20
22
  import type { ExtensionPaths } from "#src/extension-paths";
21
23
  import type { ForwardingController } from "#src/forwarding-manager";
22
24
  import type { ScopedPermissionManager } from "#src/permission-manager";
@@ -27,6 +29,7 @@ import {
27
29
  import type { Ruleset } from "#src/rule";
28
30
  import { SessionApproval } from "#src/session-approval";
29
31
  import type { SessionLogger } from "#src/session-logger";
32
+ import { SessionRules } from "#src/session-rules";
30
33
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
31
34
  import type { PermissionCheckResult, PermissionState } from "#src/types";
32
35
  import { makeCtx } from "#test/helpers/handler-fixtures";
@@ -66,11 +69,22 @@ function makeLogger(): SessionLogger {
66
69
  };
67
70
  }
68
71
 
72
+ function makeConfigStore(
73
+ overrides: Partial<SessionConfigStore> = {},
74
+ ): SessionConfigStore {
75
+ return {
76
+ current:
77
+ overrides.current ??
78
+ vi
79
+ .fn<() => typeof DEFAULT_EXTENSION_CONFIG>()
80
+ .mockReturnValue({ ...DEFAULT_EXTENSION_CONFIG }),
81
+ refresh: overrides.refresh ?? vi.fn<(ctx?: ExtensionContext) => void>(),
82
+ logResolvedPaths: overrides.logResolvedPaths ?? vi.fn<() => void>(),
83
+ };
84
+ }
85
+
69
86
  function makeRuntimeDeps(): PermissionSessionRuntimeDeps {
70
87
  return {
71
- refreshExtensionConfig: vi.fn(),
72
- logResolvedConfigPaths: vi.fn(),
73
- getConfig: vi.fn().mockReturnValue({}),
74
88
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
75
89
  promptPermission: vi
76
90
  .fn()
@@ -116,12 +130,16 @@ function createSession(overrides?: {
116
130
  logger?: SessionLogger;
117
131
  forwarding?: ForwardingController;
118
132
  permissionManager?: ScopedPermissionManager;
133
+ sessionRules?: SessionRules;
134
+ configStore?: SessionConfigStore;
119
135
  runtimeDeps?: PermissionSessionRuntimeDeps;
120
136
  }): {
121
137
  session: PermissionSession;
122
138
  paths: ExtensionPaths;
123
139
  logger: SessionLogger;
124
140
  forwarding: ForwardingController;
141
+ sessionRules: SessionRules;
142
+ configStore: SessionConfigStore;
125
143
  runtimeDeps: PermissionSessionRuntimeDeps;
126
144
  } {
127
145
  const paths = makePaths(overrides?.paths);
@@ -129,15 +147,27 @@ function createSession(overrides?: {
129
147
  const forwarding = overrides?.forwarding ?? makeForwarding();
130
148
  const permissionManager =
131
149
  overrides?.permissionManager ?? makePermissionManager();
150
+ const sessionRules = overrides?.sessionRules ?? new SessionRules();
151
+ const configStore = overrides?.configStore ?? makeConfigStore();
132
152
  const runtimeDeps = overrides?.runtimeDeps ?? makeRuntimeDeps();
133
153
  const session = new PermissionSession(
134
154
  paths,
135
155
  logger,
136
156
  forwarding,
137
157
  permissionManager,
158
+ sessionRules,
159
+ configStore,
138
160
  runtimeDeps,
139
161
  );
140
- return { session, paths, logger, forwarding, runtimeDeps };
162
+ return {
163
+ session,
164
+ paths,
165
+ logger,
166
+ forwarding,
167
+ sessionRules,
168
+ configStore,
169
+ runtimeDeps,
170
+ };
141
171
  }
142
172
 
143
173
  // ── Tests ──────────────────────────────────────────────────────────────────
@@ -484,11 +514,12 @@ describe("PermissionSession", () => {
484
514
 
485
515
  describe("infrastructure paths", () => {
486
516
  it("getInfrastructureReadDirs combines piInfrastructureDirs and piInfrastructureReadPaths", () => {
487
- const runtimeDeps = makeRuntimeDeps();
488
- (runtimeDeps.getConfig as ReturnType<typeof vi.fn>).mockReturnValue({
489
- piInfrastructureReadPaths: ["/extra/path"],
517
+ const configStore = makeConfigStore({
518
+ current: vi.fn().mockReturnValue({
519
+ piInfrastructureReadPaths: ["/extra/path"],
520
+ }),
490
521
  });
491
- const { session } = createSession({ runtimeDeps });
522
+ const { session } = createSession({ configStore });
492
523
  expect(session.getInfrastructureReadDirs()).toEqual([
493
524
  "/test/agent",
494
525
  "/test/agent/git",
@@ -506,36 +537,36 @@ describe("PermissionSession", () => {
506
537
  });
507
538
 
508
539
  describe("config delegation", () => {
509
- it("refreshConfig delegates to runtimeDeps", () => {
510
- const { session, runtimeDeps } = createSession();
540
+ it("refreshConfig delegates to configStore.refresh", () => {
541
+ const { session, configStore } = createSession();
511
542
  const ctx = makeCtx();
512
543
  session.refreshConfig(ctx);
513
- expect(runtimeDeps.refreshExtensionConfig).toHaveBeenCalledWith(ctx);
544
+ expect(configStore.refresh).toHaveBeenCalledWith(ctx);
514
545
  });
515
546
 
516
- it("logResolvedConfigPaths delegates to runtimeDeps", () => {
517
- const { session, runtimeDeps } = createSession();
547
+ it("logResolvedConfigPaths delegates to configStore.logResolvedPaths", () => {
548
+ const { session, configStore } = createSession();
518
549
  session.logResolvedConfigPaths();
519
- expect(runtimeDeps.logResolvedConfigPaths).toHaveBeenCalled();
550
+ expect(configStore.logResolvedPaths).toHaveBeenCalled();
520
551
  });
521
552
 
522
- it("config getter delegates to runtimeDeps.getConfig", () => {
523
- const runtimeDeps = makeRuntimeDeps();
524
- const fakeConfig = { debugLog: true };
525
- (runtimeDeps.getConfig as ReturnType<typeof vi.fn>).mockReturnValue(
526
- fakeConfig,
527
- );
528
- const { session } = createSession({ runtimeDeps });
553
+ it("config getter delegates to configStore.current()", () => {
554
+ const fakeConfig = { debugLog: true } as typeof DEFAULT_EXTENSION_CONFIG;
555
+ const configStore = makeConfigStore({
556
+ current: vi.fn().mockReturnValue(fakeConfig),
557
+ });
558
+ const { session } = createSession({ configStore });
529
559
  expect(session.config).toBe(fakeConfig);
530
560
  });
531
561
 
532
562
  it("getToolPreviewLimits returns resolved preview limits from config", () => {
533
- const runtimeDeps = makeRuntimeDeps();
534
- (runtimeDeps.getConfig as ReturnType<typeof vi.fn>).mockReturnValue({
535
- toolInputPreviewMaxLength: 400,
536
- toolTextSummaryMaxLength: 120,
563
+ const configStore = makeConfigStore({
564
+ current: vi.fn().mockReturnValue({
565
+ toolInputPreviewMaxLength: 400,
566
+ toolTextSummaryMaxLength: 120,
567
+ }),
537
568
  });
538
- const { session } = createSession({ runtimeDeps });
569
+ const { session } = createSession({ configStore });
539
570
  const limits = session.getToolPreviewLimits();
540
571
  expect(limits.toolInputPreviewMaxLength).toBe(400);
541
572
  expect(limits.toolTextSummaryMaxLength).toBe(120);
@@ -1,113 +1,200 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import type { ExtensionRuntime } from "#src/runtime";
1
+ import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { DEBUG_LOG_FILENAME, REVIEW_LOG_FILENAME } from "#src/config-paths";
6
+ import {
7
+ DEFAULT_EXTENSION_CONFIG,
8
+ type PermissionSystemExtensionConfig,
9
+ } from "#src/extension-config";
10
+ import type { SessionLoggerDeps } from "#src/session-logger";
3
11
  import { createSessionLogger } from "#src/session-logger";
4
12
 
5
13
  // ── helpers ────────────────────────────────────────────────────────────────
6
14
 
7
- function makeRuntime(
8
- overrides: Partial<ExtensionRuntime> = {},
9
- ): ExtensionRuntime {
15
+ let tempDir: string;
16
+
17
+ beforeEach(() => {
18
+ tempDir = mkdtempSync(join(tmpdir(), "ps-session-logger-"));
19
+ });
20
+
21
+ function makeDeps(
22
+ overrides: {
23
+ globalLogsDir?: string;
24
+ getConfig?: () => PermissionSystemExtensionConfig;
25
+ } = {},
26
+ ) {
10
27
  return {
11
- runtimeContext: null,
12
- writeDebugLog: vi.fn(),
13
- writeReviewLog: vi.fn(),
14
- ...overrides,
15
- } as unknown as ExtensionRuntime;
28
+ globalLogsDir: overrides.globalLogsDir ?? tempDir,
29
+ getConfig:
30
+ overrides.getConfig ??
31
+ ((): PermissionSystemExtensionConfig => ({
32
+ ...DEFAULT_EXTENSION_CONFIG,
33
+ })),
34
+ notify: vi.fn<(message: string) => void>(),
35
+ };
36
+ }
37
+
38
+ /** A `globalLogsDir` that cannot be created: a file at the parent path blocks it. */
39
+ function makeBlockedLogsDir(): string {
40
+ const barrier = join(tempDir, "barrier");
41
+ writeFileSync(barrier, "");
42
+ return join(barrier, "logs");
16
43
  }
17
44
 
18
45
  // ── createSessionLogger ────────────────────────────────────────────────────
19
46
 
20
47
  describe("createSessionLogger", () => {
48
+ // ── debug ────────────────────────────────────────────────────────────────
49
+
21
50
  describe("debug", () => {
22
- it("delegates to runtime.writeDebugLog with event and details", () => {
23
- const runtime = makeRuntime();
24
- const logger = createSessionLogger(runtime);
51
+ it("writes a JSONL line to the debug log file when debugLog is true", () => {
52
+ const deps = makeDeps({
53
+ getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog: true }),
54
+ });
55
+ const logger = createSessionLogger(deps);
25
56
 
26
57
  logger.debug("test.event", { key: "value" });
27
58
 
28
- expect(runtime.writeDebugLog).toHaveBeenCalledWith("test.event", {
29
- key: "value",
30
- });
59
+ expect(existsSync(join(tempDir, DEBUG_LOG_FILENAME))).toBe(true);
60
+ expect(deps.notify).not.toHaveBeenCalled();
31
61
  });
32
62
 
33
- it("delegates to runtime.writeDebugLog with event and no details", () => {
34
- const runtime = makeRuntime();
35
- const logger = createSessionLogger(runtime);
63
+ it("does not write to the debug log when debugLog is false", () => {
64
+ // DEFAULT_EXTENSION_CONFIG.debugLog === false
65
+ const deps = makeDeps();
66
+ const logger = createSessionLogger(deps);
36
67
 
37
68
  logger.debug("test.event");
38
69
 
39
- expect(runtime.writeDebugLog).toHaveBeenCalledWith(
40
- "test.event",
41
- undefined,
42
- );
70
+ expect(existsSync(join(tempDir, DEBUG_LOG_FILENAME))).toBe(false);
71
+ expect(deps.notify).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("reads getConfig at write time — a mid-session toggle change takes effect", () => {
75
+ let debugLog = true;
76
+ const deps = makeDeps({
77
+ getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog }),
78
+ });
79
+ const logger = createSessionLogger(deps);
80
+ debugLog = false;
81
+
82
+ logger.debug("test.event");
83
+
84
+ expect(existsSync(join(tempDir, DEBUG_LOG_FILENAME))).toBe(false);
43
85
  });
44
86
  });
45
87
 
88
+ // ── review ───────────────────────────────────────────────────────────────
89
+
46
90
  describe("review", () => {
47
- it("delegates to runtime.writeReviewLog with event and details", () => {
48
- const runtime = makeRuntime();
49
- const logger = createSessionLogger(runtime);
91
+ it("writes a JSONL line to the review log file when permissionReviewLog is true", () => {
92
+ // DEFAULT_EXTENSION_CONFIG.permissionReviewLog === true
93
+ const deps = makeDeps();
94
+ const logger = createSessionLogger(deps);
50
95
 
51
96
  logger.review("permission.granted", { agentName: "coder" });
52
97
 
53
- expect(runtime.writeReviewLog).toHaveBeenCalledWith(
54
- "permission.granted",
55
- { agentName: "coder" },
56
- );
98
+ expect(existsSync(join(tempDir, REVIEW_LOG_FILENAME))).toBe(true);
99
+ expect(deps.notify).not.toHaveBeenCalled();
57
100
  });
58
101
 
59
- it("delegates to runtime.writeReviewLog with event and no details", () => {
60
- const runtime = makeRuntime();
61
- const logger = createSessionLogger(runtime);
102
+ it("does not write to the review log when permissionReviewLog is false", () => {
103
+ const deps = makeDeps({
104
+ getConfig: () => ({
105
+ ...DEFAULT_EXTENSION_CONFIG,
106
+ permissionReviewLog: false,
107
+ }),
108
+ });
109
+ const logger = createSessionLogger(deps);
62
110
 
63
111
  logger.review("permission.granted");
64
112
 
65
- expect(runtime.writeReviewLog).toHaveBeenCalledWith(
66
- "permission.granted",
67
- undefined,
68
- );
113
+ expect(existsSync(join(tempDir, REVIEW_LOG_FILENAME))).toBe(false);
114
+ expect(deps.notify).not.toHaveBeenCalled();
69
115
  });
70
116
  });
71
117
 
72
- describe("warn", () => {
73
- it("calls ui.notify with the message and 'warning' severity when runtimeContext is present", () => {
74
- const notify = vi.fn();
75
- const runtime = makeRuntime({
76
- runtimeContext: {
77
- ui: { notify, setStatus: vi.fn(), select: vi.fn(), input: vi.fn() },
78
- } as unknown as ExtensionRuntime["runtimeContext"],
118
+ // ── IO-failure warnings ───────────────────────────────────────────────────
119
+
120
+ describe("IO-failure warnings", () => {
121
+ it("calls notify with the error message when the logs directory cannot be created", () => {
122
+ const deps = makeDeps({
123
+ globalLogsDir: makeBlockedLogsDir(),
124
+ getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog: true }),
79
125
  });
80
- const logger = createSessionLogger(runtime);
126
+ const logger = createSessionLogger(deps);
81
127
 
82
- logger.warn("Something went wrong");
128
+ logger.debug("test.event");
129
+
130
+ expect(deps.notify).toHaveBeenCalledOnce();
131
+ expect(deps.notify).toHaveBeenCalledWith(
132
+ expect.stringContaining("Failed to"),
133
+ );
134
+ });
135
+
136
+ it("deduplicates the same IO-failure warning across multiple writes", () => {
137
+ const deps = makeDeps({
138
+ globalLogsDir: makeBlockedLogsDir(),
139
+ getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog: true }),
140
+ });
141
+ const logger = createSessionLogger(deps);
142
+
143
+ logger.debug("event.one");
144
+ logger.debug("event.two");
145
+
146
+ expect(deps.notify).toHaveBeenCalledOnce();
147
+ });
148
+
149
+ it("shares the dedup set across debug and review — same message notified only once", () => {
150
+ const deps = makeDeps({
151
+ globalLogsDir: makeBlockedLogsDir(),
152
+ getConfig: () => ({
153
+ ...DEFAULT_EXTENSION_CONFIG,
154
+ debugLog: true,
155
+ permissionReviewLog: true,
156
+ }),
157
+ });
158
+ const logger = createSessionLogger(deps);
83
159
 
84
- expect(notify).toHaveBeenCalledWith("Something went wrong", "warning");
160
+ logger.debug("event.one"); // emits warning
161
+ logger.review("event.two"); // same error message → suppressed
162
+
163
+ expect(deps.notify).toHaveBeenCalledOnce();
85
164
  });
165
+ });
86
166
 
87
- it("does not throw when runtimeContext is null", () => {
88
- const runtime = makeRuntime({ runtimeContext: null });
89
- const logger = createSessionLogger(runtime);
167
+ // ── warn ──────────────────────────────────────────────────────────────────
90
168
 
91
- expect(() => logger.warn("no-op warning")).not.toThrow();
169
+ describe("warn", () => {
170
+ it("calls notify with the message directly", () => {
171
+ const deps = makeDeps();
172
+ const logger = createSessionLogger(deps);
173
+
174
+ logger.warn("Something went wrong");
175
+
176
+ expect(deps.notify).toHaveBeenCalledWith("Something went wrong");
92
177
  });
93
178
 
94
- it("reads runtimeContext at call time, not at creation time", () => {
95
- const runtime = makeRuntime({ runtimeContext: null });
96
- const logger = createSessionLogger(runtime);
179
+ it("calls notify for every warn not deduplicated", () => {
180
+ const deps = makeDeps();
181
+ const logger = createSessionLogger(deps);
97
182
 
98
- // runtimeContext is null at creation — warn should be a no-op now
99
- logger.warn("early warning");
183
+ logger.warn("same message");
184
+ logger.warn("same message");
100
185
 
101
- // Later runtimeContext is set
102
- const notify = vi.fn();
103
- runtime.runtimeContext = {
104
- ui: { notify, setStatus: vi.fn(), select: vi.fn(), input: vi.fn() },
105
- } as unknown as ExtensionRuntime["runtimeContext"];
186
+ expect(deps.notify).toHaveBeenCalledTimes(2);
187
+ });
106
188
 
107
- logger.warn("late warning");
189
+ it("does not throw when notify is a no-op", () => {
190
+ const deps: SessionLoggerDeps = {
191
+ globalLogsDir: tempDir,
192
+ getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG }),
193
+ notify: () => {},
194
+ };
195
+ const logger = createSessionLogger(deps);
108
196
 
109
- expect(notify).toHaveBeenCalledOnce();
110
- expect(notify).toHaveBeenCalledWith("late warning", "warning");
197
+ expect(() => logger.warn("test")).not.toThrow();
111
198
  });
112
199
  });
113
200
  });