@gotgenes/pi-permission-system 5.8.0 → 5.10.0
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 +28 -0
- package/package.json +1 -1
- package/src/forwarding-manager.ts +76 -0
- package/src/handlers/before-agent-start.ts +19 -32
- package/src/handlers/input.ts +5 -5
- package/src/handlers/lifecycle.ts +17 -33
- package/src/handlers/tool-call.ts +11 -18
- package/src/handlers/types.ts +11 -38
- package/src/index.ts +15 -19
- package/src/permission-session.ts +252 -0
- package/src/runtime.ts +5 -96
- package/src/skill-prompt-sanitizer.ts +15 -4
- package/tests/forwarding-manager.test.ts +211 -0
- package/tests/handlers/before-agent-start.test.ts +79 -111
- package/tests/handlers/input-events.test.ts +19 -32
- package/tests/handlers/input.test.ts +41 -74
- package/tests/handlers/lifecycle.test.ts +61 -180
- package/tests/handlers/tool-call-events.test.ts +66 -93
- package/tests/handlers/tool-call.test.ts +40 -62
- package/tests/permission-session.test.ts +546 -0
- package/tests/runtime.test.ts +2 -92
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getActiveAgentName,
|
|
5
|
+
getActiveAgentNameFromSystemPrompt,
|
|
6
|
+
} from "./active-agent";
|
|
7
|
+
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
8
|
+
import type { ExtensionPaths } from "./extension-paths";
|
|
9
|
+
import type { ForwardingController } from "./forwarding-manager";
|
|
10
|
+
import type { PermissionManager } from "./permission-manager";
|
|
11
|
+
import type { Rule } from "./rule";
|
|
12
|
+
import { createPermissionManagerForCwd } from "./runtime";
|
|
13
|
+
import type { SessionLogger } from "./session-logger";
|
|
14
|
+
import { SessionRules } from "./session-rules";
|
|
15
|
+
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
16
|
+
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runtime operations that `PermissionSession` delegates to but does not own.
|
|
20
|
+
*
|
|
21
|
+
* Injected at construction time from the composition root (`index.ts`),
|
|
22
|
+
* where the `ExtensionRuntime` is available.
|
|
23
|
+
*/
|
|
24
|
+
export interface PermissionSessionRuntimeDeps {
|
|
25
|
+
/** Reload merged config from disk; optionally update the stored runtime context. */
|
|
26
|
+
refreshExtensionConfig(ctx?: ExtensionContext): void;
|
|
27
|
+
/** Write the resolved config path set to the review and debug logs. */
|
|
28
|
+
logResolvedConfigPaths(): void;
|
|
29
|
+
/** Read current extension config (called at query time). */
|
|
30
|
+
getConfig(): PermissionSystemExtensionConfig;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Encapsulates all mutable session state and exposes operations instead of
|
|
35
|
+
* fields.
|
|
36
|
+
*
|
|
37
|
+
* Replaces the `SessionState` interface + scattered handler field mutations
|
|
38
|
+
* with a single class that owns the `PermissionManager`, `SessionRules`,
|
|
39
|
+
* cache keys, skill entries, and runtime context.
|
|
40
|
+
*
|
|
41
|
+
* Constructor deps:
|
|
42
|
+
* - `ExtensionPaths` — immutable path constants
|
|
43
|
+
* - `SessionLogger` — debug + review + warn
|
|
44
|
+
* - `ForwardingController` — polling lifecycle
|
|
45
|
+
* - `PermissionSessionRuntimeDeps` — config refresh + log delegates
|
|
46
|
+
*/
|
|
47
|
+
export class PermissionSession {
|
|
48
|
+
private context: ExtensionContext | null = null;
|
|
49
|
+
private permissionManager: PermissionManager;
|
|
50
|
+
private readonly sessionRules = new SessionRules();
|
|
51
|
+
private skillEntries: SkillPromptEntry[] = [];
|
|
52
|
+
private knownAgentName: string | null = null;
|
|
53
|
+
private toolsCacheKey: string | null = null;
|
|
54
|
+
private promptCacheKey: string | null = null;
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
private readonly paths: ExtensionPaths,
|
|
58
|
+
readonly logger: SessionLogger,
|
|
59
|
+
private readonly forwarding: ForwardingController,
|
|
60
|
+
private readonly runtimeDeps: PermissionSessionRuntimeDeps,
|
|
61
|
+
) {
|
|
62
|
+
this.permissionManager = createPermissionManagerForCwd(
|
|
63
|
+
paths.agentDir,
|
|
64
|
+
undefined,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Context lifecycle ──────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/** Store the current extension context and start forwarding. */
|
|
71
|
+
activate(ctx: ExtensionContext): void {
|
|
72
|
+
this.context = ctx;
|
|
73
|
+
this.forwarding.start(ctx);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Clear the context and stop forwarding. */
|
|
77
|
+
deactivate(): void {
|
|
78
|
+
this.context = null;
|
|
79
|
+
this.forwarding.stop();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Return the current runtime context, or null if not activated. */
|
|
83
|
+
getRuntimeContext(): ExtensionContext | null {
|
|
84
|
+
return this.context;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Permission checking (delegates to PermissionManager) ───────────────
|
|
88
|
+
|
|
89
|
+
checkPermission(
|
|
90
|
+
surface: string,
|
|
91
|
+
input: unknown,
|
|
92
|
+
agentName?: string,
|
|
93
|
+
sessionRules?: Rule[],
|
|
94
|
+
): PermissionCheckResult {
|
|
95
|
+
return this.permissionManager.checkPermission(
|
|
96
|
+
surface,
|
|
97
|
+
input,
|
|
98
|
+
agentName,
|
|
99
|
+
sessionRules,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState {
|
|
104
|
+
return this.permissionManager.getToolPermission(toolName, agentName);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
getConfigIssues(agentName?: string): string[] {
|
|
108
|
+
return this.permissionManager.getConfigIssues(agentName);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getPolicyCacheStamp(agentName?: string): string {
|
|
112
|
+
return this.permissionManager.getPolicyCacheStamp(agentName);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Session rules (delegates to SessionRules) ──────────────────────────
|
|
116
|
+
|
|
117
|
+
getSessionRuleset(): Rule[] {
|
|
118
|
+
return this.sessionRules.getRuleset();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
approveSessionRule(surface: string, pattern: string): void {
|
|
122
|
+
this.sessionRules.approve(surface, pattern);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Session lifecycle ────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Reset all mutable state for a new session.
|
|
129
|
+
*
|
|
130
|
+
* Creates a fresh PermissionManager scoped to `ctx.cwd`, clears caches,
|
|
131
|
+
* skill entries, and activates the new context.
|
|
132
|
+
*/
|
|
133
|
+
resetForNewSession(ctx: ExtensionContext): void {
|
|
134
|
+
this.permissionManager = createPermissionManagerForCwd(
|
|
135
|
+
this.paths.agentDir,
|
|
136
|
+
ctx.cwd,
|
|
137
|
+
);
|
|
138
|
+
this.skillEntries = [];
|
|
139
|
+
this.toolsCacheKey = null;
|
|
140
|
+
this.promptCacheKey = null;
|
|
141
|
+
this.activate(ctx);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Shut down the session: clear rules, caches, skill entries, and
|
|
146
|
+
* deactivate context + forwarding.
|
|
147
|
+
*/
|
|
148
|
+
shutdown(): void {
|
|
149
|
+
this.sessionRules.clear();
|
|
150
|
+
this.skillEntries = [];
|
|
151
|
+
this.toolsCacheKey = null;
|
|
152
|
+
this.promptCacheKey = null;
|
|
153
|
+
this.deactivate();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reload permission manager and clear caches for the current context.
|
|
158
|
+
* Used on config reload (e.g. `resources_discover` with reason "reload").
|
|
159
|
+
*/
|
|
160
|
+
reload(): void {
|
|
161
|
+
this.permissionManager = createPermissionManagerForCwd(
|
|
162
|
+
this.paths.agentDir,
|
|
163
|
+
this.context?.cwd,
|
|
164
|
+
);
|
|
165
|
+
this.skillEntries = [];
|
|
166
|
+
this.toolsCacheKey = null;
|
|
167
|
+
this.promptCacheKey = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Agent-start caching ────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
shouldUpdateActiveTools(cacheKey: string): boolean {
|
|
173
|
+
return this.toolsCacheKey !== cacheKey;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
commitActiveToolsCacheKey(cacheKey: string): void {
|
|
177
|
+
this.toolsCacheKey = cacheKey;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
shouldUpdatePromptState(cacheKey: string): boolean {
|
|
181
|
+
return this.promptCacheKey !== cacheKey;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
commitPromptStateCacheKey(cacheKey: string): void {
|
|
185
|
+
this.promptCacheKey = cacheKey;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Skill entries ──────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
getActiveSkillEntries(): SkillPromptEntry[] {
|
|
191
|
+
return this.skillEntries;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setActiveSkillEntries(entries: SkillPromptEntry[]): void {
|
|
195
|
+
this.skillEntries = entries;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Agent name ─────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resolve the active agent name from the session context, system prompt,
|
|
202
|
+
* or last known name. Updates lastKnownActiveAgentName as a side effect.
|
|
203
|
+
*/
|
|
204
|
+
resolveAgentName(
|
|
205
|
+
ctx: ExtensionContext,
|
|
206
|
+
systemPrompt?: string,
|
|
207
|
+
): string | null {
|
|
208
|
+
const fromSession = getActiveAgentName(ctx);
|
|
209
|
+
if (fromSession) {
|
|
210
|
+
this.knownAgentName = fromSession;
|
|
211
|
+
return fromSession;
|
|
212
|
+
}
|
|
213
|
+
const fromSystemPrompt = getActiveAgentNameFromSystemPrompt(systemPrompt);
|
|
214
|
+
if (fromSystemPrompt) {
|
|
215
|
+
this.knownAgentName = fromSystemPrompt;
|
|
216
|
+
return fromSystemPrompt;
|
|
217
|
+
}
|
|
218
|
+
return this.knownAgentName;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get lastKnownActiveAgentName(): string | null {
|
|
222
|
+
return this.knownAgentName;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Config ─────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/** Reload merged config from disk; optionally update the stored runtime context. */
|
|
228
|
+
refreshConfig(ctx?: ExtensionContext): void {
|
|
229
|
+
this.runtimeDeps.refreshExtensionConfig(ctx);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Write the resolved config path set to the review and debug logs. */
|
|
233
|
+
logResolvedConfigPaths(): void {
|
|
234
|
+
this.runtimeDeps.logResolvedConfigPaths();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Read current extension config. */
|
|
238
|
+
get config(): PermissionSystemExtensionConfig {
|
|
239
|
+
return this.runtimeDeps.getConfig();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Infrastructure paths ───────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
getInfrastructureDirs(): readonly string[] {
|
|
245
|
+
return this.paths.piInfrastructureDirs;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Config-derived infrastructure read paths (current at call time). */
|
|
249
|
+
getInfrastructureReadPaths(): string[] {
|
|
250
|
+
return this.config.piInfrastructureReadPaths ?? [];
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -12,10 +12,6 @@ import {
|
|
|
12
12
|
getAgentDir,
|
|
13
13
|
} from "@mariozechner/pi-coding-agent";
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
getActiveAgentName,
|
|
17
|
-
getActiveAgentNameFromSystemPrompt,
|
|
18
|
-
} from "./active-agent";
|
|
19
15
|
import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
|
|
20
16
|
import {
|
|
21
17
|
DEBUG_LOG_FILENAME,
|
|
@@ -38,24 +34,19 @@ import { computeExtensionPaths, type ExtensionPaths } from "./extension-paths";
|
|
|
38
34
|
|
|
39
35
|
export type { ExtensionPaths } from "./extension-paths";
|
|
40
36
|
|
|
41
|
-
import {
|
|
42
|
-
type PermissionForwardingDeps,
|
|
43
|
-
processForwardedPermissionRequests,
|
|
44
|
-
} from "./forwarded-permissions/polling";
|
|
45
37
|
import { createPermissionSystemLogger } from "./logging";
|
|
46
|
-
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
|
47
38
|
import { PermissionManager } from "./permission-manager";
|
|
48
39
|
import { SessionRules } from "./session-rules";
|
|
49
40
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
50
41
|
import { syncPermissionSystemStatus } from "./status";
|
|
51
|
-
import { isSubagentExecutionContext } from "./subagent-context";
|
|
52
42
|
|
|
53
43
|
/**
|
|
54
|
-
* Mutable session state — the subset of ExtensionRuntime that
|
|
55
|
-
*
|
|
56
|
-
*
|
|
44
|
+
* Mutable session state — the subset of ExtensionRuntime that holds
|
|
45
|
+
* per-session fields. `PermissionSession` now owns these for handler
|
|
46
|
+
* use; this interface remains so `ExtensionRuntime` can still serve
|
|
47
|
+
* as the internal composition root (config-modal, RPC handlers).
|
|
57
48
|
*/
|
|
58
|
-
|
|
49
|
+
interface SessionState {
|
|
59
50
|
runtimeContext: ExtensionContext | null;
|
|
60
51
|
permissionManager: PermissionManager;
|
|
61
52
|
readonly sessionRules: SessionRules;
|
|
@@ -81,11 +72,6 @@ export interface ExtensionRuntime extends ExtensionPaths, SessionState {
|
|
|
81
72
|
config: PermissionSystemExtensionConfig;
|
|
82
73
|
lastConfigWarning: string | null;
|
|
83
74
|
|
|
84
|
-
// ── Forwarding polling state ───────────────────────────────────────────
|
|
85
|
-
permissionForwardingContext: ExtensionContext | null;
|
|
86
|
-
permissionForwardingTimer: NodeJS.Timeout | null;
|
|
87
|
-
isProcessingForwardedRequests: boolean;
|
|
88
|
-
|
|
89
75
|
// ── Logging (backed by logger created at construction) ─────────────────
|
|
90
76
|
writeDebugLog(event: string, details?: Record<string, unknown>): void;
|
|
91
77
|
writeReviewLog(event: string, details?: Record<string, unknown>): void;
|
|
@@ -220,28 +206,6 @@ export function saveExtensionConfig(
|
|
|
220
206
|
});
|
|
221
207
|
}
|
|
222
208
|
|
|
223
|
-
/**
|
|
224
|
-
* Resolve the active agent name from the Pi session, system prompt, or last
|
|
225
|
-
* known name. Updates `runtime.lastKnownActiveAgentName` as a side effect.
|
|
226
|
-
*/
|
|
227
|
-
export function resolveAgentName(
|
|
228
|
-
runtime: ExtensionRuntime,
|
|
229
|
-
ctx: ExtensionContext,
|
|
230
|
-
systemPrompt?: string,
|
|
231
|
-
): string | null {
|
|
232
|
-
const fromSession = getActiveAgentName(ctx);
|
|
233
|
-
if (fromSession) {
|
|
234
|
-
runtime.lastKnownActiveAgentName = fromSession;
|
|
235
|
-
return fromSession;
|
|
236
|
-
}
|
|
237
|
-
const fromSystemPrompt = getActiveAgentNameFromSystemPrompt(systemPrompt);
|
|
238
|
-
if (fromSystemPrompt) {
|
|
239
|
-
runtime.lastKnownActiveAgentName = fromSystemPrompt;
|
|
240
|
-
return fromSystemPrompt;
|
|
241
|
-
}
|
|
242
|
-
return runtime.lastKnownActiveAgentName;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
209
|
/**
|
|
246
210
|
* Write the resolved config path set (global, project, legacy) to the review
|
|
247
211
|
* and debug logs.
|
|
@@ -277,58 +241,6 @@ export function logResolvedConfigPaths(runtime: ExtensionRuntime): void {
|
|
|
277
241
|
);
|
|
278
242
|
}
|
|
279
243
|
|
|
280
|
-
// ── Forwarding polling lifecycle ───────────────────────────────────────────
|
|
281
|
-
|
|
282
|
-
/** Stop the forwarded-permission polling interval and clear related state. */
|
|
283
|
-
export function stopForwardedPermissionPolling(
|
|
284
|
-
runtime: ExtensionRuntime,
|
|
285
|
-
): void {
|
|
286
|
-
if (runtime.permissionForwardingTimer) {
|
|
287
|
-
clearInterval(runtime.permissionForwardingTimer);
|
|
288
|
-
runtime.permissionForwardingTimer = null;
|
|
289
|
-
}
|
|
290
|
-
runtime.permissionForwardingContext = null;
|
|
291
|
-
runtime.isProcessingForwardedRequests = false;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Start the forwarded-permission polling interval.
|
|
296
|
-
* No-ops (and stops any existing poll) when the context has no UI or is a
|
|
297
|
-
* subagent execution context.
|
|
298
|
-
*/
|
|
299
|
-
export function startForwardedPermissionPolling(
|
|
300
|
-
runtime: ExtensionRuntime,
|
|
301
|
-
forwardingDeps: PermissionForwardingDeps,
|
|
302
|
-
ctx: ExtensionContext,
|
|
303
|
-
): void {
|
|
304
|
-
if (
|
|
305
|
-
!ctx.hasUI ||
|
|
306
|
-
isSubagentExecutionContext(ctx, runtime.subagentSessionsDir)
|
|
307
|
-
) {
|
|
308
|
-
stopForwardedPermissionPolling(runtime);
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
runtime.permissionForwardingContext = ctx;
|
|
312
|
-
if (runtime.permissionForwardingTimer) {
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
runtime.permissionForwardingTimer = setInterval(() => {
|
|
316
|
-
if (
|
|
317
|
-
!runtime.permissionForwardingContext ||
|
|
318
|
-
runtime.isProcessingForwardedRequests
|
|
319
|
-
) {
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
runtime.isProcessingForwardedRequests = true;
|
|
323
|
-
void processForwardedPermissionRequests(
|
|
324
|
-
runtime.permissionForwardingContext,
|
|
325
|
-
forwardingDeps,
|
|
326
|
-
).finally(() => {
|
|
327
|
-
runtime.isProcessingForwardedRequests = false;
|
|
328
|
-
});
|
|
329
|
-
}, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
244
|
// ── Factory ────────────────────────────────────────────────────────────────
|
|
333
245
|
|
|
334
246
|
/**
|
|
@@ -356,9 +268,6 @@ export function createExtensionRuntime(options?: {
|
|
|
356
268
|
lastPromptStateCacheKey: null,
|
|
357
269
|
lastConfigWarning: null,
|
|
358
270
|
sessionRules: new SessionRules(),
|
|
359
|
-
permissionForwardingContext: null,
|
|
360
|
-
permissionForwardingTimer: null,
|
|
361
|
-
isProcessingForwardedRequests: false,
|
|
362
271
|
// Logging methods are replaced below after the logger is constructed.
|
|
363
272
|
writeDebugLog: () => {},
|
|
364
273
|
writeReviewLog: () => {},
|
|
@@ -4,8 +4,19 @@ import {
|
|
|
4
4
|
isPathWithinDirectory,
|
|
5
5
|
normalizePathForComparison,
|
|
6
6
|
} from "./path-utils";
|
|
7
|
-
import type {
|
|
8
|
-
|
|
7
|
+
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Narrow interface for the permission checker used by skill prompt resolution.
|
|
11
|
+
* Both `PermissionManager` and `PermissionSession` satisfy this structurally.
|
|
12
|
+
*/
|
|
13
|
+
export interface SkillPermissionChecker {
|
|
14
|
+
checkPermission(
|
|
15
|
+
surface: string,
|
|
16
|
+
input: unknown,
|
|
17
|
+
agentName?: string,
|
|
18
|
+
): PermissionCheckResult;
|
|
19
|
+
}
|
|
9
20
|
|
|
10
21
|
const AVAILABLE_SKILLS_OPEN_TAG = "<available_skills>";
|
|
11
22
|
const AVAILABLE_SKILLS_CLOSE_TAG = "</available_skills>";
|
|
@@ -148,7 +159,7 @@ export function parseAllSkillPromptSections(
|
|
|
148
159
|
|
|
149
160
|
function resolvePermissionState(
|
|
150
161
|
skillName: string,
|
|
151
|
-
permissionManager:
|
|
162
|
+
permissionManager: SkillPermissionChecker,
|
|
152
163
|
agentName: string | null,
|
|
153
164
|
cache: Map<string, PermissionState>,
|
|
154
165
|
): PermissionState {
|
|
@@ -205,7 +216,7 @@ function removePromptRange(prompt: string, start: number, end: number): string {
|
|
|
205
216
|
|
|
206
217
|
export function resolveSkillPromptEntries(
|
|
207
218
|
prompt: string,
|
|
208
|
-
permissionManager:
|
|
219
|
+
permissionManager: SkillPermissionChecker,
|
|
209
220
|
agentName: string | null,
|
|
210
221
|
cwd: string,
|
|
211
222
|
): { prompt: string; entries: SkillPromptEntry[] } {
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { ForwardingManager } from "../src/forwarding-manager";
|
|
4
|
+
|
|
5
|
+
// ── Mocks ─────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const mockProcessForwardedPermissionRequests = vi.hoisted(() => vi.fn());
|
|
8
|
+
const mockIsSubagentExecutionContext = vi.hoisted(() => vi.fn());
|
|
9
|
+
|
|
10
|
+
vi.mock("../src/forwarded-permissions/polling", () => ({
|
|
11
|
+
processForwardedPermissionRequests: mockProcessForwardedPermissionRequests,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("../src/subagent-context", () => ({
|
|
15
|
+
isSubagentExecutionContext: mockIsSubagentExecutionContext,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function makeCtx(overrides: { hasUI?: boolean; sessionId?: string } = {}) {
|
|
21
|
+
return {
|
|
22
|
+
hasUI: overrides.hasUI ?? true,
|
|
23
|
+
sessionManager: {
|
|
24
|
+
getSessionId: vi.fn().mockReturnValue(overrides.sessionId ?? "sess-1"),
|
|
25
|
+
},
|
|
26
|
+
cwd: "/project",
|
|
27
|
+
} as unknown as import("@mariozechner/pi-coding-agent").ExtensionContext;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeForwardingDeps() {
|
|
31
|
+
return {
|
|
32
|
+
forwardingDir: "/agent/sessions/permission-forwarding",
|
|
33
|
+
subagentSessionsDir: "/agent/subagent-sessions",
|
|
34
|
+
logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
|
|
35
|
+
writeReviewLog: vi.fn(),
|
|
36
|
+
requestPermissionDecisionFromUi: vi.fn(),
|
|
37
|
+
shouldAutoApprove: vi.fn().mockReturnValue(false),
|
|
38
|
+
} as unknown as import("../src/forwarded-permissions/polling").PermissionForwardingDeps;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeManager() {
|
|
42
|
+
return new ForwardingManager(
|
|
43
|
+
"/agent/subagent-sessions",
|
|
44
|
+
makeForwardingDeps(),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe("ForwardingManager", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.useFakeTimers();
|
|
53
|
+
mockIsSubagentExecutionContext.mockReset();
|
|
54
|
+
mockIsSubagentExecutionContext.mockReturnValue(false);
|
|
55
|
+
mockProcessForwardedPermissionRequests.mockReset();
|
|
56
|
+
mockProcessForwardedPermissionRequests.mockResolvedValue(undefined);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.useRealTimers();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("stop()", () => {
|
|
64
|
+
it("is a no-op when not started", () => {
|
|
65
|
+
const manager = makeManager();
|
|
66
|
+
expect(() => manager.stop()).not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("clears the timer and processing state after start()", async () => {
|
|
70
|
+
const manager = makeManager();
|
|
71
|
+
const ctx = makeCtx();
|
|
72
|
+
manager.start(ctx);
|
|
73
|
+
manager.stop();
|
|
74
|
+
|
|
75
|
+
// After stop, the timer fires no more callbacks.
|
|
76
|
+
mockProcessForwardedPermissionRequests.mockClear();
|
|
77
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
78
|
+
expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("start()", () => {
|
|
83
|
+
it("does not start polling when hasUI is false", async () => {
|
|
84
|
+
const manager = makeManager();
|
|
85
|
+
const ctx = makeCtx({ hasUI: false });
|
|
86
|
+
manager.start(ctx);
|
|
87
|
+
|
|
88
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
89
|
+
expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("stops any existing poll and does not start a new one when hasUI is false", async () => {
|
|
93
|
+
const manager = makeManager();
|
|
94
|
+
const uiCtx = makeCtx({ hasUI: true });
|
|
95
|
+
const noUiCtx = makeCtx({ hasUI: false });
|
|
96
|
+
|
|
97
|
+
manager.start(uiCtx);
|
|
98
|
+
// Now stop the polling by calling start() with no-UI ctx.
|
|
99
|
+
manager.start(noUiCtx);
|
|
100
|
+
|
|
101
|
+
mockProcessForwardedPermissionRequests.mockClear();
|
|
102
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
103
|
+
expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does not start polling when isSubagentExecutionContext returns true", async () => {
|
|
107
|
+
mockIsSubagentExecutionContext.mockReturnValue(true);
|
|
108
|
+
const manager = makeManager();
|
|
109
|
+
const ctx = makeCtx();
|
|
110
|
+
manager.start(ctx);
|
|
111
|
+
|
|
112
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
113
|
+
expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("stops any existing poll when called with a subagent context", async () => {
|
|
117
|
+
mockIsSubagentExecutionContext.mockReturnValueOnce(false);
|
|
118
|
+
const manager = makeManager();
|
|
119
|
+
const ctx1 = makeCtx();
|
|
120
|
+
manager.start(ctx1);
|
|
121
|
+
|
|
122
|
+
// Second call with a subagent context.
|
|
123
|
+
mockIsSubagentExecutionContext.mockReturnValue(true);
|
|
124
|
+
const ctx2 = makeCtx();
|
|
125
|
+
manager.start(ctx2);
|
|
126
|
+
|
|
127
|
+
mockProcessForwardedPermissionRequests.mockClear();
|
|
128
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
129
|
+
expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("starts polling and calls processForwardedPermissionRequests on tick", async () => {
|
|
133
|
+
const manager = makeManager();
|
|
134
|
+
const ctx = makeCtx();
|
|
135
|
+
manager.start(ctx);
|
|
136
|
+
|
|
137
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
138
|
+
expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledWith(
|
|
139
|
+
ctx,
|
|
140
|
+
expect.anything(),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("is idempotent — calling start() twice does not create a second timer", async () => {
|
|
145
|
+
const manager = makeManager();
|
|
146
|
+
const ctx = makeCtx();
|
|
147
|
+
manager.start(ctx);
|
|
148
|
+
manager.start(ctx);
|
|
149
|
+
|
|
150
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
151
|
+
// Only one tick should fire per interval, not two.
|
|
152
|
+
expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("updates the context when called again while already running", async () => {
|
|
156
|
+
const manager = makeManager();
|
|
157
|
+
const ctx1 = makeCtx({ sessionId: "sess-1" });
|
|
158
|
+
const ctx2 = makeCtx({ sessionId: "sess-2" });
|
|
159
|
+
manager.start(ctx1);
|
|
160
|
+
manager.start(ctx2);
|
|
161
|
+
|
|
162
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
163
|
+
// The process call should use the newer context.
|
|
164
|
+
expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledWith(
|
|
165
|
+
ctx2,
|
|
166
|
+
expect.anything(),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("skips a tick while processing is in progress", async () => {
|
|
171
|
+
// Make processForwardedPermissionRequests hang so processing=true persists.
|
|
172
|
+
let resolveProcess: () => void;
|
|
173
|
+
mockProcessForwardedPermissionRequests.mockReturnValue(
|
|
174
|
+
new Promise<void>((resolve) => {
|
|
175
|
+
resolveProcess = resolve;
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const manager = makeManager();
|
|
180
|
+
const ctx = makeCtx();
|
|
181
|
+
manager.start(ctx);
|
|
182
|
+
|
|
183
|
+
// First tick starts processing.
|
|
184
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
185
|
+
expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
|
|
186
|
+
|
|
187
|
+
// Second tick is skipped because processing flag is still true.
|
|
188
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
189
|
+
expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
|
|
190
|
+
|
|
191
|
+
// Resolve and a third tick should fire.
|
|
192
|
+
resolveProcess!();
|
|
193
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
194
|
+
expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(2);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("passes subagentSessionsDir from the constructor to isSubagentExecutionContext", () => {
|
|
198
|
+
const manager = new ForwardingManager(
|
|
199
|
+
"/custom/subagent-dir",
|
|
200
|
+
makeForwardingDeps(),
|
|
201
|
+
);
|
|
202
|
+
const ctx = makeCtx();
|
|
203
|
+
manager.start(ctx);
|
|
204
|
+
|
|
205
|
+
expect(mockIsSubagentExecutionContext).toHaveBeenCalledWith(
|
|
206
|
+
ctx,
|
|
207
|
+
"/custom/subagent-dir",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|