@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
|
@@ -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
|
-
):
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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 {
|
|
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
|
});
|