@gotgenes/pi-permission-system 13.1.0 → 13.1.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [13.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.1.0...pi-permission-system-v13.1.1) (2026-06-13)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * preserve forwarded-permission responses dir while requests pending ([#398](https://github.com/gotgenes/pi-packages/issues/398)) ([9914e70](https://github.com/gotgenes/pi-packages/commit/9914e7093c5addee80bd39f2ff99211991b4a238))
14
+ * recreate forwarded-permission responses dir before write ([#398](https://github.com/gotgenes/pi-packages/issues/398)) ([67d34ef](https://github.com/gotgenes/pi-packages/commit/67d34efb33dbda28f363a004d0945c2a4aacea29))
15
+
8
16
  ## [13.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.0.0...pi-permission-system-v13.1.0) (2026-06-13)
9
17
 
10
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "13.1.0",
3
+ "version": "13.1.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -175,13 +175,20 @@ export function getExistingPermissionForwardingLocation(
175
175
  return existsSync(location.requestsDir) ? location : null;
176
176
  }
177
177
 
178
+ /**
179
+ * Attempt to remove a directory if it is empty.
180
+ *
181
+ * Returns `true` when the directory is absent after the call (successfully
182
+ * removed, or never existed). Returns `false` when the directory still exists
183
+ * (non-empty, or a filesystem error prevented removal).
184
+ */
178
185
  export function tryRemoveDirectoryIfEmpty(
179
186
  logger: DebugReviewLogger | null,
180
187
  path: string,
181
188
  description: string,
182
- ): void {
189
+ ): boolean {
183
190
  if (!existsSync(path)) {
184
- return;
191
+ return true;
185
192
  }
186
193
 
187
194
  let entries: string[];
@@ -193,18 +200,22 @@ export function tryRemoveDirectoryIfEmpty(
193
200
  `Failed to inspect ${description} directory '${path}'`,
194
201
  error,
195
202
  );
196
- return;
203
+ return false;
197
204
  }
198
205
 
199
206
  if (entries.length > 0) {
200
- return;
207
+ return false;
201
208
  }
202
209
 
203
210
  try {
204
211
  rmdirSync(path);
212
+ return true;
205
213
  } catch (error) {
206
- if (isErrnoCode(error, "ENOENT") || isErrnoCode(error, "ENOTEMPTY")) {
207
- return;
214
+ if (isErrnoCode(error, "ENOENT")) {
215
+ return true;
216
+ }
217
+ if (isErrnoCode(error, "ENOTEMPTY")) {
218
+ return false;
208
219
  }
209
220
 
210
221
  logPermissionForwardingWarning(
@@ -212,6 +223,7 @@ export function tryRemoveDirectoryIfEmpty(
212
223
  `Failed to remove empty ${description} directory '${path}'`,
213
224
  error,
214
225
  );
226
+ return false;
215
227
  }
216
228
  }
217
229
 
@@ -219,16 +231,20 @@ export function cleanupPermissionForwardingLocationIfEmpty(
219
231
  logger: DebugReviewLogger | null,
220
232
  location: PermissionForwardingLocation,
221
233
  ): void {
222
- tryRemoveDirectoryIfEmpty(
234
+ // Only remove responses/ when requests/ is already gone — removing responses/
235
+ // while a request is still pending causes the ENOENT write loop (issue #398).
236
+ const requestsGone = tryRemoveDirectoryIfEmpty(
223
237
  logger,
224
238
  location.requestsDir,
225
239
  `${location.label} permission forwarding requests`,
226
240
  );
227
- tryRemoveDirectoryIfEmpty(
228
- logger,
229
- location.responsesDir,
230
- `${location.label} permission forwarding responses`,
231
- );
241
+ if (requestsGone) {
242
+ tryRemoveDirectoryIfEmpty(
243
+ logger,
244
+ location.responsesDir,
245
+ `${location.label} permission forwarding responses`,
246
+ );
247
+ }
232
248
  tryRemoveDirectoryIfEmpty(
233
249
  logger,
234
250
  location.sessionRootDir,
@@ -35,6 +35,7 @@ import { shouldAutoApprovePermissionState } from "#src/yolo-mode";
35
35
 
36
36
  import {
37
37
  cleanupPermissionForwardingLocationIfEmpty,
38
+ ensureDirectoryExists,
38
39
  ensurePermissionForwardingLocation,
39
40
  getExistingPermissionForwardingLocation,
40
41
  listRequestFiles,
@@ -255,6 +256,20 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
255
256
  return;
256
257
  }
257
258
 
259
+ // Defensively recreate responses/ before writing any response — a
260
+ // concurrent cleanup pass may have removed it between the requestsDir
261
+ // existence check above and the write inside processSingleForwardedRequest
262
+ // (the ENOENT write loop reported in issue #398).
263
+ if (
264
+ !ensureDirectoryExists(
265
+ this.logger,
266
+ location.responsesDir,
267
+ "permission forwarding responses",
268
+ )
269
+ ) {
270
+ return;
271
+ }
272
+
258
273
  for (const fileName of requestFiles) {
259
274
  const requestPath = join(location.requestsDir, fileName);
260
275
  const request = readForwardedPermissionRequest(this.logger, requestPath);
@@ -1,11 +1,24 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
12
 
3
13
  import {
14
+ cleanupPermissionForwardingLocationIfEmpty,
4
15
  formatUnknownErrorMessage,
5
16
  isErrnoCode,
6
17
  logPermissionForwardingError,
7
18
  logPermissionForwardingWarning,
19
+ tryRemoveDirectoryIfEmpty,
8
20
  } from "#src/forwarded-permissions/io";
21
+ import { createPermissionForwardingLocation } from "#src/permission-forwarding";
9
22
  import type { DebugReviewLogger } from "#src/session-logger";
10
23
 
11
24
  // ── helpers ────────────────────────────────────────────────────────────────
@@ -130,3 +143,109 @@ describe("logPermissionForwardingError", () => {
130
143
  expect(() => logPermissionForwardingError(null, "ignored")).not.toThrow();
131
144
  });
132
145
  });
146
+
147
+ // ── tryRemoveDirectoryIfEmpty ──────────────────────────────────────────────
148
+
149
+ describe("tryRemoveDirectoryIfEmpty", () => {
150
+ let root: string;
151
+
152
+ afterEach(() => {
153
+ rmSync(root, { recursive: true, force: true });
154
+ });
155
+
156
+ it("returns true when the directory does not exist", () => {
157
+ root = mkdtempSync(join(tmpdir(), "io-test-"));
158
+ const absent = join(root, "nonexistent");
159
+ expect(tryRemoveDirectoryIfEmpty(null, absent, "test")).toBe(true);
160
+ });
161
+
162
+ it("returns true and removes an empty directory", () => {
163
+ root = mkdtempSync(join(tmpdir(), "io-test-"));
164
+ const dir = join(root, "empty");
165
+ mkdirSync(dir);
166
+ expect(tryRemoveDirectoryIfEmpty(null, dir, "test")).toBe(true);
167
+ expect(existsSync(dir)).toBe(false);
168
+ });
169
+
170
+ it("returns false and leaves a non-empty directory in place", () => {
171
+ root = mkdtempSync(join(tmpdir(), "io-test-"));
172
+ const dir = join(root, "nonempty");
173
+ mkdirSync(dir);
174
+ writeFileSync(join(dir, "file.json"), "{}", "utf-8");
175
+ expect(tryRemoveDirectoryIfEmpty(null, dir, "test")).toBe(false);
176
+ expect(existsSync(dir)).toBe(true);
177
+ });
178
+ });
179
+
180
+ // ── cleanupPermissionForwardingLocationIfEmpty ─────────────────────────────
181
+
182
+ describe("cleanupPermissionForwardingLocationIfEmpty", () => {
183
+ let root: string;
184
+
185
+ afterEach(() => {
186
+ rmSync(root, { recursive: true, force: true });
187
+ });
188
+
189
+ it("preserves responses/ when requests/ is non-empty (the concurrent-request race)", () => {
190
+ root = mkdtempSync(join(tmpdir(), "io-cleanup-"));
191
+ const forwardingDir = join(root, "forwarding");
192
+ const location = createPermissionForwardingLocation(
193
+ forwardingDir,
194
+ "parent-session",
195
+ );
196
+ // Simulate: requests/ has a pending file, responses/ is momentarily empty
197
+ mkdirSync(location.requestsDir, { recursive: true });
198
+ mkdirSync(location.responsesDir, { recursive: true });
199
+ writeFileSync(join(location.requestsDir, "req-b.json"), "{}", "utf-8");
200
+ // responses/ is empty (sibling subagent A already cleaned up its response)
201
+
202
+ cleanupPermissionForwardingLocationIfEmpty(null, location);
203
+
204
+ // requests/ is non-empty → should NOT be removed
205
+ expect(existsSync(location.requestsDir)).toBe(true);
206
+ // responses/ must survive — removing it causes the ENOENT write loop
207
+ expect(existsSync(location.responsesDir)).toBe(true);
208
+ // sessionRoot must also survive while subdirs are present
209
+ expect(existsSync(location.sessionRootDir)).toBe(true);
210
+ });
211
+
212
+ it("removes both subdirs and sessionRoot when both are empty (normal serial cleanup)", () => {
213
+ root = mkdtempSync(join(tmpdir(), "io-cleanup-"));
214
+ const forwardingDir = join(root, "forwarding");
215
+ const location = createPermissionForwardingLocation(
216
+ forwardingDir,
217
+ "parent-session",
218
+ );
219
+ mkdirSync(location.requestsDir, { recursive: true });
220
+ mkdirSync(location.responsesDir, { recursive: true });
221
+ // Both empty — normal end-of-lifecycle state
222
+
223
+ cleanupPermissionForwardingLocationIfEmpty(null, location);
224
+
225
+ expect(existsSync(location.requestsDir)).toBe(false);
226
+ expect(existsSync(location.responsesDir)).toBe(false);
227
+ expect(existsSync(location.sessionRootDir)).toBe(false);
228
+ });
229
+
230
+ it("leaves responses/ in place when it is non-empty even if requests/ is empty", () => {
231
+ root = mkdtempSync(join(tmpdir(), "io-cleanup-"));
232
+ const forwardingDir = join(root, "forwarding");
233
+ const location = createPermissionForwardingLocation(
234
+ forwardingDir,
235
+ "parent-session",
236
+ );
237
+ mkdirSync(location.requestsDir, { recursive: true });
238
+ mkdirSync(location.responsesDir, { recursive: true });
239
+ writeFileSync(join(location.responsesDir, "resp.json"), "{}", "utf-8");
240
+ // requests/ is empty, responses/ has a stale response
241
+
242
+ cleanupPermissionForwardingLocationIfEmpty(null, location);
243
+
244
+ // requests/ is empty so it gets removed
245
+ expect(existsSync(location.requestsDir)).toBe(false);
246
+ // responses/ is non-empty → survives
247
+ expect(existsSync(location.responsesDir)).toBe(true);
248
+ // sessionRoot survives because responses/ is still present
249
+ expect(existsSync(location.sessionRootDir)).toBe(true);
250
+ });
251
+ });
@@ -307,4 +307,63 @@ describe("processInbox", () => {
307
307
  rmSync(root, { recursive: true, force: true });
308
308
  }
309
309
  });
310
+
311
+ test("recreates a missing responses/ directory and still writes the response", async () => {
312
+ const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
313
+ try {
314
+ const forwardingDir = join(root, "forwarding");
315
+ const location = createPermissionForwardingLocation(
316
+ forwardingDir,
317
+ "parent-session",
318
+ );
319
+ // Simulate the race: requests/ exists with a pending file, but
320
+ // responses/ was removed by a concurrent cleanup pass.
321
+ mkdirSync(location.requestsDir, { recursive: true });
322
+ // Deliberately do NOT create location.responsesDir.
323
+ writeFileSync(
324
+ join(location.requestsDir, "req-race.json"),
325
+ JSON.stringify({
326
+ id: "req-race",
327
+ createdAt: Date.now(),
328
+ requesterSessionId: "child-session",
329
+ targetSessionId: "parent-session",
330
+ requesterAgentName: "Explore",
331
+ message: "Allow read?",
332
+ }),
333
+ "utf-8",
334
+ );
335
+
336
+ const logger = { review: vi.fn(), debug: vi.fn() };
337
+ const requestPermissionDecisionFromUi = vi
338
+ .fn()
339
+ .mockResolvedValue({ approved: true, state: "approved" as const });
340
+
341
+ const forwarder = new PermissionForwarder(
342
+ makeDeps({
343
+ forwardingDir,
344
+ logger,
345
+ requestPermissionDecisionFromUi,
346
+ }),
347
+ );
348
+
349
+ await forwarder.processInbox(
350
+ makeCtx({
351
+ hasUI: true,
352
+ sessionManager: {
353
+ getSessionId: vi.fn(() => "parent-session"),
354
+ },
355
+ }),
356
+ );
357
+
358
+ // processInbox must have recreated responses/ and written a response
359
+ // file — no permission_forwarding.error should have been logged.
360
+ expect(logger.review).not.toHaveBeenCalledWith(
361
+ "permission_forwarding.error",
362
+ expect.anything(),
363
+ );
364
+ expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
365
+ } finally {
366
+ rmSync(root, { recursive: true, force: true });
367
+ }
368
+ });
310
369
  });