@erichll/pi-auto-review 0.1.1 → 0.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/README.md +23 -8
- package/config/config.example.json +2 -1
- package/package.json +1 -1
- package/src/config.json +2 -1
- package/src/index.ts +65 -1
- package/src/ui-auto-confirm.ts +323 -0
package/README.md
CHANGED
|
@@ -20,14 +20,24 @@ The broker applies the following order:
|
|
|
20
20
|
|
|
21
21
|
1. Deterministic hard denies
|
|
22
22
|
2. Model review
|
|
23
|
-
3.
|
|
23
|
+
3. Local terminal when the authorizer chain requires one
|
|
24
24
|
4. Exact, expiring, one-use grants for external sandbox adapters
|
|
25
25
|
|
|
26
26
|
Model approval cannot override `pi-permission-system`'s bounded-delegation
|
|
27
27
|
checkpoint. In version 24, an authorizer's `allow` on `path` or
|
|
28
|
-
`external_directory` is still downgraded to `defer
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
`external_directory` is still downgraded to `defer`. In an interactive v24 TUI,
|
|
29
|
+
`autoConfirmBoundedAllows` can bind that exact model allow to the immediately
|
|
30
|
+
following local permission dialog and complete it as `auto_approved`. The
|
|
31
|
+
default enables this bridge only for `external_directory`; `path`, non-TUI
|
|
32
|
+
modes, UI mismatches, and reviewer defers still reach the human terminal.
|
|
33
|
+
Sandbox adapters should use concrete surfaces such as `filesystem-read`,
|
|
34
|
+
`filesystem-write`, and `network`.
|
|
35
|
+
|
|
36
|
+
The TUI bridge is request-ID-bound, expires after ten seconds, is consumed
|
|
37
|
+
once, and recognizes the v24 inline permission component before completing it.
|
|
38
|
+
Any event ordering, request, mode, component, or shared-UI wrapper mismatch
|
|
39
|
+
fails closed to the original dialog. This is a compatibility bridge around the
|
|
40
|
+
v24 delegation envelope, not permission-system authority-chain API.
|
|
31
41
|
|
|
32
42
|
The broker stops automatic review for a turn after three consecutive denials
|
|
33
43
|
or ten denials in the rolling last fifty reviews. An explicit denial is
|
|
@@ -67,13 +77,17 @@ Edit `src/config.json`, using `config/config.example.json` as the reference:
|
|
|
67
77
|
"maxToolTranscriptTokens": 1200,
|
|
68
78
|
"maxRelevantResultTokens": 800,
|
|
69
79
|
"failureMode": "deny",
|
|
70
|
-
"grantTtlMs": 60000
|
|
80
|
+
"grantTtlMs": 60000,
|
|
81
|
+
"autoConfirmBoundedAllows": ["external_directory"]
|
|
71
82
|
}
|
|
72
83
|
```
|
|
73
84
|
|
|
74
85
|
The configured provider must be registered in Pi and must support the
|
|
75
86
|
configured model. `failureMode: "deny"` is the fail-closed default;
|
|
76
87
|
`failureMode: "defer"` falls through to the normal human terminal.
|
|
88
|
+
Set `autoConfirmBoundedAllows` to `[]` to keep every bounded allow manual, or
|
|
89
|
+
include `"path"` as well to opt into the same TUI bridge for the cross-cutting
|
|
90
|
+
path surface.
|
|
77
91
|
|
|
78
92
|
Add the link to the permission-system configuration:
|
|
79
93
|
|
|
@@ -189,9 +203,10 @@ PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV=1 pi --approve
|
|
|
189
203
|
The trusted package configuration is copied and frozen at `session_start`.
|
|
190
204
|
An optional workspace file at `.pi/pi-auto-review.json` may only lower
|
|
191
205
|
timeouts, token/evidence limits, retries, and grant TTL, or set `failureMode`
|
|
192
|
-
to `deny`. It
|
|
193
|
-
|
|
194
|
-
|
|
206
|
+
to `deny`. It may remove entries from `autoConfirmBoundedAllows`, but cannot
|
|
207
|
+
add a surface not enabled by the trusted package configuration. It cannot
|
|
208
|
+
select a model, weaken fail-closed behavior, or raise a trusted limit. Invalid
|
|
209
|
+
project configuration disables the reviewer for that session.
|
|
195
210
|
|
|
196
211
|
Writes to the installed reviewer package, project security configuration,
|
|
197
212
|
global Pi security configuration, and the global audit directory are
|
package/package.json
CHANGED
package/src/config.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type BoundaryReview,
|
|
34
34
|
type BoundaryReviewerContext,
|
|
35
35
|
} from "./broker/index.ts";
|
|
36
|
+
import { PermissionUiAutoConfirmer } from "./ui-auto-confirm.ts";
|
|
36
37
|
|
|
37
38
|
export { parseDecision } from "./policy.ts";
|
|
38
39
|
export * from "./broker/index.ts";
|
|
@@ -53,6 +54,8 @@ type ReasoningLevel =
|
|
|
53
54
|
| "xhigh"
|
|
54
55
|
| "max";
|
|
55
56
|
|
|
57
|
+
type BoundedSurface = "external_directory" | "path";
|
|
58
|
+
|
|
56
59
|
export type Config = {
|
|
57
60
|
model: string;
|
|
58
61
|
reasoning: ReasoningLevel;
|
|
@@ -64,6 +67,7 @@ export type Config = {
|
|
|
64
67
|
maxRelevantResultTokens: number;
|
|
65
68
|
failureMode: "deny" | "defer";
|
|
66
69
|
grantTtlMs: number;
|
|
70
|
+
autoConfirmBoundedAllows: readonly BoundedSurface[];
|
|
67
71
|
};
|
|
68
72
|
|
|
69
73
|
type CompletionMessage = {
|
|
@@ -109,6 +113,7 @@ const DEFAULT_CONFIG: Config = {
|
|
|
109
113
|
maxRelevantResultTokens: 800,
|
|
110
114
|
failureMode: "deny",
|
|
111
115
|
grantTtlMs: 60_000,
|
|
116
|
+
autoConfirmBoundedAllows: Object.freeze(["external_directory"]),
|
|
112
117
|
};
|
|
113
118
|
|
|
114
119
|
const REVIEWER_SYSTEM_PROMPT = `You are a fail-closed permission reviewer.
|
|
@@ -163,6 +168,7 @@ function validateConfig(value: unknown, source: string): Config {
|
|
|
163
168
|
"maxRelevantResultTokens",
|
|
164
169
|
"failureMode",
|
|
165
170
|
"grantTtlMs",
|
|
171
|
+
"autoConfirmBoundedAllows",
|
|
166
172
|
]);
|
|
167
173
|
const unknownKeys = Object.keys(raw).filter((key) => !allowedKeys.has(key));
|
|
168
174
|
if (unknownKeys.length > 0) {
|
|
@@ -212,6 +218,18 @@ function validateConfig(value: unknown, source: string): Config {
|
|
|
212
218
|
) {
|
|
213
219
|
throw new Error(`${EXTENSION_NAME}: grantTtlMs must be 1000..300000`);
|
|
214
220
|
}
|
|
221
|
+
if (
|
|
222
|
+
!Array.isArray(config.autoConfirmBoundedAllows) ||
|
|
223
|
+
config.autoConfirmBoundedAllows.some(
|
|
224
|
+
(surface) => !BOUNDED_SURFACES.has(surface),
|
|
225
|
+
) ||
|
|
226
|
+
new Set(config.autoConfirmBoundedAllows).size !==
|
|
227
|
+
config.autoConfirmBoundedAllows.length
|
|
228
|
+
) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`${EXTENSION_NAME}: autoConfirmBoundedAllows must contain unique external_directory/path entries`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
215
233
|
for (const [name, entry] of [
|
|
216
234
|
["maxUserTranscriptTokens", config.maxUserTranscriptTokens],
|
|
217
235
|
["maxToolTranscriptTokens", config.maxToolTranscriptTokens],
|
|
@@ -221,7 +239,12 @@ function validateConfig(value: unknown, source: string): Config {
|
|
|
221
239
|
throw new Error(`${EXTENSION_NAME}: ${name} must be 32..8000`);
|
|
222
240
|
}
|
|
223
241
|
}
|
|
224
|
-
return
|
|
242
|
+
return {
|
|
243
|
+
...config,
|
|
244
|
+
autoConfirmBoundedAllows: Object.freeze([
|
|
245
|
+
...config.autoConfirmBoundedAllows,
|
|
246
|
+
]),
|
|
247
|
+
};
|
|
225
248
|
}
|
|
226
249
|
|
|
227
250
|
export function loadConfig(): Config {
|
|
@@ -260,6 +283,7 @@ export function applyProjectConfig(
|
|
|
260
283
|
const allowed = new Set<string>([
|
|
261
284
|
...TIGHTENABLE_NUMBER_KEYS,
|
|
262
285
|
"failureMode",
|
|
286
|
+
"autoConfirmBoundedAllows",
|
|
263
287
|
]);
|
|
264
288
|
const forbidden = Object.keys(raw).filter((key) => !allowed.has(key));
|
|
265
289
|
if (forbidden.length > 0) {
|
|
@@ -289,6 +313,25 @@ export function applyProjectConfig(
|
|
|
289
313
|
}
|
|
290
314
|
merged.failureMode = "deny";
|
|
291
315
|
}
|
|
316
|
+
if (raw.autoConfirmBoundedAllows !== undefined) {
|
|
317
|
+
if (
|
|
318
|
+
!Array.isArray(raw.autoConfirmBoundedAllows) ||
|
|
319
|
+
raw.autoConfirmBoundedAllows.some(
|
|
320
|
+
(surface) =>
|
|
321
|
+
typeof surface !== "string" ||
|
|
322
|
+
!trusted.autoConfirmBoundedAllows.includes(
|
|
323
|
+
surface as BoundedSurface,
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`${EXTENSION_NAME}: project autoConfirmBoundedAllows may only remove trusted surfaces`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
merged.autoConfirmBoundedAllows = [
|
|
332
|
+
...new Set(raw.autoConfirmBoundedAllows as BoundedSurface[]),
|
|
333
|
+
];
|
|
334
|
+
}
|
|
292
335
|
return Object.freeze(validateConfig(merged, "effective project config"));
|
|
293
336
|
}
|
|
294
337
|
|
|
@@ -376,6 +419,12 @@ function sessionConfig(
|
|
|
376
419
|
}
|
|
377
420
|
|
|
378
421
|
function surfaceOf(details: PromptPermissionDetails): string {
|
|
422
|
+
if (
|
|
423
|
+
typeof details.accessIntent?.surface === "string" &&
|
|
424
|
+
details.accessIntent.surface
|
|
425
|
+
) {
|
|
426
|
+
return details.accessIntent.surface;
|
|
427
|
+
}
|
|
379
428
|
if (typeof details.surface === "string" && details.surface) {
|
|
380
429
|
return details.surface;
|
|
381
430
|
}
|
|
@@ -721,6 +770,9 @@ export function createPiAutoReviewExtension(
|
|
|
721
770
|
let disposeBrokerService: (() => void) | undefined;
|
|
722
771
|
const reviewResults = new Map<string, ReviewResult>();
|
|
723
772
|
let broker: BoundaryApprovalBroker | undefined;
|
|
773
|
+
const uiAutoConfirmer = new PermissionUiAutoConfirmer(
|
|
774
|
+
() => config.autoConfirmBoundedAllows,
|
|
775
|
+
);
|
|
724
776
|
|
|
725
777
|
const createBroker = (): BoundaryApprovalBroker =>
|
|
726
778
|
new BoundaryApprovalBroker({
|
|
@@ -831,6 +883,7 @@ export function createPiAutoReviewExtension(
|
|
|
831
883
|
disposeBrokerService?.();
|
|
832
884
|
broker?.clear();
|
|
833
885
|
reviewResults.clear();
|
|
886
|
+
uiAutoConfirmer.clear();
|
|
834
887
|
try {
|
|
835
888
|
config = sessionConfig(
|
|
836
889
|
ctx.cwd,
|
|
@@ -852,6 +905,10 @@ export function createPiAutoReviewExtension(
|
|
|
852
905
|
}
|
|
853
906
|
});
|
|
854
907
|
|
|
908
|
+
pi.events.on("permissions:ui_prompt", (event) => {
|
|
909
|
+
if (context) uiAutoConfirmer.handlePrompt(event, context);
|
|
910
|
+
});
|
|
911
|
+
|
|
855
912
|
pi.events.on("permissions:ready", () => {
|
|
856
913
|
try {
|
|
857
914
|
const service = permissionsService();
|
|
@@ -880,6 +937,11 @@ export function createPiAutoReviewExtension(
|
|
|
880
937
|
const result = reviewResults.get(request.id);
|
|
881
938
|
reviewResults.delete(request.id);
|
|
882
939
|
const allowCapped = decision.kind === "allow" && boundedRequest(details);
|
|
940
|
+
const autoConfirmQueued =
|
|
941
|
+
allowCapped &&
|
|
942
|
+
context.mode === "tui" &&
|
|
943
|
+
context.hasUI &&
|
|
944
|
+
uiAutoConfirmer.stage(request.id, surface);
|
|
883
945
|
log.review("pi_auto_review_decision", {
|
|
884
946
|
requestId: request.id,
|
|
885
947
|
surface,
|
|
@@ -890,6 +952,7 @@ export function createPiAutoReviewExtension(
|
|
|
890
952
|
userAuthorization: decision.review.userAuthorization,
|
|
891
953
|
rationale: decision.review.rationale,
|
|
892
954
|
allowCapped,
|
|
955
|
+
autoConfirmQueued,
|
|
893
956
|
circuitBreakerTripped:
|
|
894
957
|
decision.kind === "deny"
|
|
895
958
|
? decision.circuitBreakerTripped
|
|
@@ -931,6 +994,7 @@ export function createPiAutoReviewExtension(
|
|
|
931
994
|
broker?.clear();
|
|
932
995
|
broker = undefined;
|
|
933
996
|
reviewResults.clear();
|
|
997
|
+
uiAutoConfirmer.clear();
|
|
934
998
|
context = undefined;
|
|
935
999
|
});
|
|
936
1000
|
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionContext,
|
|
3
|
+
ExtensionUIContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
type UiCustom = ExtensionUIContext["custom"];
|
|
7
|
+
|
|
8
|
+
type PermissionUiPromptEvent = {
|
|
9
|
+
requestId: string;
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type PendingApproval = {
|
|
14
|
+
surface: string;
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type PromptAttempt = {
|
|
19
|
+
controller: AbortController;
|
|
20
|
+
settled: boolean;
|
|
21
|
+
recognized: boolean;
|
|
22
|
+
autoApprove?: () => void;
|
|
23
|
+
denyForUiConflict?: () => void;
|
|
24
|
+
restore?: () => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const AUTO_APPROVED_DECISION = {
|
|
28
|
+
approved: true,
|
|
29
|
+
state: "approved",
|
|
30
|
+
autoApproved: true,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
const UI_CONFLICT_DENIED_DECISION = {
|
|
34
|
+
approved: false,
|
|
35
|
+
state: "denied",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
const DEFAULT_PENDING_TTL_MS = 10_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Bridges a model allow that the permission-system delegation envelope capped
|
|
42
|
+
* to defer into the immediately following local TUI terminal.
|
|
43
|
+
*
|
|
44
|
+
* The bridge is deliberately request-bound and one-shot. Any timing, event, UI,
|
|
45
|
+
* component, or wrapper mismatch leaves the normal human dialog in control.
|
|
46
|
+
*/
|
|
47
|
+
export class PermissionUiAutoConfirmer {
|
|
48
|
+
private readonly pending = new Map<string, PendingApproval>();
|
|
49
|
+
private activeAttempt: PromptAttempt | undefined;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly enabledSurfaces: () => readonly string[],
|
|
53
|
+
private readonly pendingTtlMs = DEFAULT_PENDING_TTL_MS,
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
stage(requestId: string, surface: string): boolean {
|
|
57
|
+
this.pruneExpired();
|
|
58
|
+
if (!this.enabledSurfaces().includes(surface)) return false;
|
|
59
|
+
this.pending.set(requestId, {
|
|
60
|
+
surface,
|
|
61
|
+
expiresAt: Date.now() + this.pendingTtlMs,
|
|
62
|
+
});
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
handlePrompt(raw: unknown, ctx: ExtensionContext): void {
|
|
67
|
+
const event = parsePermissionUiPromptEvent(raw);
|
|
68
|
+
if (!event || ctx.mode !== "tui" || !ctx.hasUI) return;
|
|
69
|
+
|
|
70
|
+
this.pruneExpired();
|
|
71
|
+
const pending = this.pending.get(event.requestId);
|
|
72
|
+
if (!pending) {
|
|
73
|
+
// A staged allow is valid only for the immediately following prompt.
|
|
74
|
+
// Never leave it latent after a different request reaches the terminal.
|
|
75
|
+
this.pending.clear();
|
|
76
|
+
this.settleActiveForConflict();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.pending.delete(event.requestId);
|
|
80
|
+
|
|
81
|
+
if (!this.enabledSurfaces().includes(pending.surface)) return;
|
|
82
|
+
this.settleActiveForConflict();
|
|
83
|
+
|
|
84
|
+
const attempt: PromptAttempt = {
|
|
85
|
+
controller: new AbortController(),
|
|
86
|
+
settled: false,
|
|
87
|
+
recognized: false,
|
|
88
|
+
};
|
|
89
|
+
this.activeAttempt = attempt;
|
|
90
|
+
if (!this.installOneShotInterceptor(ctx.ui, event, attempt)) {
|
|
91
|
+
attempt.controller.abort();
|
|
92
|
+
attempt.settled = true;
|
|
93
|
+
this.activeAttempt = undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
clear(): void {
|
|
98
|
+
this.pending.clear();
|
|
99
|
+
const attempt = this.activeAttempt;
|
|
100
|
+
this.activeAttempt = undefined;
|
|
101
|
+
if (!attempt) return;
|
|
102
|
+
attempt.restore?.();
|
|
103
|
+
attempt.controller.abort();
|
|
104
|
+
attempt.settled = true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private installOneShotInterceptor(
|
|
108
|
+
ui: ExtensionUIContext,
|
|
109
|
+
event: PermissionUiPromptEvent,
|
|
110
|
+
attempt: PromptAttempt,
|
|
111
|
+
): boolean {
|
|
112
|
+
const previousCustom = ui.custom;
|
|
113
|
+
let invoked = false;
|
|
114
|
+
let wrapper: UiCustom;
|
|
115
|
+
|
|
116
|
+
const release = () => {
|
|
117
|
+
if (this.activeAttempt === attempt) this.activeAttempt = undefined;
|
|
118
|
+
};
|
|
119
|
+
const restore = () => {
|
|
120
|
+
try {
|
|
121
|
+
if (ui.custom === wrapper) ui.custom = previousCustom;
|
|
122
|
+
} catch {
|
|
123
|
+
// A shared-UI wrapper conflict degrades to the normal human dialog.
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
wrapper = ((factory: (...args: any[]) => any, options?: any) => {
|
|
128
|
+
if (invoked) {
|
|
129
|
+
return invokeUiCustom(previousCustom, ui, factory, options);
|
|
130
|
+
}
|
|
131
|
+
invoked = true;
|
|
132
|
+
restore();
|
|
133
|
+
|
|
134
|
+
if (options?.overlay !== false) {
|
|
135
|
+
this.releaseUnrecognized(attempt, release);
|
|
136
|
+
return invokeUiCustom(previousCustom, ui, factory, options);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const wrappedFactory = (
|
|
140
|
+
tui: unknown,
|
|
141
|
+
theme: unknown,
|
|
142
|
+
keybindings: unknown,
|
|
143
|
+
done: (result: unknown) => void,
|
|
144
|
+
) => {
|
|
145
|
+
let uiFinished = false;
|
|
146
|
+
const finish = (result: unknown) => {
|
|
147
|
+
if (uiFinished) return;
|
|
148
|
+
uiFinished = true;
|
|
149
|
+
if (!attempt.settled) {
|
|
150
|
+
attempt.settled = true;
|
|
151
|
+
attempt.controller.abort();
|
|
152
|
+
release();
|
|
153
|
+
}
|
|
154
|
+
done(result);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
attempt.autoApprove = () => finish(AUTO_APPROVED_DECISION);
|
|
158
|
+
attempt.denyForUiConflict = () => finish(UI_CONFLICT_DENIED_DECISION);
|
|
159
|
+
|
|
160
|
+
let produced: unknown;
|
|
161
|
+
try {
|
|
162
|
+
produced = factory(tui, theme, keybindings, finish);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
this.releaseUnrecognized(attempt, release);
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isPromiseLike(produced)) {
|
|
169
|
+
return Promise.resolve(produced).then(
|
|
170
|
+
(component) =>
|
|
171
|
+
this.recognizeComponent(component, event, attempt, release),
|
|
172
|
+
(error) => {
|
|
173
|
+
this.releaseUnrecognized(attempt, release);
|
|
174
|
+
throw error;
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return this.recognizeComponent(produced, event, attempt, release);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return invokeUiCustom(previousCustom, ui, wrappedFactory, options);
|
|
182
|
+
}) as UiCustom;
|
|
183
|
+
|
|
184
|
+
attempt.restore = restore;
|
|
185
|
+
try {
|
|
186
|
+
ui.custom = wrapper;
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// v24 opens the permission component synchronously after the event. Never
|
|
192
|
+
// leave a wrapper installed long enough to catch an unrelated custom UI.
|
|
193
|
+
queueMicrotask(() => {
|
|
194
|
+
if (invoked) return;
|
|
195
|
+
restore();
|
|
196
|
+
this.releaseUnrecognized(attempt, release);
|
|
197
|
+
});
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private recognizeComponent(
|
|
202
|
+
component: unknown,
|
|
203
|
+
event: PermissionUiPromptEvent,
|
|
204
|
+
attempt: PromptAttempt,
|
|
205
|
+
release: () => void,
|
|
206
|
+
): unknown {
|
|
207
|
+
if (!isPermissionPromptComponent(component, event)) {
|
|
208
|
+
this.releaseUnrecognized(attempt, release);
|
|
209
|
+
return component;
|
|
210
|
+
}
|
|
211
|
+
attempt.recognized = true;
|
|
212
|
+
queueMicrotask(() => {
|
|
213
|
+
try {
|
|
214
|
+
if (!attempt.settled && attempt.recognized) {
|
|
215
|
+
attempt.autoApprove?.();
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
attempt.controller.abort();
|
|
219
|
+
attempt.settled = true;
|
|
220
|
+
release();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return component;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private releaseUnrecognized(
|
|
227
|
+
attempt: PromptAttempt,
|
|
228
|
+
release: () => void,
|
|
229
|
+
): void {
|
|
230
|
+
if (attempt.recognized || attempt.settled) return;
|
|
231
|
+
attempt.restore?.();
|
|
232
|
+
attempt.controller.abort();
|
|
233
|
+
attempt.settled = true;
|
|
234
|
+
release();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private settleActiveForConflict(): void {
|
|
238
|
+
const attempt = this.activeAttempt;
|
|
239
|
+
if (!attempt || attempt.settled) return;
|
|
240
|
+
try {
|
|
241
|
+
attempt.denyForUiConflict?.();
|
|
242
|
+
} catch {
|
|
243
|
+
attempt.controller.abort();
|
|
244
|
+
attempt.settled = true;
|
|
245
|
+
this.activeAttempt = undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private pruneExpired(): void {
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
for (const [requestId, pending] of this.pending) {
|
|
252
|
+
if (pending.expiresAt < now) this.pending.delete(requestId);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parsePermissionUiPromptEvent(
|
|
258
|
+
raw: unknown,
|
|
259
|
+
): PermissionUiPromptEvent | undefined {
|
|
260
|
+
if (!isRecord(raw)) return;
|
|
261
|
+
if (
|
|
262
|
+
typeof raw.requestId !== "string" ||
|
|
263
|
+
typeof raw.message !== "string"
|
|
264
|
+
) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
return { requestId: raw.requestId, message: raw.message };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isPermissionPromptComponent(
|
|
271
|
+
value: unknown,
|
|
272
|
+
event: PermissionUiPromptEvent,
|
|
273
|
+
): boolean {
|
|
274
|
+
if (!isRecord(value) || typeof value.render !== "function") return false;
|
|
275
|
+
const constructorName =
|
|
276
|
+
typeof value.constructor === "function" ? value.constructor.name : "";
|
|
277
|
+
if (constructorName !== "PermissionPromptComponent") return false;
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const rendered = value.render(2_000);
|
|
281
|
+
if (!Array.isArray(rendered)) return false;
|
|
282
|
+
const plain = normalizeWhitespace(stripAnsi(rendered.join("\n")));
|
|
283
|
+
const messagePrefix = normalizeWhitespace(event.message).slice(0, 120);
|
|
284
|
+
return (
|
|
285
|
+
plain.includes("Permission Required") &&
|
|
286
|
+
messagePrefix.length > 0 &&
|
|
287
|
+
plain.includes(messagePrefix)
|
|
288
|
+
);
|
|
289
|
+
} catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function invokeUiCustom(
|
|
295
|
+
method: UiCustom,
|
|
296
|
+
ui: ExtensionUIContext,
|
|
297
|
+
factory: (...args: any[]) => any,
|
|
298
|
+
options: any,
|
|
299
|
+
): Promise<any> {
|
|
300
|
+
return Reflect.apply(method as (...args: any[]) => Promise<any>, ui, [
|
|
301
|
+
factory,
|
|
302
|
+
options,
|
|
303
|
+
]);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
307
|
+
return isRecord(value) && typeof value.then === "function";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isRecord(value: unknown): value is Record<string, any> {
|
|
311
|
+
return typeof value === "object" && value !== null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function stripAnsi(value: string): string {
|
|
315
|
+
return value.replace(
|
|
316
|
+
/\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g,
|
|
317
|
+
"",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function normalizeWhitespace(value: string): string {
|
|
322
|
+
return value.replace(/\s+/g, " ").trim();
|
|
323
|
+
}
|