@gotgenes/pi-permission-system 26.3.1 → 27.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 +30 -0
- package/README.md +3 -2
- package/dist/public.d.ts +105 -21
- package/docs/configuration.md +3 -1
- package/docs/cross-extension-api.md +112 -62
- package/docs/guides/permission-frontmatter-for-subagent-extensions.md +7 -2
- package/docs/migration/0794-keyed-service-locator.md +91 -0
- package/docs/subagent-integration.md +98 -23
- package/package.json +1 -1
- package/src/authority/authorizer-registry.ts +50 -0
- package/src/authority/authorizer-selection.ts +33 -1
- package/src/authority/permission-forwarding.ts +25 -9
- package/src/authority/subagent-context.ts +4 -10
- package/src/handlers/before-agent-start.ts +5 -14
- package/src/handlers/index.ts +1 -0
- package/src/handlers/session-turn-prep.ts +58 -0
- package/src/index.ts +35 -10
- package/src/permission-events.ts +44 -11
- package/src/service-lifecycle.ts +76 -9
- package/src/service.ts +188 -16
- package/src/session-identity.ts +33 -0
package/src/index.ts
CHANGED
|
@@ -2,7 +2,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { warmBashParser } from "./access-intent/bash/parser";
|
|
4
4
|
import { buildResolvedIntentFromMatchValues } from "./access-intent/input-normalizer";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
AuthorizerRegistry,
|
|
7
|
+
ObservedAuthorizerRegistrar,
|
|
8
|
+
} from "./authority/authorizer-registry";
|
|
6
9
|
import { AuthorizerSelection } from "./authority/authorizer-selection";
|
|
7
10
|
import {
|
|
8
11
|
ForwardedRequestServer,
|
|
@@ -35,6 +38,7 @@ import {
|
|
|
35
38
|
AgentPrepHandler,
|
|
36
39
|
PermissionGateHandler,
|
|
37
40
|
SessionLifecycleHandler,
|
|
41
|
+
SessionTurnPrep,
|
|
38
42
|
} from "./handlers";
|
|
39
43
|
import { GateRunner } from "./handlers/gates/runner";
|
|
40
44
|
import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeline";
|
|
@@ -46,6 +50,7 @@ import { PermissionResolver } from "./permission-resolver";
|
|
|
46
50
|
import { PermissionSession } from "./permission-session";
|
|
47
51
|
import { LocalPermissionsService } from "./permissions-service";
|
|
48
52
|
import { resolveRenderBudget } from "./presentation/dialog-renderer";
|
|
53
|
+
import type { PermissionsService } from "./service";
|
|
49
54
|
import { PermissionServiceLifecycle } from "./service-lifecycle";
|
|
50
55
|
import { PermissionSessionLogger } from "./session-logger";
|
|
51
56
|
import { SessionRules } from "./session-rules";
|
|
@@ -233,12 +238,24 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
233
238
|
),
|
|
234
239
|
});
|
|
235
240
|
|
|
236
|
-
|
|
241
|
+
// Explicitly annotated to break a type-inference cycle: the selection's
|
|
242
|
+
// `getPermissionQuery` thunk closes over this service, and the service's
|
|
243
|
+
// registrar closes back over the selection. Both are resolved at call time
|
|
244
|
+
// at runtime; `tsc` needs one of the two typed by hand to unwind them.
|
|
245
|
+
const permissionsService: PermissionsService = new LocalPermissionsService(
|
|
237
246
|
resolver,
|
|
238
247
|
session,
|
|
239
248
|
formatterRegistry,
|
|
240
249
|
accessExtractorRegistry,
|
|
241
|
-
|
|
250
|
+
// Sibling extensions register through the observing decorator, so a link
|
|
251
|
+
// offered to a node whose chain never runs is accepted and recorded rather
|
|
252
|
+
// than vanishing (ADR 0012 decision 4). Chain resolution keeps reading the
|
|
253
|
+
// undecorated registry above.
|
|
254
|
+
new ObservedAuthorizerRegistrar(
|
|
255
|
+
authorizerRegistry,
|
|
256
|
+
authorizerSelection,
|
|
257
|
+
logger,
|
|
258
|
+
),
|
|
242
259
|
);
|
|
243
260
|
|
|
244
261
|
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
|
|
@@ -249,13 +266,16 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
249
266
|
);
|
|
250
267
|
|
|
251
268
|
// PermissionServiceLifecycle owns the process-global service publication:
|
|
252
|
-
// activate() publishes
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
269
|
+
// activate() publishes this node's service under its own session id (and to
|
|
270
|
+
// the legacy root slot unless this is a registered subagent child — see
|
|
271
|
+
// #302), then announces the node's session id and chain role on the ready
|
|
272
|
+
// channel; teardown() unsubscribes all session listeners and unpublishes.
|
|
273
|
+
// Deferred to session_start because both facts come from ctx, unavailable at
|
|
274
|
+
// factory-init time.
|
|
256
275
|
const serviceLifecycle = new PermissionServiceLifecycle(
|
|
257
276
|
permissionsService,
|
|
258
277
|
subagentDetection,
|
|
278
|
+
authorizerSelection,
|
|
259
279
|
pi.events,
|
|
260
280
|
[unsubSubagentLifecycle],
|
|
261
281
|
);
|
|
@@ -274,13 +294,18 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
274
294
|
logger,
|
|
275
295
|
audit,
|
|
276
296
|
);
|
|
277
|
-
const
|
|
297
|
+
const turnPrep = new SessionTurnPrep(
|
|
278
298
|
session,
|
|
279
|
-
resolver,
|
|
280
|
-
toolRegistry,
|
|
281
299
|
() => {
|
|
282
300
|
void warmBashParser();
|
|
283
301
|
},
|
|
302
|
+
serviceLifecycle,
|
|
303
|
+
);
|
|
304
|
+
const agentPrep = new AgentPrepHandler(
|
|
305
|
+
turnPrep,
|
|
306
|
+
session,
|
|
307
|
+
resolver,
|
|
308
|
+
toolRegistry,
|
|
284
309
|
);
|
|
285
310
|
|
|
286
311
|
const gateRunner = new GateRunner(
|
package/src/permission-events.ts
CHANGED
|
@@ -18,7 +18,14 @@ export interface PermissionEventBus {
|
|
|
18
18
|
|
|
19
19
|
// ── Channel name constants ─────────────────────────────────────────────────
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Emitted at `session_start` after the emitting node published its service, and
|
|
23
|
+
* again at that node's first `before_agent_start` (ADR 0012 decision 3).
|
|
24
|
+
*
|
|
25
|
+
* Fires at least once per session and may repeat, so a handler must be
|
|
26
|
+
* idempotent — registering on every emission hits the duplicate-registration
|
|
27
|
+
* throw.
|
|
28
|
+
*/
|
|
22
29
|
export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
|
|
23
30
|
|
|
24
31
|
/** Emitted when a permission request is committed to the active UI prompt path. */
|
|
@@ -30,13 +37,34 @@ export const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
|
|
|
30
37
|
// ── permissions:ready ──────────────────────────────────────────────────────
|
|
31
38
|
|
|
32
39
|
/**
|
|
33
|
-
* Payload emitted on `permissions:ready
|
|
40
|
+
* Payload emitted on `permissions:ready`: plain facts about the node that
|
|
41
|
+
* emitted it (ADR 0012 decision 2).
|
|
34
42
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
43
|
+
* The bus announces; the locator provides. The payload carries data a consumer
|
|
44
|
+
* can log, serialize, and replay — never a live capability — so the service
|
|
45
|
+
* itself is fetched with `getPermissionsService(sessionId)`.
|
|
46
|
+
*
|
|
47
|
+
* There is no `protocolVersion` — the published types plus package semver
|
|
48
|
+
* define the broadcast contract.
|
|
38
49
|
*/
|
|
39
|
-
export
|
|
50
|
+
export interface PermissionsReadyEvent {
|
|
51
|
+
/**
|
|
52
|
+
* The emitting node's session id: the key for
|
|
53
|
+
* `getPermissionsService`. `null` when the host exposed no session
|
|
54
|
+
* id, in which case this node published no keyed service.
|
|
55
|
+
*/
|
|
56
|
+
sessionId: string | null;
|
|
57
|
+
/**
|
|
58
|
+
* Whether this node adjudicates its own asks (its authorizer chain runs) or
|
|
59
|
+
* relays them to a serving node, which runs *its* chain over the same facts
|
|
60
|
+
* (ADR 0007 §7).
|
|
61
|
+
*
|
|
62
|
+
* A registration needs no branch on this: extractors and formatters are read
|
|
63
|
+
* by every node's own gates, and a chain link registered where no chain runs
|
|
64
|
+
* is accepted and recorded rather than refused (ADR 0012 decision 4).
|
|
65
|
+
*/
|
|
66
|
+
adjudicatesLocally: boolean;
|
|
67
|
+
}
|
|
40
68
|
|
|
41
69
|
// ── permissions:ui_prompt ──────────────────────────────────────────────────
|
|
42
70
|
|
|
@@ -152,13 +180,18 @@ export interface PermissionDecisionEvent {
|
|
|
152
180
|
|
|
153
181
|
/**
|
|
154
182
|
* Emit the `permissions:ready` broadcast.
|
|
155
|
-
* Call
|
|
156
|
-
*
|
|
183
|
+
* Call after the node published its service, so a consumer reacting to ready
|
|
184
|
+
* can immediately resolve `getPermissionsService(event.sessionId)`.
|
|
185
|
+
* Called twice per session: at `session_start`, and at the first
|
|
186
|
+
* `before_agent_start` so a consumer whose own `session_start` ran later still
|
|
187
|
+
* hears it (ADR 0012 decision 3).
|
|
157
188
|
*/
|
|
158
|
-
export function emitReadyEvent(
|
|
159
|
-
|
|
189
|
+
export function emitReadyEvent(
|
|
190
|
+
events: PermissionEventBus,
|
|
191
|
+
event: PermissionsReadyEvent,
|
|
192
|
+
): void {
|
|
160
193
|
try {
|
|
161
|
-
events.emit(PERMISSIONS_READY_CHANNEL,
|
|
194
|
+
events.emit(PERMISSIONS_READY_CHANNEL, event);
|
|
162
195
|
} catch {
|
|
163
196
|
// Broadcasts are best-effort. A throwing listener must not block the
|
|
164
197
|
// permission system from completing session startup.
|
package/src/service-lifecycle.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AdjudicationRole } from "./authority/authorizer-selection";
|
|
2
3
|
import type { RegisteredChildDetector } from "./authority/subagent-detection";
|
|
3
4
|
import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
|
|
4
5
|
import {
|
|
5
6
|
type PermissionsService,
|
|
6
7
|
publishPermissionsService,
|
|
8
|
+
publishRootPermissionsService,
|
|
7
9
|
unpublishPermissionsService,
|
|
10
|
+
unpublishRootPermissionsService,
|
|
8
11
|
} from "./service";
|
|
12
|
+
import { readSessionId } from "./session-identity";
|
|
9
13
|
|
|
10
14
|
/** The session-scoped service lifecycle that the lifecycle handler drives. */
|
|
11
15
|
export interface ServiceLifecycle {
|
|
@@ -13,35 +17,98 @@ export interface ServiceLifecycle {
|
|
|
13
17
|
teardown(): void;
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Announces this node's ready facts at most once per session, whatever the
|
|
22
|
+
* caller does (ADR 0012 decision 3, the ready latch).
|
|
23
|
+
*
|
|
24
|
+
* Kept separate from `ServiceLifecycle` because the two roles have different
|
|
25
|
+
* callers: the session lifecycle starts and tears the node down, while the
|
|
26
|
+
* latch fires from the turn about to start.
|
|
27
|
+
*/
|
|
28
|
+
export interface ReadyAnnouncer {
|
|
29
|
+
announceReady(ctx: ExtensionContext): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
/**
|
|
17
33
|
* Owns the process-global service publication lifecycle for one extension
|
|
18
|
-
* instance.
|
|
34
|
+
* instance — that is, for one node (ADR 0012).
|
|
19
35
|
*
|
|
20
|
-
* - `activate` publishes the service
|
|
21
|
-
*
|
|
22
|
-
*
|
|
36
|
+
* - `activate` publishes the service under this node's own session id, so a
|
|
37
|
+
* sibling extension loaded into this node registers into the registries this
|
|
38
|
+
* node's gates and chain read. It additionally publishes to the legacy
|
|
39
|
+
* process-root slot unless this is a registered subagent child, which must
|
|
40
|
+
* not clobber its parent's slot (#302). Then it announces both facts a
|
|
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.
|
|
43
|
+
* - `announceReady` is that second announcement: it fires at most once per
|
|
44
|
+
* activation cycle, so `permissions:ready` reaches a consumer whose own
|
|
45
|
+
* `session_start` ran after this node's (ADR 0012 decision 3). The channel's
|
|
46
|
+
* contract is therefore "at least once per session, and may repeat" —
|
|
47
|
+
* handlers must be idempotent.
|
|
23
48
|
* - `teardown` runs all session-scoped subscription cleanups in order, then
|
|
24
|
-
* unpublishes
|
|
49
|
+
* unpublishes from both slots. Each unpublish is identity-scoped, so a
|
|
50
|
+
* superseded `/reload` generation cannot evict the fresh one.
|
|
25
51
|
*/
|
|
26
|
-
export class PermissionServiceLifecycle
|
|
52
|
+
export class PermissionServiceLifecycle
|
|
53
|
+
implements ServiceLifecycle, ReadyAnnouncer
|
|
54
|
+
{
|
|
55
|
+
/** The key this instance last published under; `null` until it publishes. */
|
|
56
|
+
private publishedSessionId: string | null = null;
|
|
57
|
+
|
|
58
|
+
/** Whether the latch has already announced for the current session. */
|
|
59
|
+
private announced = false;
|
|
60
|
+
|
|
27
61
|
constructor(
|
|
28
62
|
private readonly service: PermissionsService,
|
|
29
63
|
private readonly detection: RegisteredChildDetector,
|
|
64
|
+
private readonly role: AdjudicationRole,
|
|
30
65
|
private readonly events: PermissionEventBus,
|
|
31
66
|
private readonly subscriptions: readonly (() => void)[],
|
|
32
67
|
) {}
|
|
33
68
|
|
|
34
69
|
activate(ctx: ExtensionContext): void {
|
|
70
|
+
// Re-arm: a new session generation gets its own post-session_start
|
|
71
|
+
// announcement, so a consumer that loaded after this node still hears one.
|
|
72
|
+
this.announced = false;
|
|
73
|
+
const sessionId = readSessionId(ctx);
|
|
74
|
+
if (sessionId !== null) {
|
|
75
|
+
publishPermissionsService(sessionId, this.service);
|
|
76
|
+
this.publishedSessionId = sessionId;
|
|
77
|
+
}
|
|
35
78
|
if (!this.detection.isRegisteredChild(ctx)) {
|
|
36
|
-
|
|
79
|
+
publishRootPermissionsService(this.service);
|
|
80
|
+
}
|
|
81
|
+
this.emitReady(ctx);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
announceReady(ctx: ExtensionContext): void {
|
|
85
|
+
if (this.announced) {
|
|
86
|
+
return;
|
|
37
87
|
}
|
|
38
|
-
|
|
88
|
+
this.announced = true;
|
|
89
|
+
this.emitReady(ctx);
|
|
39
90
|
}
|
|
40
91
|
|
|
41
92
|
teardown(): void {
|
|
42
93
|
for (const unsubscribe of this.subscriptions) {
|
|
43
94
|
unsubscribe();
|
|
44
95
|
}
|
|
45
|
-
|
|
96
|
+
if (this.publishedSessionId !== null) {
|
|
97
|
+
unpublishPermissionsService(this.publishedSessionId, this.service);
|
|
98
|
+
this.publishedSessionId = null;
|
|
99
|
+
}
|
|
100
|
+
unpublishRootPermissionsService(this.service);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The one place a ready payload is built, so the `session_start` emission and
|
|
105
|
+
* the latch emission cannot drift in shape: both read the node's facts from
|
|
106
|
+
* the context they are handed.
|
|
107
|
+
*/
|
|
108
|
+
private emitReady(ctx: ExtensionContext): void {
|
|
109
|
+
emitReadyEvent(this.events, {
|
|
110
|
+
sessionId: readSessionId(ctx),
|
|
111
|
+
adjudicatesLocally: this.role.adjudicatesLocally(),
|
|
112
|
+
});
|
|
46
113
|
}
|
|
47
114
|
}
|
package/src/service.ts
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cross-extension service
|
|
2
|
+
* Cross-extension service accessors backed by `Symbol.for()` on `globalThis`.
|
|
3
3
|
*
|
|
4
4
|
* `Symbol.for()` is process-global by spec, so it survives jiti's per-extension
|
|
5
5
|
* module isolation (`moduleCache: false`). A consumer doing
|
|
6
|
-
* `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but the
|
|
7
|
+
* accessors here read from the same `globalThis` slots the provider wrote to —
|
|
8
|
+
* enabling direct, synchronous, type-safe function calls.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* There are two slots, because one process can host several **nodes** (one Pi
|
|
11
|
+
* session runtime each — a root session and its in-process subagent children
|
|
12
|
+
* all load their own instance of this extension):
|
|
13
|
+
*
|
|
14
|
+
* - A session-keyed map, written by every node under its own session id.
|
|
15
|
+
* `getPermissionsService(sessionId)` resolves the service whose
|
|
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()`.
|
|
19
|
+
*
|
|
20
|
+
* Best practice: resolve per use rather than caching the reference — this
|
|
21
|
+
* ensures resilience across `/reload` and load-order edge cases.
|
|
12
22
|
*/
|
|
13
23
|
|
|
14
24
|
import type { Authorizer } from "./authority/authorizer";
|
|
@@ -62,9 +72,14 @@ export type {
|
|
|
62
72
|
} from "./presentation/prompt-payload";
|
|
63
73
|
export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
|
|
64
74
|
|
|
65
|
-
/** Process-global key for the service slot. */
|
|
75
|
+
/** Process-global key for the legacy (process-root) service slot. */
|
|
66
76
|
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
|
|
67
77
|
|
|
78
|
+
/** Process-global key for the session-keyed service map (ADR 0012 decision 2). */
|
|
79
|
+
const SESSION_SERVICES_KEY = Symbol.for(
|
|
80
|
+
"@gotgenes/pi-permission-system:session-services",
|
|
81
|
+
);
|
|
82
|
+
|
|
68
83
|
/**
|
|
69
84
|
* The narrow, read-only projection of {@link PermissionsService}: answer a
|
|
70
85
|
* policy query for a surface, and report a tool-level state. This is the
|
|
@@ -104,7 +119,14 @@ export interface PermissionQuery {
|
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
/**
|
|
107
|
-
* Public interface exposed to other extensions via
|
|
122
|
+
* Public interface exposed to other extensions via
|
|
123
|
+
* {@link getPermissionsService} (or the deprecated
|
|
124
|
+
* {@link getRootPermissionsService}).
|
|
125
|
+
*
|
|
126
|
+
* Each instance belongs to one node, and its three registration surfaces are
|
|
127
|
+
* read by that node alone: extractors and formatters by its own gates, chain
|
|
128
|
+
* links by its own chain. Resolve the service of the node whose behavior you
|
|
129
|
+
* mean to affect.
|
|
108
130
|
*
|
|
109
131
|
* `checkPermission` takes a surface + optional value + optional agent name,
|
|
110
132
|
* and delegates to `PermissionManager.checkPermission()` with current session
|
|
@@ -182,28 +204,176 @@ export interface PermissionsService extends PermissionQuery {
|
|
|
182
204
|
}
|
|
183
205
|
|
|
184
206
|
/**
|
|
185
|
-
* Store a `PermissionsService`
|
|
186
|
-
*
|
|
207
|
+
* Store a `PermissionsService` in the legacy process-root slot, read by
|
|
208
|
+
* `getRootPermissionsService()`.
|
|
187
209
|
*
|
|
188
210
|
* Called at `session_start` by the top-level (parent) instance only — an
|
|
189
211
|
* in-process subagent child skips publishing so it cannot clobber the parent's
|
|
190
212
|
* service. Overwrites any previously published service, which keeps `/reload`
|
|
191
213
|
* working: a reloaded parent re-publishes its fresh service.
|
|
192
214
|
*/
|
|
193
|
-
export function
|
|
215
|
+
export function publishRootPermissionsService(
|
|
216
|
+
service: PermissionsService,
|
|
217
|
+
): void {
|
|
194
218
|
(globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
|
|
195
219
|
}
|
|
196
220
|
|
|
197
221
|
/**
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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.
|
|
200
225
|
*/
|
|
201
|
-
|
|
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 {
|
|
202
265
|
return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
|
|
203
266
|
| PermissionsService
|
|
204
267
|
| undefined;
|
|
205
268
|
}
|
|
206
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The process-global map of session id → that node's service, created on first
|
|
272
|
+
* use.
|
|
273
|
+
*
|
|
274
|
+
* Backed by `globalThis` + `Symbol.for()` for the same reason the single slot
|
|
275
|
+
* above is: each session's `ResourceLoader` builds its own jiti instance, so a
|
|
276
|
+
* parent and its in-process child share no module state — only process globals.
|
|
277
|
+
*/
|
|
278
|
+
function sessionServices(): Map<string, PermissionsService> {
|
|
279
|
+
const store = globalThis as Record<symbol, unknown>;
|
|
280
|
+
const existing = store[SESSION_SERVICES_KEY] as
|
|
281
|
+
| Map<string, PermissionsService>
|
|
282
|
+
| undefined;
|
|
283
|
+
if (existing) {
|
|
284
|
+
return existing;
|
|
285
|
+
}
|
|
286
|
+
const services = new Map<string, PermissionsService>();
|
|
287
|
+
store[SESSION_SERVICES_KEY] = services;
|
|
288
|
+
return services;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Publish `service` as the service of the node whose session is `sessionId`
|
|
293
|
+
* (ADR 0012 decision 2 — node-locality).
|
|
294
|
+
*
|
|
295
|
+
* Every node publishes under its own key, including an in-process subagent
|
|
296
|
+
* child, so there is nothing to clobber: a child's sibling extension registers
|
|
297
|
+
* an extractor, formatter, or chain link into the registry the child's own
|
|
298
|
+
* gates and chain read.
|
|
299
|
+
*/
|
|
300
|
+
export function publishPermissionsService(
|
|
301
|
+
sessionId: string,
|
|
302
|
+
service: PermissionsService,
|
|
303
|
+
): void {
|
|
304
|
+
sessionServices().set(sessionId, service);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Retrieve the service belonging to the node whose session is `sessionId`, or
|
|
309
|
+
* `undefined` when that node has published none.
|
|
310
|
+
*
|
|
311
|
+
* This is the supported way to obtain a node's service, for registration and
|
|
312
|
+
* for policy queries alike. Take `sessionId` from the `permissions:ready`
|
|
313
|
+
* payload (or from `ctx.sessionManager.getSessionId()` inside your own session
|
|
314
|
+
* handler), and resolve per use rather than caching the reference.
|
|
315
|
+
*
|
|
316
|
+
* A caller the type checker cannot reach — JavaScript, or a consumer compiled
|
|
317
|
+
* against an earlier major — may still call this with no argument. That answers
|
|
318
|
+
* `undefined` rather than another node's service, and warns once so the missing
|
|
319
|
+
* registration is not silent.
|
|
320
|
+
*/
|
|
321
|
+
export function getPermissionsService(
|
|
322
|
+
sessionId: string,
|
|
323
|
+
): PermissionsService | undefined {
|
|
324
|
+
if (typeof sessionId !== "string") {
|
|
325
|
+
warnMissingSessionId();
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
return sessionServices().get(sessionId);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const MISSING_SESSION_ID_WARNING =
|
|
332
|
+
"getPermissionsService() was called without a session id and answered " +
|
|
333
|
+
"undefined. It resolves the service of one node, so it needs the sessionId " +
|
|
334
|
+
"from the permissions:ready payload: getPermissionsService(sessionId). To " +
|
|
335
|
+
"read the process root's service — what the zero-arg call used to do — use " +
|
|
336
|
+
"the deprecated getRootPermissionsService(). See " +
|
|
337
|
+
"https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/cross-extension-api.md";
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Warned at most once per module copy, like the deprecation guard above, so a
|
|
341
|
+
* consumer polling the locator every turn reports the defect once.
|
|
342
|
+
*
|
|
343
|
+
* Deliberately not a `DeprecationWarning`: an operator who silences those with
|
|
344
|
+
* `--no-deprecation` still needs to hear that a registration never landed.
|
|
345
|
+
*/
|
|
346
|
+
let warnedMissingSessionId = false;
|
|
347
|
+
|
|
348
|
+
function warnMissingSessionId(): void {
|
|
349
|
+
if (warnedMissingSessionId) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
warnedMissingSessionId = true;
|
|
353
|
+
process.emitWarning(MISSING_SESSION_ID_WARNING, {
|
|
354
|
+
type: "Warning",
|
|
355
|
+
code: "PI_PERMISSION_SYSTEM_WARN0001",
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Remove the `sessionId` entry, but only when it still holds `service`
|
|
361
|
+
* (identity compare-and-delete, like {@link unpublishRootPermissionsService}).
|
|
362
|
+
*
|
|
363
|
+
* Scoping the delete to the publishing instance keeps a superseded `/reload`
|
|
364
|
+
* generation's late shutdown from wiping the new generation's freshly
|
|
365
|
+
* published service.
|
|
366
|
+
*/
|
|
367
|
+
export function unpublishPermissionsService(
|
|
368
|
+
sessionId: string,
|
|
369
|
+
service: PermissionsService,
|
|
370
|
+
): void {
|
|
371
|
+
const services = sessionServices();
|
|
372
|
+
if (services.get(sessionId) === service) {
|
|
373
|
+
services.delete(sessionId);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
207
377
|
/**
|
|
208
378
|
* Remove `service` from `globalThis`, but only when the current slot still
|
|
209
379
|
* holds it (identity compare-and-delete).
|
|
@@ -217,8 +387,10 @@ export function getPermissionsService(): PermissionsService | undefined {
|
|
|
217
387
|
* - A superseded `/reload` generation no longer owns the slot, so its late
|
|
218
388
|
* shutdown cannot wipe the new generation's freshly published service.
|
|
219
389
|
*/
|
|
220
|
-
export function
|
|
221
|
-
|
|
390
|
+
export function unpublishRootPermissionsService(
|
|
391
|
+
service: PermissionsService,
|
|
392
|
+
): void {
|
|
393
|
+
if (readRootService() !== service) {
|
|
222
394
|
return;
|
|
223
395
|
}
|
|
224
396
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-identity.ts — This node's own session id.
|
|
3
|
+
*
|
|
4
|
+
* One Pi session runtime is a **node** (ADR 0012): its own `ExtensionContext`,
|
|
5
|
+
* event bus, gates, and `PermissionSession`. The session id is how a node names
|
|
6
|
+
* itself to the rest of the process — it keys the subagent-child registry, the
|
|
7
|
+
* serving-heartbeat records, and the session-keyed service publication.
|
|
8
|
+
*
|
|
9
|
+
* The read is defensive because the id is not guaranteed to be reachable: a
|
|
10
|
+
* host may expose a session manager without one. An unavailable id is `null`
|
|
11
|
+
* rather than a throw, so a caller decides what to do without an unreachable
|
|
12
|
+
* id (skip a keyed publication, report "not a registered child") instead of
|
|
13
|
+
* failing session startup.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Narrow context: the only session-manager reader {@link readSessionId} consumes. */
|
|
17
|
+
export interface SessionIdentityContext {
|
|
18
|
+
sessionManager: {
|
|
19
|
+
getSessionId(): string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Return the session id `ctx` belongs to, or `null` when the host does not
|
|
25
|
+
* expose one (absent, empty, or throwing).
|
|
26
|
+
*/
|
|
27
|
+
export function readSessionId(ctx: SessionIdentityContext): string | null {
|
|
28
|
+
try {
|
|
29
|
+
return ctx.sessionManager.getSessionId() || null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|