@gotgenes/pi-permission-system 10.3.0 → 10.3.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/config-store.ts +7 -23
- package/src/index.ts +65 -30
- package/src/permission-session.ts +4 -5
- package/src/permissions-service.ts +3 -5
- package/src/session-logger.ts +46 -9
- package/test/composition-root.test.ts +85 -1
- package/test/config-store.test.ts +16 -40
- package/test/permission-session.test.ts +14 -1
- package/test/session-logger.test.ts +151 -64
- package/src/runtime.ts +0 -147
- package/test/runtime.test.ts +0 -303
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
|
+
## [10.3.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.3.0...pi-permission-system-v10.3.1) (2026-06-06)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* share one PermissionManager and SessionRules across gate and RPC paths ([#337](https://github.com/gotgenes/pi-packages/issues/337)) ([7dd1e65](https://github.com/gotgenes/pi-packages/commit/7dd1e65493fa0061a3b84eb329457f939b953e0a))
|
|
14
|
+
|
|
8
15
|
## [10.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.2.0...pi-permission-system-v10.3.0) (2026-06-05)
|
|
9
16
|
|
|
10
17
|
|
package/package.json
CHANGED
package/src/config-store.ts
CHANGED
|
@@ -41,7 +41,7 @@ export interface ConfigReader {
|
|
|
41
41
|
*/
|
|
42
42
|
export interface SessionConfigStore extends ConfigReader {
|
|
43
43
|
refresh(ctx?: ExtensionContext): void;
|
|
44
|
-
logResolvedPaths(): void;
|
|
44
|
+
logResolvedPaths(cwd?: string): void;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
@@ -57,16 +57,6 @@ export interface CommandConfigStore extends ConfigReader {
|
|
|
57
57
|
): void;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
/**
|
|
61
|
-
* Transitional get/set seam over the runtime-owned context.
|
|
62
|
-
*
|
|
63
|
-
* Retired in Step 4 (#337) when context ownership moves to `PermissionSession`.
|
|
64
|
-
*/
|
|
65
|
-
export interface RuntimeContextRef {
|
|
66
|
-
get(): ExtensionContext | null;
|
|
67
|
-
set(ctx: ExtensionContext): void;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
60
|
/** Narrow logging sink — replaced by an injected logger in Step 3 (#336). */
|
|
71
61
|
export interface ConfigStoreLogger {
|
|
72
62
|
writeDebugLog(event: string, details?: Record<string, unknown>): void;
|
|
@@ -80,7 +70,6 @@ export interface ResolvedPolicyPathProvider {
|
|
|
80
70
|
|
|
81
71
|
export interface ConfigStoreDeps {
|
|
82
72
|
agentDir: string;
|
|
83
|
-
context: RuntimeContextRef;
|
|
84
73
|
policyPaths: ResolvedPolicyPathProvider;
|
|
85
74
|
logger: ConfigStoreLogger;
|
|
86
75
|
}
|
|
@@ -111,14 +100,11 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
|
|
|
111
100
|
/**
|
|
112
101
|
* Reload merged config from disk.
|
|
113
102
|
*
|
|
114
|
-
* If `ctx` is provided,
|
|
103
|
+
* If `ctx` is provided, uses it to derive the cwd and sync UI status.
|
|
115
104
|
* Equivalent to `refreshExtensionConfig(runtime, ctx?)`.
|
|
116
105
|
*/
|
|
117
106
|
refresh(ctx?: ExtensionContext): void {
|
|
118
|
-
|
|
119
|
-
this.deps.context.set(ctx);
|
|
120
|
-
}
|
|
121
|
-
const cwd = this.deps.context.get()?.cwd ?? null;
|
|
107
|
+
const cwd = ctx?.cwd ?? null;
|
|
122
108
|
const mergeResult = loadAndMergeConfigs(
|
|
123
109
|
this.deps.agentDir,
|
|
124
110
|
cwd ?? "",
|
|
@@ -127,9 +113,8 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
|
|
|
127
113
|
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
|
|
128
114
|
this.config = runtimeConfig;
|
|
129
115
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
syncPermissionSystemStatus(currentCtx, runtimeConfig);
|
|
116
|
+
if (ctx?.hasUI) {
|
|
117
|
+
syncPermissionSystemStatus(ctx, runtimeConfig);
|
|
133
118
|
}
|
|
134
119
|
|
|
135
120
|
const warning =
|
|
@@ -137,7 +122,7 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
|
|
|
137
122
|
|
|
138
123
|
if (warning && warning !== this.lastConfigWarning) {
|
|
139
124
|
this.lastConfigWarning = warning;
|
|
140
|
-
|
|
125
|
+
ctx?.ui.notify(warning, "warning");
|
|
141
126
|
} else if (!warning) {
|
|
142
127
|
this.lastConfigWarning = null;
|
|
143
128
|
}
|
|
@@ -210,9 +195,8 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
|
|
|
210
195
|
*
|
|
211
196
|
* Equivalent to `logResolvedConfigPaths(runtime)`.
|
|
212
197
|
*/
|
|
213
|
-
logResolvedPaths(): void {
|
|
198
|
+
logResolvedPaths(cwd?: string): void {
|
|
214
199
|
const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths();
|
|
215
|
-
const cwd = this.deps.context.get()?.cwd ?? null;
|
|
216
200
|
const { agentDir } = this.deps;
|
|
217
201
|
const legacyGlobalPolicyDetected = existsSync(
|
|
218
202
|
getLegacyGlobalPolicyPath(agentDir),
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
|
|
3
4
|
import { registerPermissionSystemCommand } from "./config-modal";
|
|
4
5
|
import { getGlobalConfigPath } from "./config-paths";
|
|
6
|
+
import { ConfigStore } from "./config-store";
|
|
5
7
|
import { GateDecisionReporter } from "./decision-reporter";
|
|
8
|
+
import { computeExtensionPaths } from "./extension-paths";
|
|
6
9
|
import {
|
|
7
10
|
PermissionForwarder,
|
|
8
11
|
type PermissionForwarderDeps,
|
|
@@ -22,9 +25,9 @@ import { PermissionManager } from "./permission-manager";
|
|
|
22
25
|
import { PermissionPrompter } from "./permission-prompter";
|
|
23
26
|
import { PermissionSession } from "./permission-session";
|
|
24
27
|
import { LocalPermissionsService } from "./permissions-service";
|
|
25
|
-
import { createExtensionRuntime } from "./runtime";
|
|
26
28
|
import { PermissionServiceLifecycle } from "./service-lifecycle";
|
|
27
29
|
import { createSessionLogger } from "./session-logger";
|
|
30
|
+
import { SessionRules } from "./session-rules";
|
|
28
31
|
import { isSubagentExecutionContext } from "./subagent-context";
|
|
29
32
|
import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
|
|
30
33
|
import { getSubagentSessionRegistry } from "./subagent-registry";
|
|
@@ -35,56 +38,85 @@ import {
|
|
|
35
38
|
} from "./yolo-mode";
|
|
36
39
|
|
|
37
40
|
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
38
|
-
const
|
|
41
|
+
const agentDir = getAgentDir();
|
|
42
|
+
const paths = computeExtensionPaths(agentDir);
|
|
43
|
+
const permissionManager = new PermissionManager({ agentDir });
|
|
44
|
+
const sessionRules = new SessionRules();
|
|
39
45
|
const subagentRegistry = getSubagentSessionRegistry();
|
|
40
46
|
const formatterRegistry = new ToolInputFormatterRegistry();
|
|
41
47
|
registerBuiltinToolInputFormatters(formatterRegistry);
|
|
42
48
|
|
|
49
|
+
// Forward reference: configStore is declared before the logger so the
|
|
50
|
+
// logger's getConfig thunk can close over the variable; assigned immediately
|
|
51
|
+
// after. Typed via cast so the closure compiles without assertions.
|
|
52
|
+
// The same null-at-init pattern used in the former createExtensionRuntime.
|
|
53
|
+
let configStore = null as unknown as ConfigStore;
|
|
54
|
+
|
|
55
|
+
// sessionNotify is a mutable holder so the logger's notify closure can
|
|
56
|
+
// reach the UI once PermissionSession is constructed. Starts as null;
|
|
57
|
+
// notify is a best-effort sink (no-op at factory-init when there is no UI).
|
|
58
|
+
let sessionNotify: PermissionSession | null = null;
|
|
59
|
+
|
|
60
|
+
const logger = createSessionLogger({
|
|
61
|
+
globalLogsDir: paths.globalLogsDir,
|
|
62
|
+
getConfig: () => configStore.current(),
|
|
63
|
+
notify: (message) =>
|
|
64
|
+
sessionNotify?.getRuntimeContext()?.ui.notify(message, "warning"),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
configStore = new ConfigStore({
|
|
68
|
+
agentDir,
|
|
69
|
+
policyPaths: permissionManager,
|
|
70
|
+
logger: {
|
|
71
|
+
writeDebugLog: (e, d) => logger.debug(e, d),
|
|
72
|
+
writeReviewLog: (e, d) => logger.review(e, d),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
43
76
|
const forwardingDeps: PermissionForwarderDeps = {
|
|
44
|
-
forwardingDir:
|
|
45
|
-
subagentSessionsDir:
|
|
77
|
+
forwardingDir: paths.forwardingDir,
|
|
78
|
+
subagentSessionsDir: paths.subagentSessionsDir,
|
|
46
79
|
registry: subagentRegistry,
|
|
47
80
|
events: pi.events,
|
|
48
81
|
logger: {
|
|
49
|
-
writeReviewLog:
|
|
50
|
-
writeDebugLog:
|
|
82
|
+
writeReviewLog: (event, details) => logger.review(event, details),
|
|
83
|
+
writeDebugLog: (event, details) => logger.debug(event, details),
|
|
51
84
|
},
|
|
52
|
-
writeReviewLog:
|
|
85
|
+
writeReviewLog: (event, details) => logger.review(event, details),
|
|
53
86
|
requestPermissionDecisionFromUi,
|
|
54
87
|
shouldAutoApprove: () =>
|
|
55
|
-
shouldAutoApprovePermissionState("ask",
|
|
88
|
+
shouldAutoApprovePermissionState("ask", configStore.current()),
|
|
56
89
|
};
|
|
57
90
|
const forwarder = new PermissionForwarder(forwardingDeps);
|
|
58
91
|
|
|
59
92
|
const prompter = new PermissionPrompter({
|
|
60
|
-
config:
|
|
61
|
-
writeReviewLog:
|
|
93
|
+
config: configStore,
|
|
94
|
+
writeReviewLog: (event, details) => logger.review(event, details),
|
|
62
95
|
events: pi.events,
|
|
63
96
|
forwarder,
|
|
64
97
|
});
|
|
65
98
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const sessionManager = new PermissionManager({ agentDir: runtime.agentDir });
|
|
99
|
+
configStore.refresh();
|
|
69
100
|
|
|
70
101
|
const session = new PermissionSession(
|
|
71
|
-
|
|
72
|
-
|
|
102
|
+
paths,
|
|
103
|
+
logger,
|
|
73
104
|
new ForwardingManager(
|
|
74
|
-
|
|
105
|
+
paths.subagentSessionsDir,
|
|
75
106
|
forwarder,
|
|
76
107
|
subagentRegistry,
|
|
77
108
|
),
|
|
78
|
-
|
|
79
|
-
|
|
109
|
+
permissionManager,
|
|
110
|
+
sessionRules,
|
|
111
|
+
configStore,
|
|
80
112
|
{
|
|
81
113
|
canRequestPermissionConfirmation: (ctx) =>
|
|
82
114
|
canResolveAskPermissionRequest({
|
|
83
|
-
config:
|
|
115
|
+
config: configStore.current(),
|
|
84
116
|
hasUI: ctx.hasUI,
|
|
85
117
|
isSubagent: isSubagentExecutionContext(
|
|
86
118
|
ctx,
|
|
87
|
-
|
|
119
|
+
paths.subagentSessionsDir,
|
|
88
120
|
subagentRegistry,
|
|
89
121
|
),
|
|
90
122
|
}),
|
|
@@ -92,26 +124,29 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
92
124
|
},
|
|
93
125
|
);
|
|
94
126
|
|
|
127
|
+
// Connect the notify sink now that session is available.
|
|
128
|
+
sessionNotify = session;
|
|
129
|
+
|
|
95
130
|
registerPermissionSystemCommand(pi, {
|
|
96
|
-
config:
|
|
97
|
-
getConfigPath: () => getGlobalConfigPath(
|
|
131
|
+
config: configStore,
|
|
132
|
+
getConfigPath: () => getGlobalConfigPath(agentDir),
|
|
98
133
|
getComposedRules: () =>
|
|
99
|
-
|
|
100
|
-
|
|
134
|
+
permissionManager.getComposedConfigRules(
|
|
135
|
+
session.lastKnownActiveAgentName ?? undefined,
|
|
101
136
|
),
|
|
102
137
|
});
|
|
103
138
|
|
|
104
139
|
const rpcHandles = registerPermissionRpcHandlers(pi.events, {
|
|
105
|
-
getPermissionManager: () =>
|
|
106
|
-
getSessionRules: () =>
|
|
107
|
-
getRuntimeContext: () =>
|
|
140
|
+
getPermissionManager: () => permissionManager,
|
|
141
|
+
getSessionRules: () => sessionRules.getRuleset(),
|
|
142
|
+
getRuntimeContext: () => session.getRuntimeContext(),
|
|
108
143
|
requestPermissionDecisionFromUi,
|
|
109
|
-
writeReviewLog:
|
|
144
|
+
writeReviewLog: (event, details) => logger.review(event, details),
|
|
110
145
|
});
|
|
111
146
|
|
|
112
147
|
const permissionsService = new LocalPermissionsService(
|
|
113
|
-
|
|
114
|
-
|
|
148
|
+
permissionManager,
|
|
149
|
+
sessionRules,
|
|
115
150
|
formatterRegistry,
|
|
116
151
|
);
|
|
117
152
|
|
|
@@ -20,7 +20,7 @@ import type { SessionApproval } from "./session-approval";
|
|
|
20
20
|
import type { SessionApprovalRecorder } from "./session-approval-recorder";
|
|
21
21
|
import type { SessionLifecycleSession } from "./session-lifecycle-session";
|
|
22
22
|
import type { SessionLogger } from "./session-logger";
|
|
23
|
-
import { SessionRules } from "./session-rules";
|
|
23
|
+
import type { SessionRules } from "./session-rules";
|
|
24
24
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
25
25
|
import {
|
|
26
26
|
resolveToolPreviewLimits,
|
|
@@ -31,8 +31,7 @@ import type { PermissionCheckResult, PermissionState } from "./types";
|
|
|
31
31
|
/**
|
|
32
32
|
* Runtime operations that `PermissionSession` delegates to but does not own.
|
|
33
33
|
*
|
|
34
|
-
* Injected at construction time from the composition root (`index.ts`)
|
|
35
|
-
* where the `ExtensionRuntime` is available.
|
|
34
|
+
* Injected at construction time from the composition root (`index.ts`).
|
|
36
35
|
*/
|
|
37
36
|
export interface PermissionSessionRuntimeDeps {
|
|
38
37
|
/** Whether the current context can show an interactive permission prompt. */
|
|
@@ -69,7 +68,6 @@ export class PermissionSession
|
|
|
69
68
|
SessionLifecycleSession
|
|
70
69
|
{
|
|
71
70
|
private context: ExtensionContext | null = null;
|
|
72
|
-
private readonly sessionRules = new SessionRules();
|
|
73
71
|
private skillEntries: SkillPromptEntry[] = [];
|
|
74
72
|
private knownAgentName: string | null = null;
|
|
75
73
|
private toolsCacheKey: string | null = null;
|
|
@@ -80,6 +78,7 @@ export class PermissionSession
|
|
|
80
78
|
readonly logger: SessionLogger,
|
|
81
79
|
private readonly forwarding: ForwardingController,
|
|
82
80
|
private readonly permissionManager: ScopedPermissionManager,
|
|
81
|
+
private readonly sessionRules: SessionRules,
|
|
83
82
|
private readonly configStore: SessionConfigStore,
|
|
84
83
|
private readonly runtimeDeps: PermissionSessionRuntimeDeps,
|
|
85
84
|
) {}
|
|
@@ -262,7 +261,7 @@ export class PermissionSession
|
|
|
262
261
|
|
|
263
262
|
/** Write the resolved config path set to the review and debug logs. */
|
|
264
263
|
logResolvedConfigPaths(): void {
|
|
265
|
-
this.configStore.logResolvedPaths();
|
|
264
|
+
this.configStore.logResolvedPaths(this.context?.cwd);
|
|
266
265
|
}
|
|
267
266
|
|
|
268
267
|
/** Read current extension config. */
|
|
@@ -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(
|
package/src/session-logger.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
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";
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Unified logging + notification surface for handler deps.
|
|
@@ -13,17 +19,48 @@ export interface SessionLogger {
|
|
|
13
19
|
warn(message: string): void;
|
|
14
20
|
}
|
|
15
21
|
|
|
22
|
+
/** Narrow dependencies for constructing a {@link SessionLogger}. */
|
|
23
|
+
export interface SessionLoggerDeps {
|
|
24
|
+
/** Root logs directory; the debug + review log file paths derive from it. */
|
|
25
|
+
globalLogsDir: string;
|
|
26
|
+
/** Reads current config for the debug/review write toggles (call-time). */
|
|
27
|
+
getConfig: () => PermissionSystemExtensionConfig;
|
|
28
|
+
/** Surfaces a warning message to the user; called at warn/IO-failure time. */
|
|
29
|
+
notify: (message: string) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
/**
|
|
17
|
-
* Create a SessionLogger
|
|
33
|
+
* Create a SessionLogger from narrow dependencies.
|
|
18
34
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
35
|
+
* Composes the JSONL log writer, owns the IO-failure warning dedup Set,
|
|
36
|
+
* and routes both IO-failure warnings and explicit warn() calls through
|
|
37
|
+
* the injected notify sink. No ExtensionRuntime reference required.
|
|
22
38
|
*/
|
|
23
|
-
export function createSessionLogger(
|
|
39
|
+
export function createSessionLogger(deps: SessionLoggerDeps): SessionLogger {
|
|
40
|
+
const writer = createPermissionSystemLogger({
|
|
41
|
+
getConfig: deps.getConfig,
|
|
42
|
+
debugLogPath: join(deps.globalLogsDir, DEBUG_LOG_FILENAME),
|
|
43
|
+
reviewLogPath: join(deps.globalLogsDir, REVIEW_LOG_FILENAME),
|
|
44
|
+
ensureLogsDirectory: () =>
|
|
45
|
+
ensurePermissionSystemLogsDirectory(deps.globalLogsDir),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const reported = new Set<string>();
|
|
49
|
+
const reportOnce = (warning: string): void => {
|
|
50
|
+
if (reported.has(warning)) return;
|
|
51
|
+
reported.add(warning);
|
|
52
|
+
deps.notify(warning);
|
|
53
|
+
};
|
|
54
|
+
|
|
24
55
|
return {
|
|
25
|
-
debug: (event, details) =>
|
|
26
|
-
|
|
27
|
-
|
|
56
|
+
debug: (event, details) => {
|
|
57
|
+
const warning = writer.debug(event, details);
|
|
58
|
+
if (warning) reportOnce(warning);
|
|
59
|
+
},
|
|
60
|
+
review: (event, details) => {
|
|
61
|
+
const warning = writer.review(event, details);
|
|
62
|
+
if (warning) reportOnce(warning);
|
|
63
|
+
},
|
|
64
|
+
warn: (message) => deps.notify(message),
|
|
28
65
|
};
|
|
29
66
|
}
|
|
@@ -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
|
|
@@ -62,26 +62,12 @@ import {
|
|
|
62
62
|
ConfigStore,
|
|
63
63
|
type ConfigStoreDeps,
|
|
64
64
|
type ResolvedPolicyPathProvider,
|
|
65
|
-
type RuntimeContextRef,
|
|
66
65
|
} from "#src/config-store";
|
|
67
66
|
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
68
67
|
import type { ResolvedPolicyPaths } from "#src/policy-loader";
|
|
69
68
|
|
|
70
69
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
71
70
|
|
|
72
|
-
function makeContextRef(
|
|
73
|
-
initialCtx: ExtensionContext | null = null,
|
|
74
|
-
): RuntimeContextRef & { _ctx: ExtensionContext | null } {
|
|
75
|
-
const ref = {
|
|
76
|
-
_ctx: initialCtx,
|
|
77
|
-
get: () => ref._ctx,
|
|
78
|
-
set: (ctx: ExtensionContext) => {
|
|
79
|
-
ref._ctx = ctx;
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
return ref;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
71
|
function makePolicyPathProvider(
|
|
86
72
|
paths?: Partial<ResolvedPolicyPaths>,
|
|
87
73
|
): ResolvedPolicyPathProvider {
|
|
@@ -133,19 +119,16 @@ function makeCommandCtx(
|
|
|
133
119
|
|
|
134
120
|
function makeStore(overrides: Partial<ConfigStoreDeps> = {}): {
|
|
135
121
|
store: ConfigStore;
|
|
136
|
-
contextRef: RuntimeContextRef & { _ctx: ExtensionContext | null };
|
|
137
122
|
logger: ReturnType<typeof makeLogger>;
|
|
138
123
|
} {
|
|
139
|
-
const contextRef = makeContextRef();
|
|
140
124
|
const logger = makeLogger();
|
|
141
125
|
const deps: ConfigStoreDeps = {
|
|
142
126
|
agentDir: "/test/agent",
|
|
143
|
-
context: contextRef,
|
|
144
127
|
policyPaths: makePolicyPathProvider(),
|
|
145
128
|
logger,
|
|
146
129
|
...overrides,
|
|
147
130
|
};
|
|
148
|
-
return { store: new ConfigStore(deps),
|
|
131
|
+
return { store: new ConfigStore(deps), logger };
|
|
149
132
|
}
|
|
150
133
|
|
|
151
134
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
@@ -180,10 +163,9 @@ describe("ConfigStore", () => {
|
|
|
180
163
|
// ── refresh() ─────────────────────────────────────────────────────────
|
|
181
164
|
|
|
182
165
|
describe("refresh()", () => {
|
|
183
|
-
it("
|
|
184
|
-
const { store
|
|
185
|
-
|
|
186
|
-
store.refresh();
|
|
166
|
+
it("uses the passed ctx cwd for loadAndMergeConfigs", () => {
|
|
167
|
+
const { store } = makeStore();
|
|
168
|
+
store.refresh(makeCtx({ cwd: "/my/project" }));
|
|
187
169
|
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
|
|
188
170
|
"/test/agent",
|
|
189
171
|
"/my/project",
|
|
@@ -191,19 +173,14 @@ describe("ConfigStore", () => {
|
|
|
191
173
|
);
|
|
192
174
|
});
|
|
193
175
|
|
|
194
|
-
it("
|
|
195
|
-
const { store
|
|
196
|
-
const ctx = makeCtx({ cwd: "/new/project" });
|
|
197
|
-
store.refresh(ctx);
|
|
198
|
-
expect(contextRef._ctx).toBe(ctx);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("does not overwrite context when ctx is omitted", () => {
|
|
202
|
-
const { store, contextRef } = makeStore();
|
|
203
|
-
const existing = makeCtx();
|
|
204
|
-
contextRef._ctx = existing;
|
|
176
|
+
it("uses empty string cwd when no ctx is provided", () => {
|
|
177
|
+
const { store } = makeStore();
|
|
205
178
|
store.refresh();
|
|
206
|
-
expect(
|
|
179
|
+
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
|
|
180
|
+
"/test/agent",
|
|
181
|
+
"",
|
|
182
|
+
expect.any(String),
|
|
183
|
+
);
|
|
207
184
|
});
|
|
208
185
|
|
|
209
186
|
it("updates current() with normalized merged result", () => {
|
|
@@ -422,13 +399,12 @@ describe("ConfigStore", () => {
|
|
|
422
399
|
});
|
|
423
400
|
|
|
424
401
|
it("passes legacy detection results to buildResolvedConfigLogEntry", () => {
|
|
425
|
-
const { store
|
|
426
|
-
contextRef._ctx = makeCtx({ cwd: "/some/project" });
|
|
402
|
+
const { store } = makeStore();
|
|
427
403
|
// Make one legacy path exist
|
|
428
404
|
mockExistsSync.mockImplementation((p: string) =>
|
|
429
405
|
p.includes("policies.json"),
|
|
430
406
|
);
|
|
431
|
-
store.logResolvedPaths();
|
|
407
|
+
store.logResolvedPaths("/some/project");
|
|
432
408
|
expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith(
|
|
433
409
|
expect.objectContaining({
|
|
434
410
|
legacyGlobalPolicyDetected: expect.any(Boolean),
|
|
@@ -438,9 +414,9 @@ describe("ConfigStore", () => {
|
|
|
438
414
|
);
|
|
439
415
|
});
|
|
440
416
|
|
|
441
|
-
it("does not check project legacy path when
|
|
442
|
-
const { store } = makeStore();
|
|
443
|
-
store.logResolvedPaths();
|
|
417
|
+
it("does not check project legacy path when no cwd is provided", () => {
|
|
418
|
+
const { store } = makeStore();
|
|
419
|
+
store.logResolvedPaths(); // no cwd
|
|
444
420
|
// existsSync called for global and ext-config legacy paths only (not project)
|
|
445
421
|
const calls = mockExistsSync.mock.calls.map(([p]: [string]) => p);
|
|
446
422
|
const projectCalls = calls.filter(
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import type { Ruleset } from "#src/rule";
|
|
30
30
|
import { SessionApproval } from "#src/session-approval";
|
|
31
31
|
import type { SessionLogger } from "#src/session-logger";
|
|
32
|
+
import { SessionRules } from "#src/session-rules";
|
|
32
33
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
33
34
|
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
34
35
|
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
@@ -129,6 +130,7 @@ function createSession(overrides?: {
|
|
|
129
130
|
logger?: SessionLogger;
|
|
130
131
|
forwarding?: ForwardingController;
|
|
131
132
|
permissionManager?: ScopedPermissionManager;
|
|
133
|
+
sessionRules?: SessionRules;
|
|
132
134
|
configStore?: SessionConfigStore;
|
|
133
135
|
runtimeDeps?: PermissionSessionRuntimeDeps;
|
|
134
136
|
}): {
|
|
@@ -136,6 +138,7 @@ function createSession(overrides?: {
|
|
|
136
138
|
paths: ExtensionPaths;
|
|
137
139
|
logger: SessionLogger;
|
|
138
140
|
forwarding: ForwardingController;
|
|
141
|
+
sessionRules: SessionRules;
|
|
139
142
|
configStore: SessionConfigStore;
|
|
140
143
|
runtimeDeps: PermissionSessionRuntimeDeps;
|
|
141
144
|
} {
|
|
@@ -144,6 +147,7 @@ function createSession(overrides?: {
|
|
|
144
147
|
const forwarding = overrides?.forwarding ?? makeForwarding();
|
|
145
148
|
const permissionManager =
|
|
146
149
|
overrides?.permissionManager ?? makePermissionManager();
|
|
150
|
+
const sessionRules = overrides?.sessionRules ?? new SessionRules();
|
|
147
151
|
const configStore = overrides?.configStore ?? makeConfigStore();
|
|
148
152
|
const runtimeDeps = overrides?.runtimeDeps ?? makeRuntimeDeps();
|
|
149
153
|
const session = new PermissionSession(
|
|
@@ -151,10 +155,19 @@ function createSession(overrides?: {
|
|
|
151
155
|
logger,
|
|
152
156
|
forwarding,
|
|
153
157
|
permissionManager,
|
|
158
|
+
sessionRules,
|
|
154
159
|
configStore,
|
|
155
160
|
runtimeDeps,
|
|
156
161
|
);
|
|
157
|
-
return {
|
|
162
|
+
return {
|
|
163
|
+
session,
|
|
164
|
+
paths,
|
|
165
|
+
logger,
|
|
166
|
+
forwarding,
|
|
167
|
+
sessionRules,
|
|
168
|
+
configStore,
|
|
169
|
+
runtimeDeps,
|
|
170
|
+
};
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
// ── Tests ──────────────────────────────────────────────────────────────────
|