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,232 @@
---
issue: 528
issue_title: "pi-permission-system: extract a shared forwarded-permission test harness"
---
# Extract a shared forwarded-permission test harness
## Release Recommendation
**Release:** ship independently
Phase 8 Step 4 is tagged `Release: independent` in the roadmap (`docs/architecture/architecture.md`, "Release batches": "Independently releasable: Steps 1, 4 (test-only; hidden changelog type)").
The change is test-only and lands under the `test:` conventional type, which is a `hidden: true` changelog type — it cuts no release on its own and auto-batches into the next `feat:`/`fix:` release.
## Problem Statement
The forwarder-family test files repeat the same forwarding scaffolding.
`test/permission-forwarder.test.ts` builds the same temp forwarding directory four times: `mkdtempSync``createPermissionForwardingLocation``mkdirSync` for `requests/` and `responses/``writeFileSync` of a `ForwardedPermissionRequest` JSON, all wrapped in a `try/finally` with `rmSync` cleanup (the roadmap's "43-line clone ×2 plus 6 groups / 110 lines").
It also inlines the `PermissionForwarderDeps` builder, the `ForwarderContext` builder, the `{ emit, on }` events mock, and the `{ approved: true, state: "approved" }` UI decision repeatedly.
Extracting these into `test/helpers/forwarding-fixtures.ts` collapses the duplication and gives Phase 8 Step 6 ([#530]) a harness to migrate its split-out per-class tests onto, instead of copying the scaffolding a fifth time.
## Goals
- Add `test/helpers/forwarding-fixtures.ts` exposing: a temp forwarding-directory fixture (handle + `cleanup`), a forwarded-request writer, a `PermissionForwarderDeps` builder, a `ForwarderContext` builder, and a UI-decision builder.
- Fully migrate `test/permission-forwarder.test.ts` onto the harness — remove its local `makeDeps` / `makeCtx` and every inline temp-dir `try/finally` block.
- Opportunistically migrate `test/permission-forwarding.test.ts` where scaffolding is genuinely shared (subagent-registry setup), leaving the pure-function option objects — which are the test subjects' inputs — inline.
- Keep every behavioral assertion byte-identical: this is arrangement-only refactoring, the suite stays green throughout.
- No production change.
## Non-Goals
- **`test/forwarding-manager.test.ts` is left unchanged.**
Despite the issue's "Why" listing it, its scaffolding does not overlap the harness: it casts a minimal `{ hasUI, sessionManager: { getSessionId }, cwd }` to `ExtensionContext` (not `ForwarderContext`), does no temp-dir or request/response I/O, mocks `subagent-context`, and exercises fake-timer polling.
Its `makeCtx` / `makeForwarder` / `makeManager` are file-local, not cross-file clones.
Forcing it onto a shared context builder would require a cast and a `getSessionId` override on a general-purpose builder for a single caller — net negative. (Operator confirmed opportunistic scope over force-all-three.)
- **Migrating the composition-root round-trip test.**
`test/composition-root.test.ts` writes a `ForwardedPermissionResponse` to `responses/` — the only place the disk-response side is exercised.
The three forwarder-family files never write responses, so a disk-response writer is out of scope; the harness's "response builder" is the in-memory UI decision (`makeUiDecision`), which is what these files actually repeat.
- **The `PermissionForwarder` split itself** — that is Phase 8 Step 6 ([#530]); this step only prepares the harness it will consume.
## Background
Relevant modules:
- `src/forwarded-permissions/permission-forwarder.ts` — defines `ForwarderContext` and `PermissionForwarderDeps`, the two interfaces the fixtures build.
- `src/permission-forwarding.ts` — defines `ForwardedPermissionRequest`, `PermissionForwardingLocation`, and `createPermissionForwardingLocation(forwardingRootDir, sessionId)`; the fixture wraps the latter.
- `src/permission-dialog.ts` — defines `PermissionPromptDecision` (the `{ approved, state }` shape `requestPermissionDecisionFromUi` resolves), the type `makeUiDecision` returns.
- `src/subagent-registry.ts``SubagentSessionRegistry`, constructed in `permission-forwarding.test.ts`'s registry-resolution describe.
Existing conventions to follow (`test/helpers/`):
- `handler-fixtures.ts` already exports `makeEvents()` returning exactly `{ emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) }` — the same events mock `permission-forwarder.test.ts` inlines four times.
Reuse it via `#test/helpers/handler-fixtures`; do not re-implement it in the new module.
- `external-directory-fixtures.ts` establishes the module style: a header docstring naming the consumers, `#src/` and `#test/helpers/` import aliases, and small JSDoc'd factory functions.
- `manager-harness.ts` (#525, Phase 8 Step 1) is the precedent for extracting a fixture module from a forwarder-family test in this exact phase.
AGENTS.md / skill constraints that apply:
- Testing skill — "Do not wrap the system-under-test call in a helper to eliminate a duplication-metric clone — the repeated act is the test subject."
The temp-dir setup, deps, ctx, request JSON, and registry are *arrangement*, so extracting them is correct; the `resolvePermissionForwardingTargetSessionId({...})` option objects and `createPermissionForwardingLocation(...)` calls in `permission-forwarding.test.ts` are the *act's inputs* and stay inline.
- Testing skill — factory return types stay unannotated so callers keep `Mock<...>` access; where a factory must structurally satisfy a production interface (`PermissionForwarderDeps`, `ForwarderContext`), give each `vi.fn()` a typed implementation rather than a bare `vi.fn()`.
- Package skill — mark the completed roadmap step (`✅` on the Step 4 heading and the `S4` Mermaid node) in the implementation doc-update commit, not a deferred ship commit.
## Design Overview
New module `test/helpers/forwarding-fixtures.ts`.
### Temp forwarding directory (handle + `cleanup`)
Operator chose the handle form over a callback wrapper.
```typescript
import type { ForwardedPermissionRequest } from "#src/permission-forwarding";
export interface ForwardingTempDir {
/** Absolute path passed as `forwardingDir` to `PermissionForwarderDeps`. */
forwardingDir: string;
/** The parent session's request/response location under `forwardingDir`. */
location: PermissionForwardingLocation;
/** Writes a `ForwardedPermissionRequest` JSON into `location.requestsDir`. */
writeRequest(overrides?: Partial<ForwardedPermissionRequest>): ForwardedPermissionRequest;
/** `rmSync(root, { recursive, force })`; register in `afterEach`. */
cleanup(): void;
}
export function createForwardingTempDir(
sessionId: string,
options?: { createResponsesDir?: boolean },
): ForwardingTempDir;
```
- `mkdtempSync(join(tmpdir(), "permission-forwarding-"))``root`; `forwardingDir = join(root, "forwarding")`; `location = createPermissionForwardingLocation(forwardingDir, sessionId)`.
- Always `mkdirSync(location.requestsDir, { recursive: true })`.
- `options.createResponsesDir` defaults to `true`; the "recreates a missing `responses/`" race test passes `{ createResponsesDir: false }` so the fixture deliberately omits it.
- `writeRequest` defaults: `{ id: "req-forwarded", createdAt: Date.now(), requesterSessionId: "child-session", targetSessionId: sessionId, requesterAgentName: "Explore", message: "Allow git push?" }`, shallow-merged with `overrides`, `writeFileSync(join(location.requestsDir, `${id}.json`), JSON.stringify(request), "utf-8")`, returns the merged request.
The two rich/auto variants override `id` (+ `source`/`surface`/`value` for the rich case).
Consumer call-site sketch (Tell-Don't-Ask: the fixture owns the I/O; the test tells it what request to stage and asserts on the forwarder's behavior):
```typescript
let temp: ForwardingTempDir;
afterEach(() => temp?.cleanup());
test("emits a UI prompt event before showing a forwarded permission dialog", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({ id: "req-forwarded" });
const events = makeEvents();
const forwarder = new PermissionForwarder(
makeForwarderDeps({ forwardingDir: temp.forwardingDir, events }),
);
await forwarder.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", /* ... */);
});
```
### Deps, context, and UI-decision builders
```typescript
export function makeForwarderDeps(
overrides?: Partial<PermissionForwarderDeps>,
): PermissionForwarderDeps; // current makeDeps defaults; requestPermissionDecisionFromUi defaults to a resolved makeUiDecision()
export function makeForwarderContext(
overrides?: { hasUI?: boolean; ui?: ForwarderContext["ui"]; sessionId?: string;
sessionManager?: Partial<ForwarderContext["sessionManager"]> },
): ForwarderContext; // current makeCtx, plus a `sessionId` shortcut that sets getSessionId
export function makeUiDecision(
overrides?: Partial<PermissionPromptDecision>,
): PermissionPromptDecision; // default { approved: true, state: "approved" }
```
- `makeForwarderContext` adds a `sessionId` convenience over the current `makeCtx`: `sessionId` populates `getSessionId`, collapsing the repeated `sessionManager: { getSessionId: vi.fn(() => "parent-session") }`.
An explicit `sessionManager` override still merges last for the tests that stub other readers.
- `makeUiDecision` is the "response builder" the issue names — the in-memory UI decision, not a disk `ForwardedPermissionResponse`.
### Opportunistic registry helper (`permission-forwarding.test.ts`)
The registry-resolution describe repeats `new SubagentSessionRegistry()` + `register(childSessionId, entry)`.
A thin `makeSubagentRegistry(childSessionId, entry?)` collapses the arrangement; the `resolvePermissionForwardingTargetSessionId({...})` option objects stay inline (test subjects).
```typescript
export function makeSubagentRegistry(
childSessionId: string,
entry?: { parentSessionId?: string },
): SubagentSessionRegistry;
```
This is borderline (a 2-line pattern); include it only if it reads cleaner across the ~5 call sites, otherwise leave `permission-forwarding.test.ts` untouched.
### Edge cases
- Race test: `createResponsesDir: false` reproduces the "requests/ exists, responses/ removed by a concurrent cleanup" condition.
- Version-skew (rich vs. degraded request): handled by `writeRequest` overrides adding `source`/`surface`/`value`.
- Yolo auto-approve test: overrides `config` on `makeForwarderDeps` (`{ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }`) and passes a bare `vi.fn()` for `requestPermissionDecisionFromUi` — the builder's default is override-replaced, no special support needed.
## Module-Level Changes
- **NEW `test/helpers/forwarding-fixtures.ts`** — header docstring naming consumers (`permission-forwarder.test.ts`, `permission-forwarding.test.ts`, and forward-looking [#530]); exports `ForwardingTempDir`, `createForwardingTempDir`, `makeForwarderDeps`, `makeForwarderContext`, `makeUiDecision`, and (conditionally) `makeSubagentRegistry`.
Imports `ForwarderContext` / `PermissionForwarderDeps` from `#src/forwarded-permissions/permission-forwarder`, `ForwardedPermissionRequest` / `PermissionForwardingLocation` / `createPermissionForwardingLocation` from `#src/permission-forwarding`, `PermissionPromptDecision` from `#src/permission-dialog`, `DEFAULT_EXTENSION_CONFIG` from `#src/extension-config`, `SubagentSessionRegistry` from `#src/subagent-registry`.
- **CHANGED `test/permission-forwarder.test.ts`** — delete local `makeDeps` and `makeCtx`; import `makeForwarderDeps` / `makeForwarderContext` / `makeUiDecision` / `createForwardingTempDir` from `#test/helpers/forwarding-fixtures` and `makeEvents` from `#test/helpers/handler-fixtures`.
Replace the four `processInbox` `try/finally` temp-dir blocks with a describe-scoped `let temp` + `afterEach(() => temp?.cleanup())` + `createForwardingTempDir(...)` / `temp.writeRequest(...)`.
Replace inline `{ emit, on }` mocks with `makeEvents()` and inline `{ approved: true, state: "approved" }` with `makeUiDecision()`.
Keep every `expect(...)` unchanged.
- **CHANGED (opportunistic) `test/permission-forwarding.test.ts`** — if `makeSubagentRegistry` is adopted, import it and replace the registry-resolution describe's `new SubagentSessionRegistry()` + `register(...)` pairs; all `resolvePermissionForwardingTargetSessionId(...)` / `createPermissionForwardingLocation(...)` calls stay inline.
Otherwise no change.
- **UNCHANGED `test/forwarding-manager.test.ts`** — see Non-Goals.
- **DOC `docs/architecture/architecture.md`** — mark Phase 8 Step 4 complete: `✅` on the " **Extract a shared forwarded-permission test harness.**
" step heading and the `S4` node in the step-dependency Mermaid diagram; add a `Landed:` line to the step.
No metric-table row flips (the "Duplication ≤ 5.5%" target is Phase-8-wide, reached at phase close, not per-step).
No `src/` symbol is removed or renamed, so no `src/` / README / skill grep for a removed symbol is required.
The only doc touch is the roadmap step-completion marker.
## Test Impact Analysis
1. **New unit tests enabled?**
None.
This extracts *test scaffolding*, not production code — no production seam moves, so no previously-impractical lower-level test becomes possible.
Test-helper modules are not themselves unit-tested (consistent with `manager-harness.ts` / `external-directory-fixtures.ts`).
2. **Tests made redundant?**
None removed.
The same behaviors are asserted with identical `expect`s; only arrangement is deduplicated.
3. **Tests that must stay as-is:** every assertion in all three files.
The migration must not weaken or alter any `expect`; a diff that changes only imports, arrangement, and the temp-dir/`cleanup` mechanics is the success condition.
## Invariants at risk
No earlier Phase 8 step refactored these files (#525 touched `permission-manager-unified.test.ts`; #526/#527 touched production yolo paths).
The invariants at risk are the behavioral assertions themselves — the forwarder's UI-prompt emission, the non-UI deny path, the yolo auto-approve suppression, and the missing-`responses/` recreation.
Each is already pinned by an existing test in `permission-forwarder.test.ts`; the migration preserves them verbatim.
Verification: run the full `pi-permission-system` suite after each step and confirm the assertion count and outcomes are unchanged (green throughout — no red phase, this is refactoring).
## TDD Order
These are refactor cycles, not red→green: the suite stays green after every step (esbuild runs the migrated tests; `pnpm run check` type-checks the fixtures against the production interfaces).
1. **Add `forwarding-fixtures.ts` and fully migrate `permission-forwarder.test.ts`.**
Create the helper module and rewrite `permission-forwarder.test.ts` onto it in the same commit (a helper with no consumer would trip `pnpm fallow dead-code`).
Verify: `pnpm --filter @gotgenes/pi-permission-system exec vitest run test/permission-forwarder.test.ts` green, `pnpm run check` clean, `pnpm fallow dead-code` clean (fixtures are consumed).
Commit: `test(pi-permission-system): extract forwarding fixtures; migrate forwarder tests (#528)`.
2. **(Opportunistic) migrate `permission-forwarding.test.ts` registry setup.**
Only if `makeSubagentRegistry` reads cleaner across its call sites; add the export and migrate the registry-resolution describe.
Verify: `pnpm --filter @gotgenes/pi-permission-system exec vitest run test/permission-forwarding.test.ts` green, `pnpm fallow dead-code` clean.
Commit: `test(pi-permission-system): use shared subagent-registry fixture in forwarding tests (#528)`.
Skip this step (and the `makeSubagentRegistry` export) if the extraction does not improve readability.
3. **Mark Phase 8 Step 4 complete in the roadmap.**
Add `✅` to the Step 4 heading and the `S4` Mermaid node; add a `Landed:` line.
Verify: full suite green (`pnpm --filter @gotgenes/pi-permission-system exec vitest run`), `pnpm run lint` clean (rumdl on the doc).
Commit: `docs(pi-permission-system): mark Phase 8 Step 4 complete (#528)`.
Run the full suite before the final commit, not just the per-file runs, since the fixtures are shared.
## Risks and Mitigations
- **Risk: a hidden assertion change during arrangement extraction.**
Mitigation: extract arrangement only; diff each migrated test to confirm the `expect(...)` lines are byte-identical, and rely on the green suite as the backstop.
- **Risk: unused-export / dead-code from an over-eager fixture surface.**
Mitigation: export only what a consumer uses in the same commit; run `pnpm fallow dead-code` (CI gates on it) and Biome `noUnusedImports` after each step.
- **Risk: `makeForwarderContext`'s `sessionId` shortcut colliding with an explicit `sessionManager` override.**
Mitigation: merge order — apply the `sessionId`-derived `getSessionId` first, then spread the explicit `sessionManager` override last so a test that stubs other readers wins.
- **Risk: the race test losing its "no `responses/`" precondition.**
Mitigation: the `createResponsesDir: false` option is exercised by exactly that test; assert `logger.review` was not called with `permission_forwarding.error` as before.
## Open Questions
- Whether `makeSubagentRegistry` earns its place (Step 2) is deferred to implementation — a judgment call made against the actual call sites, per the operator's opportunistic-scope choice.
No follow-up issue is warranted; the decision is local to this plan's Step 2.
[#530]: https://github.com/gotgenes/pi-packages/issues/530