@gotgenes/pi-permission-system 8.3.2 → 9.0.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 +21 -0
- package/package.json +1 -1
- package/src/handlers/lifecycle.ts +9 -0
- package/src/index.ts +30 -11
- package/src/permission-events.ts +3 -2
- package/src/service.ts +17 -4
- package/src/subagent-context.ts +28 -9
- package/test/composition-root.test.ts +398 -0
- package/test/handlers/lifecycle.test.ts +15 -2
- package/test/helpers/make-fake-pi.ts +95 -0
- package/test/permission-events.test.ts +32 -2
- package/test/permission-system.test.ts +16 -34
- package/test/service.test.ts +25 -6
- package/test/subagent-context.test.ts +40 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ 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
|
+
## [9.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.3.2...pi-permission-system-v9.0.0) (2026-06-01)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* unpublishPermissionsService() now requires the service to remove as its sole argument. The package's public export is service.ts, so this changes the published API surface.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* scope service teardown to the publishing instance ([#302](https://github.com/gotgenes/pi-packages/issues/302)) ([72180e9](https://github.com/gotgenes/pi-packages/commit/72180e906f7370c842cd5e31a11726c2971fc988))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* keep the parent's service published across child shutdown ([#302](https://github.com/gotgenes/pi-packages/issues/302)) ([300214c](https://github.com/gotgenes/pi-packages/commit/300214ca21d985bfba7231f261c022c394d8bf5a))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
* document session_start service publication and ready timing ([#302](https://github.com/gotgenes/pi-packages/issues/302)) ([a894fb8](https://github.com/gotgenes/pi-packages/commit/a894fb8d5c2bbc7cd9d33769859d172c5a7dbb73))
|
|
28
|
+
|
|
8
29
|
## [8.3.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.3.1...pi-permission-system-v8.3.2) (2026-06-01)
|
|
9
30
|
|
|
10
31
|
|
package/package.json
CHANGED
|
@@ -18,11 +18,14 @@ interface ResourcesDiscoverPayload {
|
|
|
18
18
|
*
|
|
19
19
|
* Constructor deps:
|
|
20
20
|
* - `session` — encapsulates all mutable session state
|
|
21
|
+
* - `activateService` — publishes the process-global service for this session
|
|
22
|
+
* (skipped for in-process subagent children) and emits the ready event
|
|
21
23
|
* - `cleanupRpc` — unsubscribes RPC handlers on shutdown
|
|
22
24
|
*/
|
|
23
25
|
export class SessionLifecycleHandler {
|
|
24
26
|
constructor(
|
|
25
27
|
private readonly session: PermissionSession,
|
|
28
|
+
private readonly activateService: (ctx: ExtensionContext) => void,
|
|
26
29
|
private readonly cleanupRpc: () => void,
|
|
27
30
|
) {}
|
|
28
31
|
|
|
@@ -47,6 +50,12 @@ export class SessionLifecycleHandler {
|
|
|
47
50
|
cwd: ctx.cwd,
|
|
48
51
|
});
|
|
49
52
|
}
|
|
53
|
+
|
|
54
|
+
// Publish the process-global service now that a ctx (and therefore the
|
|
55
|
+
// session id) is available, so an in-process subagent child can be
|
|
56
|
+
// identified and excluded. Emitting ready here keeps the
|
|
57
|
+
// service-resolvable-when-ready ordering contract.
|
|
58
|
+
this.activateService(ctx);
|
|
50
59
|
return Promise.resolve();
|
|
51
60
|
}
|
|
52
61
|
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
5
|
import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
|
|
3
6
|
import { registerPermissionSystemCommand } from "./config-modal";
|
|
4
7
|
import { getGlobalConfigPath } from "./config-paths";
|
|
@@ -27,7 +30,10 @@ import {
|
|
|
27
30
|
unpublishPermissionsService,
|
|
28
31
|
} from "./service";
|
|
29
32
|
import { createSessionLogger } from "./session-logger";
|
|
30
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
isRegisteredSubagentChild,
|
|
35
|
+
isSubagentExecutionContext,
|
|
36
|
+
} from "./subagent-context";
|
|
31
37
|
import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
|
|
32
38
|
import { getSubagentSessionRegistry } from "./subagent-registry";
|
|
33
39
|
import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
|
|
@@ -129,7 +135,18 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
129
135
|
return formatterRegistry.register(toolName, formatter);
|
|
130
136
|
},
|
|
131
137
|
};
|
|
132
|
-
|
|
138
|
+
|
|
139
|
+
// Publish the service to the process-global slot only when this instance is
|
|
140
|
+
// not an in-process subagent child, then emit ready. Deferred to
|
|
141
|
+
// session_start (vs. factory init) because identifying a child requires the
|
|
142
|
+
// session id from ctx, which the factory body does not have. A registered
|
|
143
|
+
// child therefore never clobbers the parent's published service. See #302.
|
|
144
|
+
const activateServiceForSession = (ctx: ExtensionContext): void => {
|
|
145
|
+
if (!isRegisteredSubagentChild(ctx, subagentRegistry)) {
|
|
146
|
+
publishPermissionsService(permissionsService);
|
|
147
|
+
}
|
|
148
|
+
emitReadyEvent(pi.events);
|
|
149
|
+
};
|
|
133
150
|
|
|
134
151
|
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
|
|
135
152
|
// sessions register/unregister without the core calling us (ADR 0002).
|
|
@@ -138,19 +155,21 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
138
155
|
subagentRegistry,
|
|
139
156
|
);
|
|
140
157
|
|
|
141
|
-
emitReadyEvent(pi.events);
|
|
142
|
-
|
|
143
158
|
const toolRegistry = {
|
|
144
159
|
getAll: () => pi.getAllTools(),
|
|
145
160
|
setActive: (names: string[]) => pi.setActiveTools(names),
|
|
146
161
|
};
|
|
147
162
|
|
|
148
|
-
const lifecycle = new SessionLifecycleHandler(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
163
|
+
const lifecycle = new SessionLifecycleHandler(
|
|
164
|
+
session,
|
|
165
|
+
activateServiceForSession,
|
|
166
|
+
() => {
|
|
167
|
+
rpcHandles.unsubCheck();
|
|
168
|
+
rpcHandles.unsubPrompt();
|
|
169
|
+
unsubSubagentLifecycle();
|
|
170
|
+
unpublishPermissionsService(permissionsService);
|
|
171
|
+
},
|
|
172
|
+
);
|
|
154
173
|
const agentPrep = new AgentPrepHandler(session, toolRegistry);
|
|
155
174
|
const gates = new PermissionGateHandler(
|
|
156
175
|
session,
|
package/src/permission-events.ts
CHANGED
|
@@ -24,7 +24,7 @@ export const PERMISSIONS_PROTOCOL_VERSION = 1;
|
|
|
24
24
|
|
|
25
25
|
// ── Channel name constants ─────────────────────────────────────────────────
|
|
26
26
|
|
|
27
|
-
/** Emitted
|
|
27
|
+
/** Emitted at `session_start`, after the service is published. */
|
|
28
28
|
export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
|
|
29
29
|
|
|
30
30
|
/** Emitted after every permission gate resolution. */
|
|
@@ -160,7 +160,8 @@ export interface PermissionsPromptReplyData {
|
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
162
|
* Emit the `permissions:ready` broadcast.
|
|
163
|
-
* Call
|
|
163
|
+
* Call at `session_start`, after the service is published, so a consumer
|
|
164
|
+
* reacting to ready can immediately resolve `getPermissionsService()`.
|
|
164
165
|
*/
|
|
165
166
|
export function emitReadyEvent(events: PermissionEventBus): void {
|
|
166
167
|
const payload: PermissionsReadyEvent = {
|
package/src/service.ts
CHANGED
|
@@ -81,7 +81,10 @@ export interface PermissionsService {
|
|
|
81
81
|
* Store a `PermissionsService` on `globalThis` so other extensions can
|
|
82
82
|
* retrieve it via `getPermissionsService()`.
|
|
83
83
|
*
|
|
84
|
-
*
|
|
84
|
+
* Called at `session_start` by the top-level (parent) instance only — an
|
|
85
|
+
* in-process subagent child skips publishing so it cannot clobber the parent's
|
|
86
|
+
* service. Overwrites any previously published service, which keeps `/reload`
|
|
87
|
+
* working: a reloaded parent re-publishes its fresh service.
|
|
85
88
|
*/
|
|
86
89
|
export function publishPermissionsService(service: PermissionsService): void {
|
|
87
90
|
(globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
|
|
@@ -98,12 +101,22 @@ export function getPermissionsService(): PermissionsService | undefined {
|
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
/**
|
|
101
|
-
* Remove
|
|
104
|
+
* Remove `service` from `globalThis`, but only when the current slot still
|
|
105
|
+
* holds it (identity compare-and-delete).
|
|
102
106
|
*
|
|
103
107
|
* Called during `session_shutdown` to avoid stale references after the
|
|
104
|
-
* extension is torn down.
|
|
108
|
+
* extension is torn down. Scoping the delete to the publishing instance keeps
|
|
109
|
+
* two cases correct:
|
|
110
|
+
*
|
|
111
|
+
* - An in-process subagent child never published the parent's service, so its
|
|
112
|
+
* shutdown is a no-op and the parent's slot survives.
|
|
113
|
+
* - A superseded `/reload` generation no longer owns the slot, so its late
|
|
114
|
+
* shutdown cannot wipe the new generation's freshly published service.
|
|
105
115
|
*/
|
|
106
|
-
export function unpublishPermissionsService(): void {
|
|
116
|
+
export function unpublishPermissionsService(service: PermissionsService): void {
|
|
117
|
+
if (getPermissionsService() !== service) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
107
120
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
|
|
108
121
|
delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
|
|
109
122
|
}
|
package/src/subagent-context.ts
CHANGED
|
@@ -28,6 +28,32 @@ function isPathWithinDirectoryForSubagent(
|
|
|
28
28
|
return pathValue.startsWith(prefix);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Return `true` when `ctx` belongs to an in-process subagent child registered
|
|
33
|
+
* in `registry` by its session id.
|
|
34
|
+
*
|
|
35
|
+
* This is the only signal that identifies an **in-process** child (one sharing
|
|
36
|
+
* the parent's `globalThis`); env-hint and filesystem heuristics identify
|
|
37
|
+
* **process-based** subagents instead. The composition root uses this to decide
|
|
38
|
+
* whether the instance owns the process-global service slot — a registered
|
|
39
|
+
* child must not publish over its parent.
|
|
40
|
+
*/
|
|
41
|
+
export function isRegisteredSubagentChild(
|
|
42
|
+
ctx: ExtensionContext,
|
|
43
|
+
registry: SubagentSessionRegistry,
|
|
44
|
+
): boolean {
|
|
45
|
+
try {
|
|
46
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
47
|
+
if (!sessionId) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return registry.has(sessionId);
|
|
51
|
+
} catch {
|
|
52
|
+
// getSessionId() unavailable — treat as not-a-registered-child.
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
31
57
|
export function isSubagentExecutionContext(
|
|
32
58
|
ctx: ExtensionContext,
|
|
33
59
|
subagentSessionsDir: string,
|
|
@@ -37,15 +63,8 @@ export function isSubagentExecutionContext(
|
|
|
37
63
|
// session id before bindExtensions(); checked first so it takes priority
|
|
38
64
|
// over heuristics. Each concurrent sibling has a unique session id, so
|
|
39
65
|
// one sibling's disposed event cannot affect another's registration.
|
|
40
|
-
if (registry) {
|
|
41
|
-
|
|
42
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
43
|
-
if (sessionId && registry.has(sessionId)) {
|
|
44
|
-
return true;
|
|
45
|
-
}
|
|
46
|
-
} catch {
|
|
47
|
-
// getSessionId() unavailable — fall through to env/filesystem detection.
|
|
48
|
-
}
|
|
66
|
+
if (registry && isRegisteredSubagentChild(ctx, registry)) {
|
|
67
|
+
return true;
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition-root tests for `piPermissionSystemExtension(pi)`.
|
|
3
|
+
*
|
|
4
|
+
* These run the real factory via the `makeFakePi()` harness and assert the
|
|
5
|
+
* wiring contracts that unit tests cannot see: handler-registration
|
|
6
|
+
* completeness, shared-instance contracts across factory invocations, teardown,
|
|
7
|
+
* service↔gate registry sharing, and `ready`-after-publish ordering.
|
|
8
|
+
*
|
|
9
|
+
* Every test runs the factory, which mutates two process-global `Symbol.for()`
|
|
10
|
+
* slots and reads `PI_CODING_AGENT_DIR`. The shared `beforeEach`/`afterEach`
|
|
11
|
+
* isolate the agent dir to a tmpdir and clear both global slots so factory runs
|
|
12
|
+
* do not leak across tests.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
mkdirSync,
|
|
16
|
+
mkdtempSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
createEventBus,
|
|
27
|
+
type ExtensionAPI,
|
|
28
|
+
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
30
|
+
|
|
31
|
+
import { getGlobalConfigPath } from "#src/config-paths";
|
|
32
|
+
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
33
|
+
import piPermissionSystemExtension from "#src/index";
|
|
34
|
+
import { PERMISSIONS_READY_CHANNEL } from "#src/permission-events";
|
|
35
|
+
import {
|
|
36
|
+
createPermissionForwardingLocation,
|
|
37
|
+
type ForwardedPermissionRequest,
|
|
38
|
+
} from "#src/permission-forwarding";
|
|
39
|
+
import { getPermissionsService } from "#src/service";
|
|
40
|
+
import { SUBAGENT_CHILD_SESSION_CREATED } from "#src/subagent-lifecycle-events";
|
|
41
|
+
import { getSubagentSessionRegistry } from "#src/subagent-registry";
|
|
42
|
+
import { makeFakePi } from "#test/helpers/make-fake-pi";
|
|
43
|
+
|
|
44
|
+
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
|
|
45
|
+
const SUBAGENT_REGISTRY_KEY = Symbol.for(
|
|
46
|
+
"@gotgenes/pi-permission-system:subagent-registry",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/** The six events the factory must register a handler for. */
|
|
50
|
+
const EXPECTED_HANDLERS = [
|
|
51
|
+
"before_agent_start",
|
|
52
|
+
"input",
|
|
53
|
+
"resources_discover",
|
|
54
|
+
"session_shutdown",
|
|
55
|
+
"session_start",
|
|
56
|
+
"tool_call",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
let agentDir: string;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
agentDir = mkdtempSync(join(tmpdir(), "pi-perm-comp-root-"));
|
|
63
|
+
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
// Drop both process-global slots so factory runs do not leak across tests.
|
|
68
|
+
const store = globalThis as Record<symbol, unknown>;
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property
|
|
70
|
+
delete store[SERVICE_KEY];
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property
|
|
72
|
+
delete store[SUBAGENT_REGISTRY_KEY];
|
|
73
|
+
vi.unstubAllEnvs();
|
|
74
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── Shared helpers ──────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/** Write the global config file under the stubbed agent dir. */
|
|
80
|
+
function writeGlobalConfig(config: Record<string, unknown>): void {
|
|
81
|
+
const globalConfigPath = getGlobalConfigPath(agentDir);
|
|
82
|
+
mkdirSync(dirname(globalConfigPath), { recursive: true });
|
|
83
|
+
writeFileSync(
|
|
84
|
+
globalConfigPath,
|
|
85
|
+
`${JSON.stringify({ ...DEFAULT_EXTENSION_CONFIG, ...config }, null, 2)}\n`,
|
|
86
|
+
"utf8",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Build a minimal subagent `ctx` (no UI) for driving tool-call gates. */
|
|
91
|
+
function makeChildCtx(cwd: string, sessionId: string): unknown {
|
|
92
|
+
return {
|
|
93
|
+
cwd,
|
|
94
|
+
hasUI: false,
|
|
95
|
+
sessionManager: {
|
|
96
|
+
getEntries: (): unknown[] => [],
|
|
97
|
+
getSessionId: (): string => sessionId,
|
|
98
|
+
getSessionDir: (): string => cwd,
|
|
99
|
+
},
|
|
100
|
+
ui: {
|
|
101
|
+
notify: (): void => {},
|
|
102
|
+
setStatus: (): void => {},
|
|
103
|
+
select: async (): Promise<string | undefined> => undefined,
|
|
104
|
+
input: async (): Promise<string | undefined> => undefined,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build a UI-present `ctx` that records the titles passed to `ui.select`, and
|
|
111
|
+
* approves every prompt. The ask-prompt message (which embeds the tool-input
|
|
112
|
+
* preview) is the first line of the select title.
|
|
113
|
+
*/
|
|
114
|
+
function makeUiCtx(cwd: string, capturedTitles: string[]): { ctx: unknown } {
|
|
115
|
+
const ctx = {
|
|
116
|
+
cwd,
|
|
117
|
+
hasUI: true,
|
|
118
|
+
sessionManager: {
|
|
119
|
+
getEntries: (): unknown[] => [],
|
|
120
|
+
getSessionId: (): string => "ui-session",
|
|
121
|
+
getSessionDir: (): string => cwd,
|
|
122
|
+
},
|
|
123
|
+
ui: {
|
|
124
|
+
notify: (): void => {},
|
|
125
|
+
setStatus: (): void => {},
|
|
126
|
+
select: async (title: string): Promise<string | undefined> => {
|
|
127
|
+
capturedTitles.push(title);
|
|
128
|
+
return "Yes";
|
|
129
|
+
},
|
|
130
|
+
input: async (): Promise<string | undefined> => undefined,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
return { ctx };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sleep = (ms: number): Promise<void> =>
|
|
137
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
138
|
+
|
|
139
|
+
/** Drive the registered `session_start` handler with a ctx. */
|
|
140
|
+
function fireSessionStart(
|
|
141
|
+
pi: ReturnType<typeof makeFakePi>,
|
|
142
|
+
ctx: unknown,
|
|
143
|
+
): Promise<unknown> {
|
|
144
|
+
return pi.fire("session_start", { reason: "start" }, ctx);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Simulate the parent UI session responding to a forwarded permission request.
|
|
149
|
+
*
|
|
150
|
+
* Polls the parent's requests directory for the child's request file, then
|
|
151
|
+
* writes an approval response so the child's forwarding poll resolves quickly
|
|
152
|
+
* instead of waiting out the 10-minute timeout.
|
|
153
|
+
*/
|
|
154
|
+
async function approveForwardedRequest(
|
|
155
|
+
forwardingDir: string,
|
|
156
|
+
parentSessionId: string,
|
|
157
|
+
): Promise<ForwardedPermissionRequest> {
|
|
158
|
+
const location = createPermissionForwardingLocation(
|
|
159
|
+
forwardingDir,
|
|
160
|
+
parentSessionId,
|
|
161
|
+
);
|
|
162
|
+
const deadline = Date.now() + 2000;
|
|
163
|
+
while (Date.now() < deadline) {
|
|
164
|
+
let files: string[] = [];
|
|
165
|
+
try {
|
|
166
|
+
files = readdirSync(location.requestsDir).filter((f) =>
|
|
167
|
+
f.endsWith(".json"),
|
|
168
|
+
);
|
|
169
|
+
} catch {
|
|
170
|
+
files = [];
|
|
171
|
+
}
|
|
172
|
+
const requestFile = files[0];
|
|
173
|
+
if (requestFile) {
|
|
174
|
+
const request = JSON.parse(
|
|
175
|
+
readFileSync(join(location.requestsDir, requestFile), "utf8"),
|
|
176
|
+
) as ForwardedPermissionRequest;
|
|
177
|
+
mkdirSync(location.responsesDir, { recursive: true });
|
|
178
|
+
writeFileSync(
|
|
179
|
+
join(location.responsesDir, `${request.id}.json`),
|
|
180
|
+
JSON.stringify({
|
|
181
|
+
approved: true,
|
|
182
|
+
state: "approved",
|
|
183
|
+
responderSessionId: parentSessionId,
|
|
184
|
+
respondedAt: Date.now(),
|
|
185
|
+
}),
|
|
186
|
+
"utf8",
|
|
187
|
+
);
|
|
188
|
+
return request;
|
|
189
|
+
}
|
|
190
|
+
await sleep(5);
|
|
191
|
+
}
|
|
192
|
+
throw new Error("Timed out waiting for the forwarded permission request");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
describe("event-handler registration completeness", () => {
|
|
196
|
+
it("registers a handler for every required event exactly once", () => {
|
|
197
|
+
const pi = makeFakePi();
|
|
198
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
199
|
+
|
|
200
|
+
expect([...pi.handlers.keys()].sort()).toEqual(EXPECTED_HANDLERS);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("subagent registry sharing across factory instances", () => {
|
|
205
|
+
// The #296 regression class: two factory invocations on *different* event
|
|
206
|
+
// buses must still resolve the same process-global SubagentSessionRegistry,
|
|
207
|
+
// so a child registered via the parent's bus detects itself as a subagent and
|
|
208
|
+
// forwards (rather than blocking) an external-directory `ask`.
|
|
209
|
+
it("lets a child instance forward an ask it received via the parent's bus", async () => {
|
|
210
|
+
writeGlobalConfig({
|
|
211
|
+
permission: { "*": "allow", external_directory: "ask" },
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
|
|
215
|
+
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
|
|
216
|
+
const forwardingDir = join(agentDir, "sessions", "permission-forwarding");
|
|
217
|
+
const parentSessionId = "parent-session-1";
|
|
218
|
+
const childSessionId = "child-session-1";
|
|
219
|
+
|
|
220
|
+
// Two factory instances, each wired to its own event bus (as in production:
|
|
221
|
+
// every session's ResourceLoader creates a separate bus).
|
|
222
|
+
const parentBus = createEventBus();
|
|
223
|
+
const childBus = createEventBus();
|
|
224
|
+
piPermissionSystemExtension(
|
|
225
|
+
makeFakePi({ events: parentBus }) as unknown as ExtensionAPI,
|
|
226
|
+
);
|
|
227
|
+
const childPi = makeFakePi({
|
|
228
|
+
events: childBus,
|
|
229
|
+
toolNames: ["read"],
|
|
230
|
+
});
|
|
231
|
+
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
|
|
232
|
+
|
|
233
|
+
// The child session is announced on the *parent's* bus only; the parent's
|
|
234
|
+
// lifecycle subscription writes it into the shared global registry.
|
|
235
|
+
parentBus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
236
|
+
sessionId: childSessionId,
|
|
237
|
+
parentSessionId,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// The child fires an external-directory read with no UI. With the shared
|
|
241
|
+
// registry it detects itself as a subagent and forwards; the simulated
|
|
242
|
+
// parent approves.
|
|
243
|
+
const firePromise = childPi.fire(
|
|
244
|
+
"tool_call",
|
|
245
|
+
{
|
|
246
|
+
toolName: "read",
|
|
247
|
+
toolCallId: "child-external-read",
|
|
248
|
+
input: { path: join(externalDir, "secret.txt") },
|
|
249
|
+
},
|
|
250
|
+
makeChildCtx(childCwd, childSessionId),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const request = await approveForwardedRequest(
|
|
254
|
+
forwardingDir,
|
|
255
|
+
parentSessionId,
|
|
256
|
+
);
|
|
257
|
+
expect(request.targetSessionId).toBe(parentSessionId);
|
|
258
|
+
expect(request.requesterSessionId).toBe(childSessionId);
|
|
259
|
+
|
|
260
|
+
const result = (await firePromise) as { block?: true };
|
|
261
|
+
expect(result.block).toBeUndefined();
|
|
262
|
+
|
|
263
|
+
rmSync(childCwd, { recursive: true, force: true });
|
|
264
|
+
rmSync(externalDir, { recursive: true, force: true });
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("shutdown teardown chain", () => {
|
|
269
|
+
it("unpublishes the service and unsubscribes the lifecycle on shutdown", async () => {
|
|
270
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-teardown-cwd-"));
|
|
271
|
+
const pi = makeFakePi();
|
|
272
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
273
|
+
|
|
274
|
+
// The service is published at session_start, not at factory init.
|
|
275
|
+
await fireSessionStart(pi, makeChildCtx(cwd, "top-session"));
|
|
276
|
+
expect(getPermissionsService()).toBeDefined();
|
|
277
|
+
|
|
278
|
+
await pi.fire("session_shutdown");
|
|
279
|
+
|
|
280
|
+
// Service slot cleared.
|
|
281
|
+
expect(getPermissionsService()).toBeUndefined();
|
|
282
|
+
|
|
283
|
+
// Lifecycle unsubscribed: a post-shutdown session-created must not register.
|
|
284
|
+
pi.events.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
285
|
+
sessionId: "late-child",
|
|
286
|
+
parentSessionId: "p-late",
|
|
287
|
+
});
|
|
288
|
+
expect(getSubagentSessionRegistry().has("late-child")).toBe(false);
|
|
289
|
+
|
|
290
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("service and gate share one formatter registry", () => {
|
|
295
|
+
// A formatter registered through the published service must be consulted by
|
|
296
|
+
// the live gate handler — proving both reference the same
|
|
297
|
+
// ToolInputFormatterRegistry instance the factory created once.
|
|
298
|
+
it("surfaces a service-registered formatter in the gate's ask prompt", async () => {
|
|
299
|
+
writeGlobalConfig({
|
|
300
|
+
permission: { "*": "allow", demo: "ask" },
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ui-cwd-"));
|
|
304
|
+
const pi = makeFakePi({ toolNames: ["demo"] });
|
|
305
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
306
|
+
|
|
307
|
+
const capturedTitles: string[] = [];
|
|
308
|
+
const { ctx } = makeUiCtx(cwd, capturedTitles);
|
|
309
|
+
// The service is published at session_start; publish before resolving it.
|
|
310
|
+
await fireSessionStart(pi, ctx);
|
|
311
|
+
|
|
312
|
+
const previewMarker = "PREVIEW::shared-registry-proof";
|
|
313
|
+
getPermissionsService()!.registerToolInputFormatter(
|
|
314
|
+
"demo",
|
|
315
|
+
() => previewMarker,
|
|
316
|
+
);
|
|
317
|
+
const result = (await pi.fire(
|
|
318
|
+
"tool_call",
|
|
319
|
+
{ toolName: "demo", toolCallId: "demo-ask", input: { foo: "bar" } },
|
|
320
|
+
ctx,
|
|
321
|
+
)) as { block?: true };
|
|
322
|
+
|
|
323
|
+
// The gate prompted (not blocked) and the prompt embedded the formatter's
|
|
324
|
+
// preview — so the gate consulted the same registry the service wrote to.
|
|
325
|
+
expect(result.block).toBeUndefined();
|
|
326
|
+
expect(capturedTitles.some((t) => t.includes(previewMarker))).toBe(true);
|
|
327
|
+
|
|
328
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("ready emitted after service publication", () => {
|
|
333
|
+
// Ordering contracts exist only at the composition root: a consumer reacting
|
|
334
|
+
// to permissions:ready must be able to resolve the service immediately. The
|
|
335
|
+
// service is published and ready fires at session_start (not factory init).
|
|
336
|
+
it("publishes the service before emitting permissions:ready", async () => {
|
|
337
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ready-cwd-"));
|
|
338
|
+
const seen: string[] = [];
|
|
339
|
+
const pi = makeFakePi();
|
|
340
|
+
pi.events.on(PERMISSIONS_READY_CHANNEL, () => {
|
|
341
|
+
seen.push(getPermissionsService() ? "present" : "missing");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
345
|
+
|
|
346
|
+
// ready is not emitted at load; only after session_start publishes.
|
|
347
|
+
expect(seen).toEqual([]);
|
|
348
|
+
|
|
349
|
+
await fireSessionStart(pi, makeChildCtx(cwd, "top-session"));
|
|
350
|
+
|
|
351
|
+
expect(seen).toEqual(["present"]);
|
|
352
|
+
|
|
353
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("multi-instance global service interplay", () => {
|
|
358
|
+
// The fix (#302) scopes the process-global service slot to the publishing
|
|
359
|
+
// instance. The parent publishes at its session_start; an in-process child
|
|
360
|
+
// (registered by session id) skips publishing, and its identity-scoped
|
|
361
|
+
// teardown is a no-op — so the parent's service is the one that resolves
|
|
362
|
+
// throughout the child's lifecycle and survives the child's shutdown.
|
|
363
|
+
it("keeps the parent's service published across the child's lifecycle", async () => {
|
|
364
|
+
const parentCwd = mkdtempSync(join(tmpdir(), "pi-perm-parent-cwd-"));
|
|
365
|
+
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
|
|
366
|
+
const childSessionId = "child-session-mi";
|
|
367
|
+
|
|
368
|
+
const parentPi = makeFakePi({ events: createEventBus() });
|
|
369
|
+
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI);
|
|
370
|
+
const childPi = makeFakePi({ events: createEventBus() });
|
|
371
|
+
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
|
|
372
|
+
|
|
373
|
+
// The parent is not a registered child, so it publishes its service.
|
|
374
|
+
await fireSessionStart(
|
|
375
|
+
parentPi,
|
|
376
|
+
makeChildCtx(parentCwd, "parent-session-mi"),
|
|
377
|
+
);
|
|
378
|
+
const parentService = getPermissionsService();
|
|
379
|
+
expect(parentService).toBeDefined();
|
|
380
|
+
|
|
381
|
+
// The child is registered in the shared global registry before its own
|
|
382
|
+
// session_start, so it detects itself and skips publishing.
|
|
383
|
+
getSubagentSessionRegistry().register(childSessionId, {
|
|
384
|
+
parentSessionId: "parent-session-mi",
|
|
385
|
+
});
|
|
386
|
+
await fireSessionStart(childPi, makeChildCtx(childCwd, childSessionId));
|
|
387
|
+
|
|
388
|
+
// Mid-run: the slot resolves the parent's service, never the child's.
|
|
389
|
+
expect(getPermissionsService()).toBe(parentService);
|
|
390
|
+
|
|
391
|
+
// The child's shutdown is a no-op for the slot it never owned.
|
|
392
|
+
await childPi.fire("session_shutdown");
|
|
393
|
+
expect(getPermissionsService()).toBe(parentService);
|
|
394
|
+
|
|
395
|
+
rmSync(parentCwd, { recursive: true, force: true });
|
|
396
|
+
rmSync(childCwd, { recursive: true, force: true });
|
|
397
|
+
});
|
|
398
|
+
});
|
|
@@ -36,12 +36,18 @@ function makeHandler(
|
|
|
36
36
|
): {
|
|
37
37
|
handler: SessionLifecycleHandler;
|
|
38
38
|
session: PermissionSession;
|
|
39
|
+
activateService: ReturnType<typeof vi.fn>;
|
|
39
40
|
cleanupRpc: ReturnType<typeof vi.fn>;
|
|
40
41
|
} {
|
|
41
42
|
const session = makeSession(overrides);
|
|
43
|
+
const activateService = vi.fn();
|
|
42
44
|
const cleanupRpc = vi.fn();
|
|
43
|
-
const handler = new SessionLifecycleHandler(
|
|
44
|
-
|
|
45
|
+
const handler = new SessionLifecycleHandler(
|
|
46
|
+
session,
|
|
47
|
+
activateService,
|
|
48
|
+
cleanupRpc,
|
|
49
|
+
);
|
|
50
|
+
return { handler, session, activateService, cleanupRpc };
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
// ── handleSessionStart ─────────────────────────────────────────────────────
|
|
@@ -106,6 +112,13 @@ describe("handleSessionStart", () => {
|
|
|
106
112
|
expect(session.logger.debug).not.toHaveBeenCalled();
|
|
107
113
|
});
|
|
108
114
|
|
|
115
|
+
it("activates the service for the session with ctx", async () => {
|
|
116
|
+
const ctx = makeCtx();
|
|
117
|
+
const { handler, activateService } = makeHandler();
|
|
118
|
+
await handler.handleSessionStart({ reason: "startup" }, ctx);
|
|
119
|
+
expect(activateService).toHaveBeenCalledWith(ctx);
|
|
120
|
+
});
|
|
121
|
+
|
|
109
122
|
it("calls refreshConfig before resetForNewSession", async () => {
|
|
110
123
|
const callOrder: string[] = [];
|
|
111
124
|
const { handler } = makeHandler({
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `makeFakePi()` — a composition-root test harness.
|
|
3
|
+
*
|
|
4
|
+
* Lets a test run the real `piPermissionSystemExtension(pi)` factory and then
|
|
5
|
+
* introspect and drive the result. Unlike the per-handler unit fixtures in
|
|
6
|
+
* `handler-fixtures.ts` (which inject collaborators), this harness exercises the
|
|
7
|
+
* factory itself — the wiring layer where registration completeness, shared-
|
|
8
|
+
* instance contracts, teardown, and event ordering live.
|
|
9
|
+
*
|
|
10
|
+
* It provides:
|
|
11
|
+
* - `events` — a real `createEventBus()` so cross-extension pub/sub and RPC
|
|
12
|
+
* behave as in production (tests can inject a shared bus to model parent/child
|
|
13
|
+
* instances).
|
|
14
|
+
* - `handlers` — every `pi.on(event, handler)` registration, keyed by event
|
|
15
|
+
* name, so a test can assert completeness and fire handlers.
|
|
16
|
+
* - `commands` — every `pi.registerCommand(name, …)` registration.
|
|
17
|
+
* - `fire(event, input, ctx)` — drive a registered handler; resolves to its
|
|
18
|
+
* (possibly async) result.
|
|
19
|
+
*
|
|
20
|
+
* The harness object is cast to `ExtensionAPI` at the call to the factory; the
|
|
21
|
+
* `FakePi` interface itself stays narrow (ISP — only what the factory touches).
|
|
22
|
+
*/
|
|
23
|
+
import { createEventBus, type EventBus } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { vi } from "vitest";
|
|
25
|
+
|
|
26
|
+
/** A handler recorded by `pi.on(...)`, kept generic over event/result shapes. */
|
|
27
|
+
export type RecordedHandler = (event: unknown, ctx: unknown) => unknown;
|
|
28
|
+
|
|
29
|
+
export interface FakePi {
|
|
30
|
+
/** Real event bus so cross-extension pub/sub and RPC behave as in production. */
|
|
31
|
+
events: EventBus;
|
|
32
|
+
/** Every `pi.on(event, handler)` registration, keyed by event name. */
|
|
33
|
+
handlers: Map<string, RecordedHandler>;
|
|
34
|
+
/** Every `pi.registerCommand(name, …)` registration, keyed by command name. */
|
|
35
|
+
commands: Map<string, unknown>;
|
|
36
|
+
/**
|
|
37
|
+
* Drive a registered handler; resolves to its (possibly async) result.
|
|
38
|
+
*
|
|
39
|
+
* Throws if no handler is registered for `event` so a typo in a test surfaces
|
|
40
|
+
* loudly instead of silently resolving to `undefined`.
|
|
41
|
+
*/
|
|
42
|
+
fire(event: string, input?: unknown, ctx?: unknown): Promise<unknown>;
|
|
43
|
+
/** Minimal tool registry — returns the configured tool names. */
|
|
44
|
+
getAllTools(): { name: string }[];
|
|
45
|
+
setActiveTools(names: string[]): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MakeFakePiOptions {
|
|
49
|
+
/** Inject a shared bus to model parent/child instances; defaults to a fresh bus. */
|
|
50
|
+
events?: EventBus;
|
|
51
|
+
/** Tool names returned by `getAllTools()`; defaults to a small set. */
|
|
52
|
+
toolNames?: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULT_TOOL_NAMES = ["read", "write", "edit", "bash", "ls", "grep"];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a fake `ExtensionAPI` for composition-root tests.
|
|
59
|
+
*
|
|
60
|
+
* The returned object is structurally a `FakePi`; pass it to the factory as
|
|
61
|
+
* `piPermissionSystemExtension(pi as unknown as ExtensionAPI)`.
|
|
62
|
+
*/
|
|
63
|
+
export function makeFakePi(options: MakeFakePiOptions = {}): FakePi {
|
|
64
|
+
const events = options.events ?? createEventBus();
|
|
65
|
+
const toolNames = options.toolNames ?? DEFAULT_TOOL_NAMES;
|
|
66
|
+
const handlers = new Map<string, RecordedHandler>();
|
|
67
|
+
const commands = new Map<string, unknown>();
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
events,
|
|
71
|
+
handlers,
|
|
72
|
+
commands,
|
|
73
|
+
fire(event, input, ctx): Promise<unknown> {
|
|
74
|
+
const handler = handlers.get(event);
|
|
75
|
+
if (!handler) {
|
|
76
|
+
throw new Error(`No handler registered for event "${event}"`);
|
|
77
|
+
}
|
|
78
|
+
return Promise.resolve(handler(input, ctx));
|
|
79
|
+
},
|
|
80
|
+
getAllTools(): { name: string }[] {
|
|
81
|
+
return toolNames.map((name) => ({ name }));
|
|
82
|
+
},
|
|
83
|
+
setActiveTools: vi.fn(),
|
|
84
|
+
// ── ExtensionAPI methods the factory touches (recorded) ────────────────
|
|
85
|
+
on(event: string, handler: RecordedHandler): void {
|
|
86
|
+
handlers.set(event, handler);
|
|
87
|
+
},
|
|
88
|
+
registerCommand(name: string, optionsArg: unknown): void {
|
|
89
|
+
commands.set(name, optionsArg);
|
|
90
|
+
},
|
|
91
|
+
// ── ExtensionAPI methods present for the cast but unused by the factory ─
|
|
92
|
+
registerProvider: vi.fn(),
|
|
93
|
+
exec: vi.fn(),
|
|
94
|
+
} as FakePi & Record<string, unknown>;
|
|
95
|
+
}
|
|
@@ -279,10 +279,18 @@ describe("piPermissionSystemExtension ready event wiring", () => {
|
|
|
279
279
|
rmSync(baseDir, { recursive: true, force: true });
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
-
it("emits permissions:ready with protocolVersion
|
|
282
|
+
it("emits permissions:ready with protocolVersion at session_start", async () => {
|
|
283
283
|
const emitSpy = vi.fn();
|
|
284
|
+
const handlers = new Map<
|
|
285
|
+
string,
|
|
286
|
+
(event: unknown, ctx: unknown) => unknown
|
|
287
|
+
>();
|
|
284
288
|
piPermissionSystemExtension({
|
|
285
|
-
on: vi.fn(
|
|
289
|
+
on: vi.fn(
|
|
290
|
+
(event: string, handler: (e: unknown, c: unknown) => unknown) => {
|
|
291
|
+
handlers.set(event, handler);
|
|
292
|
+
},
|
|
293
|
+
),
|
|
286
294
|
registerCommand: vi.fn(),
|
|
287
295
|
getAllTools: vi.fn().mockReturnValue([]),
|
|
288
296
|
setActiveTools: vi.fn(),
|
|
@@ -290,6 +298,28 @@ describe("piPermissionSystemExtension ready event wiring", () => {
|
|
|
290
298
|
events: { emit: emitSpy, on: vi.fn().mockReturnValue(() => undefined) },
|
|
291
299
|
} as never);
|
|
292
300
|
|
|
301
|
+
// ready is not emitted at load — only after session_start publishes.
|
|
302
|
+
expect(
|
|
303
|
+
emitSpy.mock.calls.filter(([c]) => c === PERMISSIONS_READY_CHANNEL),
|
|
304
|
+
).toHaveLength(0);
|
|
305
|
+
|
|
306
|
+
const ctx = {
|
|
307
|
+
cwd: baseDir,
|
|
308
|
+
hasUI: false,
|
|
309
|
+
sessionManager: {
|
|
310
|
+
getEntries: (): unknown[] => [],
|
|
311
|
+
getSessionId: (): string => "top-session",
|
|
312
|
+
getSessionDir: (): string => baseDir,
|
|
313
|
+
},
|
|
314
|
+
ui: {
|
|
315
|
+
notify: (): void => {},
|
|
316
|
+
setStatus: (): void => {},
|
|
317
|
+
select: async (): Promise<string | undefined> => undefined,
|
|
318
|
+
input: async (): Promise<string | undefined> => undefined,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
await handlers.get("session_start")?.({ reason: "start" }, ctx);
|
|
322
|
+
|
|
293
323
|
const readyCalls = emitSpy.mock.calls.filter(
|
|
294
324
|
([channel]) => channel === PERMISSIONS_READY_CHANNEL,
|
|
295
325
|
);
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "node:fs";
|
|
9
9
|
import { homedir, tmpdir } from "node:os";
|
|
10
10
|
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
import { expect, test } from "vitest";
|
|
12
13
|
import {
|
|
13
14
|
createActiveToolsCacheKey,
|
|
@@ -46,23 +47,16 @@ import {
|
|
|
46
47
|
canResolveAskPermissionRequest,
|
|
47
48
|
shouldAutoApprovePermissionState,
|
|
48
49
|
} from "#src/yolo-mode";
|
|
50
|
+
import { type FakePi, makeFakePi } from "#test/helpers/make-fake-pi";
|
|
49
51
|
import {
|
|
50
52
|
type CreateManagerOptions,
|
|
51
53
|
createManager,
|
|
52
54
|
} from "#test/helpers/manager-harness";
|
|
53
55
|
|
|
54
|
-
type MockHandler = (
|
|
55
|
-
event: Record<string, unknown>,
|
|
56
|
-
ctx: Record<string, unknown>,
|
|
57
|
-
) =>
|
|
58
|
-
| Promise<Record<string, unknown> | undefined>
|
|
59
|
-
| Record<string, unknown>
|
|
60
|
-
| undefined;
|
|
61
|
-
|
|
62
56
|
type ExtensionHarness = {
|
|
63
57
|
baseDir: string;
|
|
64
58
|
cwd: string;
|
|
65
|
-
|
|
59
|
+
pi: FakePi;
|
|
66
60
|
prompts: string[];
|
|
67
61
|
cleanup: () => Promise<void>;
|
|
68
62
|
};
|
|
@@ -112,7 +106,6 @@ function createToolCallHarness(
|
|
|
112
106
|
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-runtime-"));
|
|
113
107
|
const cwd = options.cwd ?? baseDir;
|
|
114
108
|
const prompts: string[] = [];
|
|
115
|
-
const handlers: Record<string, MockHandler> = {};
|
|
116
109
|
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
117
110
|
const globalConfigPath = getGlobalConfigPath(baseDir);
|
|
118
111
|
mkdirSync(join(baseDir, "agents"), { recursive: true });
|
|
@@ -124,22 +117,10 @@ function createToolCallHarness(
|
|
|
124
117
|
"utf8",
|
|
125
118
|
);
|
|
126
119
|
|
|
120
|
+
const pi = makeFakePi({ toolNames });
|
|
127
121
|
process.env.PI_CODING_AGENT_DIR = baseDir;
|
|
128
122
|
try {
|
|
129
|
-
piPermissionSystemExtension(
|
|
130
|
-
on: (name: string, handler: MockHandler): void => {
|
|
131
|
-
handlers[name] = handler;
|
|
132
|
-
},
|
|
133
|
-
registerCommand: (): void => {},
|
|
134
|
-
getAllTools: (): Array<{ name: string }> =>
|
|
135
|
-
toolNames.map((name) => ({ name })),
|
|
136
|
-
setActiveTools: (): void => {},
|
|
137
|
-
registerProvider: (): void => {},
|
|
138
|
-
events: {
|
|
139
|
-
emit: (): void => {},
|
|
140
|
-
on: (): (() => void) => () => undefined,
|
|
141
|
-
},
|
|
142
|
-
} as never);
|
|
123
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
143
124
|
} finally {
|
|
144
125
|
if (originalAgentDir === undefined) {
|
|
145
126
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
@@ -151,11 +132,13 @@ function createToolCallHarness(
|
|
|
151
132
|
return {
|
|
152
133
|
baseDir,
|
|
153
134
|
cwd,
|
|
154
|
-
|
|
135
|
+
pi,
|
|
155
136
|
prompts,
|
|
156
137
|
cleanup: async (): Promise<void> => {
|
|
157
|
-
await
|
|
158
|
-
|
|
138
|
+
await pi.fire(
|
|
139
|
+
"session_shutdown",
|
|
140
|
+
{},
|
|
141
|
+
createMockContext(cwd, prompts, options),
|
|
159
142
|
);
|
|
160
143
|
rmSync(baseDir, { recursive: true, force: true });
|
|
161
144
|
},
|
|
@@ -192,15 +175,14 @@ async function runToolCall(
|
|
|
192
175
|
event: Record<string, unknown>,
|
|
193
176
|
options: ExtensionHarnessOptions = {},
|
|
194
177
|
): Promise<Record<string, unknown>> {
|
|
195
|
-
const handler = harness.handlers.tool_call;
|
|
196
|
-
expect(handler).toBeTypeOf("function");
|
|
197
|
-
|
|
198
178
|
const result = await withIsolatedSubagentEnv(async () =>
|
|
199
|
-
|
|
200
|
-
|
|
179
|
+
harness.pi.fire(
|
|
180
|
+
"tool_call",
|
|
181
|
+
event,
|
|
182
|
+
createMockContext(harness.cwd, harness.prompts, options),
|
|
201
183
|
),
|
|
202
184
|
);
|
|
203
|
-
return result ?? {};
|
|
185
|
+
return (result as Record<string, unknown> | undefined) ?? {};
|
|
204
186
|
}
|
|
205
187
|
|
|
206
188
|
test("Yolo mode only auto-approves ask-state permissions", () => {
|
|
@@ -2335,7 +2317,7 @@ test("session approval: session_shutdown clears session approvals", async () =>
|
|
|
2335
2317
|
hasUI: true,
|
|
2336
2318
|
selectResponse: "Yes",
|
|
2337
2319
|
});
|
|
2338
|
-
await
|
|
2320
|
+
await harness.pi.fire("session_shutdown", {}, shutdownCtx);
|
|
2339
2321
|
|
|
2340
2322
|
// Access same path again — should prompt because cache was cleared
|
|
2341
2323
|
const result = await runToolCall(
|
package/test/service.test.ts
CHANGED
|
@@ -26,7 +26,10 @@ function makeService(
|
|
|
26
26
|
|
|
27
27
|
describe("globalThis accessor", () => {
|
|
28
28
|
afterEach(() => {
|
|
29
|
-
|
|
29
|
+
const current = getPermissionsService();
|
|
30
|
+
if (current) {
|
|
31
|
+
unpublishPermissionsService(current);
|
|
32
|
+
}
|
|
30
33
|
});
|
|
31
34
|
|
|
32
35
|
it("returns undefined when nothing has been published", () => {
|
|
@@ -47,15 +50,25 @@ describe("globalThis accessor", () => {
|
|
|
47
50
|
expect(getPermissionsService()).toBe(second);
|
|
48
51
|
});
|
|
49
52
|
|
|
50
|
-
it("
|
|
53
|
+
it("removes the slot when it still holds the given service", () => {
|
|
51
54
|
const service = makeService();
|
|
52
55
|
publishPermissionsService(service);
|
|
53
|
-
unpublishPermissionsService();
|
|
56
|
+
unpublishPermissionsService(service);
|
|
54
57
|
expect(getPermissionsService()).toBeUndefined();
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
it("does not remove the slot when a different service occupies it", () => {
|
|
61
|
+
const parent = makeService();
|
|
62
|
+
const child = makeService();
|
|
63
|
+
publishPermissionsService(parent);
|
|
64
|
+
// A child instance never published `parent`; unpublishing its own service
|
|
65
|
+
// must be a no-op that leaves the parent's slot intact.
|
|
66
|
+
unpublishPermissionsService(child);
|
|
67
|
+
expect(getPermissionsService()).toBe(parent);
|
|
68
|
+
});
|
|
69
|
+
|
|
57
70
|
it("unpublish is safe to call when nothing was published", () => {
|
|
58
|
-
expect(() => unpublishPermissionsService()).not.toThrow();
|
|
71
|
+
expect(() => unpublishPermissionsService(makeService())).not.toThrow();
|
|
59
72
|
expect(getPermissionsService()).toBeUndefined();
|
|
60
73
|
});
|
|
61
74
|
});
|
|
@@ -64,7 +77,10 @@ describe("globalThis accessor", () => {
|
|
|
64
77
|
|
|
65
78
|
describe("service adapter delegation", () => {
|
|
66
79
|
afterEach(() => {
|
|
67
|
-
|
|
80
|
+
const current = getPermissionsService();
|
|
81
|
+
if (current) {
|
|
82
|
+
unpublishPermissionsService(current);
|
|
83
|
+
}
|
|
68
84
|
});
|
|
69
85
|
|
|
70
86
|
const fakeResult: PermissionCheckResult = {
|
|
@@ -191,7 +207,10 @@ describe("service adapter delegation", () => {
|
|
|
191
207
|
|
|
192
208
|
describe("registerToolInputFormatter delegation", () => {
|
|
193
209
|
afterEach(() => {
|
|
194
|
-
|
|
210
|
+
const current = getPermissionsService();
|
|
211
|
+
if (current) {
|
|
212
|
+
unpublishPermissionsService(current);
|
|
213
|
+
}
|
|
195
214
|
});
|
|
196
215
|
|
|
197
216
|
it("delegates to the registry and returns its disposer", () => {
|
|
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
3
|
import { SUBAGENT_ENV_HINT_KEYS } from "#src/permission-forwarding";
|
|
4
4
|
import {
|
|
5
|
+
isRegisteredSubagentChild,
|
|
5
6
|
isSubagentExecutionContext,
|
|
6
7
|
normalizeFilesystemPath,
|
|
7
8
|
} from "#src/subagent-context";
|
|
@@ -24,6 +25,45 @@ function makeCtx(
|
|
|
24
25
|
} as unknown as ExtensionContext;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
describe("isRegisteredSubagentChild", () => {
|
|
29
|
+
const childSessionId = "child-session-abc";
|
|
30
|
+
|
|
31
|
+
test("returns true when the session id is registered", () => {
|
|
32
|
+
const registry = new SubagentSessionRegistry();
|
|
33
|
+
registry.register(childSessionId, {});
|
|
34
|
+
expect(
|
|
35
|
+
isRegisteredSubagentChild(makeCtx(null, childSessionId), registry),
|
|
36
|
+
).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns false when the session id is not registered", () => {
|
|
40
|
+
const registry = new SubagentSessionRegistry();
|
|
41
|
+
expect(
|
|
42
|
+
isRegisteredSubagentChild(makeCtx(null, childSessionId), registry),
|
|
43
|
+
).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns false when the session id is empty", () => {
|
|
47
|
+
const registry = new SubagentSessionRegistry();
|
|
48
|
+
registry.register("", {});
|
|
49
|
+
expect(isRegisteredSubagentChild(makeCtx(null, ""), registry)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("returns false when getSessionId throws", () => {
|
|
53
|
+
const registry = new SubagentSessionRegistry();
|
|
54
|
+
registry.register(childSessionId, {});
|
|
55
|
+
const ctx = {
|
|
56
|
+
sessionManager: {
|
|
57
|
+
getSessionDir: vi.fn(() => null),
|
|
58
|
+
getSessionId: vi.fn(() => {
|
|
59
|
+
throw new Error("session id unavailable");
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
} as unknown as ExtensionContext;
|
|
63
|
+
expect(isRegisteredSubagentChild(ctx, registry)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
27
67
|
describe("normalizeFilesystemPath", () => {
|
|
28
68
|
test("normalizes a simple absolute path", () => {
|
|
29
69
|
expect(normalizeFilesystemPath("/projects/my-app")).toBe(
|