mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
259 lines
18 KiB
Markdown
259 lines
18 KiB
Markdown
---
|
|
issue: 145
|
|
issue_title: "Add Symbol.for()-backed service accessor, deprecate permissions:rpc:check"
|
|
---
|
|
|
|
# Symbol.for()-backed service accessor
|
|
|
|
## Problem Statement
|
|
|
|
The current cross-extension API for policy queries (`permissions:rpc:check`) wraps a synchronous `checkPermission()` call in async RPC ceremony: `requestId` → scoped reply channel → timeout handling → `as`-cast deserialization.
|
|
This works, but the ergonomics are poor for what is fundamentally a direct function call.
|
|
|
|
`Symbol.for()` is process-global by spec and survives jiti's per-extension module isolation.
|
|
A service object stored on `globalThis` via `Symbol.for()` enables direct, type-safe, synchronous function calls from any extension — eliminating the RPC envelope entirely.
|
|
|
|
## Goals
|
|
|
|
1. Add `src/service.ts` with a `PermissionsService` interface and `Symbol.for()`-backed accessor functions (`getPermissionsService`, `publishPermissionsService`, `unpublishPermissionsService`).
|
|
2. Add an `exports` field to `package.json` so `import("@gotgenes/pi-permission-system")` resolves to the service module.
|
|
3. Publish the service during the extension factory and clear it on shutdown.
|
|
4. Deprecate `permissions:rpc:check` — keep the handler working but document the service accessor as the preferred path.
|
|
|
|
## Non-Goals
|
|
|
|
- **Remove `permissions:rpc:check`** — it stays as a zero-dependency fallback for consumers who do not want to add an optional peer dep.
|
|
- **Move `permissions:rpc:prompt` to the service** — prompt forwarding is genuinely async and the event bus is a reasonable fit.
|
|
- **Move `permissions:decision` broadcasts** — fire-and-forget observation belongs on the event bus.
|
|
- **Add a Proxy delegate for reload safety** — during `/reload`, all extensions re-initialize; both provider and consumer call their factories anew, so captured references are naturally refreshed.
|
|
Document the "call per use, don't cache" pattern as a best practice.
|
|
- **Add a JS build step** — consumers are Pi extensions that use jiti; pointing `exports` to `.ts` source is sufficient.
|
|
- **Upstream `registerService`/`getService`** — tracked in earendil-works/pi#4207; this plan works independently.
|
|
|
|
## Background
|
|
|
|
### Dependency status
|
|
|
|
| Issue | Description | Status |
|
|
| ---------------------- | ----------------------------------- | ---------------------------------------------- |
|
|
| #29 | Permission event channel with RPC | ✅ Implemented |
|
|
| earendil-works/pi#4207 | Upstream registerService/getService | Open — independent; this plan works without it |
|
|
|
|
### jiti isolation model
|
|
|
|
Pi's extension loader creates a fresh jiti instance per extension with `moduleCache: false`.
|
|
Module-scoped state is invisible across extensions.
|
|
The only shared channels are:
|
|
|
|
- `pi.events` — the event bus, explicitly passed by the loader.
|
|
- `globalThis` + `Symbol.for()` — process-global by spec, survives jiti isolation.
|
|
|
|
When a consumer does `import("@gotgenes/pi-permission-system")`, their jiti loads a fresh module copy.
|
|
That copy's `getPermissionsService()` reads `globalThis[Symbol.for(...)]`, which was set by the provider's factory running in a different jiti instance.
|
|
The accessor works because `globalThis` and `Symbol.for()` are both process-global.
|
|
|
|
### Affected permission surfaces
|
|
|
|
This change adds a new **cross-extension access layer**.
|
|
It does not alter any allow/deny/ask decision logic.
|
|
All six surfaces (tools, bash, mcp, skills, special, external\_directory) are queryable through the service's `checkPermission` method.
|
|
|
|
### Existing `buildInputForSurface` utility
|
|
|
|
`src/permission-event-rpc.ts` contains a non-exported `buildInputForSurface()` helper that translates `(surface, value)` into the input object `PermissionManager.checkPermission()` expects.
|
|
The new service adapter needs the same logic.
|
|
The plan extracts it to `src/input-normalizer.ts` so both the RPC handler and the service factory can import it.
|
|
|
|
## Design Overview
|
|
|
|
### Service interface
|
|
|
|
```typescript
|
|
import type { PermissionCheckResult } from "./types";
|
|
|
|
export interface PermissionsService {
|
|
/**
|
|
* Query the permission policy for a surface and value.
|
|
* Returns the full check result including state, matched pattern, and origin.
|
|
* Session rules are included automatically.
|
|
*/
|
|
checkPermission(
|
|
surface: string,
|
|
value?: string,
|
|
agentName?: string,
|
|
): PermissionCheckResult;
|
|
}
|
|
```
|
|
|
|
The interface exposes a single method matching the simplified RPC signature.
|
|
Internally it delegates to `PermissionManager.checkPermission()` with the current session rules, mirroring the existing `permissions:rpc:check` handler logic.
|
|
|
|
The return type is the existing `PermissionCheckResult` — re-exported from the service module so consumers get full type safety without importing internal modules.
|
|
|
|
### globalThis accessor
|
|
|
|
```typescript
|
|
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
|
|
|
|
export function publishPermissionsService(service: PermissionsService): void {
|
|
(globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
|
|
}
|
|
|
|
export function getPermissionsService(): PermissionsService | undefined {
|
|
return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
|
|
| PermissionsService
|
|
| undefined;
|
|
}
|
|
|
|
export function unpublishPermissionsService(): void {
|
|
delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
|
|
}
|
|
```
|
|
|
|
- `publishPermissionsService` overwrites the slot — safe for `/reload`.
|
|
- `unpublishPermissionsService` clears the slot — called during `session_shutdown` to avoid stale references after the extension is torn down.
|
|
- `getPermissionsService` returns `undefined` when the extension has not loaded (or has been unloaded).
|
|
Consumers handle this with a `try/catch` around the dynamic import plus an `if` guard.
|
|
|
|
### Consumer usage
|
|
|
|
```typescript
|
|
try {
|
|
const { getPermissionsService } = await import(
|
|
"@gotgenes/pi-permission-system"
|
|
);
|
|
const permissions = getPermissionsService();
|
|
if (permissions) {
|
|
const result = permissions.checkPermission("bash", "git push");
|
|
// Direct call, full type safety, no async envelope
|
|
}
|
|
} catch {
|
|
// Not installed — graceful degradation
|
|
}
|
|
```
|
|
|
|
### Reload safety
|
|
|
|
During `/reload`, the Pi extension loader:
|
|
|
|
1. Fires `session_shutdown` to all extensions (provider calls `unpublishPermissionsService()`).
|
|
2. Tears down all extension runtimes.
|
|
3. Creates fresh jiti instances and calls each extension factory anew.
|
|
4. Provider's factory calls `publishPermissionsService(newImpl)`.
|
|
5. Consumer's factory calls `getPermissionsService()` and gets the new impl.
|
|
|
|
Both sides re-initialize, so there is no stale-reference window in the normal flow.
|
|
The plan documents "call `getPermissionsService()` per use, do not cache the reference" as a best practice for resilience against load-order edge cases.
|
|
|
|
### `package.json` exports
|
|
|
|
```json
|
|
{
|
|
"exports": {
|
|
".": "./src/service.ts"
|
|
}
|
|
}
|
|
```
|
|
|
|
Points to the TypeScript source — jiti consumers resolve it natively.
|
|
TypeScript consumers with `moduleResolution: "Bundler"` get full type inference from the source.
|
|
No build step is required.
|
|
|
|
### Deprecation of `permissions:rpc:check`
|
|
|
|
- Add `@deprecated` JSDoc annotations to `PERMISSIONS_RPC_CHECK_CHANNEL`, `PermissionsCheckRequest`, and `PermissionsCheckReplyData` in `src/permission-events.ts`.
|
|
- The RPC handler in `src/permission-event-rpc.ts` continues to function — no runtime change.
|
|
- README and architecture docs note the service accessor as the preferred API.
|
|
|
|
## Module-Level Changes
|
|
|
|
| File | Action | Detail |
|
|
| ------------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `src/service.ts` | **new** | `PermissionsService` interface, `SERVICE_KEY` constant, `publishPermissionsService()`, `getPermissionsService()`, `unpublishPermissionsService()`. Re-exports `PermissionCheckResult` and `PermissionState` from `src/types.ts`. |
|
|
| `src/input-normalizer.ts` | changed | Export new `buildInputForSurface(surface, value)` function (moved from `src/permission-event-rpc.ts`). |
|
|
| `src/permission-event-rpc.ts` | changed | Remove local `buildInputForSurface`; import from `src/input-normalizer.ts`. |
|
|
| `src/permission-events.ts` | changed | Add `@deprecated` JSDoc to `PERMISSIONS_RPC_CHECK_CHANNEL`, `PermissionsCheckRequest`, `PermissionsCheckReplyData`. |
|
|
| `src/index.ts` | changed | Build service adapter object, call `publishPermissionsService()` after RPC registration. Pass `unpublishPermissionsService` to `SessionLifecycleHandler` cleanup. |
|
|
| `src/handlers/lifecycle.ts` | changed | Call the additional cleanup function (unpublish) alongside `cleanupRpc()`. |
|
|
| `package.json` | changed | Add `"exports": { ".": "./src/service.ts" }`. |
|
|
| `tests/service.test.ts` | **new** | Unit tests for accessor functions and service delegation. |
|
|
| `tests/permission-event-rpc.test.ts` | unchanged | Existing RPC tests remain valid — the handler still works. |
|
|
| `docs/architecture/architecture.md` | changed | Add "Cross-extension service accessor" section describing the `Symbol.for()` pattern. |
|
|
| `README.md` | changed | Add "Service API" section; mark RPC check as deprecated in the event API section. |
|
|
|
|
## Test Impact Analysis
|
|
|
|
1. **New unit tests enabled**: `tests/service.test.ts` tests the `globalThis` accessor in isolation — publish, get, unpublish, overwrite.
|
|
Also tests the service adapter's `checkPermission` delegation via a mock `PermissionManager`.
|
|
2. **No existing tests become redundant**: the RPC handler tests cover the event-bus path which remains the fallback API.
|
|
3. **Existing tests that must stay**: `tests/permission-event-rpc.test.ts` — the RPC handler is not removed, only deprecated.
|
|
4. **`buildInputForSurface` extraction**: no test changes needed — the function is non-exported today and tested only indirectly through the RPC handler tests, which continue to exercise it after the move.
|
|
|
|
## TDD Order
|
|
|
|
### Step 1 — Service accessor module
|
|
|
|
- **Red**: `tests/service.test.ts` — assert `getPermissionsService()` returns `undefined` by default; assert `publishPermissionsService(mock)` makes it retrievable; assert `unpublishPermissionsService()` clears it; assert a second publish overwrites the first.
|
|
- **Green**: implement `src/service.ts` with the `PermissionsService` interface, `SERVICE_KEY`, and the three accessor functions.
|
|
Re-export `PermissionCheckResult` and `PermissionState`.
|
|
- **Commit**: `feat: add Symbol.for()-backed service accessor module (#145)`
|
|
|
|
### Step 2 — Extract `buildInputForSurface`
|
|
|
|
- **Green**: move `buildInputForSurface` from `src/permission-event-rpc.ts` to `src/input-normalizer.ts` as a named export.
|
|
Update `src/permission-event-rpc.ts` to import it.
|
|
Run existing tests to confirm no breakage.
|
|
- **Commit**: `refactor: extract buildInputForSurface to input-normalizer (#145)`
|
|
|
|
### Step 3 — Service adapter and lifecycle wiring
|
|
|
|
- **Red**: `tests/service.test.ts` — add tests that construct a service adapter object using a mock `PermissionManager` and mock `SessionRules`, call `checkPermission("bash", "git push")`, and assert it delegates correctly with the right input shape and session rules.
|
|
- **Red**: verify that `getPermissionsService()` returns `undefined` after the shutdown cleanup runs (test the cleanup callback separately or via the `SessionLifecycleHandler` test).
|
|
- **Green**: in `src/index.ts`, build the service adapter object and call `publishPermissionsService()`.
|
|
Pass `unpublishPermissionsService` into the lifecycle handler's cleanup callback.
|
|
Update `src/handlers/lifecycle.ts` to accept and call the additional cleanup.
|
|
- **Build**: run `pnpm run build` to verify the `handlers/lifecycle.ts` signature change compiles.
|
|
- **Commit**: `feat: publish permissions service on startup, clear on shutdown (#145)`
|
|
|
|
### Step 4 — Package exports
|
|
|
|
- Add `"exports": { ".": "./src/service.ts" }` to `package.json`.
|
|
- **Verify**: `pnpm run build` passes; `node -e "import('@gotgenes/pi-permission-system').then(m => console.log(Object.keys(m)))"` lists the exported names (or verify via a simpler smoke test).
|
|
- **Commit**: `feat: add package.json exports field for cross-extension import (#145)`
|
|
|
|
### Step 5 — Deprecate `permissions:rpc:check`
|
|
|
|
- Add `@deprecated` JSDoc to `PERMISSIONS_RPC_CHECK_CHANNEL`, `PermissionsCheckRequest`, and `PermissionsCheckReplyData` in `src/permission-events.ts`.
|
|
- **Commit**: `docs: deprecate permissions:rpc:check types in favor of service accessor (#145)`
|
|
|
|
### Step 6 — Documentation
|
|
|
|
- Update `README.md`: add "Service API" section documenting the `Symbol.for()` accessor, consumer usage pattern, and reload behavior.
|
|
Mark `permissions:rpc:check` as deprecated in the existing event API section.
|
|
- Update `docs/architecture/architecture.md`: add cross-extension service accessor description.
|
|
- **Commit**: `docs: document service accessor and deprecate RPC check (#145)`
|
|
|
|
## Risks and Mitigations
|
|
|
|
| Risk | Mitigation |
|
|
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
| Could this silently weaken a permission? | No. The service delegates to the same `PermissionManager.checkPermission()` and `SessionRules` that the event-bus RPC and tool-call handler use. No decision logic changes. |
|
|
| Stale service reference after `/reload` | Both provider and consumer re-initialize during reload. Document "call per use, don't cache" as best practice. `unpublishPermissionsService()` on shutdown clears the slot as extra safety. |
|
|
| `exports` field breaks Pi's jiti loader resolution | Pi's loader uses `pi.extensions` (not `exports`) to find the extension factory. The `exports` field only affects bare-specifier `import()` from other extensions. Verify with `pnpm run build` + smoke test. |
|
|
| Consumer calls `getPermissionsService()` before provider has loaded | Returns `undefined` — the consumer's `if (permissions) { ... }` guard handles this. Same as the RPC fallback path's timeout. Document load-order independence. |
|
|
| `buildInputForSurface` extraction breaks RPC handler | The function body is unchanged; only its location moves. Existing `permission-event-rpc.test.ts` tests pass as-is. |
|
|
| `globalThis` pollution across unrelated processes | `Symbol.for()` keys are scoped by the full string name (`"@gotgenes/pi-permission-system:service"`). Collision with other packages is infeasible. Cleanup on shutdown removes the slot. |
|
|
|
|
## Open Questions
|
|
|
|
1. **Should the service expose `getToolPermission()` for tool-filtering queries?**
|
|
The current RPC only exposes `checkPermission`.
|
|
Adding `getToolPermission` would let consumers replicate before\_agent\_start filtering.
|
|
Deferred — add when a consumer needs it.
|
|
2. **Should additional event types be re-exported from `src/service.ts`?**
|
|
Consumers using the service accessor for policy queries may also want `PermissionDecisionEvent` for observation.
|
|
Subpath exports (`"./events"`) can be added later without breaking changes.
|
|
3. **Should the `exports` field include a `"types"` condition?**
|
|
Since the entry point is a `.ts` file and jiti consumers resolve types natively, a `"types"` condition adds no value today.
|
|
Revisit if a JS build step is added.
|