@gotgenes/pi-permission-system 10.1.0 → 10.3.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/src/runtime.ts CHANGED
@@ -1,35 +1,12 @@
1
+ import { join } from "node:path";
1
2
  import {
2
- existsSync,
3
- mkdirSync,
4
- renameSync,
5
- unlinkSync,
6
- writeFileSync,
7
- } from "node:fs";
8
- import { dirname, join, normalize } from "node:path";
9
- import {
10
- type ExtensionCommandContext,
11
3
  type ExtensionContext,
12
4
  getAgentDir,
13
5
  } from "@earendil-works/pi-coding-agent";
14
6
 
15
- import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
16
- import {
17
- DEBUG_LOG_FILENAME,
18
- getGlobalConfigPath,
19
- getLegacyExtensionConfigPath,
20
- getLegacyGlobalPolicyPath,
21
- getLegacyProjectPolicyPath,
22
- getProjectConfigPath,
23
- REVIEW_LOG_FILENAME,
24
- } from "./config-paths";
25
- import { buildResolvedConfigLogEntry } from "./config-reporter";
26
- import {
27
- DEFAULT_EXTENSION_CONFIG,
28
- EXTENSION_ROOT,
29
- ensurePermissionSystemLogsDirectory,
30
- normalizePermissionSystemConfig,
31
- type PermissionSystemExtensionConfig,
32
- } from "./extension-config";
7
+ import { DEBUG_LOG_FILENAME, REVIEW_LOG_FILENAME } from "./config-paths";
8
+ import { ConfigStore, type RuntimeContextRef } from "./config-store";
9
+ import { ensurePermissionSystemLogsDirectory } from "./extension-config";
33
10
  import { computeExtensionPaths, type ExtensionPaths } from "./extension-paths";
34
11
 
35
12
  export type { ExtensionPaths } from "./extension-paths";
@@ -38,7 +15,6 @@ import { createPermissionSystemLogger } from "./logging";
38
15
  import { PermissionManager } from "./permission-manager";
39
16
  import { SessionRules } from "./session-rules";
40
17
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
41
- import { syncPermissionSystemStatus } from "./status";
42
18
 
43
19
  /**
44
20
  * Mutable session state — the subset of ExtensionRuntime that holds
@@ -68,179 +44,14 @@ interface SessionState {
68
44
  * without timing issues around `PI_CODING_AGENT_DIR`.
69
45
  */
70
46
  export interface ExtensionRuntime extends ExtensionPaths, SessionState {
71
- // ── Mutable state (beyond SessionState) ───────────────────────────────────
72
- config: PermissionSystemExtensionConfig;
73
- lastConfigWarning: string | null;
47
+ /** The store that owns extension config. */
48
+ configStore: ConfigStore;
74
49
 
75
50
  // ── Logging (backed by logger created at construction) ─────────────────
76
51
  writeDebugLog(event: string, details?: Record<string, unknown>): void;
77
52
  writeReviewLog(event: string, details?: Record<string, unknown>): void;
78
53
  }
79
54
 
