@gotgenes/pi-permission-system 32.0.1 → 32.0.2
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 +12 -0
- package/docs/troubleshooting.md +10 -9
- package/package.json +1 -1
- package/src/authority/forwarding-io.ts +47 -2
- package/src/authority/transient-fs-retry.ts +118 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ 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
|
+
## [32.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.1...pi-permission-system-v32.0.2) (2026-09-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** keep a forwarded write when a file lock blocks the atomic rename ([d901116](https://github.com/gotgenes/pi-packages/commit/d9011165203b130675a3affd20a2792f7abd7105)), closes [#914](https://github.com/gotgenes/pi-packages/issues/914)
|
|
14
|
+
* **pi-permission-system:** create a forwarding directory blocked by a transient file lock ([245683c](https://github.com/gotgenes/pi-packages/commit/245683c9153712d8991ceb21ece7884b2fb12f2a)), closes [#914](https://github.com/gotgenes/pi-packages/issues/914)
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** record the transient filesystem retry ([eb7ab58](https://github.com/gotgenes/pi-packages/commit/eb7ab58cfd79d15de77bafa6afe8813e9dff7b72)), closes [#914](https://github.com/gotgenes/pi-packages/issues/914)
|
|
19
|
+
|
|
8
20
|
## [32.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.0...pi-permission-system-v32.0.1) (2026-09-11)
|
|
9
21
|
|
|
10
22
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Common Issues
|
|
4
4
|
|
|
5
|
-
| Problem
|
|
6
|
-
|
|
|
7
|
-
| Config not applied (everything asks)
|
|
8
|
-
| Per-agent override not applied
|
|
9
|
-
| Tool blocked as unregistered
|
|
10
|
-
| `/skill:<name>` blocked
|
|
11
|
-
| External file path blocked
|
|
12
|
-
| Spurious external-path prompt for `cd <subdir> && grep … ../path`
|
|
13
|
-
| Permission prompt is too verbose
|
|
5
|
+
| Problem | Cause | Solution |
|
|
6
|
+
| ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| Config not applied (everything asks) | File not found or parse error | Verify the global config at `~/.pi/agent/extensions/pi-permission-system/config.json` (respects `PI_CODING_AGENT_DIR`); check for trailing commas |
|
|
8
|
+
| Per-agent override not applied | Frontmatter parsing issue | Ensure `---` delimiters at file top; keep YAML simple; restart session |
|
|
9
|
+
| Tool blocked as unregistered | Unknown tool name | Use a registered `mcp` tool for server tools: `{ "tool": "server:tool" }` |
|
|
10
|
+
| `/skill:<name>` blocked | Deny policy or confirmation unavailable | Check merged `skill` policy (global/project/agent layers). `ask` still requires UI or forwarded confirmation. |
|
|
11
|
+
| External file path blocked | `external_directory` is `ask` without UI or `deny` | Allow/ask the permission or keep file tools inside the active working directory. |
|
|
12
|
+
| Spurious external-path prompt for `cd <subdir> && grep … ../path` | Relative path was resolved against cwd instead of the `cd` target | Fixed in current version — paths after a leading `cd <subdir> &&` are resolved against the cd target, matching actual shell behavior. |
|
|
13
|
+
| Permission prompt is too verbose | Generic extension tool input is large | Built-in file tools are summarized automatically; third-party tools are capped to a bounded one-line JSON preview. |
|
|
14
|
+
| Windows: `permission_forwarding.error — EPERM … rename` in the logs, and a subagent's tool call refused | An antivirus scanner or the search indexer held a transient handle on a forwarding file | The write is retried automatically for a short window. If it still fails, enable `debugLog` and look for `permission_forwarding.fs_retried` entries — their `attempts` and `code` say whether retrying is helping — then exclude the forwarding directory from the scanner. |
|
|
14
15
|
|
|
15
16
|
## Diagnostic Logging
|
|
16
17
|
|
package/package.json
CHANGED
|
@@ -29,6 +29,10 @@ import {
|
|
|
29
29
|
type ForwardedSessionApproval,
|
|
30
30
|
type PermissionForwardingLocation,
|
|
31
31
|
} from "./permission-forwarding";
|
|
32
|
+
import {
|
|
33
|
+
retryOnTransientFsError,
|
|
34
|
+
type TransientFsRetryRecord,
|
|
35
|
+
} from "./transient-fs-retry";
|
|
32
36
|
|
|
33
37
|
/** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
|
|
34
38
|
const UI_PROMPT_SOURCES = [
|
|
@@ -217,7 +221,14 @@ export function ensureDirectoryExists(
|
|
|
217
221
|
description: string,
|
|
218
222
|
): boolean {
|
|
219
223
|
try {
|
|
220
|
-
|
|
224
|
+
recordFsRetry(
|
|
225
|
+
logger,
|
|
226
|
+
"mkdir",
|
|
227
|
+
path,
|
|
228
|
+
retryOnTransientFsError(() => {
|
|
229
|
+
mkdirSync(path, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
221
232
|
return true;
|
|
222
233
|
} catch (error) {
|
|
223
234
|
logPermissionForwardingError(
|
|
@@ -399,17 +410,51 @@ export function writeJsonFileAtomic(
|
|
|
399
410
|
try {
|
|
400
411
|
// `rename` preserves the temp file's mode, so setting it here is enough —
|
|
401
412
|
// a response overwriting an existing file also comes through a fresh temp.
|
|
413
|
+
// The temp write is deliberately outside the retry: its failure shape is a
|
|
414
|
+
// write-denied directory, which no number of attempts resolves.
|
|
402
415
|
writeFileSync(tempPath, JSON.stringify(value), {
|
|
403
416
|
encoding: "utf-8",
|
|
404
417
|
mode: OWNER_ONLY_FILE_MODE,
|
|
405
418
|
});
|
|
406
|
-
|
|
419
|
+
recordFsRetry(
|
|
420
|
+
logger,
|
|
421
|
+
"rename",
|
|
422
|
+
filePath,
|
|
423
|
+
retryOnTransientFsError(() => {
|
|
424
|
+
renameSync(tempPath, filePath);
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
407
427
|
} catch (error) {
|
|
408
428
|
safeDeleteFile(logger, tempPath, "temporary permission-forwarding");
|
|
409
429
|
throw error;
|
|
410
430
|
}
|
|
411
431
|
}
|
|
412
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Record a filesystem operation that only succeeded after retrying.
|
|
435
|
+
*
|
|
436
|
+
* Debug-only: a recovered write decided nothing, so it does not belong in the
|
|
437
|
+
* permission-decision record — but `attempts` and `code` are the whole
|
|
438
|
+
* diagnosis when a host keeps losing writes, and `debugLog` is exactly the
|
|
439
|
+
* switch a user reaching for that diagnosis turns on.
|
|
440
|
+
*/
|
|
441
|
+
function recordFsRetry(
|
|
442
|
+
logger: DebugReviewLogger | null,
|
|
443
|
+
operation: "rename" | "mkdir",
|
|
444
|
+
path: string,
|
|
445
|
+
record: TransientFsRetryRecord,
|
|
446
|
+
): void {
|
|
447
|
+
if (record.attempts === 1) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
logger?.debug("permission_forwarding.fs_retried", {
|
|
451
|
+
operation,
|
|
452
|
+
path,
|
|
453
|
+
attempts: record.attempts,
|
|
454
|
+
code: record.code,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
413
458
|
export function readForwardedPermissionRequest(
|
|
414
459
|
logger: DebugReviewLogger | null,
|
|
415
460
|
filePath: string,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transient-fs-retry.ts — Survive a file lock that is about to clear.
|
|
3
|
+
*
|
|
4
|
+
* On Windows a `rename` or `mkdir` is not the POSIX operation of the same
|
|
5
|
+
* name: an antivirus scanner, the search indexer, or any other process holding
|
|
6
|
+
* a transient handle on a path fails the call with `EPERM`/`EBUSY`/`EACCES`.
|
|
7
|
+
* The forwarded-permission writes that ride on those calls are then simply
|
|
8
|
+
* lost — a child's request write becomes a refused tool call, and a serving
|
|
9
|
+
* heartbeat goes unpublished (#914).
|
|
10
|
+
*
|
|
11
|
+
* A few attempts a few milliseconds apart is enough for the common case. The
|
|
12
|
+
* decision lives here rather than at either call site so both inherit one
|
|
13
|
+
* errno set and one budget, and so it can be tested without a filesystem.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Tuning seams, injected so a unit test asserts the backoff without sleeping. */
|
|
17
|
+
export interface TransientFsRetryOptions {
|
|
18
|
+
delaysMs?: readonly number[];
|
|
19
|
+
sleep?: (ms: number) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** How an operation that eventually succeeded got there. */
|
|
23
|
+
export interface TransientFsRetryRecord {
|
|
24
|
+
/** Attempts made, counting the first — `1` when it succeeded outright. */
|
|
25
|
+
attempts: number;
|
|
26
|
+
/** The transient errno that forced the retries, or `null` when there were none. */
|
|
27
|
+
code: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run `operation`, retrying while a transient filesystem lock rejects it.
|
|
32
|
+
*
|
|
33
|
+
* Returns how it went, so the caller can record a recovery without this module
|
|
34
|
+
* having to know anything about logging. An error outside the transient set is
|
|
35
|
+
* rethrown immediately with no sleep, keeping a permanent `ENOTDIR`/`ENOENT`
|
|
36
|
+
* exactly as fast as it is today; an exhausted budget rethrows the last error,
|
|
37
|
+
* so every caller's existing `catch` keeps working unchanged.
|
|
38
|
+
*/
|
|
39
|
+
export function retryOnTransientFsError(
|
|
40
|
+
operation: () => void,
|
|
41
|
+
options?: TransientFsRetryOptions,
|
|
42
|
+
): TransientFsRetryRecord {
|
|
43
|
+
const delaysMs = options?.delaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
44
|
+
const sleep = options?.sleep ?? sleepBlocking;
|
|
45
|
+
|
|
46
|
+
let attempts = 0;
|
|
47
|
+
let code: string | null = null;
|
|
48
|
+
|
|
49
|
+
for (;;) {
|
|
50
|
+
try {
|
|
51
|
+
operation();
|
|
52
|
+
return { attempts: attempts + 1, code };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const transientCode = transientErrorCode(error);
|
|
55
|
+
if (transientCode === null || attempts >= delaysMs.length) {
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
code = transientCode;
|
|
59
|
+
sleep(delaysMs[attempts] ?? 0);
|
|
60
|
+
attempts += 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Module-private ─────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Three retries, 60 ms of blocking at worst.
|
|
69
|
+
*
|
|
70
|
+
* Sized against the two windows it has to fit inside: well under one
|
|
71
|
+
* `PERMISSION_FORWARDING_POLL_INTERVAL_MS` tick, so a retrying heartbeat write
|
|
72
|
+
* cannot push the serving node's timer into the next tick, and far under the
|
|
73
|
+
* grace a forwarding child waits out, so a parent retrying its own writes can
|
|
74
|
+
* never be the reason a child gives up on it.
|
|
75
|
+
*/
|
|
76
|
+
const DEFAULT_RETRY_DELAYS_MS: readonly number[] = [10, 20, 30];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The errno values a transient lock produces on an otherwise-valid operation.
|
|
80
|
+
*
|
|
81
|
+
* This set is the platform gate: `process.platform` is unreadable in `src/`,
|
|
82
|
+
* and a POSIX host that produces one of these on a rename is in the same
|
|
83
|
+
* situation a Windows host is — worth one more attempt, and no worse off for
|
|
84
|
+
* it.
|
|
85
|
+
*/
|
|
86
|
+
const TRANSIENT_FS_ERROR_CODES: ReadonlySet<string> = new Set([
|
|
87
|
+
"EPERM",
|
|
88
|
+
"EBUSY",
|
|
89
|
+
"EACCES",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/** The transient errno `error` carries, or `null` when retrying cannot help. */
|
|
93
|
+
function transientErrorCode(error: unknown): string | null {
|
|
94
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const { code } = error as { code?: unknown };
|
|
98
|
+
return typeof code === "string" && TRANSIENT_FS_ERROR_CODES.has(code)
|
|
99
|
+
? code
|
|
100
|
+
: null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Block the calling thread for `ms`.
|
|
105
|
+
*
|
|
106
|
+
* Synchronous because every caller is: the heartbeat publish satisfies a
|
|
107
|
+
* `void` seam a timer drives. `Atomics.wait` on a never-notified buffer is the
|
|
108
|
+
* standard blocking sleep and is permitted on Node's main thread.
|
|
109
|
+
*
|
|
110
|
+
* The buffer is module-scoped — which persists across same-cwd session
|
|
111
|
+
* switches — and that is safe here precisely because nothing ever writes to
|
|
112
|
+
* it: every wait sees the initial zero and times out.
|
|
113
|
+
*/
|
|
114
|
+
const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
115
|
+
|
|
116
|
+
function sleepBlocking(ms: number): void {
|
|
117
|
+
Atomics.wait(SLEEP_BUFFER, 0, 0, ms);
|
|
118
|
+
}
|