@gotgenes/pi-permission-system 13.1.0 → 13.1.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 +17 -0
- package/README.md +18 -0
- package/config/config.example.json +2 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +2 -2
- package/src/forwarded-permissions/io.ts +28 -12
- package/src/forwarded-permissions/permission-forwarder.ts +15 -0
- package/test/forwarded-permissions/io.test.ts +120 -1
- package/test/permission-forwarder.test.ts +59 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.1.1...pi-permission-system-v13.1.2) (2026-06-16)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** clarify external_directory surface in README ([#413](https://github.com/gotgenes/pi-packages/issues/413)) ([c09929b](https://github.com/gotgenes/pi-packages/commit/c09929be209050c1f85e9e01dbb231f99e940f82))
|
|
14
|
+
* **pi-permission-system:** document external_directory allow-list for outside-CWD caches ([#413](https://github.com/gotgenes/pi-packages/issues/413)) ([86b1d87](https://github.com/gotgenes/pi-packages/commit/86b1d87ff92e63c26d3f81ddb41aad7aab085074))
|
|
15
|
+
* **pi-permission-system:** show external_directory allow-list in example config and schema ([#413](https://github.com/gotgenes/pi-packages/issues/413)) ([8178a7e](https://github.com/gotgenes/pi-packages/commit/8178a7ebc3237e188b8e492d5c3a8bfca9b197aa))
|
|
16
|
+
|
|
17
|
+
## [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)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* 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))
|
|
23
|
+
* 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))
|
|
24
|
+
|
|
8
25
|
## [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
26
|
|
|
10
27
|
|
package/README.md
CHANGED
|
@@ -72,7 +72,25 @@ For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patt
|
|
|
72
72
|
This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
|
|
73
73
|
When Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`.
|
|
74
74
|
|
|
75
|
+
The `external_directory` surface is the CWD-boundary gate: it decides whether reaching **outside** the working tree is allowed, and accepts a pattern map so you can allow specific outside-CWD directories without opening up all external access.
|
|
76
|
+
This is the right surface for silencing repeated prompts on a local cache like `~/.cargo/registry` — allow it here, not on `path`:
|
|
77
|
+
|
|
78
|
+
```jsonc
|
|
79
|
+
{
|
|
80
|
+
"permission": {
|
|
81
|
+
"external_directory": {
|
|
82
|
+
"*": "ask",
|
|
83
|
+
"~/.cargo/registry/*": "allow"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The trailing `*` is greedy and crosses subdirectory boundaries, so it allows every file beneath the directory; a bare `~/.cargo/registry` matches only the directory entry itself.
|
|
90
|
+
|
|
75
91
|
Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `external_directory` (CWD boundary) → per-tool patterns → `bash` command patterns.
|
|
92
|
+
Because `ask` is more restrictive than `allow`, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories on `external_directory`.
|
|
93
|
+
See [docs/configuration.md](docs/configuration.md) for the full recipe.
|
|
76
94
|
|
|
77
95
|
## Configuration
|
|
78
96
|
|
package/package.json
CHANGED
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"permission": {
|
|
55
55
|
"description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
|
|
56
|
-
"markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, `path`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `\"read\": { \"*\": \"allow\", \"*.env\": \"deny\" }` allows reads but denies `.env` files.\n\nWhen Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. Bash path tokens use the effective directory after literal `cd` commands for this matching; non-literal `cd \"$DIR\"` style commands remain conservative.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor). A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all path-aware tools at once.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
|
|
56
|
+
"markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, `path`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `\"read\": { \"*\": \"allow\", \"*.env\": \"deny\" }` allows reads but denies `.env` files.\n\nWhen Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. Bash path tokens use the effective directory after literal `cd` commands for this matching; non-literal `cd \"$DIR\"` style commands remain conservative.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor). A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all path-aware tools at once.\n\nThe `external_directory` surface gates access **outside** the working directory. Give it a pattern map to allow specific outside-CWD directories without opening all external access — e.g. `\"external_directory\": { \"*\": \"ask\", \"~/.cargo/registry/*\": \"allow\" }` to silence repeated prompts on a local cache. The trailing `*` is greedy and crosses subdirectory boundaries; a bare `~/.cargo/registry` matches only the directory entry itself. Because layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories here, not on `path`.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
|
|
57
57
|
"type": "object",
|
|
58
58
|
"propertyNames": {
|
|
59
59
|
"description": "A surface name or the universal fallback key '*'.",
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
},
|
|
93
93
|
"mcp": { "*": "ask", "mcp_status": "allow", "exa:*": "allow" },
|
|
94
94
|
"skill": { "*": "ask", "librarian": "allow" },
|
|
95
|
-
"external_directory": "ask"
|
|
95
|
+
"external_directory": { "*": "ask", "~/.cargo/registry/*": "allow" }
|
|
96
96
|
}
|
|
97
97
|
]
|
|
98
98
|
}
|
|
@@ -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
|
});
|