80
- // ── Pure helpers ───────────────────────────────────────────────────────────
81
-
82
- /**
83
- * Derive Pi project-level config and agents paths from a working directory.
84
- * Returns null when cwd is absent (headless / global-only config).
85
- */
86
- export function derivePiProjectPaths(cwd: string | undefined | null): {
87
- projectGlobalConfigPath: string;
88
- projectAgentsDir: string;
89
- } | null {
90
- if (!cwd) {
91
- return null;
92
- }
93
- return {
94
- projectGlobalConfigPath: getProjectConfigPath(cwd),
95
- projectAgentsDir: join(cwd, ".pi", "agent", "agents"),
96
- };
97
- }
98
-
99
- /**
100
- * Create a new PermissionManager scoped to a working directory's config hierarchy.
101
- * Pass `cwd` as null/undefined to use global config only.
102
- */
103
- export function createPermissionManagerForCwd(
104
- agentDir: string,
105
- cwd: string | undefined | null,
106
- ): PermissionManager {
107
- const projectPaths = derivePiProjectPaths(cwd);
108
- return new PermissionManager({
109
- globalConfigPath: getGlobalConfigPath(agentDir),
110
- projectGlobalConfigPath: projectPaths?.projectGlobalConfigPath,
111
- projectAgentsDir: projectPaths?.projectAgentsDir,
112
- });
113
- }
114
-
115
- /**
116
- * Reload merged config from disk into the runtime.
117
- * If `ctx` is provided, updates `runtime.runtimeContext` first.
118
- */
119
- export function refreshExtensionConfig(
120
- runtime: ExtensionRuntime,
121
- ctx?: ExtensionContext,
122
- ): void {
123
- if (ctx) {
124
- runtime.runtimeContext = ctx;
125
- }
126
- const cwd = runtime.runtimeContext?.cwd ?? null;
127
- const mergeResult = loadAndMergeConfigs(
128
- runtime.agentDir,
129
- cwd ?? "",
130
- EXTENSION_ROOT,
131
- );
132
- const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
133
- runtime.config = runtimeConfig;
134
-
135
- if (runtime.runtimeContext?.hasUI) {
136
- syncPermissionSystemStatus(runtime.runtimeContext, runtimeConfig);
137
- }
138
-
139
- const warning =
140
- mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
141
-
142
- if (warning && warning !== runtime.lastConfigWarning) {
143
- runtime.lastConfigWarning = warning;
144
- runtime.runtimeContext?.ui.notify(warning, "warning");
145
- } else if (!warning) {
146
- runtime.lastConfigWarning = null;
147
- }
148
-
149
- runtime.writeDebugLog("config.loaded", {
150
- warning: warning ?? null,
151
- debugLog: runtimeConfig.debugLog,
152
- permissionReviewLog: runtimeConfig.permissionReviewLog,
153
- yoloMode: runtimeConfig.yoloMode,
154
- });
155
- }
156
-
157
- /**
158
- * Save updated runtime knobs (debugLog, permissionReviewLog, yoloMode) to the
159
- * global config file, then update runtime.config and sync UI status.
160
- */
161
- export function saveExtensionConfig(
162
- runtime: ExtensionRuntime,
163
- next: PermissionSystemExtensionConfig,
164
- ctx: ExtensionCommandContext,
165
- ): void {
166
- const normalized = normalizePermissionSystemConfig(next);
167
- const globalPath = getGlobalConfigPath(runtime.agentDir);
168
-
169
- const existing = loadUnifiedConfig(globalPath);
170
- const merged = {
171
- ...existing.config,
172
- debugLog: normalized.debugLog,
173
- permissionReviewLog: normalized.permissionReviewLog,
174
- yoloMode: normalized.yoloMode,
175
- };
176
-
177
- const tmpPath = `${globalPath}.tmp`;
178
- try {
179
- mkdirSync(dirname(globalPath), { recursive: true });
180
- writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
181
- renameSync(tmpPath, globalPath);
182
- } catch (error) {
183
- try {
184
- if (existsSync(tmpPath)) {
185
- unlinkSync(tmpPath);
186
- }
187
- } catch {
188
- // Ignore cleanup failures.
189
- }
190
- const message = error instanceof Error ? error.message : String(error);
191
- ctx.ui.notify(
192
- `Failed to save permission-system config at '${globalPath}': ${message}`,
193
- "error",
194
- );
195
- return;
196
- }
197
-
198
- runtime.config = normalized;
199
- syncPermissionSystemStatus(ctx, normalized);
200
- runtime.lastConfigWarning = null;
201
-
202
- runtime.writeDebugLog("config.saved", {
203
- debugLog: normalized.debugLog,
204
- permissionReviewLog: normalized.permissionReviewLog,
205
- yoloMode: normalized.yoloMode,
206
- });
207
- }
208
-
209
- /**
210
- * Write the resolved config path set (global, project, legacy) to the review
211
- * and debug logs.
212
- */
213
- export function logResolvedConfigPaths(runtime: ExtensionRuntime): void {
214
- const policyPaths = runtime.permissionManager.getResolvedPolicyPaths();
215
- const cwd = runtime.runtimeContext?.cwd ?? null;
216
- const { agentDir } = runtime;
217
- const legacyGlobalPolicyDetected = existsSync(
218
- getLegacyGlobalPolicyPath(agentDir),
219
- );
220
- const legacyProjectPolicyDetected = cwd
221
- ? existsSync(getLegacyProjectPolicyPath(cwd))
222
- : false;
223
- const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
224
- const newGlobalPath = getGlobalConfigPath(agentDir);
225
- const legacyExtensionConfigDetected =
226
- normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
227
- existsSync(legacyExtConfigPath);
228
- const entry = buildResolvedConfigLogEntry({
229
- policyPaths,
230
- legacyGlobalPolicyDetected,
231
- legacyProjectPolicyDetected,
232
- legacyExtensionConfigDetected,
233
- });
234
- runtime.writeReviewLog(
235
- "config.resolved",
236
- entry as unknown as Record<string, unknown>,
237
- );
238
- runtime.writeDebugLog(
239
- "config.resolved",
240
- entry as unknown as Record<string, unknown>,
241
- );
242
- }
243
-
244
55
  // ── Factory ────────────────────────────────────────────────────────────────
245
56
 
