Files
my-pi/pi-permission-system/docs/plans/0337-dissolve-extension-runtime.md
T

254 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 337
issue_title: "Dissolve ExtensionRuntime; one source of truth for session state"
---
# Dissolve ExtensionRuntime; one source of truth for session state
## Problem Statement
`createExtensionRuntime()` returns a god object literal that mixes path constants, mutable per-session state, mutable `config`, and logging methods.
After Phase 4 Steps 2 and 3 ([#335], [#336]) moved config into `ConfigStore` and made the logger injectable, the runtime object's remaining job is to hold a `PermissionManager`, a `SessionRules`, and the runtime `ExtensionContext` — and to serve as the internal composition root for the config-modal and the deprecated RPC handlers.
The object is a split-brain.
`runtime.permissionManager` and `runtime.sessionRules` are read by the deprecated `permissions:rpc:check` handler, the `/permission-system` config-modal, and `LocalPermissionsService`.
But the gate path uses a *different* `PermissionManager` (the separately-constructed `sessionManager` in `index.ts`) and a *different* `SessionRules` (the one `PermissionSession` constructs internally).
Session approvals recorded by the gate path land in `PermissionSession`'s private `SessionRules`, which the RPC check never sees — so the RPC check reads an empty session-rules set and answers `ask` for a surface the user already approved for the session.
There is a second, quieter split-brain on context: `ConfigStore` reads/writes `runtime.runtimeContext` through a transitional `RuntimeContextRef`, while `PermissionSession` owns its own private `this.context` (set in `activate`).
They are kept in sync only by coincidence of call order at `session_start`.
This issue dissolves the runtime object, establishes a single `PermissionManager` / `SessionRules` / context shared by every consumer, and closes both split-brains.
## Goals
- Fix the session-rules split-brain: the deprecated RPC check, the config-modal, `LocalPermissionsService`, and the gate path read and write the *same* `SessionRules` instance.
- Fix the manager split-brain: those same consumers read the *same* `PermissionManager` instance that `PermissionSession` configures for the project cwd at `session_start`.
- Make `PermissionSession` the single owner of the runtime `ExtensionContext`; retire the transitional `RuntimeContextRef` seam on `ConfigStore`.
- Remove the `ExtensionRuntime` interface and `createExtensionRuntime()` factory entirely; `index.ts` constructs `ExtensionPaths`, the single `PermissionManager`, the single `SessionRules`, the `ConfigStore`, and the logger directly.
- Behavior-preserving except for the two bug fixes above; the suite stays green at every commit.
## Non-Goals
- Collapsing the remaining `index.ts` closure bags into plain object references — that is Step 5 ([#338]) and is sequenced after this step.
- Splitting `PermissionSession`'s six role interfaces into distinct collaborators (`PromptingGateway`, `PermissionResolver`) — Steps 68 ([#339], [#340], [#341]).
- Changing the RPC contract, the config-modal UI, the `PermissionsService` surface, or any config-file format.
- Un-deprecating the `permissions:rpc:check` channel; it stays deprecated, it just stops lying.
## Background
Relevant modules (see `docs/architecture/architecture.md` § Module structure):
- `src/runtime.ts``ExtensionRuntime` interface (extends `ExtensionPaths` + an internal `SessionState`) and `createExtensionRuntime()`.
After [#335]/[#336] it constructs the `PermissionManager`, the `SessionRules`, the `ConfigStore` (passing a `RuntimeContextRef` that closes over `runtime.runtimeContext`), and the logger.
- `src/config-store.ts``ConfigStore` plus the narrow `SessionConfigStore` / `CommandConfigStore` / `ConfigReader` interfaces and the transitional `RuntimeContextRef`.
`refresh(ctx?)` and `logResolvedPaths()` reach context through `this.deps.context`.
- `src/permission-session.ts` — owns `this.context`, a private `new SessionRules()`, and an injected `ScopedPermissionManager`.
Already implements `getRuntimeContext()`, `getSessionRuleset()`, `recordSessionApproval()`, and the lifecycle methods that `configureForCwd` the manager.
- `src/permission-event-rpc.ts``registerPermissionRpcHandlers(events, deps)`; `deps.getPermissionManager()`, `deps.getSessionRules()`, `deps.getRuntimeContext()`.
- `src/config-modal.ts``registerPermissionSystemCommand(pi, controller)`; `controller.getComposedRules()` reads the manager.
- `src/permissions-service.ts``LocalPermissionsService` injected with `PermissionManager` + `SessionRules` + `ToolInputFormatterRegistry`.
- `src/index.ts` — the composition root that wires all of the above to one `ExtensionRuntime`.
Constraints from AGENTS.md and the package skill:
- Default to least privilege; the bug fix only ever *adds* visibility of already-granted session approvals — it never widens policy.
- Keep schema/example/loader/docs aligned — not touched here (no config-format change).
- `PermissionManager`'s narrow `ScopedPermissionManager` interface is the seam `PermissionSession` depends on; RPC depends on `Pick<PermissionManager, "checkPermission">`.
The composition root owns the single concrete instance and hands each consumer the narrow view it already declares.
- When removing an export that consumers import, fold every consumer + consumer-test update into the same commit (type-coupled).
- When a roadmap step ships, mark it `✓ complete` in `architecture.md` as part of the shipping change.
### Verified: cwd never changes mid-session
[#334] already verified against Pi core that the project cwd is assigned once and never reassigned, and that `/reload` re-emits `session_start` with the same cwd.
A single `PermissionManager`, configured once per session via `configureForCwd(ctx.cwd)`, is therefore correct for every consumer — there is no scenario where the RPC reader and the gate reader need different managers.
## Design Overview
### One PermissionManager, one SessionRules, one context
The composition root owns three shared collaborators and injects each into every consumer that needs it:
```typescript
// index.ts (after dissolution — illustrative)
const agentDir = getAgentDir();
const paths = computeExtensionPaths(agentDir);
const permissionManager = new PermissionManager({ agentDir });
const sessionRules = new SessionRules();
const session = new PermissionSession(
paths,
logger,
forwarding,
permissionManager, // configured for cwd at session_start
sessionRules, // injected, not self-constructed
configStore,
runtimeDeps,
);
// RPC, config-modal, service all reference the SAME two instances:
registerPermissionRpcHandlers(pi.events, {
getPermissionManager: () => permissionManager,
getSessionRules: () => sessionRules.getRuleset(),
getRuntimeContext: () => session.getRuntimeContext(),
// …
});
registerPermissionSystemCommand(pi, {
config: configStore,
getConfigPath: () => getGlobalConfigPath(agentDir),
getComposedRules: () =>
permissionManager.getComposedConfigRules(
session.lastKnownActiveAgentName ?? undefined,
),
});
const permissionsService = new LocalPermissionsService(
permissionManager,
sessionRules,
formatterRegistry,
);
```
`PermissionSession.recordSessionApproval()` and the RPC `getSessionRules()` now drive and read the same `SessionRules`.
`PermissionSession.resetForNewSession()` calls `permissionManager.configureForCwd(ctx.cwd)` on the same manager the RPC/service/config-modal read, so project-scope config and session approvals are visible everywhere.
`PermissionSession.shutdown()` clears that shared `SessionRules` — which is the desired single-source behavior (approvals end with the session for every reader).
### Inject SessionRules instead of self-constructing it
`PermissionSession` currently does `private readonly sessionRules = new SessionRules()`.
Change it to accept `SessionRules` as a constructor parameter so the composition root owns the single instance.
This is the same DIP move [#334] made for `PermissionManager`.
```typescript
constructor(
private readonly paths: ExtensionPaths,
readonly logger: SessionLogger,
private readonly forwarding: ForwardingController,
private readonly permissionManager: ScopedPermissionManager,
private readonly sessionRules: SessionRules,
private readonly configStore: SessionConfigStore,
private readonly runtimeDeps: PermissionSessionRuntimeDeps,
) {}
```
`getSessionRuleset()`, `recordSessionApproval()`, and the `clear()` in `shutdown()` are unchanged — they already delegate to `this.sessionRules`.
### PermissionSession owns context; retire RuntimeContextRef
`ConfigStore` stops storing the runtime context.
The two methods that need it receive what they read from the caller, which is `PermissionSession` (the owner):
- `refresh(ctx?: ExtensionContext)` keeps its signature but uses the passed `ctx` directly for `cwd`, `hasUI` status sync, and the warning `ui.notify` — no `context.set` / `context.get`.
The only no-`ctx` caller is the factory-init `configStore.refresh()`, where the cwd is legitimately unknown (`null`), exactly as today.
- `logResolvedPaths(cwd?: string)` gains a `cwd` parameter; `PermissionSession.logResolvedConfigPaths()` passes `this.context?.cwd`.
`ConfigStoreDeps` drops its `context: RuntimeContextRef` field (4 → 3 fields), and the `RuntimeContextRef` interface is deleted from `config-store.ts`.
Call-order check at `session_start` (unchanged ordering, preserved behavior):
1. `session.refreshConfig(ctx)``configStore.refresh(ctx)` — uses `ctx.cwd`, notifies warnings via `ctx.ui`.
2. `session.resetForNewSession(ctx)``activate(ctx)` sets `this.context = ctx` and `configureForCwd(ctx.cwd)`.
3. `session.logResolvedConfigPaths()``configStore.logResolvedPaths(this.context?.cwd)``this.context` is set by step 2.
The RPC prompt handler's `getRuntimeContext` and the lifecycle handler's `getRuntimeContext` both already route through `session.getRuntimeContext()` after this change, so the context split-brain is closed by construction.
### Logger notify sink
The logger's IO-failure notify sink currently reads `runtime.runtimeContext?.ui.notify`.
It becomes `(message) => session.getRuntimeContext()?.ui.notify(message, "warning")`.
This is a forward reference: `session` is constructed after the logger (the logger is a `PermissionSession` dependency), so `index.ts` declares `let session` before the logger and assigns it after.
This mirrors the existing `getConfig: () => configStore.current()` forward-reference pattern already used in `createExtensionRuntime`, so it introduces no new idiom.
### Delete runtime.ts
After removing the `ExtensionRuntime` interface, the internal `SessionState` interface, and `createExtensionRuntime()`, the only remaining content of `runtime.ts` is `export type { ExtensionPaths } from "./extension-paths"`.
No module imports `ExtensionPaths` from `runtime.ts` (consumers already import it from `extension-paths.ts`), so the file is deleted outright rather than left as a re-export shell.
### Edge cases
- Factory-init `configStore.refresh()` with no `ctx`: cwd `null`, no status sync, no notify — identical to today.
- `reload` via `resources_discover`: `session.reload()` reconfigures the shared manager and clears caches; it does not refresh config (unchanged).
- Subagent child: context ownership and instance sharing are per-session-instance; the process-global service slot logic ([#302]) is untouched.
## Module-Level Changes
- `src/runtime.ts` — deleted.
Removes `ExtensionRuntime`, the internal `SessionState`, `createExtensionRuntime()`, and the `ExtensionPaths` re-export.
- `src/permission-session.ts` — add `sessionRules: SessionRules` constructor parameter (between `permissionManager` and `configStore`); remove the internal `private readonly sessionRules = new SessionRules()` field initializer.
Update the constructor doc comment that references "where the `ExtensionRuntime` is available".
- `src/config-store.ts` — delete the `RuntimeContextRef` interface; remove `context` from `ConfigStoreDeps`; rework `refresh(ctx?)` to use the passed `ctx` directly; change `logResolvedPaths()` to `logResolvedPaths(cwd?: string)`; update `SessionConfigStore.logResolvedPaths` accordingly and its doc comment.
- `src/index.ts` — remove `createExtensionRuntime` import; construct `paths` (`computeExtensionPaths`), `permissionManager`, `sessionRules`, `configStore`, and `logger` directly; remove the separate `sessionManager`; route every `runtime.*` reference to the new locals; point RPC `getRuntimeContext` and the logger notify sink at `session.getRuntimeContext()`; point config-modal `getComposedRules` at `session.lastKnownActiveAgentName`.
- `src/permissions-service.ts` — no signature change; update the class doc comment that references "the runtime's permission manager".
- `test/runtime.test.ts` — deleted (see Test Impact Analysis).
- `test/config-store.test.ts` — remove the `makeContextRef` helper and `RuntimeContextRef` import; pass `ctx` directly to `refresh`; pass `cwd` to `logResolvedPaths`; rewrite the two context-seam tests ("updates context via context.set", "does not overwrite context when ctx is omitted") as ctx-parameter behavior.
- `test/permission-session.test.ts` — update `createSession` to inject a `SessionRules` (real instance or narrow mock) into the new constructor slot.
- `test/composition-root.test.ts` — add the single-source-of-truth characterization tests (gate session-approval visible to the RPC check and the service).
- `docs/architecture/architecture.md` — remove the `runtime.ts` line from § Module structure; update the `config-store.ts` line to drop the "`RuntimeContextRef` context seam (#335)" note; the Finding 3 / metrics rows (`as unknown as ExtensionRuntime` ×3, `runtime`-as-first-arg free functions) move toward zero — update if the shipping commit refreshes metrics.
Mark Step 4 `✓ complete` at ship time.
No removed symbol is referenced by `.pi/skills/package-pi-permission-system/SKILL.md` (it does not name `ExtensionRuntime`, `createExtensionRuntime`, or `RuntimeContextRef`).
## Test Impact Analysis
New unit/integration tests this change enables:
- Composition-root characterization tests proving the gate path and the RPC/service path share one `SessionRules` — previously impossible to assert because the gate recorded into a different instance than the readers saw.
These are the regression guards for the headline bug.
Existing tests that become redundant:
- `test/runtime.test.ts` is deleted.
Its path-derivation cases (sessionsDir, subagentSessionsDir, forwardingDir, globalLogsDir, `piInfrastructureDirs`, null-discovery omission) are already covered one-for-one by `test/extension-paths.test.ts` against `computeExtensionPaths`.
Its default-config case is covered by `config-store.test.ts`; its logger-construction wiring moves into `index.ts` and is exercised by `composition-root.test.ts` + `session-logger.test.ts`.
No coverage is lost.
Existing tests that must stay as-is (they exercise the layers, not the runtime wrapper):
- `test/permission-event-rpc.test.ts`, `test/config-modal.test.ts`, `test/permissions-service.test.ts` — these unit-test the handlers with injected mock deps and are agnostic to which concrete instances the composition root supplies.
They do not change; the bug lived only in the wiring, so the proof lives at the composition root.
- `test/config-store.test.ts` keeps its config-load, save, and resolved-path coverage; only the context-seam plumbing changes.
## TDD Order
1. `fix:` — single source of truth for session state.
Test surface: `test/composition-root.test.ts` — new `describe("single source of truth for session state")`.
Add a characterization test that writes a global config (`permission: { "*": "allow", demo: "ask" }`), fires `session_start` with a UI `ctx` whose `ui.select` returns `options[1]` (the "for this session" option, robust to label text), drives a `tool_call` on `demo` so the gate records a session approval, then issues a `permissions:rpc:check` for `surface: "demo"` and asserts the reply `result` is `"allow"` (it is `"ask"` before the fix); also assert `getPermissionsService().checkPermission("demo")` reports `allow`.
Implementation in the same commit: in `index.ts`, drop the separate `sessionManager` and pass the runtime's single `PermissionManager` to `PermissionSession`; inject the runtime's single `SessionRules` into `PermissionSession` (new constructor slot); update `test/permission-session.test.ts` `createSession` to supply the `SessionRules`.
The `ExtensionRuntime` object still exists at this point — this commit fixes the bug with the minimum structural change.
Suggested message: `fix: share one PermissionManager and SessionRules across gate and RPC paths (#337)`.
2. `refactor:` — dissolve `ExtensionRuntime`.
Test surface: existing suite stays green (`composition-root`, `config-store`, `permission-session`).
Inline-construct `paths` / `permissionManager` / `sessionRules` / `configStore` / `logger` in `index.ts`; move context ownership fully to `PermissionSession`; retire `RuntimeContextRef` (rework `ConfigStore.refresh(ctx?)` to use the passed ctx, change `logResolvedPaths()``logResolvedPaths(cwd?)`, drop `context` from `ConfigStoreDeps`); point the logger notify sink and RPC `getRuntimeContext` at `session.getRuntimeContext()`; delete `src/runtime.ts`; delete `test/runtime.test.ts`; update `test/config-store.test.ts` (remove `makeContextRef`, pass ctx/cwd directly); update the doc comments in `permission-session.ts` and `permissions-service.ts`.
This is behavior-preserving — the bug was already fixed in step 1 — so every commit leaves the suite green.
Suggested message: `refactor: dissolve ExtensionRuntime god object (#337)`.
3. `docs:` — architecture sync.
Remove the `runtime.ts` line from § Module structure; drop the `RuntimeContextRef` note on the `config-store.ts` line; refresh the Finding 3 / constructibility-metric rows that referenced `ExtensionRuntime` and the runtime-arg free functions.
Suggested message: `docs: update architecture for dissolved ExtensionRuntime (#337)`. (Marking the roadmap step `✓ complete` is done at ship time per the package skill.)
## Risks and Mitigations
- Risk: the type-coupled removal of `createExtensionRuntime` and the `RuntimeContextRef`/constructor changes break compilation across `index.ts`, `config-store.ts`, `permission-session.ts`, and their tests.
Mitigation: split the bug fix (step 1) from the structural dissolution (step 2); each step folds all consumer + consumer-test updates into one commit and is verified with `pnpm run check` + `pnpm -r run test` before committing.
- Risk: the logger notify sink's forward reference to `session` could fire before `session` is assigned (factory-init `configStore.refresh()`).
Mitigation: at factory init there is no UI context anyway; `session?.getRuntimeContext()` resolves to `undefined` and no notify occurs — identical to the current `runtime.runtimeContext` being `null` at init.
- Risk: a behavior change sneaks in via the context-seam retirement (e.g. `refresh` without `ctx` losing a previously-set context).
Mitigation: the only no-`ctx` caller is the one-time factory-init refresh, which never had a prior context; the design-overview call-order check confirms `session_start` ordering is preserved.
- Risk: deleting `runtime.test.ts` drops coverage.
Mitigation: the Test Impact Analysis maps every `runtime.test.ts` case to an equivalent in `extension-paths.test.ts` / `config-store.test.ts` / `composition-root.test.ts`.
## Open Questions
- Whether to also add a manager-split-brain regression test (project-scope config visible to the RPC check after `configureForCwd`).
Defer-until-needed: composition-root tests write to the global config path, so the session-rules characterization test is the clearer and sufficient guard; add the manager case only if a regression appears.
[#302]: https://github.com/gotgenes/pi-packages/issues/302
[#334]: https://github.com/gotgenes/pi-packages/issues/334
[#335]: https://github.com/gotgenes/pi-packages/issues/335
[#336]: https://github.com/gotgenes/pi-packages/issues/336
[#338]: https://github.com/gotgenes/pi-packages/issues/338
[#339]: https://github.com/gotgenes/pi-packages/issues/339
[#340]: https://github.com/gotgenes/pi-packages/issues/340
[#341]: https://github.com/gotgenes/pi-packages/issues/341