@gotgenes/pi-permission-system 10.3.0 → 10.4.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 +19 -0
- package/package.json +1 -1
- package/src/config-modal.ts +10 -8
- package/src/config-store.ts +13 -34
- package/src/forwarded-permissions/io.ts +16 -22
- package/src/forwarded-permissions/permission-forwarder.ts +16 -19
- package/src/gate-prompter.ts +1 -3
- package/src/handlers/gates/runner.ts +1 -1
- package/src/index.ts +68 -51
- package/src/permission-event-rpc.ts +19 -15
- package/src/permission-prompter.ts +4 -3
- package/src/permission-session.ts +10 -67
- package/src/permissions-service.ts +3 -5
- package/src/prompting-gateway.ts +104 -0
- package/src/session-logger.ts +63 -12
- package/test/composition-root.test.ts +85 -1
- package/test/config-modal.test.ts +13 -7
- package/test/config-store.test.ts +23 -49
- package/test/forwarded-permissions/io.test.ts +23 -26
- package/test/handlers/external-directory-integration.test.ts +45 -32
- package/test/handlers/external-directory-session-dedup.test.ts +36 -46
- package/test/handlers/gates/runner.test.ts +10 -16
- package/test/handlers/input-events.test.ts +19 -4
- package/test/handlers/input.test.ts +29 -13
- package/test/handlers/tool-call-events.test.ts +23 -5
- package/test/helpers/gate-fixtures.ts +6 -6
- package/test/helpers/handler-fixtures.ts +24 -39
- package/test/permission-event-rpc.test.ts +30 -28
- package/test/permission-forwarder.test.ts +6 -5
- package/test/permission-prompter.test.ts +28 -28
- package/test/permission-session.test.ts +40 -112
- package/test/prompting-gateway.test.ts +230 -0
- package/test/session-logger.test.ts +151 -64
- package/src/runtime.ts +0 -147
- package/test/runtime.test.ts +0 -303
|
@@ -28,19 +28,20 @@ import {
|
|
|
28
28
|
} from "./permission-events";
|
|
29
29
|
import type { PermissionManager } from "./permission-manager";
|
|
30
30
|
import { buildRpcUiPrompt } from "./permission-ui-prompt";
|
|
31
|
-
import type {
|
|
31
|
+
import type { ReviewLogger } from "./session-logger";
|
|
32
|
+
import type { SessionRules } from "./session-rules";
|
|
32
33
|
|
|
33
34
|
/** Dependencies injected into the RPC handler registry. */
|
|
34
35
|
export interface PermissionRpcDeps {
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
|
|
36
|
+
/** The shared PermissionManager instance. */
|
|
37
|
+
permissionManager: Pick<PermissionManager, "checkPermission">;
|
|
38
|
+
/** The shared SessionRules instance. */
|
|
39
|
+
sessionRules: Pick<SessionRules, "getRuleset">;
|
|
39
40
|
/**
|
|
40
|
-
*
|
|
41
|
+
* Narrow session view: provides runtime context.
|
|
41
42
|
* Used by the prompt handler to check hasUI and access the UI dialog.
|
|
42
43
|
*/
|
|
43
|
-
getRuntimeContext(): ExtensionContext | null;
|
|
44
|
+
session: { getRuntimeContext(): ExtensionContext | null };
|
|
44
45
|
/** Show the interactive permission dialog in the parent session UI. */
|
|
45
46
|
requestPermissionDecisionFromUi(
|
|
46
47
|
ui: ExtensionContext["ui"],
|
|
@@ -48,8 +49,8 @@ export interface PermissionRpcDeps {
|
|
|
48
49
|
message: string,
|
|
49
50
|
options?: RequestPermissionOptions,
|
|
50
51
|
): Promise<PermissionPromptDecision>;
|
|
51
|
-
/** Write
|
|
52
|
-
|
|
52
|
+
/** Write review-log entries for prompted decisions. */
|
|
53
|
+
logger: ReviewLogger;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
/** Unsubscribe handles returned from registerPermissionRpcHandlers. */
|
|
@@ -107,10 +108,13 @@ function handleCheckRpc(
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
const input = buildInputForSurface(surface, value);
|
|
110
|
-
const sessionRules = deps.
|
|
111
|
-
const result = deps
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
const sessionRules = deps.sessionRules.getRuleset();
|
|
112
|
+
const result = deps.permissionManager.checkPermission(
|
|
113
|
+
surface,
|
|
114
|
+
input,
|
|
115
|
+
agentName ?? undefined,
|
|
116
|
+
sessionRules,
|
|
117
|
+
);
|
|
114
118
|
|
|
115
119
|
const data: PermissionsCheckReplyData = {
|
|
116
120
|
result: result.state,
|
|
@@ -141,7 +145,7 @@ async function handlePromptRpc(
|
|
|
141
145
|
|
|
142
146
|
const replyChannel = `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:${requestId}`;
|
|
143
147
|
|
|
144
|
-
const ctx = deps.getRuntimeContext();
|
|
148
|
+
const ctx = deps.session.getRuntimeContext();
|
|
145
149
|
if (!ctx?.hasUI) {
|
|
146
150
|
events.emit(replyChannel, errorReply("no_ui"));
|
|
147
151
|
return;
|
|
@@ -169,7 +173,7 @@ async function handlePromptRpc(
|
|
|
169
173
|
sessionLabel ? { sessionLabel } : undefined,
|
|
170
174
|
);
|
|
171
175
|
|
|
172
|
-
deps.
|
|
176
|
+
deps.logger.review("permission_request.rpc_prompt", {
|
|
173
177
|
requestId,
|
|
174
178
|
surface: surface ?? null,
|
|
175
179
|
value: value ?? null,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type PermissionEventBus,
|
|
8
8
|
} from "./permission-events";
|
|
9
9
|
import { buildDirectUiPrompt } from "./permission-ui-prompt";
|
|
10
|
+
import type { ReviewLogger } from "./session-logger";
|
|
10
11
|
import { shouldAutoApprovePermissionState } from "./yolo-mode";
|
|
11
12
|
|
|
12
13
|
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
@@ -40,14 +41,14 @@ export interface PermissionPrompterApi {
|
|
|
40
41
|
* Dependencies required by PermissionPrompter.
|
|
41
42
|
*
|
|
42
43
|
* Keeps the prompter's external surface narrow: callers provide config
|
|
43
|
-
* access, review
|
|
44
|
+
* access, a review logger, the UI-prompt event bus, and the forwarder
|
|
44
45
|
* that owns the UI/subagent-forwarding branching logic.
|
|
45
46
|
*/
|
|
46
47
|
export interface PermissionPrompterDeps {
|
|
47
48
|
/** Read current config for yolo-mode check (called at prompt time). */
|
|
48
49
|
config: ConfigReader;
|
|
49
50
|
/** Write structured entries to the permission review log. */
|
|
50
|
-
|
|
51
|
+
logger: ReviewLogger;
|
|
51
52
|
/** Event bus used for UI prompt broadcasts. */
|
|
52
53
|
events: PermissionEventBus;
|
|
53
54
|
/** Resolves the permission decision: direct UI dialog or forwarded to parent. */
|
|
@@ -122,7 +123,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
122
123
|
denialReason?: string;
|
|
123
124
|
},
|
|
124
125
|
): void {
|
|
125
|
-
this.deps.
|
|
126
|
+
this.deps.logger.review(event, {
|
|
126
127
|
requestId: details.requestId,
|
|
127
128
|
source: details.source,
|
|
128
129
|
agentName: details.agentName,
|
|
@@ -10,17 +10,15 @@ import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
|
10
10
|
import type { ExtensionPaths } from "./extension-paths";
|
|
11
11
|
import type { ForwardingController } from "./forwarding-manager";
|
|
12
12
|
import type { GateHandlerSession } from "./gate-handler-session";
|
|
13
|
-
import type { GatePrompter } from "./gate-prompter";
|
|
14
|
-
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
15
13
|
import type { ScopedPermissionManager } from "./permission-manager";
|
|
16
|
-
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
17
14
|
import type { PermissionResolver } from "./permission-resolver";
|
|
15
|
+
import type { PromptingGatewayLifecycle } from "./prompting-gateway";
|
|
18
16
|
import type { Rule } from "./rule";
|
|
19
17
|
import type { SessionApproval } from "./session-approval";
|
|
20
18
|
import type { SessionApprovalRecorder } from "./session-approval-recorder";
|
|
21
19
|
import type { SessionLifecycleSession } from "./session-lifecycle-session";
|
|
22
20
|
import type { SessionLogger } from "./session-logger";
|
|
23
|
-
import { SessionRules } from "./session-rules";
|
|
21
|
+
import type { SessionRules } from "./session-rules";
|
|
24
22
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
25
23
|
import {
|
|
26
24
|
resolveToolPreviewLimits,
|
|
@@ -28,22 +26,6 @@ import {
|
|
|
28
26
|
} from "./tool-preview-formatter";
|
|
29
27
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
30
28
|
|
|
31
|
-
/**
|
|
32
|
-
* Runtime operations that `PermissionSession` delegates to but does not own.
|
|
33
|
-
*
|
|
34
|
-
* Injected at construction time from the composition root (`index.ts`),
|
|
35
|
-
* where the `ExtensionRuntime` is available.
|
|
36
|
-
*/
|
|
37
|
-
export interface PermissionSessionRuntimeDeps {
|
|
38
|
-
/** Whether the current context can show an interactive permission prompt. */
|
|
39
|
-
canRequestPermissionConfirmation(ctx: ExtensionContext): boolean;
|
|
40
|
-
/** Prompt the user for a permission decision, log the outcome, and return it. */
|
|
41
|
-
promptPermission(
|
|
42
|
-
ctx: ExtensionContext,
|
|
43
|
-
details: PromptPermissionDetails,
|
|
44
|
-
): Promise<PermissionPromptDecision>;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
29
|
/**
|
|
48
30
|
* Encapsulates all mutable session state and exposes operations instead of
|
|
49
31
|
* fields.
|
|
@@ -57,19 +39,17 @@ export interface PermissionSessionRuntimeDeps {
|
|
|
57
39
|
* - `SessionLogger` — debug + review + warn
|
|
58
40
|
* - `ForwardingController` — polling lifecycle
|
|
59
41
|
* - `SessionConfigStore` — owns extension config; provides refresh, log, read
|
|
60
|
-
* - `
|
|
42
|
+
* - `PromptingGatewayLifecycle` — prompting lifecycle forwarded via activate/deactivate
|
|
61
43
|
*/
|
|
62
44
|
export class PermissionSession
|
|
63
45
|
implements
|
|
64
46
|
PermissionResolver,
|
|
65
47
|
SessionApprovalRecorder,
|
|
66
|
-
GatePrompter,
|
|
67
48
|
GateHandlerSession,
|
|
68
49
|
AgentPrepSession,
|
|
69
50
|
SessionLifecycleSession
|
|
70
51
|
{
|
|
71
52
|
private context: ExtensionContext | null = null;
|
|
72
|
-
private readonly sessionRules = new SessionRules();
|
|
73
53
|
private skillEntries: SkillPromptEntry[] = [];
|
|
74
54
|
private knownAgentName: string | null = null;
|
|
75
55
|
private toolsCacheKey: string | null = null;
|
|
@@ -80,22 +60,25 @@ export class PermissionSession
|
|
|
80
60
|
readonly logger: SessionLogger,
|
|
81
61
|
private readonly forwarding: ForwardingController,
|
|
82
62
|
private readonly permissionManager: ScopedPermissionManager,
|
|
63
|
+
private readonly sessionRules: SessionRules,
|
|
83
64
|
private readonly configStore: SessionConfigStore,
|
|
84
|
-
private readonly
|
|
65
|
+
private readonly gateway: PromptingGatewayLifecycle,
|
|
85
66
|
) {}
|
|
86
67
|
|
|
87
68
|
// ── Context lifecycle ──────────────────────────────────────────────────
|
|
88
69
|
|
|
89
|
-
/** Store the current extension context and
|
|
70
|
+
/** Store the current extension context, start forwarding, and activate the gateway. */
|
|
90
71
|
activate(ctx: ExtensionContext): void {
|
|
91
72
|
this.context = ctx;
|
|
92
73
|
this.forwarding.start(ctx);
|
|
74
|
+
this.gateway.activate(ctx);
|
|
93
75
|
}
|
|
94
76
|
|
|
95
|
-
/** Clear the context and
|
|
77
|
+
/** Clear the context, stop forwarding, and deactivate the gateway. */
|
|
96
78
|
deactivate(): void {
|
|
97
79
|
this.context = null;
|
|
98
80
|
this.forwarding.stop();
|
|
81
|
+
this.gateway.deactivate();
|
|
99
82
|
}
|
|
100
83
|
|
|
101
84
|
/** Return the current runtime context, or null if not activated. */
|
|
@@ -262,7 +245,7 @@ export class PermissionSession
|
|
|
262
245
|
|
|
263
246
|
/** Write the resolved config path set to the review and debug logs. */
|
|
264
247
|
logResolvedConfigPaths(): void {
|
|
265
|
-
this.configStore.logResolvedPaths();
|
|
248
|
+
this.configStore.logResolvedPaths(this.context?.cwd);
|
|
266
249
|
}
|
|
267
250
|
|
|
268
251
|
/** Read current extension config. */
|
|
@@ -292,44 +275,4 @@ export class PermissionSession
|
|
|
292
275
|
getToolPreviewLimits(): ToolPreviewFormatterOptions {
|
|
293
276
|
return resolveToolPreviewLimits(this.config);
|
|
294
277
|
}
|
|
295
|
-
|
|
296
|
-
// ── Prompting ──────────────────────────────────────────────────────────
|
|
297
|
-
|
|
298
|
-
/** Whether the current context can show an interactive permission prompt. */
|
|
299
|
-
canPrompt(ctx: ExtensionContext): boolean {
|
|
300
|
-
return this.runtimeDeps.canRequestPermissionConfirmation(ctx);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Prompt the user for a permission decision, log the outcome, and return it. */
|
|
304
|
-
prompt(
|
|
305
|
-
ctx: ExtensionContext,
|
|
306
|
-
details: PromptPermissionDetails,
|
|
307
|
-
): Promise<PermissionPromptDecision> {
|
|
308
|
-
return this.runtimeDeps.promptPermission(ctx, details);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Whether an interactive confirmation is possible using the stored context.
|
|
313
|
-
* Returns `false` when no context is active (before `activate` is called).
|
|
314
|
-
* Implements {@link GatePrompter}.
|
|
315
|
-
*/
|
|
316
|
-
canConfirm(): boolean {
|
|
317
|
-
return this.context !== null && this.canPrompt(this.context);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Prompt the user for a permission decision using the stored context.
|
|
322
|
-
* Throws if no context is active — `canConfirm()` guards this in normal use.
|
|
323
|
-
* Implements {@link GatePrompter}.
|
|
324
|
-
*/
|
|
325
|
-
promptPermission(
|
|
326
|
-
details: PromptPermissionDetails,
|
|
327
|
-
): Promise<PermissionPromptDecision> {
|
|
328
|
-
if (this.context === null) {
|
|
329
|
-
return Promise.reject(
|
|
330
|
-
new Error("promptPermission called before the session was activated"),
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
return this.prompt(this.context, details);
|
|
334
|
-
}
|
|
335
278
|
}
|
|
@@ -10,11 +10,9 @@ import type {
|
|
|
10
10
|
/**
|
|
11
11
|
* In-process implementation of the cross-extension {@link PermissionsService}.
|
|
12
12
|
*
|
|
13
|
-
* Constructed once in the composition root and backed by the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* reassigned on the runtime object (only `PermissionSession` reassigns its
|
|
17
|
-
* own internal copy), and `runtime.sessionRules` is `readonly`.
|
|
13
|
+
* Constructed once in the composition root and backed by the single shared
|
|
14
|
+
* `PermissionManager` and `SessionRules` instances that `PermissionSession`
|
|
15
|
+
* also uses — so service queries and gate-path approvals see the same state.
|
|
18
16
|
*/
|
|
19
17
|
export class LocalPermissionsService implements PermissionsService {
|
|
20
18
|
constructor(
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { ConfigReader } from "./config-store";
|
|
4
|
+
import type { GatePrompter } from "./gate-prompter";
|
|
5
|
+
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
6
|
+
import type {
|
|
7
|
+
PermissionPrompterApi,
|
|
8
|
+
PromptPermissionDetails,
|
|
9
|
+
} from "./permission-prompter";
|
|
10
|
+
import { isSubagentExecutionContext } from "./subagent-context";
|
|
11
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
12
|
+
import { canResolveAskPermissionRequest } from "./yolo-mode";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Dependencies required by PromptingGateway.
|
|
16
|
+
*
|
|
17
|
+
* All four fields are actively consumed:
|
|
18
|
+
* - `config` + `subagentSessionsDir` + `registry` drive `canConfirm()`.
|
|
19
|
+
* - `prompter` is called by `prompt()`.
|
|
20
|
+
*/
|
|
21
|
+
export interface PromptingGatewayDeps {
|
|
22
|
+
/** Read current config for the yolo-mode branch of the can-prompt policy. */
|
|
23
|
+
config: ConfigReader;
|
|
24
|
+
/** Static path used to detect a forwarding subagent context. */
|
|
25
|
+
subagentSessionsDir: string;
|
|
26
|
+
/** Process-global registry used to detect a registered child session. */
|
|
27
|
+
registry?: SubagentSessionRegistry;
|
|
28
|
+
/** Resolves the permission decision: direct UI dialog or forwarded to parent. */
|
|
29
|
+
prompter: PermissionPrompterApi;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The lifecycle slice of the gateway that PermissionSession drives.
|
|
34
|
+
*
|
|
35
|
+
* PermissionSession calls activate/deactivate to keep the gateway's stored
|
|
36
|
+
* context in sync with its own — the same pattern used for ForwardingController.
|
|
37
|
+
*/
|
|
38
|
+
export interface PromptingGatewayLifecycle {
|
|
39
|
+
activate(ctx: ExtensionContext): void;
|
|
40
|
+
deactivate(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Context-owning implementation of the GatePrompter role.
|
|
45
|
+
*
|
|
46
|
+
* Owns the stored ExtensionContext and the "can we prompt?" policy
|
|
47
|
+
* (UI / subagent / yolo-mode), replacing the four twin methods
|
|
48
|
+
* that previously lived on PermissionSession.
|
|
49
|
+
*
|
|
50
|
+
* Lifecycle: PermissionSession drives activate/deactivate so the stored
|
|
51
|
+
* context mirrors the session context without independent call-site changes.
|
|
52
|
+
*/
|
|
53
|
+
export class PromptingGateway
|
|
54
|
+
implements GatePrompter, PromptingGatewayLifecycle
|
|
55
|
+
{
|
|
56
|
+
private context: ExtensionContext | null = null;
|
|
57
|
+
|
|
58
|
+
constructor(private readonly deps: PromptingGatewayDeps) {}
|
|
59
|
+
|
|
60
|
+
/** Store the current extension context. */
|
|
61
|
+
activate(ctx: ExtensionContext): void {
|
|
62
|
+
this.context = ctx;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Clear the stored context. */
|
|
66
|
+
deactivate(): void {
|
|
67
|
+
this.context = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether an interactive permission prompt can be shown.
|
|
72
|
+
*
|
|
73
|
+
* Returns false when no context is active. Otherwise delegates to
|
|
74
|
+
* canResolveAskPermissionRequest, which checks hasUI, subagent status,
|
|
75
|
+
* and yolo-mode — relocating the policy from the index.ts closure.
|
|
76
|
+
*/
|
|
77
|
+
canConfirm(): boolean {
|
|
78
|
+
if (this.context === null) return false;
|
|
79
|
+
return canResolveAskPermissionRequest({
|
|
80
|
+
config: this.deps.config.current(),
|
|
81
|
+
hasUI: this.context.hasUI,
|
|
82
|
+
isSubagent: isSubagentExecutionContext(
|
|
83
|
+
this.context,
|
|
84
|
+
this.deps.subagentSessionsDir,
|
|
85
|
+
this.deps.registry,
|
|
86
|
+
),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Prompt the user for a permission decision using the stored context.
|
|
92
|
+
*
|
|
93
|
+
* Rejects if no context is active — canConfirm() guards this in normal use.
|
|
94
|
+
* Implements {@link GatePrompter}.
|
|
95
|
+
*/
|
|
96
|
+
prompt(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
|
|
97
|
+
if (this.context === null) {
|
|
98
|
+
return Promise.reject(
|
|
99
|
+
new Error("prompt called before the session was activated"),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return this.deps.prompter.prompt(this.context, details);
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/session-logger.ts
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { DEBUG_LOG_FILENAME, REVIEW_LOG_FILENAME } from "./config-paths";
|
|
3
|
+
import {
|
|
4
|
+
ensurePermissionSystemLogsDirectory,
|
|
5
|
+
type PermissionSystemExtensionConfig,
|
|
6
|
+
} from "./extension-config";
|
|
7
|
+
import { createPermissionSystemLogger } from "./logging";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Narrowest logging seam — consumers that only write review-log entries.
|
|
11
|
+
* Injected into `PermissionPrompter` and the RPC handlers.
|
|
12
|
+
*/
|
|
13
|
+
export interface ReviewLogger {
|
|
14
|
+
review(event: string, details?: Record<string, unknown>): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Logging seam for consumers that write both debug and review entries.
|
|
19
|
+
* Injected into `ConfigStore` and `PermissionForwarder`.
|
|
20
|
+
*/
|
|
21
|
+
export interface DebugReviewLogger extends ReviewLogger {
|
|
22
|
+
debug(event: string, details?: Record<string, unknown>): void;
|
|
23
|
+
}
|
|
2
24
|
|
|
3
25
|
/**
|
|
4
26
|
* Unified logging + notification surface for handler deps.
|
|
@@ -7,23 +29,52 @@ import type { ExtensionRuntime } from "./runtime";
|
|
|
7
29
|
* `writeReviewLog`, `notifyWarning`) with a single typed collaborator.
|
|
8
30
|
* This is an intermediate abstraction on the path to PermissionSession (#129).
|
|
9
31
|
*/
|
|
10
|
-
export interface SessionLogger {
|
|
11
|
-
debug(event: string, details?: Record<string, unknown>): void;
|
|
12
|
-
review(event: string, details?: Record<string, unknown>): void;
|
|
32
|
+
export interface SessionLogger extends DebugReviewLogger {
|
|
13
33
|
warn(message: string): void;
|
|
14
34
|
}
|
|
15
35
|
|
|
36
|
+
/** Narrow dependencies for constructing a {@link SessionLogger}. */
|
|
37
|
+
export interface SessionLoggerDeps {
|
|
38
|
+
/** Root logs directory; the debug + review log file paths derive from it. */
|
|
39
|
+
globalLogsDir: string;
|
|
40
|
+
/** Reads current config for the debug/review write toggles (call-time). */
|
|
41
|
+
getConfig: () => PermissionSystemExtensionConfig;
|
|
42
|
+
/** Surfaces a warning message to the user; called at warn/IO-failure time. */
|
|
43
|
+
notify: (message: string) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
16
46
|
/**
|
|
17
|
-
* Create a SessionLogger
|
|
47
|
+
* Create a SessionLogger from narrow dependencies.
|
|
18
48
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
49
|
+
* Composes the JSONL log writer, owns the IO-failure warning dedup Set,
|
|
50
|
+
* and routes both IO-failure warnings and explicit warn() calls through
|
|
51
|
+
* the injected notify sink. No ExtensionRuntime reference required.
|
|
22
52
|
*/
|
|
23
|
-
export function createSessionLogger(
|
|
53
|
+
export function createSessionLogger(deps: SessionLoggerDeps): SessionLogger {
|
|
54
|
+
const writer = createPermissionSystemLogger({
|
|
55
|
+
getConfig: deps.getConfig,
|
|
56
|
+
debugLogPath: join(deps.globalLogsDir, DEBUG_LOG_FILENAME),
|
|
57
|
+
reviewLogPath: join(deps.globalLogsDir, REVIEW_LOG_FILENAME),
|
|
58
|
+
ensureLogsDirectory: () =>
|
|
59
|
+
ensurePermissionSystemLogsDirectory(deps.globalLogsDir),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const reported = new Set<string>();
|
|
63
|
+
const reportOnce = (warning: string): void => {
|
|
64
|
+
if (reported.has(warning)) return;
|
|
65
|
+
reported.add(warning);
|
|
66
|
+
deps.notify(warning);
|
|
67
|
+
};
|
|
68
|
+
|
|
24
69
|
return {
|
|
25
|
-
debug: (event, details) =>
|
|
26
|
-
|
|
27
|
-
|
|
70
|
+
debug: (event, details) => {
|
|
71
|
+
const warning = writer.debug(event, details);
|
|
72
|
+
if (warning) reportOnce(warning);
|
|
73
|
+
},
|
|
74
|
+
review: (event, details) => {
|
|
75
|
+
const warning = writer.review(event, details);
|
|
76
|
+
if (warning) reportOnce(warning);
|
|
77
|
+
},
|
|
78
|
+
warn: (message) => deps.notify(message),
|
|
28
79
|
};
|
|
29
80
|
}
|
|
@@ -31,7 +31,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
31
31
|
import { getGlobalConfigPath } from "#src/config-paths";
|
|
32
32
|
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
33
33
|
import piPermissionSystemExtension from "#src/index";
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
PERMISSIONS_READY_CHANNEL,
|
|
36
|
+
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
37
|
+
} from "#src/permission-events";
|
|
35
38
|
import {
|
|
36
39
|
createPermissionForwardingLocation,
|
|
37
40
|
type ForwardedPermissionRequest,
|
|
@@ -359,6 +362,87 @@ describe("ready emitted after service publication", () => {
|
|
|
359
362
|
});
|
|
360
363
|
});
|
|
361
364
|
|
|
365
|
+
describe("single source of truth for session state", () => {
|
|
366
|
+
// Regression guard for the split-brain bug: before the fix, the gate path
|
|
367
|
+
// recorded session approvals into a private SessionRules instance that the
|
|
368
|
+
// RPC check and the service never saw. After the fix, both readers use the
|
|
369
|
+
// same SessionRules the gate writes into.
|
|
370
|
+
it("gate session-approval is visible to the RPC check and the service", async () => {
|
|
371
|
+
writeGlobalConfig({
|
|
372
|
+
permission: { "*": "allow", demo: "ask" },
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-sot-cwd-"));
|
|
376
|
+
const pi = makeFakePi({ toolNames: ["demo"] });
|
|
377
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
378
|
+
|
|
379
|
+
// UI ctx that approves the gate prompt for this session (options[1]).
|
|
380
|
+
const ctx = {
|
|
381
|
+
cwd,
|
|
382
|
+
hasUI: true,
|
|
383
|
+
sessionManager: {
|
|
384
|
+
getEntries: (): unknown[] => [],
|
|
385
|
+
getSessionId: (): string => "sot-session",
|
|
386
|
+
getSessionDir: (): string => cwd,
|
|
387
|
+
},
|
|
388
|
+
ui: {
|
|
389
|
+
notify: (): void => {},
|
|
390
|
+
setStatus: (): void => {},
|
|
391
|
+
// Return the second option label-agnostically — always the
|
|
392
|
+
// "for this session" choice regardless of the exact label text.
|
|
393
|
+
select: async (
|
|
394
|
+
_title: string,
|
|
395
|
+
options: string[],
|
|
396
|
+
): Promise<string | undefined> => options[1],
|
|
397
|
+
input: async (): Promise<string | undefined> => undefined,
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
await fireSessionStart(pi, ctx);
|
|
402
|
+
|
|
403
|
+
// Drive a tool_call on "demo"; the gate prompts and the mock selects
|
|
404
|
+
// options[1], recording a session-scoped approval.
|
|
405
|
+
await pi.fire(
|
|
406
|
+
"tool_call",
|
|
407
|
+
{
|
|
408
|
+
toolName: "demo",
|
|
409
|
+
toolCallId: "demo-for-session",
|
|
410
|
+
input: { foo: "bar" },
|
|
411
|
+
},
|
|
412
|
+
ctx,
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
// RPC check — the deprecated channel must now reflect the session approval.
|
|
416
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- intentionally testing the deprecated RPC channel's session-rules visibility
|
|
417
|
+
const rpcCheckChannel: string = PERMISSIONS_RPC_CHECK_CHANNEL;
|
|
418
|
+
const requestId = "sot-rpc-1";
|
|
419
|
+
const replyPromise = new Promise<unknown>((resolve) => {
|
|
420
|
+
const unsub = pi.events.on(
|
|
421
|
+
`${rpcCheckChannel}:reply:${requestId}`,
|
|
422
|
+
(data) => {
|
|
423
|
+
unsub();
|
|
424
|
+
resolve(data);
|
|
425
|
+
},
|
|
426
|
+
);
|
|
427
|
+
});
|
|
428
|
+
pi.events.emit(rpcCheckChannel, { requestId, surface: "demo" });
|
|
429
|
+
const reply = (await replyPromise) as {
|
|
430
|
+
success: boolean;
|
|
431
|
+
data?: { result: string };
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
expect(reply.success).toBe(true);
|
|
435
|
+
// Before the fix this was "ask" — the RPC channel read an empty SessionRules.
|
|
436
|
+
expect(reply.data?.result).toBe("allow");
|
|
437
|
+
|
|
438
|
+
// Service accessor must also see the session approval.
|
|
439
|
+
const serviceResult = getPermissionsService()!.checkPermission("demo");
|
|
440
|
+
expect(serviceResult.state).toBe("allow");
|
|
441
|
+
|
|
442
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
362
446
|
describe("multi-instance global service interplay", () => {
|
|
363
447
|
// The fix (#302) scopes the process-global service slot to the publishing
|
|
364
448
|
// instance. The parent publishes at its session_start; an in-process child
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
normalizePermissionSystemConfig,
|
|
10
10
|
type PermissionSystemExtensionConfig,
|
|
11
11
|
} from "#src/extension-config";
|
|
12
|
-
import type { Rule } from "#src/rule";
|
|
12
|
+
import type { Rule, Ruleset } from "#src/rule";
|
|
13
13
|
|
|
14
14
|
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
15
15
|
getSettingsListTheme: () => ({}),
|
|
@@ -88,7 +88,9 @@ test("permission-system command completions expose top-level config actions", ()
|
|
|
88
88
|
};
|
|
89
89
|
const controller = {
|
|
90
90
|
config: configStore,
|
|
91
|
-
|
|
91
|
+
configPath,
|
|
92
|
+
permissionManager: { getComposedConfigRules: () => [] as Ruleset },
|
|
93
|
+
session: { lastKnownActiveAgentName: null },
|
|
92
94
|
};
|
|
93
95
|
|
|
94
96
|
let definition: {
|
|
@@ -160,7 +162,9 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
160
162
|
};
|
|
161
163
|
const controller = {
|
|
162
164
|
config: configStore,
|
|
163
|
-
|
|
165
|
+
configPath,
|
|
166
|
+
permissionManager: { getComposedConfigRules: () => [] as Ruleset },
|
|
167
|
+
session: { lastKnownActiveAgentName: null },
|
|
164
168
|
};
|
|
165
169
|
|
|
166
170
|
let registeredName = "";
|
|
@@ -257,8 +261,9 @@ test("show output includes rule origins when getComposedRules is provided", asyn
|
|
|
257
261
|
|
|
258
262
|
const controller = {
|
|
259
263
|
config: { current: () => config, save: () => {} } as CommandConfigStore,
|
|
260
|
-
|
|
261
|
-
|
|
264
|
+
configPath: "/fake/config.json",
|
|
265
|
+
permissionManager: { getComposedConfigRules: () => composedRules },
|
|
266
|
+
session: { lastKnownActiveAgentName: null },
|
|
262
267
|
};
|
|
263
268
|
|
|
264
269
|
let definition: {
|
|
@@ -289,8 +294,9 @@ test("show output omits rule summary when getComposedRules is not provided", asy
|
|
|
289
294
|
|
|
290
295
|
const controller = {
|
|
291
296
|
config: { current: () => config, save: () => {} } as CommandConfigStore,
|
|
292
|
-
|
|
293
|
-
|
|
297
|
+
configPath: "/fake/config.json",
|
|
298
|
+
permissionManager: { getComposedConfigRules: () => [] as Ruleset },
|
|
299
|
+
session: { lastKnownActiveAgentName: null },
|
|
294
300
|
};
|
|
295
301
|
|
|
296
302
|
let definition: {
|