@gotgenes/pi-permission-system 19.0.0 → 19.0.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 +7 -0
- package/package.json +1 -1
- package/src/{forwarded-permissions/permission-forwarder.ts → authority/approval-escalator.ts} +35 -301
- package/src/authority/forwarded-request-server.ts +285 -0
- package/src/authority/forwarder-context.ts +32 -0
- package/src/{subagent-context.ts → authority/subagent-context.ts} +2 -2
- package/src/authority/subagent-detection.ts +65 -0
- package/src/forwarding-manager.ts +4 -16
- package/src/index.ts +31 -20
- package/src/permission-prompter.ts +1 -1
- package/src/prompting-gateway.ts +5 -19
- package/src/service-lifecycle.ts +3 -5
- package/src/session-logger.ts +1 -1
- /package/src/{forwarded-permissions/io.ts → authority/forwarding-io.ts} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ 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
|
+
## [19.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v19.0.0...pi-permission-system-v19.0.1) (2026-07-07)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** mark Phase 8 Step 6 complete; retarget forwarder docs ([c11d8a2](https://github.com/gotgenes/pi-packages/commit/c11d8a2653edbac3637e45c1e5c5867f0a882afe))
|
|
14
|
+
|
|
8
15
|
## [19.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.2.0...pi-permission-system-v19.0.0) (2026-07-06)
|
|
9
16
|
|
|
10
17
|
|
package/package.json
CHANGED
package/src/{forwarded-permissions/permission-forwarder.ts → authority/approval-escalator.ts}
RENAMED
|
@@ -3,86 +3,53 @@ import { join } from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
getActiveAgentName,
|
|
5
5
|
getActiveAgentNameFromSystemPrompt,
|
|
6
|
-
type SessionEntryView,
|
|
7
6
|
} from "#src/active-agent";
|
|
8
|
-
import
|
|
9
|
-
|
|
7
|
+
import {
|
|
8
|
+
type ForwarderContext,
|
|
9
|
+
getSessionId,
|
|
10
|
+
} from "#src/authority/forwarder-context";
|
|
11
|
+
import {
|
|
12
|
+
cleanupPermissionForwardingLocationIfEmpty,
|
|
13
|
+
ensurePermissionForwardingLocation,
|
|
14
|
+
logPermissionForwardingError,
|
|
15
|
+
logPermissionForwardingWarning,
|
|
16
|
+
readForwardedPermissionResponse,
|
|
17
|
+
safeDeleteFile,
|
|
18
|
+
sleep,
|
|
19
|
+
writeJsonFileAtomic,
|
|
20
|
+
} from "#src/authority/forwarding-io";
|
|
21
|
+
import type { SubagentDetector } from "#src/authority/subagent-detection";
|
|
10
22
|
import type {
|
|
11
23
|
PermissionDecisionUi,
|
|
12
24
|
PermissionPromptDecision,
|
|
13
25
|
RequestPermissionOptions,
|
|
14
26
|
} from "#src/permission-dialog";
|
|
15
|
-
import {
|
|
16
|
-
emitUiPromptEvent,
|
|
17
|
-
type PermissionEventBus,
|
|
18
|
-
} from "#src/permission-events";
|
|
19
27
|
import {
|
|
20
28
|
type ForwardedPermissionRequest,
|
|
21
|
-
type ForwardedPermissionResponse,
|
|
22
29
|
type ForwardedPromptDisplay,
|
|
23
|
-
isForwardedPermissionRequestForSession,
|
|
24
30
|
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
25
31
|
PERMISSION_FORWARDING_TIMEOUT_MS,
|
|
26
32
|
type PermissionForwardingLocation,
|
|
27
33
|
resolvePermissionForwardingTargetSessionId,
|
|
28
34
|
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
29
35
|
} from "#src/permission-forwarding";
|
|
30
|
-
import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
|
|
31
36
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
32
|
-
import { isSubagentExecutionContext } from "#src/subagent-context";
|
|
33
37
|
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
34
38
|
import { toRecord } from "#src/value-guards";
|
|
35
39
|
|
|
36
|
-
import {
|
|
37
|
-
cleanupPermissionForwardingLocationIfEmpty,
|
|
38
|
-
ensureDirectoryExists,
|
|
39
|
-
ensurePermissionForwardingLocation,
|
|
40
|
-
getExistingPermissionForwardingLocation,
|
|
41
|
-
listRequestFiles,
|
|
42
|
-
logPermissionForwardingError,
|
|
43
|
-
logPermissionForwardingWarning,
|
|
44
|
-
readForwardedPermissionRequest,
|
|
45
|
-
readForwardedPermissionResponse,
|
|
46
|
-
safeDeleteFile,
|
|
47
|
-
sleep,
|
|
48
|
-
writeJsonFileAtomic,
|
|
49
|
-
} from "./io";
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Narrow context the forwarder reads: the UI gate (`hasUI`), the dialog UI
|
|
53
|
-
* surface, and the three session-manager readers it uses directly or via
|
|
54
|
-
* {@link isSubagentExecutionContext} / {@link getActiveAgentName}.
|
|
55
|
-
*
|
|
56
|
-
* `getSystemPrompt` is read reflectively (see `getContextSystemPrompt`), so it
|
|
57
|
-
* is intentionally not a typed member. A full `ExtensionContext` satisfies this
|
|
58
|
-
* structurally, so production callers pass `ctx` unchanged.
|
|
59
|
-
*/
|
|
60
|
-
export interface ForwarderContext {
|
|
61
|
-
hasUI: boolean;
|
|
62
|
-
ui: PermissionDecisionUi;
|
|
63
|
-
sessionManager: {
|
|
64
|
-
getSessionId(): string;
|
|
65
|
-
getSessionDir(): string;
|
|
66
|
-
getEntries(): readonly SessionEntryView[];
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
40
|
/**
|
|
71
|
-
* Constructor config for `
|
|
41
|
+
* Constructor config for `ApprovalEscalator`.
|
|
72
42
|
*
|
|
73
43
|
* Replaces the `PermissionForwardingDeps` interface that was previously
|
|
74
|
-
* threaded into free functions in `polling.ts`. The
|
|
44
|
+
* threaded into free functions in `polling.ts`. The escalator consumes it
|
|
75
45
|
* once at construction and stores each member as a private readonly field.
|
|
76
46
|
*/
|
|
77
|
-
export interface
|
|
47
|
+
export interface ApprovalEscalatorDeps {
|
|
78
48
|
forwardingDir: string;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
/** In-process subagent session registry for detection and forwarding target resolution. */
|
|
49
|
+
/** Single owner of subagent detection; gates the forward-vs-deny decision. */
|
|
50
|
+
detection: SubagentDetector;
|
|
51
|
+
/** In-process subagent session registry for forwarding target resolution. */
|
|
83
52
|
registry?: SubagentSessionRegistry;
|
|
84
|
-
/** Event bus used for UI prompt broadcasts. */
|
|
85
|
-
events?: PermissionEventBus;
|
|
86
53
|
logger: DebugReviewLogger;
|
|
87
54
|
requestPermissionDecisionFromUi: (
|
|
88
55
|
ui: PermissionDecisionUi,
|
|
@@ -90,23 +57,10 @@ export interface PermissionForwarderDeps {
|
|
|
90
57
|
message: string,
|
|
91
58
|
options?: RequestPermissionOptions,
|
|
92
59
|
) => Promise<PermissionPromptDecision>;
|
|
93
|
-
/** Read current config for the retained forwarded-inbox yolo auto-approve check. */
|
|
94
|
-
config: ConfigReader;
|
|
95
60
|
}
|
|
96
61
|
|
|
97
62
|
// ── Module-private helpers ────────────────────────────────────────────────
|
|
98
63
|
|
|
99
|
-
function getSessionId(ctx: ForwarderContext): string {
|
|
100
|
-
try {
|
|
101
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
102
|
-
if (typeof sessionId === "string" && sessionId.trim()) {
|
|
103
|
-
return sessionId.trim();
|
|
104
|
-
}
|
|
105
|
-
} catch {}
|
|
106
|
-
|
|
107
|
-
return "unknown";
|
|
108
|
-
}
|
|
109
|
-
|
|
110
64
|
function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
|
|
111
65
|
const getSystemPrompt = toRecord(ctx).getSystemPrompt;
|
|
112
66
|
if (typeof getSystemPrompt !== "function") {
|
|
@@ -128,27 +82,14 @@ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
|
|
|
128
82
|
}
|
|
129
83
|
}
|
|
130
84
|
|
|
131
|
-
function formatForwardedPermissionPrompt(
|
|
132
|
-
request: ForwardedPermissionRequest,
|
|
133
|
-
): string {
|
|
134
|
-
const agentName = request.requesterAgentName || "unknown";
|
|
135
|
-
const sessionId = request.requesterSessionId || "unknown";
|
|
136
|
-
return [
|
|
137
|
-
`Subagent '${agentName}' requested permission.`,
|
|
138
|
-
`Session ID: ${sessionId}`,
|
|
139
|
-
"",
|
|
140
|
-
request.message,
|
|
141
|
-
].join("\n");
|
|
142
|
-
}
|
|
143
|
-
|
|
144
85
|
// ── Public seam interfaces ────────────────────────────────────────────────
|
|
145
86
|
|
|
146
87
|
/**
|
|
147
|
-
* Narrow seam describing what `PermissionPrompter` needs from the
|
|
88
|
+
* Narrow seam describing what `PermissionPrompter` needs from the escalator:
|
|
148
89
|
* a single method that resolves a permission decision for the current context
|
|
149
90
|
* (prompt directly when the session has UI, otherwise forward to the parent).
|
|
150
91
|
*
|
|
151
|
-
* Depending on the interface (not the concrete `
|
|
92
|
+
* Depending on the interface (not the concrete `ApprovalEscalator`) keeps
|
|
152
93
|
* the prompter's unit tests free of casts — they inject a plain
|
|
153
94
|
* `{ requestApproval: vi.fn() }` mock.
|
|
154
95
|
*/
|
|
@@ -161,35 +102,20 @@ export interface ApprovalRequester {
|
|
|
161
102
|
): Promise<PermissionPromptDecision>;
|
|
162
103
|
}
|
|
163
104
|
|
|
164
|
-
|
|
165
|
-
* Narrow seam describing what `ForwardingManager` needs from the forwarder:
|
|
166
|
-
* a single method that drains this session's forwarded-permission inbox.
|
|
167
|
-
*
|
|
168
|
-
* Depending on the interface (not the concrete `PermissionForwarder`) keeps
|
|
169
|
-
* the manager's unit tests free of casts — they inject a plain
|
|
170
|
-
* `{ processInbox: vi.fn() }` mock.
|
|
171
|
-
*/
|
|
172
|
-
export interface InboxProcessor {
|
|
173
|
-
processInbox(ctx: ForwarderContext): Promise<void>;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ── PermissionForwarder ───────────────────────────────────────────────────
|
|
105
|
+
// ── ApprovalEscalator ────────────────────────────────────────────────
|
|
177
106
|
|
|
178
107
|
/**
|
|
179
|
-
* Owner of the forwarded-permission behavior.
|
|
108
|
+
* Owner of the escalation-up role of the forwarded-permission behavior.
|
|
180
109
|
*
|
|
181
|
-
* Holds all forwarding state as private readonly fields and provides
|
|
182
|
-
* public
|
|
183
|
-
* the
|
|
184
|
-
*
|
|
185
|
-
* responses, and processing the parent-session inbox.
|
|
110
|
+
* Holds all forwarding state as private readonly fields and provides the
|
|
111
|
+
* public `requestApproval` method: deciding whether to prompt directly or
|
|
112
|
+
* forward to the parent, building and persisting request files, and polling
|
|
113
|
+
* for responses.
|
|
186
114
|
*/
|
|
187
|
-
export class
|
|
115
|
+
export class ApprovalEscalator implements ApprovalRequester {
|
|
188
116
|
private readonly forwardingDir: string;
|
|
189
|
-
private readonly
|
|
190
|
-
private readonly platform: NodeJS.Platform;
|
|
117
|
+
private readonly detection: SubagentDetector;
|
|
191
118
|
private readonly registry: SubagentSessionRegistry | undefined;
|
|
192
|
-
private readonly events: PermissionEventBus | undefined;
|
|
193
119
|
private readonly logger: DebugReviewLogger;
|
|
194
120
|
private readonly requestPermissionDecisionFromUi: (
|
|
195
121
|
ui: PermissionDecisionUi,
|
|
@@ -197,17 +123,13 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
197
123
|
message: string,
|
|
198
124
|
options?: RequestPermissionOptions,
|
|
199
125
|
) => Promise<PermissionPromptDecision>;
|
|
200
|
-
private readonly config: ConfigReader;
|
|
201
126
|
|
|
202
|
-
constructor(deps:
|
|
127
|
+
constructor(deps: ApprovalEscalatorDeps) {
|
|
203
128
|
this.forwardingDir = deps.forwardingDir;
|
|
204
|
-
this.
|
|
205
|
-
this.platform = deps.platform;
|
|
129
|
+
this.detection = deps.detection;
|
|
206
130
|
this.registry = deps.registry;
|
|
207
|
-
this.events = deps.events;
|
|
208
131
|
this.logger = deps.logger;
|
|
209
132
|
this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
|
|
210
|
-
this.config = deps.config;
|
|
211
133
|
}
|
|
212
134
|
|
|
213
135
|
// ── Public seam methods ────────────────────────────────────────────────
|
|
@@ -231,78 +153,13 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
231
153
|
);
|
|
232
154
|
}
|
|
233
155
|
|
|
234
|
-
if (
|
|
235
|
-
!isSubagentExecutionContext(
|
|
236
|
-
ctx,
|
|
237
|
-
this.subagentSessionsDir,
|
|
238
|
-
this.platform,
|
|
239
|
-
this.registry,
|
|
240
|
-
)
|
|
241
|
-
) {
|
|
156
|
+
if (!this.detection.isSubagent(ctx)) {
|
|
242
157
|
return Promise.resolve({ approved: false, state: "denied" });
|
|
243
158
|
}
|
|
244
159
|
|
|
245
160
|
return this.waitForForwardedApproval(ctx, message, forwarded);
|
|
246
161
|
}
|
|
247
162
|
|
|
248
|
-
/** Drain and respond to this session's forwarded-permission inbox. */
|
|
249
|
-
async processInbox(ctx: ForwarderContext): Promise<void> {
|
|
250
|
-
if (!ctx.hasUI) {
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const currentSessionId = getSessionId(ctx);
|
|
255
|
-
const location = getExistingPermissionForwardingLocation(
|
|
256
|
-
this.forwardingDir,
|
|
257
|
-
currentSessionId,
|
|
258
|
-
);
|
|
259
|
-
if (!location) {
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const requestFiles = listRequestFiles(this.logger, location.requestsDir);
|
|
264
|
-
if (requestFiles.length === 0) {
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Defensively recreate responses/ before writing any response — a
|
|
269
|
-
// concurrent cleanup pass may have removed it between the requestsDir
|
|
270
|
-
// existence check above and the write inside processSingleForwardedRequest
|
|
271
|
-
// (the ENOENT write loop reported in issue #398).
|
|
272
|
-
if (
|
|
273
|
-
!ensureDirectoryExists(
|
|
274
|
-
this.logger,
|
|
275
|
-
location.responsesDir,
|
|
276
|
-
"permission forwarding responses",
|
|
277
|
-
)
|
|
278
|
-
) {
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
for (const fileName of requestFiles) {
|
|
283
|
-
const requestPath = join(location.requestsDir, fileName);
|
|
284
|
-
const request = readForwardedPermissionRequest(this.logger, requestPath);
|
|
285
|
-
if (!request) {
|
|
286
|
-
safeDeleteFile(
|
|
287
|
-
this.logger,
|
|
288
|
-
requestPath,
|
|
289
|
-
`${location.label} forwarded permission request`,
|
|
290
|
-
);
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
await this.processSingleForwardedRequest(
|
|
295
|
-
ctx,
|
|
296
|
-
request,
|
|
297
|
-
location,
|
|
298
|
-
requestPath,
|
|
299
|
-
currentSessionId,
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
163
|
// ── Private methods ────────────────────────────────────────────────────
|
|
307
164
|
|
|
308
165
|
private async waitForForwardedApproval(
|
|
@@ -313,12 +170,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
313
170
|
const requesterSessionId = getSessionId(ctx);
|
|
314
171
|
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
315
172
|
hasUI: ctx.hasUI,
|
|
316
|
-
isSubagent:
|
|
317
|
-
ctx,
|
|
318
|
-
this.subagentSessionsDir,
|
|
319
|
-
this.platform,
|
|
320
|
-
this.registry,
|
|
321
|
-
),
|
|
173
|
+
isSubagent: this.detection.isSubagent(ctx),
|
|
322
174
|
currentSessionId: requesterSessionId,
|
|
323
175
|
env: process.env,
|
|
324
176
|
sessionId: requesterSessionId,
|
|
@@ -472,122 +324,4 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
472
324
|
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
473
325
|
return { approved: false, state: "denied" };
|
|
474
326
|
}
|
|
475
|
-
|
|
476
|
-
private async processSingleForwardedRequest(
|
|
477
|
-
ctx: ForwarderContext,
|
|
478
|
-
request: ForwardedPermissionRequest,
|
|
479
|
-
location: PermissionForwardingLocation,
|
|
480
|
-
requestPath: string,
|
|
481
|
-
currentSessionId: string,
|
|
482
|
-
): Promise<void> {
|
|
483
|
-
if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
|
|
484
|
-
logPermissionForwardingWarning(
|
|
485
|
-
this.logger,
|
|
486
|
-
`Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
|
|
487
|
-
);
|
|
488
|
-
safeDeleteFile(
|
|
489
|
-
this.logger,
|
|
490
|
-
requestPath,
|
|
491
|
-
`${location.label} forwarded permission request`,
|
|
492
|
-
);
|
|
493
|
-
return;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const forwardedPermissionLogDetails = {
|
|
497
|
-
requestId: request.id,
|
|
498
|
-
source: location.label,
|
|
499
|
-
requesterAgentName: request.requesterAgentName,
|
|
500
|
-
requesterSessionId: request.requesterSessionId,
|
|
501
|
-
targetSessionId: request.targetSessionId,
|
|
502
|
-
requestPath,
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
let decision: PermissionPromptDecision = {
|
|
506
|
-
approved: false,
|
|
507
|
-
state: "denied",
|
|
508
|
-
};
|
|
509
|
-
// Last yolo check outside the composed ruleset: dissolves when
|
|
510
|
-
// processInbox is refactored onto evaluate() + Authorizer selection in
|
|
511
|
-
// the Phase 9 spine work.
|
|
512
|
-
if (isYoloModeEnabled(this.config.current())) {
|
|
513
|
-
this.logger.review(
|
|
514
|
-
"forwarded_permission.auto_approved",
|
|
515
|
-
forwardedPermissionLogDetails,
|
|
516
|
-
);
|
|
517
|
-
decision = { approved: true, state: "approved" };
|
|
518
|
-
} else {
|
|
519
|
-
this.logger.review(
|
|
520
|
-
"forwarded_permission.prompted",
|
|
521
|
-
forwardedPermissionLogDetails,
|
|
522
|
-
);
|
|
523
|
-
try {
|
|
524
|
-
const forwardedMessage = formatForwardedPermissionPrompt(request);
|
|
525
|
-
if (this.events) {
|
|
526
|
-
emitUiPromptEvent(
|
|
527
|
-
this.events,
|
|
528
|
-
buildForwardedUiPrompt({
|
|
529
|
-
requestId: request.id,
|
|
530
|
-
message: forwardedMessage,
|
|
531
|
-
requesterAgentName: request.requesterAgentName || null,
|
|
532
|
-
requesterSessionId: request.requesterSessionId || null,
|
|
533
|
-
source: request.source ?? null,
|
|
534
|
-
surface: request.surface ?? null,
|
|
535
|
-
value: request.value ?? null,
|
|
536
|
-
}),
|
|
537
|
-
);
|
|
538
|
-
}
|
|
539
|
-
decision = await this.requestPermissionDecisionFromUi(
|
|
540
|
-
ctx.ui,
|
|
541
|
-
"Permission Required (Subagent)",
|
|
542
|
-
forwardedMessage,
|
|
543
|
-
);
|
|
544
|
-
} catch (error) {
|
|
545
|
-
logPermissionForwardingError(
|
|
546
|
-
this.logger,
|
|
547
|
-
"Failed to show forwarded permission confirmation dialog",
|
|
548
|
-
error,
|
|
549
|
-
);
|
|
550
|
-
decision = { approved: false, state: "denied" };
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
555
|
-
this.logger.review(
|
|
556
|
-
decision.approved
|
|
557
|
-
? "forwarded_permission.approved"
|
|
558
|
-
: "forwarded_permission.denied",
|
|
559
|
-
{
|
|
560
|
-
requestId: request.id,
|
|
561
|
-
source: location.label,
|
|
562
|
-
requesterAgentName: request.requesterAgentName,
|
|
563
|
-
requesterSessionId: request.requesterSessionId,
|
|
564
|
-
targetSessionId: request.targetSessionId,
|
|
565
|
-
responsePath,
|
|
566
|
-
resolution: decision.state,
|
|
567
|
-
denialReason: decision.denialReason ?? null,
|
|
568
|
-
},
|
|
569
|
-
);
|
|
570
|
-
try {
|
|
571
|
-
writeJsonFileAtomic(this.logger, responsePath, {
|
|
572
|
-
approved: decision.approved,
|
|
573
|
-
state: decision.state,
|
|
574
|
-
denialReason: decision.denialReason,
|
|
575
|
-
responderSessionId: currentSessionId,
|
|
576
|
-
respondedAt: Date.now(),
|
|
577
|
-
} satisfies ForwardedPermissionResponse);
|
|
578
|
-
} catch (error) {
|
|
579
|
-
logPermissionForwardingError(
|
|
580
|
-
this.logger,
|
|
581
|
-
`Failed to write ${location.label} forwarded permission response '${responsePath}'`,
|
|
582
|
-
error,
|
|
583
|
-
);
|
|
584
|
-
return;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
safeDeleteFile(
|
|
588
|
-
this.logger,
|
|
589
|
-
requestPath,
|
|
590
|
-
`${location.label} forwarded permission request`,
|
|
591
|
-
);
|
|
592
|
-
}
|
|
593
327
|
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
type ForwarderContext,
|
|
4
|
+
getSessionId,
|
|
5
|
+
} from "#src/authority/forwarder-context";
|
|
6
|
+
import type { ConfigReader } from "#src/config-store";
|
|
7
|
+
import { isYoloModeEnabled } from "#src/extension-config";
|
|
8
|
+
import type {
|
|
9
|
+
PermissionDecisionUi,
|
|
10
|
+
PermissionPromptDecision,
|
|
11
|
+
RequestPermissionOptions,
|
|
12
|
+
} from "#src/permission-dialog";
|
|
13
|
+
import {
|
|
14
|
+
emitUiPromptEvent,
|
|
15
|
+
type PermissionEventBus,
|
|
16
|
+
} from "#src/permission-events";
|
|
17
|
+
import {
|
|
18
|
+
type ForwardedPermissionRequest,
|
|
19
|
+
type ForwardedPermissionResponse,
|
|
20
|
+
isForwardedPermissionRequestForSession,
|
|
21
|
+
type PermissionForwardingLocation,
|
|
22
|
+
} from "#src/permission-forwarding";
|
|
23
|
+
import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
|
|
24
|
+
import type { DebugReviewLogger } from "#src/session-logger";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
cleanupPermissionForwardingLocationIfEmpty,
|
|
28
|
+
ensureDirectoryExists,
|
|
29
|
+
getExistingPermissionForwardingLocation,
|
|
30
|
+
listRequestFiles,
|
|
31
|
+
logPermissionForwardingError,
|
|
32
|
+
logPermissionForwardingWarning,
|
|
33
|
+
readForwardedPermissionRequest,
|
|
34
|
+
safeDeleteFile,
|
|
35
|
+
writeJsonFileAtomic,
|
|
36
|
+
} from "./forwarding-io";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Narrow seam describing what `ForwardingManager` needs from the server: a
|
|
40
|
+
* single method that drains this session's forwarded-permission inbox.
|
|
41
|
+
*
|
|
42
|
+
* Depending on the interface (not the concrete `ForwardedRequestServer`)
|
|
43
|
+
* keeps the manager's unit tests free of casts — they inject a plain
|
|
44
|
+
* `{ processInbox: vi.fn() }` mock.
|
|
45
|
+
*/
|
|
46
|
+
export interface InboxProcessor {
|
|
47
|
+
processInbox(ctx: ForwarderContext): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Constructor config for `ForwardedRequestServer`. */
|
|
51
|
+
export interface ForwardedRequestServerDeps {
|
|
52
|
+
forwardingDir: string;
|
|
53
|
+
logger: DebugReviewLogger;
|
|
54
|
+
/** Event bus used for UI prompt broadcasts. */
|
|
55
|
+
events?: PermissionEventBus;
|
|
56
|
+
requestPermissionDecisionFromUi: (
|
|
57
|
+
ui: PermissionDecisionUi,
|
|
58
|
+
title: string,
|
|
59
|
+
message: string,
|
|
60
|
+
options?: RequestPermissionOptions,
|
|
61
|
+
) => Promise<PermissionPromptDecision>;
|
|
62
|
+
/** Read current config for the retained forwarded-inbox yolo auto-approve check. */
|
|
63
|
+
config: ConfigReader;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Module-private helpers ────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function formatForwardedPermissionPrompt(
|
|
69
|
+
request: ForwardedPermissionRequest,
|
|
70
|
+
): string {
|
|
71
|
+
const agentName = request.requesterAgentName || "unknown";
|
|
72
|
+
const sessionId = request.requesterSessionId || "unknown";
|
|
73
|
+
return [
|
|
74
|
+
`Subagent '${agentName}' requested permission.`,
|
|
75
|
+
`Session ID: ${sessionId}`,
|
|
76
|
+
"",
|
|
77
|
+
request.message,
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── ForwardedRequestServer ────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Owner of the serving-down role of the forwarded-permission behavior:
|
|
85
|
+
* draining this session's forwarded-permission inbox and answering each
|
|
86
|
+
* request (auto-approve under yolo, or prompt via the injected UI).
|
|
87
|
+
*/
|
|
88
|
+
export class ForwardedRequestServer implements InboxProcessor {
|
|
89
|
+
private readonly forwardingDir: string;
|
|
90
|
+
private readonly events: PermissionEventBus | undefined;
|
|
91
|
+
private readonly logger: DebugReviewLogger;
|
|
92
|
+
private readonly requestPermissionDecisionFromUi: (
|
|
93
|
+
ui: PermissionDecisionUi,
|
|
94
|
+
title: string,
|
|
95
|
+
message: string,
|
|
96
|
+
options?: RequestPermissionOptions,
|
|
97
|
+
) => Promise<PermissionPromptDecision>;
|
|
98
|
+
private readonly config: ConfigReader;
|
|
99
|
+
|
|
100
|
+
constructor(deps: ForwardedRequestServerDeps) {
|
|
101
|
+
this.forwardingDir = deps.forwardingDir;
|
|
102
|
+
this.events = deps.events;
|
|
103
|
+
this.logger = deps.logger;
|
|
104
|
+
this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
|
|
105
|
+
this.config = deps.config;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Drain and respond to this session's forwarded-permission inbox. */
|
|
109
|
+
async processInbox(ctx: ForwarderContext): Promise<void> {
|
|
110
|
+
if (!ctx.hasUI) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const currentSessionId = getSessionId(ctx);
|
|
115
|
+
const location = getExistingPermissionForwardingLocation(
|
|
116
|
+
this.forwardingDir,
|
|
117
|
+
currentSessionId,
|
|
118
|
+
);
|
|
119
|
+
if (!location) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const requestFiles = listRequestFiles(this.logger, location.requestsDir);
|
|
124
|
+
if (requestFiles.length === 0) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Defensively recreate responses/ before writing any response — a
|
|
129
|
+
// concurrent cleanup pass may have removed it between the requestsDir
|
|
130
|
+
// existence check above and the write inside processSingleForwardedRequest
|
|
131
|
+
// (the ENOENT write loop reported in issue #398).
|
|
132
|
+
if (
|
|
133
|
+
!ensureDirectoryExists(
|
|
134
|
+
this.logger,
|
|
135
|
+
location.responsesDir,
|
|
136
|
+
"permission forwarding responses",
|
|
137
|
+
)
|
|
138
|
+
) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const fileName of requestFiles) {
|
|
143
|
+
const requestPath = join(location.requestsDir, fileName);
|
|
144
|
+
const request = readForwardedPermissionRequest(this.logger, requestPath);
|
|
145
|
+
if (!request) {
|
|
146
|
+
safeDeleteFile(
|
|
147
|
+
this.logger,
|
|
148
|
+
requestPath,
|
|
149
|
+
`${location.label} forwarded permission request`,
|
|
150
|
+
);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await this.processSingleForwardedRequest(
|
|
155
|
+
ctx,
|
|
156
|
+
request,
|
|
157
|
+
location,
|
|
158
|
+
requestPath,
|
|
159
|
+
currentSessionId,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Private methods ────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
private async processSingleForwardedRequest(
|
|
169
|
+
ctx: ForwarderContext,
|
|
170
|
+
request: ForwardedPermissionRequest,
|
|
171
|
+
location: PermissionForwardingLocation,
|
|
172
|
+
requestPath: string,
|
|
173
|
+
currentSessionId: string,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
|
|
176
|
+
logPermissionForwardingWarning(
|
|
177
|
+
this.logger,
|
|
178
|
+
`Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
|
|
179
|
+
);
|
|
180
|
+
safeDeleteFile(
|
|
181
|
+
this.logger,
|
|
182
|
+
requestPath,
|
|
183
|
+
`${location.label} forwarded permission request`,
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const forwardedPermissionLogDetails = {
|
|
189
|
+
requestId: request.id,
|
|
190
|
+
source: location.label,
|
|
191
|
+
requesterAgentName: request.requesterAgentName,
|
|
192
|
+
requesterSessionId: request.requesterSessionId,
|
|
193
|
+
targetSessionId: request.targetSessionId,
|
|
194
|
+
requestPath,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
let decision: PermissionPromptDecision = {
|
|
198
|
+
approved: false,
|
|
199
|
+
state: "denied",
|
|
200
|
+
};
|
|
201
|
+
// Last yolo check outside the composed ruleset: dissolves when
|
|
202
|
+
// processInbox is refactored onto evaluate() + Authorizer selection in
|
|
203
|
+
// the Phase 9 spine work.
|
|
204
|
+
if (isYoloModeEnabled(this.config.current())) {
|
|
205
|
+
this.logger.review(
|
|
206
|
+
"forwarded_permission.auto_approved",
|
|
207
|
+
forwardedPermissionLogDetails,
|
|
208
|
+
);
|
|
209
|
+
decision = { approved: true, state: "approved" };
|
|
210
|
+
} else {
|
|
211
|
+
this.logger.review(
|
|
212
|
+
"forwarded_permission.prompted",
|
|
213
|
+
forwardedPermissionLogDetails,
|
|
214
|
+
);
|
|
215
|
+
try {
|
|
216
|
+
const forwardedMessage = formatForwardedPermissionPrompt(request);
|
|
217
|
+
if (this.events) {
|
|
218
|
+
emitUiPromptEvent(
|
|
219
|
+
this.events,
|
|
220
|
+
buildForwardedUiPrompt({
|
|
221
|
+
requestId: request.id,
|
|
222
|
+
message: forwardedMessage,
|
|
223
|
+
requesterAgentName: request.requesterAgentName || null,
|
|
224
|
+
requesterSessionId: request.requesterSessionId || null,
|
|
225
|
+
source: request.source ?? null,
|
|
226
|
+
surface: request.surface ?? null,
|
|
227
|
+
value: request.value ?? null,
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
decision = await this.requestPermissionDecisionFromUi(
|
|
232
|
+
ctx.ui,
|
|
233
|
+
"Permission Required (Subagent)",
|
|
234
|
+
forwardedMessage,
|
|
235
|
+
);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
logPermissionForwardingError(
|
|
238
|
+
this.logger,
|
|
239
|
+
"Failed to show forwarded permission confirmation dialog",
|
|
240
|
+
error,
|
|
241
|
+
);
|
|
242
|
+
decision = { approved: false, state: "denied" };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
247
|
+
this.logger.review(
|
|
248
|
+
decision.approved
|
|
249
|
+
? "forwarded_permission.approved"
|
|
250
|
+
: "forwarded_permission.denied",
|
|
251
|
+
{
|
|
252
|
+
requestId: request.id,
|
|
253
|
+
source: location.label,
|
|
254
|
+
requesterAgentName: request.requesterAgentName,
|
|
255
|
+
requesterSessionId: request.requesterSessionId,
|
|
256
|
+
targetSessionId: request.targetSessionId,
|
|
257
|
+
responsePath,
|
|
258
|
+
resolution: decision.state,
|
|
259
|
+
denialReason: decision.denialReason ?? null,
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
try {
|
|
263
|
+
writeJsonFileAtomic(this.logger, responsePath, {
|
|
264
|
+
approved: decision.approved,
|
|
265
|
+
state: decision.state,
|
|
266
|
+
denialReason: decision.denialReason,
|
|
267
|
+
responderSessionId: currentSessionId,
|
|
268
|
+
respondedAt: Date.now(),
|
|
269
|
+
} satisfies ForwardedPermissionResponse);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
logPermissionForwardingError(
|
|
272
|
+
this.logger,
|
|
273
|
+
`Failed to write ${location.label} forwarded permission response '${responsePath}'`,
|
|
274
|
+
error,
|
|
275
|
+
);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
safeDeleteFile(
|
|
280
|
+
this.logger,
|
|
281
|
+
requestPath,
|
|
282
|
+
`${location.label} forwarded permission request`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SessionEntryView } from "#src/active-agent";
|
|
2
|
+
import type { PermissionDecisionUi } from "#src/permission-dialog";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Narrow context the forwarding subsystem reads: the UI gate (`hasUI`), the
|
|
6
|
+
* dialog UI surface, and the three session-manager readers `getSessionId`
|
|
7
|
+
* and the `active-agent` helpers use.
|
|
8
|
+
*
|
|
9
|
+
* A full `ExtensionContext` satisfies this structurally, so production
|
|
10
|
+
* callers pass `ctx` unchanged.
|
|
11
|
+
*/
|
|
12
|
+
export interface ForwarderContext {
|
|
13
|
+
hasUI: boolean;
|
|
14
|
+
ui: PermissionDecisionUi;
|
|
15
|
+
sessionManager: {
|
|
16
|
+
getSessionId(): string;
|
|
17
|
+
getSessionDir(): string;
|
|
18
|
+
getEntries(): readonly SessionEntryView[];
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Reads the current session id off `ctx`, falling back to `"unknown"`. */
|
|
23
|
+
export function getSessionId(ctx: ForwarderContext): string {
|
|
24
|
+
try {
|
|
25
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
26
|
+
if (typeof sessionId === "string" && sessionId.trim()) {
|
|
27
|
+
return sessionId.trim();
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
|
|
31
|
+
return "unknown";
|
|
32
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
2
2
|
|
|
3
|
-
import { SUBAGENT_ENV_HINT_KEYS } from "
|
|
4
|
-
import type { SubagentSessionRegistry } from "
|
|
3
|
+
import { SUBAGENT_ENV_HINT_KEYS } from "#src/permission-forwarding";
|
|
4
|
+
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Narrow context for subagent detection — the only session-manager readers
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isRegisteredSubagentChild,
|
|
3
|
+
isSubagentExecutionContext,
|
|
4
|
+
type SubagentDetectionContext,
|
|
5
|
+
} from "#src/authority/subagent-context";
|
|
6
|
+
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Narrow seam for the ask-path consumers: "is the current session a subagent?"
|
|
10
|
+
*
|
|
11
|
+
* `PromptingGateway`, `ForwardingManager`, and `ApprovalEscalator` depend on
|
|
12
|
+
* this single-method view so their unit tests inject a one-field fake without
|
|
13
|
+
* casts. It is the Authorizer-selection predicate the Phase 9 spine consumes.
|
|
14
|
+
*/
|
|
15
|
+
export interface SubagentDetector {
|
|
16
|
+
isSubagent(ctx: SubagentDetectionContext): boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Narrow seam for the service-publication guard (#302): "is the current
|
|
21
|
+
* session a registered in-process child?"
|
|
22
|
+
*
|
|
23
|
+
* `PermissionServiceLifecycle` depends on this single-method view so a
|
|
24
|
+
* registered child never publishes over its parent's process-global slot.
|
|
25
|
+
*/
|
|
26
|
+
export interface RegisteredChildDetector {
|
|
27
|
+
isRegisteredChild(ctx: SubagentDetectionContext): boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Composition-root inputs for {@link SubagentDetection}. */
|
|
31
|
+
export interface SubagentDetectionDeps {
|
|
32
|
+
subagentSessionsDir: string;
|
|
33
|
+
platform: NodeJS.Platform;
|
|
34
|
+
registry?: SubagentSessionRegistry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Single owner of subagent detection.
|
|
39
|
+
*
|
|
40
|
+
* Constructed once in the composition root with the detection inputs
|
|
41
|
+
* (`subagentSessionsDir`, `platform`, `registry`) and shared across every
|
|
42
|
+
* consumer, replacing the dep triple those consumers previously threaded
|
|
43
|
+
* individually. Delegates to the pure detection functions in
|
|
44
|
+
* {@link ./subagent-context}, holding only the deps.
|
|
45
|
+
*/
|
|
46
|
+
export class SubagentDetection
|
|
47
|
+
implements SubagentDetector, RegisteredChildDetector
|
|
48
|
+
{
|
|
49
|
+
constructor(private readonly deps: SubagentDetectionDeps) {}
|
|
50
|
+
|
|
51
|
+
isSubagent(ctx: SubagentDetectionContext): boolean {
|
|
52
|
+
return isSubagentExecutionContext(
|
|
53
|
+
ctx,
|
|
54
|
+
this.deps.subagentSessionsDir,
|
|
55
|
+
this.deps.platform,
|
|
56
|
+
this.deps.registry,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isRegisteredChild(ctx: SubagentDetectionContext): boolean {
|
|
61
|
+
return this.deps.registry
|
|
62
|
+
? isRegisteredSubagentChild(ctx, this.deps.registry)
|
|
63
|
+
: false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
import type {
|
|
2
|
+
import type { InboxProcessor } from "./authority/forwarded-request-server";
|
|
3
|
+
import type { SubagentDetector } from "./authority/subagent-detection";
|
|
4
4
|
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
|
5
|
-
import { isSubagentExecutionContext } from "./subagent-context";
|
|
6
|
-
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
7
5
|
|
|
8
6
|
/**
|
|
9
7
|
* Narrow interface for the forwarding lifecycle used by `PermissionSession`.
|
|
@@ -28,10 +26,8 @@ export class ForwardingManager {
|
|
|
28
26
|
private processing = false;
|
|
29
27
|
|
|
30
28
|
constructor(
|
|
31
|
-
private readonly
|
|
29
|
+
private readonly detection: SubagentDetector,
|
|
32
30
|
private readonly forwarder: InboxProcessor,
|
|
33
|
-
private readonly platform: NodeJS.Platform,
|
|
34
|
-
private readonly registry?: SubagentSessionRegistry,
|
|
35
31
|
) {}
|
|
36
32
|
|
|
37
33
|
/**
|
|
@@ -41,15 +37,7 @@ export class ForwardingManager {
|
|
|
41
37
|
* Stops any existing poll when the context does not qualify for forwarding.
|
|
42
38
|
*/
|
|
43
39
|
start(ctx: ExtensionContext): void {
|
|
44
|
-
if (
|
|
45
|
-
!ctx.hasUI ||
|
|
46
|
-
isSubagentExecutionContext(
|
|
47
|
-
ctx,
|
|
48
|
-
this.subagentSessionsDir,
|
|
49
|
-
this.platform,
|
|
50
|
-
this.registry,
|
|
51
|
-
)
|
|
52
|
-
) {
|
|
40
|
+
if (!ctx.hasUI || this.detection.isSubagent(ctx)) {
|
|
53
41
|
this.stop();
|
|
54
42
|
return;
|
|
55
43
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
ApprovalEscalator,
|
|
5
|
+
type ApprovalEscalatorDeps,
|
|
6
|
+
} from "./authority/approval-escalator";
|
|
7
|
+
import {
|
|
8
|
+
ForwardedRequestServer,
|
|
9
|
+
type ForwardedRequestServerDeps,
|
|
10
|
+
} from "./authority/forwarded-request-server";
|
|
11
|
+
import { SubagentDetection } from "./authority/subagent-detection";
|
|
3
12
|
import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
|
|
4
13
|
import { registerPermissionSystemCommand } from "./config-modal";
|
|
5
14
|
import { getGlobalConfigPath } from "./config-paths";
|
|
@@ -8,10 +17,6 @@ import { DecisionAudit } from "./decision-audit";
|
|
|
8
17
|
import { GateDecisionReporter } from "./decision-reporter";
|
|
9
18
|
import { isYoloModeEnabled } from "./extension-config";
|
|
10
19
|
import { computeExtensionPaths } from "./extension-paths";
|
|
11
|
-
import {
|
|
12
|
-
PermissionForwarder,
|
|
13
|
-
type PermissionForwarderDeps,
|
|
14
|
-
} from "./forwarded-permissions/permission-forwarder";
|
|
15
20
|
import { ForwardingManager } from "./forwarding-manager";
|
|
16
21
|
import {
|
|
17
22
|
AgentPrepHandler,
|
|
@@ -49,6 +54,13 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
49
54
|
const hostPlatform = process.platform;
|
|
50
55
|
const sessionRules = new SessionRules();
|
|
51
56
|
const subagentRegistry = getSubagentSessionRegistry();
|
|
57
|
+
// Single owner of subagent detection, shared across every consumer instead of
|
|
58
|
+
// threading the (subagentSessionsDir, platform, registry) triple into each.
|
|
59
|
+
const subagentDetection = new SubagentDetection({
|
|
60
|
+
subagentSessionsDir: paths.subagentSessionsDir,
|
|
61
|
+
platform: hostPlatform,
|
|
62
|
+
registry: subagentRegistry,
|
|
63
|
+
});
|
|
52
64
|
const formatterRegistry = new ToolInputFormatterRegistry();
|
|
53
65
|
registerBuiltinToolInputFormatters(formatterRegistry);
|
|
54
66
|
const accessExtractorRegistry = new ToolAccessExtractorRegistry();
|
|
@@ -83,39 +95,38 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
83
95
|
logger,
|
|
84
96
|
});
|
|
85
97
|
|
|
86
|
-
const
|
|
98
|
+
const escalatorDeps: ApprovalEscalatorDeps = {
|
|
87
99
|
forwardingDir: paths.forwardingDir,
|
|
88
|
-
|
|
89
|
-
platform: hostPlatform,
|
|
100
|
+
detection: subagentDetection,
|
|
90
101
|
registry: subagentRegistry,
|
|
91
|
-
events: pi.events,
|
|
92
102
|
logger,
|
|
93
103
|
requestPermissionDecisionFromUi,
|
|
104
|
+
};
|
|
105
|
+
const escalator = new ApprovalEscalator(escalatorDeps);
|
|
106
|
+
|
|
107
|
+
const requestServerDeps: ForwardedRequestServerDeps = {
|
|
108
|
+
forwardingDir: paths.forwardingDir,
|
|
109
|
+
logger,
|
|
110
|
+
events: pi.events,
|
|
111
|
+
requestPermissionDecisionFromUi,
|
|
94
112
|
config: configStore,
|
|
95
113
|
};
|
|
96
|
-
const
|
|
114
|
+
const requestServer = new ForwardedRequestServer(requestServerDeps);
|
|
97
115
|
|
|
98
116
|
const prompter = new PermissionPrompter({
|
|
99
117
|
logger,
|
|
100
118
|
events: pi.events,
|
|
101
|
-
forwarder,
|
|
119
|
+
forwarder: escalator,
|
|
102
120
|
});
|
|
103
121
|
|
|
104
122
|
const gateway = new PromptingGateway({
|
|
105
|
-
|
|
106
|
-
platform: hostPlatform,
|
|
107
|
-
registry: subagentRegistry,
|
|
123
|
+
detection: subagentDetection,
|
|
108
124
|
prompter,
|
|
109
125
|
});
|
|
110
126
|
|
|
111
127
|
session = new PermissionSession(
|
|
112
128
|
paths,
|
|
113
|
-
new ForwardingManager(
|
|
114
|
-
paths.subagentSessionsDir,
|
|
115
|
-
forwarder,
|
|
116
|
-
hostPlatform,
|
|
117
|
-
subagentRegistry,
|
|
118
|
-
),
|
|
129
|
+
new ForwardingManager(subagentDetection, requestServer),
|
|
119
130
|
permissionManager,
|
|
120
131
|
sessionRules,
|
|
121
132
|
configStore,
|
|
@@ -171,7 +182,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
171
182
|
// requires the session id from ctx, unavailable at factory-init time.
|
|
172
183
|
const serviceLifecycle = new PermissionServiceLifecycle(
|
|
173
184
|
permissionsService,
|
|
174
|
-
|
|
185
|
+
subagentDetection,
|
|
175
186
|
pi.events,
|
|
176
187
|
[rpcHandles.unsubCheck, rpcHandles.unsubPrompt, unsubSubagentLifecycle],
|
|
177
188
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { ApprovalRequester } from "./
|
|
2
|
+
import type { ApprovalRequester } from "./authority/approval-escalator";
|
|
3
3
|
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
4
4
|
import {
|
|
5
5
|
emitUiPromptEvent,
|
package/src/prompting-gateway.ts
CHANGED
|
@@ -1,28 +1,22 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
2
|
+
import type { SubagentDetector } from "./authority/subagent-detection";
|
|
3
3
|
import type { GatePrompter } from "./gate-prompter";
|
|
4
4
|
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
5
5
|
import type {
|
|
6
6
|
PermissionPrompterApi,
|
|
7
7
|
PromptPermissionDetails,
|
|
8
8
|
} from "./permission-prompter";
|
|
9
|
-
import { isSubagentExecutionContext } from "./subagent-context";
|
|
10
|
-
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
11
9
|
|
|
12
10
|
/**
|
|
13
11
|
* Dependencies required by PromptingGateway.
|
|
14
12
|
*
|
|
15
13
|
* All fields are actively consumed:
|
|
16
|
-
* - `
|
|
14
|
+
* - `detection` drives `canConfirm()`.
|
|
17
15
|
* - `prompter` is called by `prompt()`.
|
|
18
16
|
*/
|
|
19
17
|
export interface PromptingGatewayDeps {
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
/** Host platform, injected from the composition root, for subagent-context path detection. */
|
|
23
|
-
platform: NodeJS.Platform;
|
|
24
|
-
/** Process-global registry used to detect a registered child session. */
|
|
25
|
-
registry?: SubagentSessionRegistry;
|
|
18
|
+
/** Single owner of subagent detection; drives `canConfirm()`. */
|
|
19
|
+
detection: SubagentDetector;
|
|
26
20
|
/** Resolves the permission decision: direct UI dialog or forwarded to parent. */
|
|
27
21
|
prompter: PermissionPrompterApi;
|
|
28
22
|
}
|
|
@@ -75,15 +69,7 @@ export class PromptingGateway
|
|
|
75
69
|
*/
|
|
76
70
|
canConfirm(): boolean {
|
|
77
71
|
if (this.context === null) return false;
|
|
78
|
-
return (
|
|
79
|
-
this.context.hasUI ||
|
|
80
|
-
isSubagentExecutionContext(
|
|
81
|
-
this.context,
|
|
82
|
-
this.deps.subagentSessionsDir,
|
|
83
|
-
this.deps.platform,
|
|
84
|
-
this.deps.registry,
|
|
85
|
-
)
|
|
86
|
-
);
|
|
72
|
+
return this.context.hasUI || this.deps.detection.isSubagent(this.context);
|
|
87
73
|
}
|
|
88
74
|
|
|
89
75
|
/**
|
package/src/service-lifecycle.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
2
|
+
import type { RegisteredChildDetector } from "./authority/subagent-detection";
|
|
3
3
|
import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
|
|
4
4
|
import {
|
|
5
5
|
type PermissionsService,
|
|
6
6
|
publishPermissionsService,
|
|
7
7
|
unpublishPermissionsService,
|
|
8
8
|
} from "./service";
|
|
9
|
-
import { isRegisteredSubagentChild } from "./subagent-context";
|
|
10
|
-
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
11
9
|
|
|
12
10
|
/** The session-scoped service lifecycle that the lifecycle handler drives. */
|
|
13
11
|
export interface ServiceLifecycle {
|
|
@@ -28,13 +26,13 @@ export interface ServiceLifecycle {
|
|
|
28
26
|
export class PermissionServiceLifecycle implements ServiceLifecycle {
|
|
29
27
|
constructor(
|
|
30
28
|
private readonly service: PermissionsService,
|
|
31
|
-
private readonly
|
|
29
|
+
private readonly detection: RegisteredChildDetector,
|
|
32
30
|
private readonly events: PermissionEventBus,
|
|
33
31
|
private readonly subscriptions: readonly (() => void)[],
|
|
34
32
|
) {}
|
|
35
33
|
|
|
36
34
|
activate(ctx: ExtensionContext): void {
|
|
37
|
-
if (!
|
|
35
|
+
if (!this.detection.isRegisteredChild(ctx)) {
|
|
38
36
|
publishPermissionsService(this.service);
|
|
39
37
|
}
|
|
40
38
|
emitReadyEvent(this.events);
|
package/src/session-logger.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface ReviewLogger {
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Logging seam for consumers that write both debug and review entries.
|
|
22
|
-
* Injected into `ConfigStore` and `
|
|
22
|
+
* Injected into `ConfigStore`, `ApprovalEscalator`, and `ForwardedRequestServer`.
|
|
23
23
|
*/
|
|
24
24
|
export interface DebugReviewLogger extends ReviewLogger {
|
|
25
25
|
debug(event: string, details?: Record<string, unknown>): void;
|
|
File without changes
|