246
57
  /**
@@ -255,28 +66,49 @@ export function createExtensionRuntime(options?: {
255
66
  const agentDir = options?.agentDir ?? getAgentDir();
256
67
  const paths = computeExtensionPaths(agentDir);
257
68
 
258
- // Build a plain-object runtime first so the logger's `getConfig` closure
259
- // can reference `runtime.config` directly (always reads current value).
69
+ const permissionManager = new PermissionManager({ agentDir });
70
+
260
71
  const runtime: ExtensionRuntime = {
261
72
  ...paths,
262
- config: { ...DEFAULT_EXTENSION_CONFIG },
263
73
  runtimeContext: null,
264
- permissionManager: createPermissionManagerForCwd(agentDir, undefined),
74
+ configStore: null as unknown as ConfigStore,
75
+ permissionManager,
265
76
  activeSkillEntries: [],
266
77
  lastKnownActiveAgentName: null,
267
78
  lastActiveToolsCacheKey: null,
268
79
  lastPromptStateCacheKey: null,
269
- lastConfigWarning: null,
270
80
  sessionRules: new SessionRules(),
271
81
  // Logging methods are replaced below after the logger is constructed.
272
82
  writeDebugLog: () => {},
273
83
  writeReviewLog: () => {},
274
84
  };
275
85
 
86
+ // Transitional RuntimeContextRef: reads/writes the still-runtime-owned
87
+ // `runtimeContext` field until Step 4 (#337) unifies context onto
88
+ // PermissionSession.
89
+ const contextRef: RuntimeContextRef = {
90
+ get: () => runtime.runtimeContext,
91
+ set: (ctx) => {
92
+ runtime.runtimeContext = ctx;
93
+ },
94
+ };
95
+
96
+ const configStore = new ConfigStore({
97
+ agentDir,
98
+ context: contextRef,
99
+ policyPaths: permissionManager,
100
+ logger: {
101
+ // Deferred-binding: `runtime.writeDebugLog` is replaced below after
102
+ // the logger is constructed — same deferred pattern as before Step 2.
103
+ writeDebugLog: (e, d) => runtime.writeDebugLog(e, d),
104
+ writeReviewLog: (e, d) => runtime.writeReviewLog(e, d),
105
+ },
106
+ });
107
+ runtime.configStore = configStore;
108
+
276
109
  const reportedLoggingWarnings = new Set<string>();
277
110
  const logger = createPermissionSystemLogger({
278
- // Reads runtime.config at call time — always current.
279
- getConfig: () => runtime.config,
111
+ getConfig: () => configStore.current(),
280
112
  debugLogPath: join(paths.globalLogsDir, DEBUG_LOG_FILENAME),
281
113
  reviewLogPath: join(paths.globalLogsDir, REVIEW_LOG_FILENAME),
282
114
  ensureLogsDirectory: () =>
@@ -288,7 +120,6 @@ export function createExtensionRuntime(options?: {
288
120
  return;
289
121
  }
290
122
  reportedLoggingWarnings.add(message);
291
- // Reads runtime.runtimeContext at call time — always current.
292
123
  runtime.runtimeContext?.ui.notify(message, "warning");
293
124
  };
294
125
 
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { expect, test, vi } from "vitest";
5
5
  import { registerPermissionSystemCommand } from "#src/config-modal";
6
+ import type { CommandConfigStore } from "#src/config-store";
6
7
  import {
7
8
  DEFAULT_EXTENSION_CONFIG,
8
9
  normalizePermissionSystemConfig,
@@ -79,11 +80,14 @@ test("permission-system command completions expose top-level config actions", ()
79
80
  let config: PermissionSystemExtensionConfig = { ...DEFAULT_EXTENSION_CONFIG };
80
81
 
81
82
  try {
82
- const controller = {
83
- getConfig: () => config,
84
- setConfig: (next: PermissionSystemExtensionConfig) => {
83
+ const configStore: CommandConfigStore = {
84
+ current: () => config,
85
+ save: (next) => {
85
86
  config = next;
86
87
  },
88
+ };
89
+ const controller = {
90
+ config: configStore,
87
91
  getConfigPath: () => configPath,
88
92
  };
89
93
 
@@ -136,9 +140,9 @@ test("permission-system command handlers manage config summary, persistence, and
136
140
  "utf-8",
137
141
  );
138
142
 
139
- const controller = {
140
- getConfig: () => config,
141
- setConfig: (next: PermissionSystemExtensionConfig) => {
143
+ const configStore: CommandConfigStore = {
144
+ current: () => config,
145
+ save: (next) => {
142
146
  const currentConfig = normalizePermissionSystemConfig(
143
147
  JSON.parse(readFileSync(configPath, "utf-8")) as unknown,
144
148
  );
@@ -153,6 +157,9 @@ test("permission-system command handlers manage config summary, persistence, and
153
157
  );
154
158
  expect(config).not.toEqual(currentConfig);
155
159
  },
160
+ };
161
+ const controller = {
162
+ config: configStore,
156
163
  getConfigPath: () => configPath,
157
164
  };
158
165
 
@@ -249,8 +256,7 @@ test("show output includes rule origins when getComposedRules is provided", asyn
249
256
  ];
250
257
 
251
258
  const controller = {
252
- getConfig: () => config,
253
- setConfig: () => {},
259
+ config: { current: () => config, save: () => {} } as CommandConfigStore,
254
260
  getConfigPath: () => "/fake/config.json",
255
261
  getComposedRules: () => composedRules,
256
262
  };
@@ -282,8 +288,7 @@ test("show output omits rule summary when getComposedRules is not provided", asy
282
288
  const config = { ...DEFAULT_EXTENSION_CONFIG, yoloMode: true };
283
289
 
284
290
  const controller = {
285
- getConfig: () => config,
286
- setConfig: () => {},
291
+ config: { current: () => config, save: () => {} } as CommandConfigStore,
287
292
  getConfigPath: () => "/fake/config.json",
288
293
  // no getComposedRules
289
294
  };