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,325 @@
---
issue: 530
issue_title: "pi-permission-system: split PermissionForwarder by direction of authority flow"
---
# Split `PermissionForwarder` by direction of authority flow
## Release Recommendation
**Release:** ship independently
This is Phase 8 Step 6, tagged `Release: independent` in the roadmap; Steps 46 carry no batch.
It is a pure `refactor:` (a hidden changelog type), so it does not cut a release on its own — it lands on `main` and auto-batches into the next `feat:`/`fix:`/unhidden-`docs:` release.
## Problem Statement
`src/forwarded-permissions/permission-forwarder.ts` is a 578-line class holding two distinct roles that flow authority in opposite directions.
`requestApproval` escalates *up* toward the parent — a three-way dispatch (has-UI → direct dialog, not-a-subagent → deny, else → forward-and-poll) that Phase 9 will turn into `LocalUserAuthorizer` / `DenyingAuthorizer` / `ParentAuthorizer`.
`processInbox` serves escalations from *below* — draining this session's forwarded-request inbox and answering each.
The [architecture roadmap](../architecture/architecture.md) already names this split as preparatory work for the Phase 9 authority spine: doing it now means Phase 9 turns three already-named branches into three `Authorizer`s instead of first dissecting a dual-role class.
## Goals
- Split `PermissionForwarder` into two classes, one per direction of authority flow, each constructing with only its own dependencies.
- `ApprovalEscalator implements ApprovalRequester` — the escalation-up role: the three-way dispatch (each branch a named private method) plus the request-write/poll machinery.
- `ForwardedRequestServer implements InboxProcessor` — the serving-down role: `processInbox` plus the per-request serve flow.
- Relocate the forwarding subsystem into `src/authority/`; the `src/forwarded-permissions/` directory dissolves.
- Keep behavior, output, and config identical — this is a non-breaking `refactor:`.
- Keep the existing consumer seams intact: `PermissionPrompter` depends on `ApprovalRequester`, `ForwardingManager` on `InboxProcessor`.
## Non-Goals
- No behavior change: no new decision logic, no altered review-log events, no config-shape change.
This is not breaking.
- Do not build the Phase 9 spine (`Authorizer` interface, `canConfirm()` dissolution, serving-as-resolution).
The forwarded-inbox yolo serve arm stays as-is and dissolves with the spine, exactly as documented today.
- Do not touch `src/permission-forwarding.ts` (the cross-session constants module) or `src/forwarding-manager.ts` beyond its single import line.
- Do not rewrite the frozen history docs under `docs/architecture/history/` — they describe past state by design.
## Background
Relevant modules (all under `packages/pi-permission-system/`):
- `src/forwarded-permissions/permission-forwarder.ts` — the 578-line dual-role class.
Exports `PermissionForwarder`, the two seam interfaces `ApprovalRequester` / `InboxProcessor`, the `ForwarderContext` read-interface, and `PermissionForwarderDeps`.
Module-private helpers: `getSessionId` (used by *both* roles), `getContextSystemPrompt` (escalation only), `formatForwardedPermissionPrompt` (serving only).
- `src/forwarded-permissions/io.ts` — pure filesystem IO (request/response read-write, location derivation, atomic JSON writes, cleanup).
Logger-null-tolerant; no dependency on `ForwarderContext`.
- `src/forwarding-manager.ts``ForwardingManager` (implements `ForwardingController`); imports `InboxProcessor`, drives inbox polling.
- `src/permission-prompter.ts` — imports `ApprovalRequester` (type only); delegates the UI/forwarding decision to the injected forwarder.
- `src/index.ts` — the composition root; constructs the single `PermissionForwarder` from `PermissionForwarderDeps` and injects it into both the prompter and the `ForwardingManager`.
- `src/authority/` — seeded by Step 5 (#529): holds `subagent-detection.ts` and `subagent-context.ts`.
This step adds the forwarding modules here.
Test surface:
- `test/permission-forwarder.test.ts` — two `requestApproval` describes (escalation) + one `processInbox` describe (serving), all on the Step 4 (#528) harness (`test/helpers/forwarding-fixtures.ts`).
- `test/forwarded-permissions/io.test.ts` — the IO helper tests; imports from `#src/forwarded-permissions/io`.
- `test/helpers/forwarding-fixtures.ts``makeForwarderDeps`, `makeForwarderContext`, `makeUiDecision`, `createForwardingTempDir`, `makeSubagentRegistry`; imports `ForwarderContext` / `PermissionForwarderDeps`.
Constraints from AGENTS.md and the package skill that apply:
- When a roadmap step completes, mark it `✅` (step heading + Mermaid node) in `docs/architecture/architecture.md` in the implementation doc-update commit, not a deferred ship commit.
- `docs/architecture/architecture.md` names internal symbols in narrative prose and a module-layout tree; a `src/`-only grep misses them.
- `.pi/skills/package-*/SKILL.md` names `PermissionForwarder.requestApproval` in prose — a renamed symbol must be updated there too.
- `src` ships recursively in the `package.json` `files` allowlist, so relocating files within `src/` needs no allowlist edit.
### Dependency partition
The combined `PermissionForwarderDeps` (7 fields) partitions cleanly by role:
| Field | Escalation (`requestApproval`) | Serving (`processInbox`) |
| --------------------------------- | ------------------------------ | ------------------------ |
| `forwardingDir` | ✓ | ✓ |
| `logger` | ✓ | ✓ |
| `requestPermissionDecisionFromUi` | ✓ | ✓ |
| `detection` | ✓ | — |
| `registry` | ✓ | — |
| `config` | — | ✓ (yolo serve arm) |
| `events` | — | ✓ (UI prompt broadcast) |
Each new deps interface (5 fields) is strictly narrower than the current 7-field bag — the escalator never reads `config`/`events`, the server never reads `detection`/`registry`.
The escalation UI fast path does **not** emit a UI event (the prompter does — pinned by the "does not emit a UI prompt event" test), which is why the escalator drops `events`.
## Design Overview
### Target module layout (`src/authority/`)
```text
src/authority/
├── subagent-detection.ts (existing, #529)
├── subagent-context.ts (existing, #529)
├── forwarding-io.ts (renamed from forwarded-permissions/io.ts; content unchanged)
├── forwarder-context.ts (new: ForwarderContext + getSessionId)
├── approval-escalator.ts (new: ApprovalEscalator + ApprovalRequester + ApprovalEscalatorDeps)
└── forwarded-request-server.ts (new: ForwardedRequestServer + InboxProcessor + ForwardedRequestServerDeps)
```
`src/forwarded-permissions/` (both `permission-forwarder.ts` and `io.ts`) is deleted; the directory dissolves.
### Shared context module
`ForwarderContext` (the narrow `{ hasUI, ui, sessionManager }` read-interface) and `getSessionId(ctx)` (the ~8-line session-id reader) are shared by both classes and both seam interfaces.
Per the operator's decision, they get a dedicated cohesive home rather than being folded into `forwarding-io.ts` (which stays purely filesystem) or duplicated across the two sibling classes:
```typescript
// src/authority/forwarder-context.ts
export interface ForwarderContext {
hasUI: boolean;
ui: PermissionDecisionUi;
sessionManager: {
getSessionId(): string;
getSessionDir(): string;
getEntries(): readonly SessionEntryView[];
};
}
export function getSessionId(ctx: ForwarderContext): string {
/* unchanged body: try ctx.sessionManager.getSessionId(), trim, fallback "unknown" */
}
```
`getSessionId` becomes an exported function with two consumers (both classes), so fallow sees it as live.
### `ApprovalEscalator` (escalation-up)
```typescript
// src/authority/approval-escalator.ts
export interface ApprovalRequester {
requestApproval(
ctx: ForwarderContext,
message: string,
options?: RequestPermissionOptions,
forwarded?: ForwardedPromptDisplay,
): Promise<PermissionPromptDecision>;
}
export interface ApprovalEscalatorDeps {
forwardingDir: string;
detection: SubagentDetector;
registry?: SubagentSessionRegistry;
logger: DebugReviewLogger;
requestPermissionDecisionFromUi: (
ui: PermissionDecisionUi,
title: string,
message: string,
options?: RequestPermissionOptions,
) => Promise<PermissionPromptDecision>;
}
export class ApprovalEscalator implements ApprovalRequester { /* ... */ }
```
Owns `requestApproval` and the private `waitForForwardedApproval`, `buildForwardedRequest`, `pollForForwardedResponse`.
Module-private helper `getContextSystemPrompt` moves here (escalation-only).
Imports `getSessionId` / `ForwarderContext` from `forwarder-context.ts`, IO helpers from `forwarding-io.ts`.
### `ForwardedRequestServer` (serving-down)
```typescript
// src/authority/forwarded-request-server.ts
export interface InboxProcessor {
processInbox(ctx: ForwarderContext): Promise<void>;
}
export interface ForwardedRequestServerDeps {
forwardingDir: string;
logger: DebugReviewLogger;
config: ConfigReader;
events?: PermissionEventBus;
requestPermissionDecisionFromUi: (
ui: PermissionDecisionUi,
title: string,
message: string,
options?: RequestPermissionOptions,
) => Promise<PermissionPromptDecision>;
}
export class ForwardedRequestServer implements InboxProcessor { /* ... */ }
```
Owns `processInbox` and the private `processSingleForwardedRequest`.
Module-private helper `formatForwardedPermissionPrompt` moves here (serving-only).
Imports `getSessionId` / `ForwarderContext` from `forwarder-context.ts`, IO helpers from `forwarding-io.ts`.
### Composition-root call site (`index.ts`)
```typescript
const escalator = new ApprovalEscalator({
forwardingDir: paths.forwardingDir,
detection: subagentDetection,
registry: subagentRegistry,
logger,
requestPermissionDecisionFromUi,
});
const requestServer = new ForwardedRequestServer({
forwardingDir: paths.forwardingDir,
logger,
config: configStore,
events: pi.events,
requestPermissionDecisionFromUi,
});
const prompter = new PermissionPrompter({ logger, events: pi.events, forwarder: escalator });
// ...
session = new PermissionSession(
paths,
new ForwardingManager(subagentDetection, requestServer),
/* ... */
);
```
The `forwardingDeps: PermissionForwarderDeps` intermediate is removed; each class takes its own inline deps object.
The prompter gets the escalator (`ApprovalRequester`), the manager gets the server (`InboxProcessor`) — matching the two seams they already depend on.
### Design-review pass
- **Dependency width** — both new deps interfaces (5 fields) are narrower than the old bag (7); every field is read by its owner.
No wide interface.
- **Law of Demeter** — the classes call free IO functions and `getSessionId(ctx)`; no new reach-through chains.
- **Output arguments** — none; no writes back into injected deps.
- **Duplication** — `getSessionId` and `ForwarderContext` are shared via `forwarder-context.ts`, not duplicated.
The two classes are genuinely different logical concerns (opposite authority directions), so keeping them separate is correct, not extractable duplication.
## Module-Level Changes
Production:
- **Add** `src/authority/forwarder-context.ts``ForwarderContext` interface + exported `getSessionId`.
- **Add** `src/authority/approval-escalator.ts``ApprovalEscalator`, `ApprovalRequester`, `ApprovalEscalatorDeps`; private `waitForForwardedApproval` / `buildForwardedRequest` / `pollForForwardedResponse`; module-private `getContextSystemPrompt`.
- **Add** `src/authority/forwarded-request-server.ts``ForwardedRequestServer`, `InboxProcessor`, `ForwardedRequestServerDeps`; private `processSingleForwardedRequest`; module-private `formatForwardedPermissionPrompt`.
- **Rename** `src/forwarded-permissions/io.ts``src/authority/forwarding-io.ts` (content unchanged).
- **Delete** `src/forwarded-permissions/permission-forwarder.ts`; **remove** the now-empty `src/forwarded-permissions/` directory.
- **Edit** `src/index.ts` — replace the `PermissionForwarder` import + `forwardingDeps` bag with `ApprovalEscalator` / `ForwardedRequestServer` imports and two inline deps objects; inject `escalator` into the prompter and `requestServer` into `ForwardingManager`.
- **Edit** `src/permission-prompter.ts` — import `ApprovalRequester` from `./authority/approval-escalator`.
- **Edit** `src/forwarding-manager.ts` — import `InboxProcessor` from `./authority/forwarded-request-server`.
- **Edit** `src/session-logger.ts` — the doc comment "Injected into `ConfigStore` and `PermissionForwarder`" → name the two new classes (prose-only).
- **Edit** `src/authority/subagent-detection.ts` — the doc comment naming `PermissionForwarder` (prose-only) → name `ApprovalEscalator` / `ForwardedRequestServer`.
Tests:
- **Split** `test/permission-forwarder.test.ts``test/authority/approval-escalator.test.ts` (the two `requestApproval` describes) + `test/authority/forwarded-request-server.test.ts` (the `processInbox` describe); delete the original.
- **Move** `test/forwarded-permissions/io.test.ts``test/authority/forwarding-io.test.ts` (import path → `#src/authority/forwarding-io`); remove the now-empty `test/forwarded-permissions/` directory.
- **Edit** `test/helpers/forwarding-fixtures.ts` — import `ForwarderContext` from `#src/authority/forwarder-context`; split `makeForwarderDeps` into `makeEscalatorDeps` (`ApprovalEscalatorDeps`) + `makeServerDeps` (`ForwardedRequestServerDeps`); keep `makeForwarderContext` / `makeUiDecision` / `createForwardingTempDir` / `makeSubagentRegistry`.
Update the header comment referencing `PermissionForwarderDeps` / #530.
Docs (implementation doc-update commit):
- **Edit** `docs/architecture/architecture.md`:
- Module-layout tree — replace the `forwarded-permissions/` block with `forwarding-io.ts`, `forwarder-context.ts`, `approval-escalator.ts`, `forwarded-request-server.ts` under `authority/`; drop the `forwarded-permissions/` node.
- Roadmap Step 6 heading → `✅`; Mermaid node `S6``✅`.
- Metrics table row `PermissionForwarder roles per class` → mark `✅` (2 → 1 each).
- The Phase 8 "Findings" bullet describing the 591-LOC dual-role class stays (it is a historical finding), but verify no *current-state* prose still claims the class is unsplit.
- **Edit** `docs/architecture/permission-prompter.md` — lines naming `src/forwarded-permissions/permission-forwarder.ts`, `PermissionForwarder`, `PermissionForwarderDeps`, `new PermissionForwarder(forwardingDeps)``src/authority/approval-escalator.ts`, `ApprovalEscalator`, `ApprovalEscalatorDeps`, `new ApprovalEscalator({ ... })`.
- **Edit** `.pi/skills/package-pi-permission-system/SKILL.md``PermissionForwarder.requestApproval``ApprovalEscalator.requestApproval`.
- **Leave unchanged** `docs/architecture/history/phase-3-*.md` and `phase-5-*.md` — frozen historical records.
## Test Impact Analysis
1. **New tests the split enables** — none strictly *new*; the split lets each role's tests construct the narrower class with only its own deps (escalator tests no longer supply `config`/`events`; server tests no longer supply `detection`/`registry`), removing incidental setup.
The existing behavior coverage transfers 1:1.
2. **Redundant tests** — none become redundant.
The `requestApproval` and `processInbox` describes exercise genuinely different behavior; they relocate, they do not collapse.
3. **Tests that must stay as-is** — all of them.
This is a lift-and-shift; every existing assertion (UI fast-path no-emit, non-subagent deny, forwarded-inbox emit/rich-emit/auto-approve/responses-race, and the full `io` suite) must stay green against the relocated code, since they pin the behavior the refactor must preserve.
## Invariants at risk
This step relocates surfaces earlier Phase 8 steps refactored; each documented outcome must stay green:
- Step 4 (#528) — the forwarding test harness (`forwarding-fixtures.ts`).
Splitting `makeForwarderDeps` must keep `makeForwarderContext` / `createForwardingTempDir` / `makeSubagentRegistry` behavior identical; the migrated tests are the pin.
- Step 5 (#529) — `SubagentDetection` is the single owner of subagent detection; the escalator keeps `registry` for *target resolution only* and reads detection via the injected `SubagentDetector`.
Do not re-introduce a second detection path.
Pinned by the existing `requestApproval` non-subagent-deny test and the `subagent-detection` suite.
- Behavioral parity is pinned by the relocated `approval-escalator.test.ts` / `forwarded-request-server.test.ts` / `forwarding-io.test.ts` — no invariant lives only in prose here.
## TDD Order
This is a pure refactor, so each cycle is "relocate code + tests, keep the suite green" rather than red→green.
`pnpm --filter @gotgenes/pi-permission-system run check` and `run test` gate every step.
Sequenced tidy-first so each commit leaves the repo valid.
1. **Prep: rename `io.ts` → `forwarding-io.ts`; extract `forwarder-context.ts`.**
Rename `src/forwarded-permissions/io.ts``src/authority/forwarding-io.ts` (content unchanged); add `src/authority/forwarder-context.ts` with `ForwarderContext` + exported `getSessionId`.
Update `permission-forwarder.ts` to import IO helpers from `#src/authority/forwarding-io` and `ForwarderContext` / `getSessionId` from `#src/authority/forwarder-context` (remove its local `ForwarderContext` + `getSessionId`).
Move `test/forwarded-permissions/io.test.ts``test/authority/forwarding-io.test.ts` (import → `#src/authority/forwarding-io`); remove `test/forwarded-permissions/`.
`PermissionForwarder`, both seams, and all consumers still resolve — no consumer edits yet.
Commit: `refactor(pi-permission-system): rename forwarding io and extract forwarder-context`.
2. **Extract `ForwardedRequestServer` (serving-down).**
Add `src/authority/forwarded-request-server.ts` with `ForwardedRequestServer`, `InboxProcessor`, `ForwardedRequestServerDeps`, `processSingleForwardedRequest`, and module-private `formatForwardedPermissionPrompt`.
Remove `processInbox` / `processSingleForwardedRequest` / `formatForwardedPermissionPrompt` / `InboxProcessor` from `permission-forwarder.ts`; narrow `PermissionForwarderDeps` to drop `config` + `events`.
Update `forwarding-manager.ts` to import `InboxProcessor` from the new module, and `index.ts` to construct `ForwardedRequestServer` and pass it to `ForwardingManager`.
Split the `processInbox` describe out of `permission-forwarder.test.ts` into `test/authority/forwarded-request-server.test.ts`; add `makeServerDeps` to `forwarding-fixtures.ts`.
Removing an exported interface (`InboxProcessor`) and narrowing the deps bag breaks its importer and the object literal in `index.ts` in the same commit — fold the manager + index + test updates in here.
Commit: `refactor(pi-permission-system): extract ForwardedRequestServer`.
3. **Rename the escalation role → `ApprovalEscalator`; dissolve `forwarded-permissions/`.**
Move `src/forwarded-permissions/permission-forwarder.ts``src/authority/approval-escalator.ts`; rename `PermissionForwarder``ApprovalEscalator`, `PermissionForwarderDeps``ApprovalEscalatorDeps`; keep `ApprovalRequester` defined here; keep `getContextSystemPrompt` module-private.
Remove the now-empty `src/forwarded-permissions/` directory.
Update `index.ts` (construct `ApprovalEscalator`, inject into prompter) and `permission-prompter.ts` (import `ApprovalRequester` from `./authority/approval-escalator`).
Rename `permission-forwarder.test.ts``test/authority/approval-escalator.test.ts`; rename `makeForwarderDeps``makeEscalatorDeps` (typed `ApprovalEscalatorDeps`) in `forwarding-fixtures.ts` and update its header comment.
Removing the `ApprovalRequester` export's old home breaks the prompter import in the same commit — fold both in.
Commit: `refactor(pi-permission-system): rename PermissionForwarder to ApprovalEscalator`.
4. **Doc-update commit.**
Update `docs/architecture/architecture.md` (module tree, Step 6 heading + Mermaid `S6``✅`, `PermissionForwarder roles per class` metric row), `docs/architecture/permission-prompter.md` (class/deps/path/wiring names), and `.pi/skills/package-pi-permission-system/SKILL.md` (`ApprovalEscalator.requestApproval`); fix the `session-logger.ts` / `subagent-detection.ts` doc comments if not already handled inline.
Commit: `docs(pi-permission-system): mark Phase 8 Step 6 complete; retarget forwarder docs`.
## Risks and Mitigations
- **Atomic type-break on export removal** — removing `InboxProcessor` / `ApprovalRequester` from their old homes breaks importers in the same commit.
Mitigation: Steps 2 and 3 each fold the consumer + `index.ts` + test edits into the same commit, as the TDD order specifies; `pnpm run check` gates each.
- **Silent behavior drift in the yolo serve arm** — the serving role keeps its out-of-ruleset yolo check (dissolves with the Phase 9 spine).
Mitigation: the relocated `forwarded-request-server.test.ts` "does not emit … when forwarded permission auto-approves" test pins it.
- **Stale symbol references in prose** — architecture/prompter docs and the package skill name the old symbols.
Mitigation: Step 4 grep-sweep for `PermissionForwarder` / `forwarded-permissions` across `docs/` (excluding frozen `history/`) and `.pi/skills/`.
- **`ForwardingManager` unchanged behavior** — only its `InboxProcessor` import path changes.
Mitigation: `forwarding-manager.test.ts` (untouched by Step 4 of #528) stays green.
## Open Questions
None.
The shared-context placement (dedicated `src/authority/forwarder-context.ts`) was confirmed with the operator during planning.