@gotgenes/pi-permission-system 8.3.2 → 9.0.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.
@@ -0,0 +1,32 @@
1
+ import type { PermissionCheckResult, PermissionState } from "#src/types";
2
+
3
+ /** Restrictiveness ordering: deny is the most restrictive, allow the least. */
4
+ const RESTRICTIVENESS: Record<PermissionState, number> = {
5
+ allow: 0,
6
+ ask: 1,
7
+ deny: 2,
8
+ };
9
+
10
+ /**
11
+ * Select the most restrictive permission result from a list (deny > ask > allow).
12
+ *
13
+ * The first occurrence wins on ties, so a caller passing results in candidate
14
+ * order receives the earliest worst case. Returns `undefined` for an empty list.
15
+ *
16
+ * Shared by the bash gates (path, external-directory) to combine the per-candidate
17
+ * `checkPermission` results their tree-sitter token extraction produces.
18
+ */
19
+ export function pickMostRestrictive(
20
+ results: readonly PermissionCheckResult[],
21
+ ): PermissionCheckResult | undefined {
22
+ let worst: PermissionCheckResult | undefined;
23
+ for (const result of results) {
24
+ if (
25
+ worst === undefined ||
26
+ RESTRICTIVENESS[result.state] > RESTRICTIVENESS[worst.state]
27
+ ) {
28
+ worst = result;
29
+ }
30
+ }
31
+ return worst;
32
+ }
@@ -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
 
@@ -3,7 +3,7 @@ import type {
3
3
  InputEventResult,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
 
6
- import { toRecord } from "#src/common";
6
+ import { getNonEmptyString, toRecord } from "#src/common";
7
7
  import {
8
8
  emitDecisionEvent,
9
9
  type PermissionEventBus,
@@ -26,6 +26,7 @@ import {
26
26
  getToolNameFromValue,
27
27
  type ToolRegistry,
28
28
  } from "#src/tool-registry";
29
+ import { resolveBashCommandCheck } from "./gates/bash-command";
29
30
  import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
30
31
  import { describeBashPathGate } from "./gates/bash-path";
31
32
  import type { GateResult, GateRunnerDeps } from "./gates/descriptor";
@@ -169,13 +170,25 @@ export class PermissionGateHandler {
169
170
  getSessionRuleset,
170
171
  ),
171
172
  () => describeBashPathGate(tcc, checkPermission, getSessionRuleset),
172
- () => {
173
- const toolCheck = checkPermission(
174
- tcc.toolName,
175
- tcc.input,
176
- tcc.agentName ?? undefined,
177
- getSessionRuleset(),
178
- );
173
+ async () => {
174
+ // Bash commands may chain several sub-commands (`a && b`, `a | b`, …);
175
+ // evaluate each on the bash surface and select the most restrictive,
176
+ // rather than matching the whole program string (#301). Other tools
177
+ // evaluate their single input directly.
178
+ const toolCheck =
179
+ tcc.toolName === "bash"
180
+ ? await resolveBashCommandCheck(
181
+ getNonEmptyString(toRecord(tcc.input).command) ?? "",
182
+ tcc.agentName ?? undefined,
183
+ getSessionRuleset(),
184
+ checkPermission,
185
+ )
186
+ : checkPermission(
187
+ tcc.toolName,
188
+ tcc.input,
189
+ tcc.agentName ?? undefined,
190
+ getSessionRuleset(),
191
+ );
179
192
  const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
180
193
  toolDescriptor.preCheck = toolCheck;
181
194
  return toolDescriptor;
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
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 { isSubagentExecutionContext } from "./subagent-context";
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
- publishPermissionsService(permissionsService);
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(session, () => {
149
- rpcHandles.unsubCheck();
150
- rpcHandles.unsubPrompt();
151
- unsubSubagentLifecycle();
152
- unpublishPermissionsService();
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,
@@ -24,7 +24,7 @@ export const PERMISSIONS_PROTOCOL_VERSION = 1;
24
24
 
25
25
  // ── Channel name constants ─────────────────────────────────────────────────
26
26
 
27
- /** Emitted once on extension load. */
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 once after the extension has finished setup.
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
- * Overwrites any previously published service safe for `/reload`.
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 the service from `globalThis`.
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
  }
@@ -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
- try {
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
+ });