@gotgenes/pi-permission-system 28.0.1 → 29.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +1 -0
- package/dist/public.d.ts +12 -49
- package/docs/cross-extension-api.md +4 -6
- package/docs/guides/permission-frontmatter-for-subagent-extensions.md +1 -1
- package/docs/migration/0794-keyed-service-locator.md +6 -1
- package/docs/migration/0796-remove-process-root-slot.md +68 -0
- package/docs/subagent-integration.md +29 -4
- package/package.json +1 -1
- package/src/authority/child-node-audit.ts +94 -0
- package/src/authority/subagent-detection.ts +1 -21
- package/src/authority/subagent-lifecycle-events.ts +38 -3
- package/src/index.ts +14 -7
- package/src/service-lifecycle.ts +4 -14
- package/src/service.ts +15 -110
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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
|
+
## [29.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v29.0.0...pi-permission-system-v29.1.0) (2026-08-31)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** warn when a subagent child runs with no permission node ([223710f](https://github.com/gotgenes/pi-packages/commit/223710ff2ed81977161ba2a367deb81fe6c7f207)), closes [#792](https://github.com/gotgenes/pi-packages/issues/792)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** document the optional bound channel and the unguarded-child alarm ([9a16e1b](https://github.com/gotgenes/pi-packages/commit/9a16e1b09934ecdb529875b59f7649c50ccfd5b9)), closes [#792](https://github.com/gotgenes/pi-packages/issues/792)
|
|
19
|
+
|
|
20
|
+
## [29.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v28.0.1...pi-permission-system-v29.0.0) (2026-08-30)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### ⚠ BREAKING CHANGES
|
|
24
|
+
|
|
25
|
+
* **pi-permission-system:** `getRootPermissionsService()`, `publishRootPermissionsService()`, and `unpublishRootPermissionsService()` are removed. Resolve a node's service with `getPermissionsService(sessionId)`, taking `sessionId` from the `permissions:ready` payload (or from `ctx.sessionManager.getSessionId()` inside your own session handler). See docs/migration/0796-remove-process-root-slot.md.
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** remove the deprecated process-root service accessor ([65cbbee](https://github.com/gotgenes/pi-packages/commit/65cbbee5b4d98a8f34e34b831ad4a33250735f7e)), closes [#796](https://github.com/gotgenes/pi-packages/issues/796)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
* **pi-permission-system:** retire the process-root service slot from the docs ([649b1f0](https://github.com/gotgenes/pi-packages/commit/649b1f063a840e9a7c1f259ed03620a71624e55f)), closes [#796](https://github.com/gotgenes/pi-packages/issues/796)
|
|
35
|
+
|
|
8
36
|
## [28.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v28.0.0...pi-permission-system-v28.0.1) (2026-08-30)
|
|
9
37
|
|
|
10
38
|
|
package/README.md
CHANGED
|
@@ -209,6 +209,7 @@ Approve-and-steer, edit diffs, and risk explanations → a downstream package ov
|
|
|
209
209
|
| [docs/migration/0745-prompt-payload-contracts.md](docs/migration/0745-prompt-payload-contracts.md) | Prompt payload contracts (breaking) — the forwarded wire, the `ui_prompt` broadcast, and the deprecated preview caps |
|
|
210
210
|
| [docs/migration/0746-review-log-fields.md](docs/migration/0746-review-log-fields.md) | Review-log fields (breaking) — `message` replaced by request facts, and the `reviewLogFieldMaxWidth` bound |
|
|
211
211
|
| [docs/migration/0794-keyed-service-locator.md](docs/migration/0794-keyed-service-locator.md) | Keyed service locator (breaking) — `getPermissionsService(sessionId)`, and the repeating ready event |
|
|
212
|
+
| [docs/migration/0796-remove-process-root-slot.md](docs/migration/0796-remove-process-root-slot.md) | Process-root slot removed (breaking) — `getRootPermissionsService()` and its publish/unpublish pair are gone |
|
|
212
213
|
|
|
213
214
|
## Development
|
|
214
215
|
|
package/dist/public.d.ts
CHANGED
|
@@ -465,15 +465,12 @@ type ToolInputFormatter = (input: Record<string, unknown>) => string | undefined
|
|
|
465
465
|
* accessors here read from the same `globalThis` slots the provider wrote to —
|
|
466
466
|
* enabling direct, synchronous, type-safe function calls.
|
|
467
467
|
*
|
|
468
|
-
*
|
|
469
|
-
* session runtime each — a root session and its in-process
|
|
470
|
-
* all load their own instance of this extension)
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
* registries that node's own gates and chain read (ADR 0012 decision 2).
|
|
475
|
-
* - A single legacy slot holding the process root's service, read by the
|
|
476
|
-
* deprecated `getRootPermissionsService()`.
|
|
468
|
+
* The slot is a session-keyed map, because one process can host several
|
|
469
|
+
* **nodes** (one Pi session runtime each — a root session and its in-process
|
|
470
|
+
* subagent children all load their own instance of this extension). Every node
|
|
471
|
+
* writes under its own session id, and `getPermissionsService(sessionId)`
|
|
472
|
+
* resolves the service whose registries that node's own gates and chain read
|
|
473
|
+
* (ADR 0012 decision 2).
|
|
477
474
|
*
|
|
478
475
|
* Best practice: resolve per use rather than caching the reference — this
|
|
479
476
|
* ensures resilience across `/reload` and load-order edge cases.
|
|
@@ -529,8 +526,7 @@ interface PermissionQuery {
|
|
|
529
526
|
}
|
|
530
527
|
/**
|
|
531
528
|
* Public interface exposed to other extensions via
|
|
532
|
-
* {@link getPermissionsService}
|
|
533
|
-
* {@link getRootPermissionsService}).
|
|
529
|
+
* {@link getPermissionsService}.
|
|
534
530
|
*
|
|
535
531
|
* Each instance belongs to one node, and its three registration surfaces are
|
|
536
532
|
* read by that node alone: extractors and formatters by its own gates, chain
|
|
@@ -600,26 +596,6 @@ interface PermissionsService extends PermissionQuery {
|
|
|
600
596
|
*/
|
|
601
597
|
registerAuthorizer(name: string, authorize: Authorizer["authorize"]): () => void;
|
|
602
598
|
}
|
|
603
|
-
/**
|
|
604
|
-
* Store a `PermissionsService` in the legacy process-root slot, read by
|
|
605
|
-
* `getRootPermissionsService()`.
|
|
606
|
-
*
|
|
607
|
-
* Called at `session_start` by the top-level (parent) instance only — an
|
|
608
|
-
* in-process subagent child skips publishing so it cannot clobber the parent's
|
|
609
|
-
* service. Overwrites any previously published service, which keeps `/reload`
|
|
610
|
-
* working: a reloaded parent re-publishes its fresh service.
|
|
611
|
-
*/
|
|
612
|
-
declare function publishRootPermissionsService(service: PermissionsService): void;
|
|
613
|
-
/**
|
|
614
|
-
* Retrieve the process root's published `PermissionsService`, or `undefined`
|
|
615
|
-
* if the permission-system extension has not loaded (or has been unloaded).
|
|
616
|
-
*
|
|
617
|
-
* @deprecated Use {@link getPermissionsService} with the `sessionId`
|
|
618
|
-
* from the `permissions:ready` payload. This accessor answers "the process
|
|
619
|
-
* root's service", which is the wrong question in every node but the root.
|
|
620
|
-
* Removal is deferred to a future major (ADR 0012 decision 7).
|
|
621
|
-
*/
|
|
622
|
-
declare function getRootPermissionsService(): PermissionsService | undefined;
|
|
623
599
|
/**
|
|
624
600
|
* Publish `service` as the service of the node whose session is `sessionId`
|
|
625
601
|
* (ADR 0012 decision 2 — node-locality).
|
|
@@ -647,27 +623,14 @@ declare function publishPermissionsService(sessionId: string, service: Permissio
|
|
|
647
623
|
declare function getPermissionsService(sessionId: string): PermissionsService | undefined;
|
|
648
624
|
/**
|
|
649
625
|
* Remove the `sessionId` entry, but only when it still holds `service`
|
|
650
|
-
* (identity compare-and-delete
|
|
626
|
+
* (identity compare-and-delete).
|
|
651
627
|
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
628
|
+
* Called during `session_shutdown` to avoid stale references after the node is
|
|
629
|
+
* torn down. Scoping the delete to the publishing instance keeps a superseded
|
|
630
|
+
* `/reload` generation's late shutdown from wiping the new generation's freshly
|
|
654
631
|
* published service.
|
|
655
632
|
*/
|
|
656
633
|
declare function unpublishPermissionsService(sessionId: string, service: PermissionsService): void;
|
|
657
|
-
/**
|
|
658
|
-
* Remove `service` from `globalThis`, but only when the current slot still
|
|
659
|
-
* holds it (identity compare-and-delete).
|
|
660
|
-
*
|
|
661
|
-
* Called during `session_shutdown` to avoid stale references after the
|
|
662
|
-
* extension is torn down. Scoping the delete to the publishing instance keeps
|
|
663
|
-
* two cases correct:
|
|
664
|
-
*
|
|
665
|
-
* - An in-process subagent child never published the parent's service, so its
|
|
666
|
-
* shutdown is a no-op and the parent's slot survives.
|
|
667
|
-
* - A superseded `/reload` generation no longer owns the slot, so its late
|
|
668
|
-
* shutdown cannot wipe the new generation's freshly published service.
|
|
669
|
-
*/
|
|
670
|
-
declare function unpublishRootPermissionsService(service: PermissionsService): void;
|
|
671
634
|
|
|
672
|
-
export { PERMISSIONS_DECISION_CHANNEL, PERMISSIONS_READY_CHANNEL, PERMISSIONS_UI_PROMPT_CHANNEL, getPermissionsService,
|
|
635
|
+
export { PERMISSIONS_DECISION_CHANNEL, PERMISSIONS_READY_CHANNEL, PERMISSIONS_UI_PROMPT_CHANNEL, getPermissionsService, publishPermissionsService, unpublishPermissionsService };
|
|
673
636
|
export type { Authorizer, AuthorizerLog, AuthorizerVerdict, ForwardedPromptContext, PermissionCheckResult, PermissionDecisionEvent, PermissionQuery, PermissionState, PermissionUiPromptEvent, PermissionUiPromptSource, PermissionsReadyEvent, PermissionsService, PromptAnnotation, PromptEvidence, PromptPayload, PromptPayloadKind, PromptPermissionDetails, PromptRequestFacts, PromptRequester, ToolInputFormatter };
|
|
@@ -49,11 +49,9 @@ Each node therefore publishes its own service at `session_start`, into a `global
|
|
|
49
49
|
Consumers call `getPermissionsService(sessionId)` to retrieve it — even though their `import()` loads a fresh module copy, the accessor reads from the shared `globalThis` slot.
|
|
50
50
|
The session id arrives as a field on the `permissions:ready` broadcast, which each node emits at its own `session_start`, right after publishing — and again at that node's first `before_agent_start`, so a consumer whose own `session_start` ran later still hears it.
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
Both accessors were renamed in the major that reclaimed `getPermissionsService` for the keyed locator; if you are upgrading from a release whose `getPermissionsService()` took no argument, see [migration/0794-keyed-service-locator.md](migration/0794-keyed-service-locator.md).
|
|
52
|
+
That keyed map is the only service slot.
|
|
53
|
+
A separate legacy slot once held the process root's service, read by a deprecated `getRootPermissionsService()`; both were removed, because in any node but the root that accessor answered the wrong question — handing an in-process child the parent's service.
|
|
54
|
+
If you are upgrading from a release that had it, see [migration/0796-remove-process-root-slot.md](migration/0796-remove-process-root-slot.md); if you are upgrading from one whose `getPermissionsService()` took no argument, start with [migration/0794-keyed-service-locator.md](migration/0794-keyed-service-locator.md).
|
|
57
55
|
|
|
58
56
|
All types below are directly importable and type-check with `tsc` out of the box.
|
|
59
57
|
`@gotgenes/pi-permission-system`'s published `exports` resolve `import type { … }` to a self-contained, bundled declaration file with no internal module references, so a downstream `tsconfig.json` needs no special path configuration.
|
|
@@ -301,7 +299,7 @@ Best practice: resolve the service per use rather than caching the reference.
|
|
|
301
299
|
The `import()` throws if the package is not installed.
|
|
302
300
|
Wrap both in `try/catch` + `if` guard as shown in the Quick Start example.
|
|
303
301
|
|
|
304
|
-
It also returns `undefined` when called with no session id at all — a shape TypeScript rejects but JavaScript reaches — rather than
|
|
302
|
+
It also returns `undefined` when called with no session id at all — a shape TypeScript rejects but JavaScript reaches — rather than guessing a node, since answering with another node's service is the defect the keyed locator exists to prevent.
|
|
305
303
|
That call emits a once-guarded Node warning (code `PI_PERMISSION_SYSTEM_WARN0001`), because the guard above turns the missing service into a registration that silently never happens.
|
|
306
304
|
It is deliberately not a `DeprecationWarning`: `--no-deprecation` does not silence it.
|
|
307
305
|
|
|
@@ -149,7 +149,7 @@ try {
|
|
|
149
149
|
|
|
150
150
|
Query the service belonging to the session whose policy you mean.
|
|
151
151
|
Each session that loads the permission system publishes its own — a subagent child's config (and cwd) may differ from its parent's, so the parent's service is not a stand-in for the child's.
|
|
152
|
-
|
|
152
|
+
That is why the session id is required: an accessor answering "the process root's service" would hand a child the parent's, and the one that did was removed.
|
|
153
153
|
|
|
154
154
|
If `pi-permission-system` is not installed, `import()` throws; if that session has published no service yet (or has been unloaded), `getPermissionsService(sessionId)` returns `undefined`.
|
|
155
155
|
Guard both cases as shown above.
|
|
@@ -22,7 +22,7 @@ Each node now publishes its own service under its own session id, and you resolv
|
|
|
22
22
|
| Before | After |
|
|
23
23
|
| -------------------------------------- | ------------------------------------------------------------------ |
|
|
24
24
|
| `getPermissionsService()` | `getPermissionsService(sessionId)` |
|
|
25
|
-
| — (no equivalent) | `getRootPermissionsService()` — the old behavior,
|
|
25
|
+
| — (no equivalent) | `getRootPermissionsService()` — the old behavior, deprecated |
|
|
26
26
|
| `publishPermissionsService(service)` | `publishRootPermissionsService(service)` |
|
|
27
27
|
| `unpublishPermissionsService(service)` | `unpublishRootPermissionsService(service)` |
|
|
28
28
|
|
|
@@ -89,3 +89,8 @@ Guard it with a stored dispose handle, as shown above, and release the handle on
|
|
|
89
89
|
- The `permissions:ready`, `permissions:ui_prompt`, and `permissions:decision` payload shapes.
|
|
90
90
|
- The process-root slot itself: a node that is not an in-process subagent child still publishes to it, so `getRootPermissionsService()` answers exactly as the old zero-arg accessor did.
|
|
91
91
|
It remains deprecated (`PI_PERMISSION_SYSTEM_DEP0001`), and its removal is deferred to a later major.
|
|
92
|
+
|
|
93
|
+
> [!NOTE]
|
|
94
|
+
> That last bullet held only for this release.
|
|
95
|
+
> The process-root slot, its accessor, and its publish/unpublish pair were removed in a later major — see [0796-remove-process-root-slot.md](0796-remove-process-root-slot.md).
|
|
96
|
+
> If you are migrating from a pre-27.0.0 release, work through this guide first and that one second.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Migration guide: the process-root service slot is removed
|
|
2
|
+
|
|
3
|
+
Starting with the release that closes #796, the deprecated process-root service accessor and the `globalThis` slot behind it no longer exist.
|
|
4
|
+
|
|
5
|
+
This is a **breaking change**.
|
|
6
|
+
If your extension does not import `getRootPermissionsService`, `publishRootPermissionsService`, or `unpublishRootPermissionsService` from `@gotgenes/pi-permission-system`, nothing here affects you.
|
|
7
|
+
|
|
8
|
+
## What was removed
|
|
9
|
+
|
|
10
|
+
| Removed | Replacement |
|
|
11
|
+
| -------------------------------------------------- | ------------------------------------------------ |
|
|
12
|
+
| `getRootPermissionsService()` | `getPermissionsService(sessionId)` |
|
|
13
|
+
| `publishRootPermissionsService(service)` | — (internal; a node publishes its own service) |
|
|
14
|
+
| `unpublishRootPermissionsService(service)` | — (internal; a node unpublishes its own service) |
|
|
15
|
+
| `PI_PERMISSION_SYSTEM_DEP0001` deprecation warning | — (the deprecated path is gone) |
|
|
16
|
+
|
|
17
|
+
The `Symbol.for("@gotgenes/pi-permission-system:service")` slot is no longer written by any node.
|
|
18
|
+
`Symbol.for("@gotgenes/pi-permission-system:session-services")` — the session-keyed map — is the only service slot.
|
|
19
|
+
|
|
20
|
+
## What to change
|
|
21
|
+
|
|
22
|
+
Resolve the service of the node whose behavior you mean to affect, keyed by its session id.
|
|
23
|
+
The id arrives as a field on the `permissions:ready` payload; inside your own session handler, `ctx.sessionManager.getSessionId()` is the same value.
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import {
|
|
27
|
+
getPermissionsService,
|
|
28
|
+
PERMISSIONS_READY_CHANNEL,
|
|
29
|
+
type PermissionsReadyEvent,
|
|
30
|
+
} from "@gotgenes/pi-permission-system";
|
|
31
|
+
|
|
32
|
+
let dispose: (() => void) | undefined;
|
|
33
|
+
|
|
34
|
+
pi.events.on(PERMISSIONS_READY_CHANNEL, (event) => {
|
|
35
|
+
const { sessionId } = event as PermissionsReadyEvent;
|
|
36
|
+
// Idempotent: ready may repeat, so a second emission must be a no-op.
|
|
37
|
+
if (dispose || !sessionId) return;
|
|
38
|
+
dispose = getPermissionsService(sessionId)?.registerAuthorizer(
|
|
39
|
+
"my-link",
|
|
40
|
+
authorize,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If you are still on a zero-argument `getPermissionsService()` from a release before 27.0.0, migrate through [0794-keyed-service-locator.md](0794-keyed-service-locator.md) first — that release is where the accessor's signature changed.
|
|
46
|
+
|
|
47
|
+
## Why the deprecation window closed
|
|
48
|
+
|
|
49
|
+
[ADR 0012](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0012-cross-node-extension-contract.md) decision 7 deferred the removal until downstream consumers had migrated.
|
|
50
|
+
`pi-permission-model-judge` 2.0.0 — the named migration case — registers through the keyed locator and floors its peer range at `>=27.0.0`, and no other known consumer reads the root slot.
|
|
51
|
+
|
|
52
|
+
The removal is also narrower than a deprecation window's usual population suggests.
|
|
53
|
+
`getRootPermissionsService` did not exist before 27.0.0: it is the name that release gave the old behavior when it reclaimed `getPermissionsService` for the keyed locator.
|
|
54
|
+
So no consumer predating the deprecation can be calling it — every caller adopted the name after it was already marked deprecated, in preference to the keyed locator this guide's predecessor recommends.
|
|
55
|
+
|
|
56
|
+
## What you will see if you miss a call site
|
|
57
|
+
|
|
58
|
+
The export is gone, so TypeScript reports `TS2724` at your import and plain JavaScript throws `TypeError: getRootPermissionsService is not a function`.
|
|
59
|
+
|
|
60
|
+
Calling `getPermissionsService()` with **no** argument still answers `undefined` and emits a once-guarded Node warning under code `PI_PERMISSION_SYSTEM_WARN0001`, naming the keyed call and the ready payload.
|
|
61
|
+
That warning is deliberately not a `DeprecationWarning`, so `--no-deprecation` does not silence it: a registration that never landed is not something to hide.
|
|
62
|
+
|
|
63
|
+
## What has not changed
|
|
64
|
+
|
|
65
|
+
- The `PermissionsService` interface — the five methods and their signatures are untouched.
|
|
66
|
+
- The keyed accessors `getPermissionsService` / `publishPermissionsService` / `unpublishPermissionsService`.
|
|
67
|
+
- The `permissions:ready`, `permissions:ui_prompt`, and `permissions:decision` payload shapes and cadence.
|
|
68
|
+
- Node-locality: a registration is still read only by the node it was made in, and an in-process subagent child still gets its own service.
|
|
@@ -16,13 +16,32 @@ An implementation that creates child sessions inside its own process (via `creat
|
|
|
16
16
|
| `subagents:child:session-created` | `{ sessionId, parentSessionId? }` | After the child session is created, immediately before `bindExtensions()` |
|
|
17
17
|
| `subagents:child:disposed` | `{ sessionId }` | In the run's `finally`, on success and on error alike |
|
|
18
18
|
|
|
19
|
+
A third channel is **optional**, and buys one diagnostic:
|
|
20
|
+
|
|
21
|
+
| Channel | Payload | When |
|
|
22
|
+
| ------------------------ | --------------------------------- | ------------------------------------------------------------------- |
|
|
23
|
+
| `subagents:child:bound` | `{ sessionId, parentSessionId? }` | After `bindExtensions()` resolves, and not at all when it throws |
|
|
24
|
+
|
|
19
25
|
The pre-bind ordering of `session-created` is **contract, not an implementation detail**.
|
|
20
26
|
Emit it synchronously, on the same call stack, before `bindExtensions()`: this package's subscriber registers the child synchronously, and the registration must land before binding proceeds so the child's own instance can detect itself the moment it loads.
|
|
21
27
|
An implementation that awaits between creating the session and emitting, or that emits after binding, breaks detection for every child it spawns.
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
All are fire-and-forget broadcasts.
|
|
24
30
|
Nothing is returned, nothing is awaited, and no reply travels back over the bus.
|
|
25
31
|
|
|
32
|
+
#### The optional `bound` channel
|
|
33
|
+
|
|
34
|
+
Emitting `subagents:child:bound` is not part of the obligation, and an implementation that never emits it is fully conformant.
|
|
35
|
+
What it buys is the unguarded-child alarm described under [What this package does on both ends](#what-this-package-does-on-both-ends): without it, a child that loads no instance of this package runs every tool in its `tools:` allowlist ungated and nothing says so.
|
|
36
|
+
|
|
37
|
+
The timing is the whole point, and it is the one moment that answers the question.
|
|
38
|
+
`bindExtensions()` awaits the child's `session_start` emit, so when it resolves every child extension has initialized and this package's child instance — if it loaded at all — has published its service.
|
|
39
|
+
Emit it there: after the `await`, on the success path only.
|
|
40
|
+
A child whose binding threw never ran, so reporting it would be a false alarm about a session that does not exist.
|
|
41
|
+
|
|
42
|
+
No other moment works.
|
|
43
|
+
`session-created` fires before the child's extensions have loaded, and by `disposed` the child's `session_shutdown` has already withdrawn its service — so a healthy child looks identical to an unguarded one at both.
|
|
44
|
+
|
|
26
45
|
### Out-of-process implementations
|
|
27
46
|
|
|
28
47
|
An implementation that spawns a child as its own `pi` process sets one environment variable at spawn:
|
|
@@ -72,7 +91,7 @@ The full condition, with a worked example, is documented where the setting lives
|
|
|
72
91
|
|
|
73
92
|
The announcement is all an implementation provides; this section is what it buys.
|
|
74
93
|
|
|
75
|
-
On the announcing side, this package subscribes to the child lifecycle (`src/authority/subagent-lifecycle-events.ts`) and registers every in-process child session in the `SubagentSessionRegistry` on `subagents:child:session-created`, unregistering it on `subagents:child:disposed`.
|
|
94
|
+
On the announcing side, this package subscribes to the child lifecycle (`src/authority/subagent-lifecycle-events.ts`) and registers every in-process child session in the `SubagentSessionRegistry` on `subagents:child:session-created`, unregistering it on `subagents:child:disposed`, and auditing it on `subagents:child:bound`.
|
|
76
95
|
Because the event bus dispatches synchronously, that registration completes before `bindExtensions()` proceeds.
|
|
77
96
|
|
|
78
97
|
The `SubagentSessionRegistry` is backed by a process-global singleton (`globalThis` + `Symbol.for()`), accessed via `getSubagentSessionRegistry()` in `src/authority/subagent-registry.ts`.
|
|
@@ -83,7 +102,10 @@ What the announcement enables:
|
|
|
83
102
|
|
|
84
103
|
1. **Deterministic child detection** — `isSubagentExecutionContext()` hits the process-global registry on the first check for an in-process child, and reads the environment for one spawned as its own process, with a session-directory heuristic behind both.
|
|
85
104
|
2. **Per-agent policy enforcement** - the permission system's `before_agent_start` handler resolves the agent name from the `<active_agent>` system-prompt tag and applies per-agent `permission:` frontmatter overrides.
|
|
86
|
-
3.
|
|
105
|
+
3. **An unguarded child is announced** — when a child finishes binding without publishing a service of its own, it has no permission node: no `tool_call` gate, no tool filtering, no `permission:` frontmatter resolution, and no ask-forwarding.
|
|
106
|
+
The parent records a `child_node_absent` review entry for every such child and warns once per session.
|
|
107
|
+
The likeliest cause is the child's own configuration — `@gotgenes/pi-subagents` excluding this package under `excludedExtensionPackages` — and a failure to load this extension in the child leaves the identical absence, which the parent cannot tell apart, so the warning names both.
|
|
108
|
+
4. **`ask`-state forwarding** - when a child triggers an `ask` permission, the request forwards to the parent session's UI through the existing polling mechanism.
|
|
87
109
|
The parent approves or denies, and the child resumes.
|
|
88
110
|
When the parent approves "for this session," it chooses a scope: **this subagent only** (the least-privilege default) records the grant on the requesting child, while **the whole session** records it on the serving parent so the parent and all its subagents resolve it without re-prompting.
|
|
89
111
|
|
|
@@ -162,7 +184,7 @@ Nothing needs to be edited, and in-process children are unaffected: parent and c
|
|
|
162
184
|
|
|
163
185
|
## Conformance of known implementations
|
|
164
186
|
|
|
165
|
-
Conformance is a property of the announcement alone — whether an implementation emits the in-process events, or sets the out-of-process variable — not of the frontmatter vocabulary it offers for tool visibility.
|
|
187
|
+
Conformance is a property of the announcement alone — whether an implementation emits the two in-process events, or sets the out-of-process variable — not of the frontmatter vocabulary it offers for tool visibility, and not of the optional `bound` channel.
|
|
166
188
|
|
|
167
189
|
| Extension | Shape | Adopts the convention | Visibility key |
|
|
168
190
|
| ----------------------------------------------------------------------------------- | ---------- | --------------------------------- | ---------------------------------- |
|
|
@@ -177,6 +199,9 @@ Adopting the convention is a one-line change at their spawn site.
|
|
|
177
199
|
|
|
178
200
|
The upstream `tintinweb/pi-subagents` (which `@gotgenes/pi-subagents` forks) publishes no `subagents:child:session-created` event, so its in-process children have neither deterministic detection nor `ask`-state forwarding.
|
|
179
201
|
|
|
202
|
+
`@gotgenes/pi-subagents` is also the only implementation that emits the optional `subagents:child:bound` channel, so it is the only one whose unguarded children are announced.
|
|
203
|
+
The others forfeit that alarm without forfeiting conformance.
|
|
204
|
+
|
|
180
205
|
See [guides/permission-frontmatter-for-subagent-extensions.md](guides/permission-frontmatter-for-subagent-extensions.md) for the companion convention on `permission:` frontmatter, which implementations document rather than implement.
|
|
181
206
|
|
|
182
207
|
---
|
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* child-node-audit.ts — Report an in-process subagent child that runs with no
|
|
3
|
+
* permission node.
|
|
4
|
+
*
|
|
5
|
+
* Gating is node-local (ADR 0012 decision 1): each node loads its own instance
|
|
6
|
+
* of this extension and gates its own `tool_call`s. A child that loads none has
|
|
7
|
+
* no gate, no tool filtering, no `permission:` frontmatter resolution, and no
|
|
8
|
+
* ask-forwarding — every tool in its `tools:` allowlist runs ungated. The
|
|
9
|
+
* parent's own gating is unaffected, so without this audit the operator watches
|
|
10
|
+
* the permission system work and never learns the child is unguarded.
|
|
11
|
+
*
|
|
12
|
+
* The signal is publication: since #699 every node publishes its service under
|
|
13
|
+
* its own session id, and a subagent implementation announces the moment a
|
|
14
|
+
* child finished binding its extensions. A child that has published nothing by
|
|
15
|
+
* then has no node.
|
|
16
|
+
*
|
|
17
|
+
* The two halves of the alarm fire at different rates on purpose. The review
|
|
18
|
+
* entry is the durable record and must be complete, so it is written for every
|
|
19
|
+
* affected child. The visible warning is capped at one per parent session: the
|
|
20
|
+
* cause is a single line of configuration, and a parent that fans out ten
|
|
21
|
+
* children would otherwise emit ten identical warnings.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Answers whether the node whose session is `sessionId` published a service. */
|
|
25
|
+
export type NodePresenceLookup = (sessionId: string) => boolean;
|
|
26
|
+
|
|
27
|
+
/** The narrow log seam this audit needs (ISP): a durable record and a warning. */
|
|
28
|
+
export interface ChildNodeAuditLog {
|
|
29
|
+
review(event: string, details?: Record<string, unknown>): void;
|
|
30
|
+
warn(message: string): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Fields read from the child-bound announcement (ISP). */
|
|
34
|
+
export interface BoundChild {
|
|
35
|
+
/** Child session id — the key its node would have published under. */
|
|
36
|
+
sessionId: string;
|
|
37
|
+
parentSessionId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The agent-facing text for an unguarded child.
|
|
42
|
+
*
|
|
43
|
+
* The parent cannot tell a deliberate exclusion from a load failure — both
|
|
44
|
+
* leave the identical absence — so the message names the likelier cause and
|
|
45
|
+
* admits the other in the same sentence.
|
|
46
|
+
*/
|
|
47
|
+
export function childNodeAbsentMessage(childSessionId: string): string {
|
|
48
|
+
return (
|
|
49
|
+
`pi-permission-system: subagent child session ${childSessionId} is ` +
|
|
50
|
+
"running with no permission node — this extension is not loaded in it, so " +
|
|
51
|
+
"its tool calls are not gated there. Most often the package is listed in " +
|
|
52
|
+
"pi-subagents' excludedExtensionPackages; a failure to load this extension " +
|
|
53
|
+
"in the child does the same. Further affected children are recorded in the " +
|
|
54
|
+
"permission review log as child_node_absent."
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The audit seam the child-lifecycle subscription drives (ISP). */
|
|
59
|
+
export interface BoundChildAuditor {
|
|
60
|
+
auditBoundChild(child: BoundChild): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Audits each child that finishes binding, and alarms on one with no node.
|
|
65
|
+
*
|
|
66
|
+
* The warn-once latch is a plain field with no re-arm hook, because the
|
|
67
|
+
* extension factory is re-invoked per session generation — a `/new`, `/resume`,
|
|
68
|
+
* `/fork`, or `/import` switch builds a fresh audit. A `session_start` with
|
|
69
|
+
* `reason: "reload"` reuses this instance and deliberately does not re-warn:
|
|
70
|
+
* the operator has already been told.
|
|
71
|
+
*/
|
|
72
|
+
export class ChildNodeAudit implements BoundChildAuditor {
|
|
73
|
+
private warned = false;
|
|
74
|
+
|
|
75
|
+
constructor(
|
|
76
|
+
private readonly hasNode: NodePresenceLookup,
|
|
77
|
+
private readonly log: ChildNodeAuditLog,
|
|
78
|
+
) {}
|
|
79
|
+
|
|
80
|
+
auditBoundChild(child: BoundChild): void {
|
|
81
|
+
if (this.hasNode(child.sessionId)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.log.review("child_node_absent", {
|
|
85
|
+
childSessionId: child.sessionId,
|
|
86
|
+
parentSessionId: child.parentSessionId ?? null,
|
|
87
|
+
});
|
|
88
|
+
if (this.warned) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.warned = true;
|
|
92
|
+
this.log.warn(childNodeAbsentMessage(child.sessionId));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
isRegisteredSubagentChild,
|
|
3
2
|
isSubagentExecutionContext,
|
|
4
3
|
type SubagentDetectionContext,
|
|
5
4
|
} from "#src/authority/subagent-context";
|
|
@@ -17,17 +16,6 @@ export interface SubagentDetector {
|
|
|
17
16
|
isSubagent(ctx: SubagentDetectionContext): boolean;
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
/**
|
|
21
|
-
* Narrow seam for the service-publication guard (#302): "is the current
|
|
22
|
-
* session a registered in-process child?"
|
|
23
|
-
*
|
|
24
|
-
* `PermissionServiceLifecycle` depends on this single-method view so a
|
|
25
|
-
* registered child never publishes over its parent's process-global slot.
|
|
26
|
-
*/
|
|
27
|
-
export interface RegisteredChildDetector {
|
|
28
|
-
isRegisteredChild(ctx: SubagentDetectionContext): boolean;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
19
|
/** Composition-root inputs for {@link SubagentDetection}. */
|
|
32
20
|
export interface SubagentDetectionDeps {
|
|
33
21
|
subagentSessionsDir: string;
|
|
@@ -44,9 +32,7 @@ export interface SubagentDetectionDeps {
|
|
|
44
32
|
* individually. Delegates to the pure detection functions in
|
|
45
33
|
* {@link ./subagent-context}, holding only the deps.
|
|
46
34
|
*/
|
|
47
|
-
export class SubagentDetection
|
|
48
|
-
implements SubagentDetector, RegisteredChildDetector
|
|
49
|
-
{
|
|
35
|
+
export class SubagentDetection implements SubagentDetector {
|
|
50
36
|
constructor(private readonly deps: SubagentDetectionDeps) {}
|
|
51
37
|
|
|
52
38
|
isSubagent(ctx: SubagentDetectionContext): boolean {
|
|
@@ -57,10 +43,4 @@ export class SubagentDetection
|
|
|
57
43
|
this.deps.registry,
|
|
58
44
|
);
|
|
59
45
|
}
|
|
60
|
-
|
|
61
|
-
isRegisteredChild(ctx: SubagentDetectionContext): boolean {
|
|
62
|
-
return this.deps.registry
|
|
63
|
-
? isRegisteredSubagentChild(ctx, this.deps.registry)
|
|
64
|
-
: false;
|
|
65
|
-
}
|
|
66
46
|
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* subagent-lifecycle-events.ts — Subscribe to @gotgenes/pi-subagents' child
|
|
3
|
-
* lifecycle events and
|
|
3
|
+
* lifecycle events and dispatch each fact to its owner.
|
|
4
4
|
*
|
|
5
5
|
* @gotgenes/pi-subagents publishes its child-execution lifecycle on the Pi
|
|
6
6
|
* event bus (ADR 0002): it no longer calls this package's service directly.
|
|
7
|
-
* We register the child on `session-created
|
|
7
|
+
* We register the child on `session-created`, audit it for a permission node on
|
|
8
|
+
* `bound`, and unregister it on `disposed`.
|
|
9
|
+
*
|
|
10
|
+
* The module subscribes to the announcement and hands each fact to its owner —
|
|
11
|
+
* the registry for the two registration events, the audit for `bound` — so the
|
|
12
|
+
* channel names and payload shapes of the whole contract stay declared in one
|
|
13
|
+
* place.
|
|
8
14
|
*
|
|
9
15
|
* The channel names and payload shapes are declared independently here (the two
|
|
10
16
|
* packages must not depend on each other under jiti) and MUST match the
|
|
@@ -15,13 +21,25 @@
|
|
|
15
21
|
* event bus dispatches listeners synchronously, so a synchronous handler lands
|
|
16
22
|
* the registry entry before binding proceeds. Introducing an `await` before
|
|
17
23
|
* `registry.register(...)` would break the pre-bind ordering.
|
|
24
|
+
*
|
|
25
|
+
* The `bound` handler carries no such requirement — nothing waits on it — but it
|
|
26
|
+
* must not make the other two async either, since they share this module.
|
|
18
27
|
*/
|
|
19
28
|
|
|
29
|
+
import type { BoundChildAuditor } from "./child-node-audit";
|
|
20
30
|
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
21
31
|
|
|
22
32
|
/** Emitted by the core after session creation, before `bindExtensions()`. */
|
|
23
33
|
export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
|
|
24
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Emitted by the core once the child's extensions have bound, after every child
|
|
37
|
+
* `session_start` handler has run — the one moment a parent can observe what
|
|
38
|
+
* those extensions installed. Optional: an implementation that never emits it
|
|
39
|
+
* is still conformant, and simply forfeits the unguarded-child alarm.
|
|
40
|
+
*/
|
|
41
|
+
export const SUBAGENT_CHILD_BOUND = "subagents:child:bound";
|
|
42
|
+
|
|
25
43
|
/** Emitted by the core in the run's `finally` (success and error). */
|
|
26
44
|
export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
|
|
27
45
|
|
|
@@ -37,6 +55,13 @@ interface ChildSessionCreatedEvent {
|
|
|
37
55
|
parentSessionId?: string;
|
|
38
56
|
}
|
|
39
57
|
|
|
58
|
+
/** Fields read from the `bound` payload (ISP). */
|
|
59
|
+
interface ChildBoundEvent {
|
|
60
|
+
/** Child session id — the key its node would have published under. */
|
|
61
|
+
sessionId: string;
|
|
62
|
+
parentSessionId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
/** Fields read from the `disposed` payload (ISP). */
|
|
41
66
|
interface ChildDisposedEvent {
|
|
42
67
|
/** Child session id — the registry key. Must match the publisher. */
|
|
@@ -46,12 +71,13 @@ interface ChildDisposedEvent {
|
|
|
46
71
|
/**
|
|
47
72
|
* Subscribe to the subagent child lifecycle.
|
|
48
73
|
*
|
|
49
|
-
* @returns an unsubscribe that detaches
|
|
74
|
+
* @returns an unsubscribe that detaches every handler (call during
|
|
50
75
|
* `session_shutdown`).
|
|
51
76
|
*/
|
|
52
77
|
export function subscribeSubagentLifecycle(
|
|
53
78
|
events: LifecycleEventBus,
|
|
54
79
|
registry: SubagentSessionRegistry,
|
|
80
|
+
audit: BoundChildAuditor,
|
|
55
81
|
): () => void {
|
|
56
82
|
const unsubCreated = events.on(SUBAGENT_CHILD_SESSION_CREATED, (data) => {
|
|
57
83
|
const event = data as ChildSessionCreatedEvent;
|
|
@@ -60,6 +86,14 @@ export function subscribeSubagentLifecycle(
|
|
|
60
86
|
});
|
|
61
87
|
});
|
|
62
88
|
|
|
89
|
+
const unsubBound = events.on(SUBAGENT_CHILD_BOUND, (data) => {
|
|
90
|
+
const event = data as ChildBoundEvent;
|
|
91
|
+
audit.auditBoundChild({
|
|
92
|
+
sessionId: event.sessionId,
|
|
93
|
+
parentSessionId: event.parentSessionId,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
63
97
|
const unsubDisposed = events.on(SUBAGENT_CHILD_DISPOSED, (data) => {
|
|
64
98
|
const event = data as ChildDisposedEvent;
|
|
65
99
|
registry.unregister(event.sessionId);
|
|
@@ -67,6 +101,7 @@ export function subscribeSubagentLifecycle(
|
|
|
67
101
|
|
|
68
102
|
return () => {
|
|
69
103
|
unsubCreated();
|
|
104
|
+
unsubBound();
|
|
70
105
|
unsubDisposed();
|
|
71
106
|
};
|
|
72
107
|
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
ObservedAuthorizerRegistrar,
|
|
8
8
|
} from "./authority/authorizer-registry";
|
|
9
9
|
import { AuthorizerSelection } from "./authority/authorizer-selection";
|
|
10
|
+
import { ChildNodeAudit } from "./authority/child-node-audit";
|
|
10
11
|
import {
|
|
11
12
|
ForwardedRequestServer,
|
|
12
13
|
type ServingPolicy,
|
|
@@ -50,7 +51,7 @@ import { PermissionResolver } from "./permission-resolver";
|
|
|
50
51
|
import { PermissionSession } from "./permission-session";
|
|
51
52
|
import { LocalPermissionsService } from "./permissions-service";
|
|
52
53
|
import { resolveRenderBudget } from "./presentation/dialog-renderer";
|
|
53
|
-
import type
|
|
54
|
+
import { getPermissionsService, type PermissionsService } from "./service";
|
|
54
55
|
import { PermissionServiceLifecycle } from "./service-lifecycle";
|
|
55
56
|
import { PermissionSessionLogger } from "./session-logger";
|
|
56
57
|
import { SessionRules } from "./session-rules";
|
|
@@ -259,22 +260,28 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
259
260
|
);
|
|
260
261
|
|
|
261
262
|
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
|
|
262
|
-
// sessions register/unregister without the core calling us (ADR 0002)
|
|
263
|
+
// sessions register/unregister without the core calling us (ADR 0002), and
|
|
264
|
+
// so a child that bound its extensions without loading one of ours is
|
|
265
|
+
// reported rather than silently ungated (#792). The lookup is a thunk over
|
|
266
|
+
// the locator, never a cached reference, per the guidance in service.ts.
|
|
267
|
+
const childNodeAudit = new ChildNodeAudit(
|
|
268
|
+
(sessionId) => getPermissionsService(sessionId) !== undefined,
|
|
269
|
+
logger,
|
|
270
|
+
);
|
|
263
271
|
const unsubSubagentLifecycle = subscribeSubagentLifecycle(
|
|
264
272
|
pi.events,
|
|
265
273
|
subagentRegistry,
|
|
274
|
+
childNodeAudit,
|
|
266
275
|
);
|
|
267
276
|
|
|
268
277
|
// PermissionServiceLifecycle owns the process-global service publication:
|
|
269
|
-
// activate() publishes this node's service under its own session id
|
|
270
|
-
// the
|
|
271
|
-
//
|
|
272
|
-
// channel; teardown() unsubscribes all session listeners and unpublishes.
|
|
278
|
+
// activate() publishes this node's service under its own session id, then
|
|
279
|
+
// announces the node's session id and chain role on the ready channel;
|
|
280
|
+
// teardown() unsubscribes all session listeners and unpublishes.
|
|
273
281
|
// Deferred to session_start because both facts come from ctx, unavailable at
|
|
274
282
|
// factory-init time.
|
|
275
283
|
const serviceLifecycle = new PermissionServiceLifecycle(
|
|
276
284
|
permissionsService,
|
|
277
|
-
subagentDetection,
|
|
278
285
|
authorizerSelection,
|
|
279
286
|
pi.events,
|
|
280
287
|
[unsubSubagentLifecycle],
|
package/src/service-lifecycle.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AdjudicationRole } from "./authority/authorizer-selection";
|
|
3
|
-
import type { RegisteredChildDetector } from "./authority/subagent-detection";
|
|
4
3
|
import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
|
|
5
4
|
import {
|
|
6
5
|
type PermissionsService,
|
|
7
6
|
publishPermissionsService,
|
|
8
|
-
publishRootPermissionsService,
|
|
9
7
|
unpublishPermissionsService,
|
|
10
|
-
unpublishRootPermissionsService,
|
|
11
8
|
} from "./service";
|
|
12
9
|
import { readSessionId } from "./session-identity";
|
|
13
10
|
|
|
@@ -35,18 +32,16 @@ export interface ReadyAnnouncer {
|
|
|
35
32
|
*
|
|
36
33
|
* - `activate` publishes the service under this node's own session id, so a
|
|
37
34
|
* sibling extension loaded into this node registers into the registries this
|
|
38
|
-
* node's gates and chain read.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* consumer needs — the session id and the chain role — on the ready channel,
|
|
42
|
-
* and re-arms the latch so the turn about to start announces once more.
|
|
35
|
+
* node's gates and chain read. Then it announces both facts a consumer needs
|
|
36
|
+
* — the session id and the chain role — on the ready channel, and re-arms the
|
|
37
|
+
* latch so the turn about to start announces once more.
|
|
43
38
|
* - `announceReady` is that second announcement: it fires at most once per
|
|
44
39
|
* activation cycle, so `permissions:ready` reaches a consumer whose own
|
|
45
40
|
* `session_start` ran after this node's (ADR 0012 decision 3). The channel's
|
|
46
41
|
* contract is therefore "at least once per session, and may repeat" —
|
|
47
42
|
* handlers must be idempotent.
|
|
48
43
|
* - `teardown` runs all session-scoped subscription cleanups in order, then
|
|
49
|
-
* unpublishes
|
|
44
|
+
* unpublishes this node's entry. The unpublish is identity-scoped, so a
|
|
50
45
|
* superseded `/reload` generation cannot evict the fresh one.
|
|
51
46
|
*/
|
|
52
47
|
export class PermissionServiceLifecycle
|
|
@@ -60,7 +55,6 @@ export class PermissionServiceLifecycle
|
|
|
60
55
|
|
|
61
56
|
constructor(
|
|
62
57
|
private readonly service: PermissionsService,
|
|
63
|
-
private readonly detection: RegisteredChildDetector,
|
|
64
58
|
private readonly role: AdjudicationRole,
|
|
65
59
|
private readonly events: PermissionEventBus,
|
|
66
60
|
private readonly subscriptions: readonly (() => void)[],
|
|
@@ -75,9 +69,6 @@ export class PermissionServiceLifecycle
|
|
|
75
69
|
publishPermissionsService(sessionId, this.service);
|
|
76
70
|
this.publishedSessionId = sessionId;
|
|
77
71
|
}
|
|
78
|
-
if (!this.detection.isRegisteredChild(ctx)) {
|
|
79
|
-
publishRootPermissionsService(this.service);
|
|
80
|
-
}
|
|
81
72
|
this.emitReady(ctx);
|
|
82
73
|
}
|
|
83
74
|
|
|
@@ -97,7 +88,6 @@ export class PermissionServiceLifecycle
|
|
|
97
88
|
unpublishPermissionsService(this.publishedSessionId, this.service);
|
|
98
89
|
this.publishedSessionId = null;
|
|
99
90
|
}
|
|
100
|
-
unpublishRootPermissionsService(this.service);
|
|
101
91
|
}
|
|
102
92
|
|
|
103
93
|
/**
|
package/src/service.ts
CHANGED
|
@@ -7,15 +7,12 @@
|
|
|
7
7
|
* accessors here read from the same `globalThis` slots the provider wrote to —
|
|
8
8
|
* enabling direct, synchronous, type-safe function calls.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* session runtime each — a root session and its in-process
|
|
12
|
-
* all load their own instance of this extension)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* registries that node's own gates and chain read (ADR 0012 decision 2).
|
|
17
|
-
* - A single legacy slot holding the process root's service, read by the
|
|
18
|
-
* deprecated `getRootPermissionsService()`.
|
|
10
|
+
* The slot is a session-keyed map, because one process can host several
|
|
11
|
+
* **nodes** (one Pi session runtime each — a root session and its in-process
|
|
12
|
+
* subagent children all load their own instance of this extension). Every node
|
|
13
|
+
* writes under its own session id, and `getPermissionsService(sessionId)`
|
|
14
|
+
* resolves the service whose registries that node's own gates and chain read
|
|
15
|
+
* (ADR 0012 decision 2).
|
|
19
16
|
*
|
|
20
17
|
* Best practice: resolve per use rather than caching the reference — this
|
|
21
18
|
* ensures resilience across `/reload` and load-order edge cases.
|
|
@@ -72,9 +69,6 @@ export type {
|
|
|
72
69
|
} from "./presentation/prompt-payload";
|
|
73
70
|
export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
|
|
74
71
|
|
|
75
|
-
/** Process-global key for the legacy (process-root) service slot. */
|
|
76
|
-
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
|
|
77
|
-
|
|
78
72
|
/** Process-global key for the session-keyed service map (ADR 0012 decision 2). */
|
|
79
73
|
const SESSION_SERVICES_KEY = Symbol.for(
|
|
80
74
|
"@gotgenes/pi-permission-system:session-services",
|
|
@@ -120,8 +114,7 @@ export interface PermissionQuery {
|
|
|
120
114
|
|
|
121
115
|
/**
|
|
122
116
|
* Public interface exposed to other extensions via
|
|
123
|
-
* {@link getPermissionsService}
|
|
124
|
-
* {@link getRootPermissionsService}).
|
|
117
|
+
* {@link getPermissionsService}.
|
|
125
118
|
*
|
|
126
119
|
* Each instance belongs to one node, and its three registration surfaces are
|
|
127
120
|
* read by that node alone: extractors and formatters by its own gates, chain
|
|
@@ -203,77 +196,13 @@ export interface PermissionsService extends PermissionQuery {
|
|
|
203
196
|
): () => void;
|
|
204
197
|
}
|
|
205
198
|
|
|
206
|
-
/**
|
|
207
|
-
* Store a `PermissionsService` in the legacy process-root slot, read by
|
|
208
|
-
* `getRootPermissionsService()`.
|
|
209
|
-
*
|
|
210
|
-
* Called at `session_start` by the top-level (parent) instance only — an
|
|
211
|
-
* in-process subagent child skips publishing so it cannot clobber the parent's
|
|
212
|
-
* service. Overwrites any previously published service, which keeps `/reload`
|
|
213
|
-
* working: a reloaded parent re-publishes its fresh service.
|
|
214
|
-
*/
|
|
215
|
-
export function publishRootPermissionsService(
|
|
216
|
-
service: PermissionsService,
|
|
217
|
-
): void {
|
|
218
|
-
(globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Warned at most once per module copy, so a consumer hears it on its first
|
|
223
|
-
* deprecated call and never again. Under jiti isolation each consumer
|
|
224
|
-
* extension holds its own copy, so each hears it for its own call site.
|
|
225
|
-
*/
|
|
226
|
-
let warnedDeprecatedAccessor = false;
|
|
227
|
-
|
|
228
|
-
const DEPRECATED_ACCESSOR_WARNING =
|
|
229
|
-
"getRootPermissionsService() is deprecated: it answers with the process root's " +
|
|
230
|
-
"service, which is the wrong node in every node but the root — inside an " +
|
|
231
|
-
"in-process subagent child it hands back the parent's service, so a " +
|
|
232
|
-
"registration lands where the child's gates never read it and a policy " +
|
|
233
|
-
"query answers against the parent's config. Use " +
|
|
234
|
-
"getPermissionsService(sessionId) with the sessionId from the " +
|
|
235
|
-
"permissions:ready payload. See " +
|
|
236
|
-
"https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/cross-extension-api.md";
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Retrieve the process root's published `PermissionsService`, or `undefined`
|
|
240
|
-
* if the permission-system extension has not loaded (or has been unloaded).
|
|
241
|
-
*
|
|
242
|
-
* @deprecated Use {@link getPermissionsService} with the `sessionId`
|
|
243
|
-
* from the `permissions:ready` payload. This accessor answers "the process
|
|
244
|
-
* root's service", which is the wrong question in every node but the root.
|
|
245
|
-
* Removal is deferred to a future major (ADR 0012 decision 7).
|
|
246
|
-
*/
|
|
247
|
-
export function getRootPermissionsService(): PermissionsService | undefined {
|
|
248
|
-
if (!warnedDeprecatedAccessor) {
|
|
249
|
-
warnedDeprecatedAccessor = true;
|
|
250
|
-
process.emitWarning(DEPRECATED_ACCESSOR_WARNING, {
|
|
251
|
-
type: "DeprecationWarning",
|
|
252
|
-
code: "PI_PERMISSION_SYSTEM_DEP0001",
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
return readRootService();
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* The undeprecated read of the root slot, for this package's own lifecycle.
|
|
260
|
-
*
|
|
261
|
-
* `unpublishRootPermissionsService` must compare identities without warning the
|
|
262
|
-
* host about a call the host did not make.
|
|
263
|
-
*/
|
|
264
|
-
function readRootService(): PermissionsService | undefined {
|
|
265
|
-
return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
|
|
266
|
-
| PermissionsService
|
|
267
|
-
| undefined;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
199
|
/**
|
|
271
200
|
* The process-global map of session id → that node's service, created on first
|
|
272
201
|
* use.
|
|
273
202
|
*
|
|
274
|
-
* Backed by `globalThis` + `Symbol.for()`
|
|
275
|
-
*
|
|
276
|
-
*
|
|
203
|
+
* Backed by `globalThis` + `Symbol.for()` because each session's
|
|
204
|
+
* `ResourceLoader` builds its own jiti instance, so a parent and its in-process
|
|
205
|
+
* child share no module state — only process globals.
|
|
277
206
|
*/
|
|
278
207
|
function sessionServices(): Map<string, PermissionsService> {
|
|
279
208
|
const store = globalThis as Record<symbol, unknown>;
|
|
@@ -331,9 +260,7 @@ export function getPermissionsService(
|
|
|
331
260
|
const MISSING_SESSION_ID_WARNING =
|
|
332
261
|
"getPermissionsService() was called without a session id and answered " +
|
|
333
262
|
"undefined. It resolves the service of one node, so it needs the sessionId " +
|
|
334
|
-
"from the permissions:ready payload: getPermissionsService(sessionId).
|
|
335
|
-
"read the process root's service — what the zero-arg call used to do — use " +
|
|
336
|
-
"the deprecated getRootPermissionsService(). See " +
|
|
263
|
+
"from the permissions:ready payload: getPermissionsService(sessionId). See " +
|
|
337
264
|
"https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/cross-extension-api.md";
|
|
338
265
|
|
|
339
266
|
/**
|
|
@@ -358,10 +285,11 @@ function warnMissingSessionId(): void {
|
|
|
358
285
|
|
|
359
286
|
/**
|
|
360
287
|
* Remove the `sessionId` entry, but only when it still holds `service`
|
|
361
|
-
* (identity compare-and-delete
|
|
288
|
+
* (identity compare-and-delete).
|
|
362
289
|
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
290
|
+
* Called during `session_shutdown` to avoid stale references after the node is
|
|
291
|
+
* torn down. Scoping the delete to the publishing instance keeps a superseded
|
|
292
|
+
* `/reload` generation's late shutdown from wiping the new generation's freshly
|
|
365
293
|
* published service.
|
|
366
294
|
*/
|
|
367
295
|
export function unpublishPermissionsService(
|
|
@@ -373,26 +301,3 @@ export function unpublishPermissionsService(
|
|
|
373
301
|
services.delete(sessionId);
|
|
374
302
|
}
|
|
375
303
|
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Remove `service` from `globalThis`, but only when the current slot still
|
|
379
|
-
* holds it (identity compare-and-delete).
|
|
380
|
-
*
|
|
381
|
-
* Called during `session_shutdown` to avoid stale references after the
|
|
382
|
-
* extension is torn down. Scoping the delete to the publishing instance keeps
|
|
383
|
-
* two cases correct:
|
|
384
|
-
*
|
|
385
|
-
* - An in-process subagent child never published the parent's service, so its
|
|
386
|
-
* shutdown is a no-op and the parent's slot survives.
|
|
387
|
-
* - A superseded `/reload` generation no longer owns the slot, so its late
|
|
388
|
-
* shutdown cannot wipe the new generation's freshly published service.
|
|
389
|
-
*/
|
|
390
|
-
export function unpublishRootPermissionsService(
|
|
391
|
-
service: PermissionsService,
|
|
392
|
-
): void {
|
|
393
|
-
if (readRootService() !== service) {
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
|
|
397
|
-
delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
|
|
398
|
-
}
|