feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,224 @@
---
issue: 128
issue_title: "refactor: extract ForwardingManager class to encapsulate polling lifecycle"
---
# Extract ForwardingManager class
## Problem statement
Forwarding poll lifecycle is spread across 3 mutable fields on `ExtensionRuntime` (`permissionForwardingTimer`, `permissionForwardingContext`, `isProcessingForwardedRequests`), 2 free functions (`startForwardedPermissionPolling`, `stopForwardedPermissionPolling` in `runtime.ts`), and raw `PermissionForwardingDeps`.
Handlers call `deps.startForwardedPermissionPolling(ctx)` and `deps.stopForwardedPermissionPolling()` as opaque callbacks without understanding the lifecycle.
This is the third extraction in the handler decomposition series (after #126 ExtensionPaths, #127 SessionLogger).
## Goals
- Extract a `ForwardingManager` class that owns the timer, context, and processing-lock state.
- Remove `permissionForwardingTimer`, `permissionForwardingContext`, `isProcessingForwardedRequests` from `ExtensionRuntime`.
- Remove `startForwardedPermissionPolling` and `stopForwardedPermissionPolling` free functions from `runtime.ts`.
- Replace `startForwardedPermissionPolling` / `stopForwardedPermissionPolling` in `HandlerDeps` with a `forwarding: ForwardingManager` dep (or equivalent narrow interface).
- No behavioral change — same polling logic, same timer intervals, same subagent-context detection.
## Non-goals
- Changing `PermissionForwardingDeps` shape or polling logic.
- Extracting `PermissionSession` (#129) — that depends on this issue.
- Changing the `/permission-system` slash command or config format.
- Refactoring `PermissionPrompter` internals (it builds its own `PermissionForwardingDeps` internally).
## Background
### Permission surface
This is an infrastructure refactoring — no permission surface semantics change.
Forwarding is the mechanism by which subagent permission prompts are relayed to the parent agent's UI.
### Dependencies
- **#126 ExtensionPaths** — closed, implemented. `ForwardingManager` constructor takes `ExtensionPaths` (needs `subagentSessionsDir` for the subagent-context check).
- **#127 SessionLogger** — closed, implemented.
Not directly consumed by `ForwardingManager` (logging goes through `PermissionForwardingDeps`).
### Current layout
| Artifact | Location |
| -------------------------------------- | ---------------------------------------------------------------------------------------------- |
| 3 mutable fields | `ExtensionRuntime` interface in `src/runtime.ts` |
| `startForwardedPermissionPolling()` | Free function in `src/runtime.ts` (~30 lines) |
| `stopForwardedPermissionPolling()` | Free function in `src/runtime.ts` (~10 lines) |
| `PermissionForwardingDeps` | `src/forwarded-permissions/polling.ts` |
| `isSubagentExecutionContext()` | `src/subagent-context.ts` (called inside `start`) |
| `processForwardedPermissionRequests()` | `src/forwarded-permissions/polling.ts` |
| `HandlerDeps` forwarding fields | `startForwardedPermissionPolling`, `stopForwardedPermissionPolling` in `src/handlers/types.ts` |
| Handler call sites | `before-agent-start.ts`, `input.ts`, `tool-call.ts`, `lifecycle.ts` |
| Composition root wiring | `src/index.ts` lines ~46108 |
| Runtime init (3 fields) | `createExtensionRuntime()` in `src/runtime.ts` |
## Design overview
### ForwardingManager class
```typescript
// src/forwarding-manager.ts
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
export class ForwardingManager {
private timer: NodeJS.Timeout | null = null;
private context: ExtensionContext | null = null;
private processing = false;
constructor(
private readonly subagentSessionsDir: string,
private readonly forwardingDeps: PermissionForwardingDeps,
) {}
/** Start polling if ctx has UI and is not a subagent. No-op if already running. */
start(ctx: ExtensionContext): void { /* moved from runtime.ts */ }
/** Stop polling and clear state. */
stop(): void { /* moved from runtime.ts */ }
}
```
The constructor takes `subagentSessionsDir` (from `ExtensionPaths`) rather than the full `ExtensionPaths` object — it is the only path field used by the start/stop logic.
`PermissionForwardingDeps` is passed at construction time, same as currently wired in `index.ts`.
### HandlerDeps change
```typescript
// In src/handlers/types.ts — replace two methods with one dep:
// Before:
startForwardedPermissionPolling(ctx: ExtensionContext): void;
stopForwardedPermissionPolling(): void;
// After:
readonly forwarding: ForwardingManager;
```
Handler call sites change from `deps.startForwardedPermissionPolling(ctx)``deps.forwarding.start(ctx)` and `deps.stopForwardedPermissionPolling()``deps.forwarding.stop()`.
### ExtensionRuntime slimming
Remove from `ExtensionRuntime`:
- `permissionForwardingContext: ExtensionContext | null`
- `permissionForwardingTimer: NodeJS.Timeout | null`
- `isProcessingForwardedRequests: boolean`
Remove from `createExtensionRuntime()` the three field initializations.
### Composition root (index.ts) change
Replace the `forwardingDeps` construction + two closure wrappers with:
```typescript
const forwardingManager = new ForwardingManager(
runtime.subagentSessionsDir,
forwardingDeps,
);
```
And in the `deps` object: `forwarding: forwardingManager`.
## Module-level changes
### New files
1. **`src/forwarding-manager.ts`** — `ForwardingManager` class.
Moves `startForwardedPermissionPolling` and `stopForwardedPermissionPolling` logic from `runtime.ts`.
Imports `isSubagentExecutionContext`, `processForwardedPermissionRequests`, `PERMISSION_FORWARDING_POLL_INTERVAL_MS`.
2. **`tests/forwarding-manager.test.ts`** — Unit tests for `ForwardingManager.start()` and `.stop()`.
### Changed files
1. **`src/runtime.ts`**
- Remove `permissionForwardingContext`, `permissionForwardingTimer`, `isProcessingForwardedRequests` from `ExtensionRuntime` interface.
- Remove `startForwardedPermissionPolling()` and `stopForwardedPermissionPolling()` free functions.
- Remove the three field initializations from `createExtensionRuntime()`.
- Remove `PermissionForwardingDeps` import (if no longer needed).
2. **`src/handlers/types.ts`**
- Replace `startForwardedPermissionPolling(ctx)` and `stopForwardedPermissionPolling()` with `readonly forwarding: ForwardingManager`.
- Add `ForwardingManager` import.
3. **`src/index.ts`**
- Import `ForwardingManager`.
- Construct `ForwardingManager` instance.
- Replace `startForwardedPermissionPolling` / `stopForwardedPermissionPolling` closures in `deps` with `forwarding: forwardingManager`.
4. **`src/handlers/before-agent-start.ts`** — `deps.startForwardedPermissionPolling(ctx)``deps.forwarding.start(ctx)`.
5. **`src/handlers/input.ts`** — Same call-site update.
6. **`src/handlers/tool-call.ts`** — Same call-site update.
7. **`src/handlers/lifecycle.ts`** — `deps.startForwardedPermissionPolling(ctx)``deps.forwarding.start(ctx)`, `deps.stopForwardedPermissionPolling()``deps.forwarding.stop()`.
### Changed test files
1. **`tests/runtime.test.ts`** — Remove the 3 tests asserting initial `null`/`false` values for the removed fields.
2. **`tests/handlers/before-agent-start.test.ts`** — Replace `startForwardedPermissionPolling: vi.fn()` / `stopForwardedPermissionPolling: vi.fn()` with `forwarding: { start: vi.fn(), stop: vi.fn() }`.
Update assertion from `deps.startForwardedPermissionPolling` to `deps.forwarding.start`.
3. **`tests/handlers/input.test.ts`** — Same mock shape update + assertion update.
4. **`tests/handlers/input-events.test.ts`** — Same mock shape update (no assertions on these mocks).
5. **`tests/handlers/tool-call.test.ts`** — Same mock shape + assertion update.
6. **`tests/handlers/tool-call-events.test.ts`** — Same mock shape update.
7. **`tests/handlers/lifecycle.test.ts`** — Same mock shape + both start/stop assertion updates.
### Unchanged files
- **`src/forwarded-permissions/polling.ts`** — `PermissionForwardingDeps`, `processForwardedPermissionRequests` stay as-is.
- **`src/permission-prompter.ts`** — Builds its own `PermissionForwardingDeps` internally; no dependency on `ForwardingManager`.
- **`tests/permission-system.test.ts`** — Integration test; calls `piPermissionSystemExtension(mockPi)` and never sees handler internals.
- **`docs/architecture/architecture.md`** — Does not describe forwarding internals in detail; no update needed unless the decomposition plan doc is referenced.
## Test impact analysis
1. **New unit tests enabled**: `ForwardingManager.start()` and `.stop()` can be tested in isolation with a mock `PermissionForwardingDeps` and fake timers.
Previously, testing required constructing a full `ExtensionRuntime` or going through the integration test.
- `start()` with `hasUI: false` → no-op (no timer created).
- `start()` with subagent context → stops any existing timer.
- `start()` when already running → updates context but does not create a second timer.
- `stop()` → clears timer, context, and processing flag.
- `start()` followed by timer tick → calls `processForwardedPermissionRequests`.
- Timer tick while `processing` is true → skipped.
2. **Existing tests that become simpler**: The 3 `createExtensionRuntime` init tests in `runtime.test.ts` for the forwarding fields are deleted — the class constructor handles initialization internally.
3. **Existing tests that stay**: Handler tests still verify that `start(ctx)` and `stop()` are called at the right lifecycle points — the assertion target changes from `deps.startForwardedPermissionPolling` to `deps.forwarding.start` but the intent is identical.
## TDD order
1. **Red → Green**: Add `src/forwarding-manager.ts` with the class skeleton and `tests/forwarding-manager.test.ts` with core lifecycle tests (start no-op for non-UI, start no-op for subagent, stop clears state, timer tick calls process, tick skipped while processing, idempotent start).
Commit: `feat: add ForwardingManager class (#128)`
2. **Green → Refactor**: Remove the 3 forwarding fields from `ExtensionRuntime`, delete the two free functions from `runtime.ts`, update `createExtensionRuntime()`.
Remove the 3 init-value tests from `runtime.test.ts`.
Run `pnpm run build` to verify.
Commit: `refactor: remove forwarding state from ExtensionRuntime (#128)`
3. **Green → Refactor**: Update `HandlerDeps` in `src/handlers/types.ts` — replace the two methods with `readonly forwarding: ForwardingManager`.
Update all 4 handler files to use `deps.forwarding.start(ctx)` / `deps.forwarding.stop()`.
Update all 7 handler test files (mock shape + assertions).
Run `pnpm run build` and full test suite.
Commit: `refactor: wire ForwardingManager through HandlerDeps (#128)`
4. **Green → Refactor**: Update `src/index.ts` — construct `ForwardingManager`, pass it as `forwarding` in the deps object, remove the two closure wrappers.
Run full test suite.
Commit: `refactor: construct ForwardingManager in composition root (#128)`
Note: Steps 24 can be combined into fewer commits if the changes are small enough, but the ordering must be maintained.
Step 2 will break the build until step 3 updates callers, so steps 2 and 3 should be done together or step 2 should keep the old functions as deprecated wrappers temporarily.
**Revised strategy**: Combine steps 2, 3, and 4 into a single commit since removing the fields from `ExtensionRuntime` and updating `HandlerDeps` + `index.ts` are interdependent.
The sequence becomes:
1. `feat: add ForwardingManager class (#128)` — new file + tests, no existing code changed.
2. `refactor: wire ForwardingManager and remove legacy forwarding state (#128)` — all mechanical changes in one commit: runtime, types, handlers, index, handler tests, runtime tests.
## Risks and mitigations
| Risk | Mitigation |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Could this silently weaken a permission? | No — pure structural refactor. Same `processForwardedPermissionRequests` call, same `isSubagentExecutionContext` guard, same timer interval. Integration tests unchanged. |
| Timer leak if `ForwardingManager` is not stopped | Same risk exists today. `handleSessionEnd` calls `stop()` (via `deps.stopForwardedPermissionPolling()`); the new code calls `deps.forwarding.stop()` in the same place. |
| Handler test mock shape changes break tests | Mechanical — replace two `vi.fn()` fields with one `{ start: vi.fn(), stop: vi.fn() }` object. Grep ensures no mock factory is missed. |
| `ForwardingManager` import creates a circular dependency | `forwarding-manager.ts` imports from `forwarded-permissions/polling.ts` and `subagent-context.ts` — neither imports back. No cycle. |
## Open questions
- Should `ForwardingManager` accept the full `ExtensionPaths` or just `subagentSessionsDir`?
The issue suggests `ExtensionPaths`; this plan uses the narrower `subagentSessionsDir` to follow the dependency-width heuristic.
Either works — the `PermissionSession` (#129) will wrap it regardless.