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,191 @@
---
issue: 356
issue_title: "Harden config pipeline against silently-dropped fields (follow-up to #332)"
---
# Harden the config pipeline against silently-dropped fields
## Problem Statement
Issue [#332] fixed a specific bug — `toolInputPreviewMaxLength` / `toolTextSummaryMaxLength` were declared on `PermissionSystemExtensionConfig` and read by `ToolPreviewFormatter`, but silently dropped by the `UnifiedPermissionConfig` intermediate in `config-loader.ts`.
The fix wired those two fields through the loader, but it did not close the *class* of bug that let the omission ship undetected.
Two structural gaps remain:
1. `normalizePermissionSystemConfig` accepts `unknown` and re-parses its argument through `toRecord(raw)`.
Its sole production caller (`ConfigStore.refresh`) feeds it `mergeResult.merged`, a `UnifiedPermissionConfig`.
Because the parameter is `unknown`, TypeScript never objected that the merged object lacked the two fields.
A field declared on the runtime type but missing from the merge intermediate is a silent runtime drop, not a compile error.
2. Every existing test injects config at a single layer in isolation.
Nothing exercises the full seam — temp `config.json``loadAndMergeConfigs``normalizePermissionSystemConfig` — so a field dropped in the middle is never observed end to end.
This plan attacks the class of bug rather than the single instance.
## Goals
- Retype `normalizePermissionSystemConfig`'s parameter from `unknown` to `UnifiedPermissionConfig` and read fields directly, so a future field declared on the runtime type but absent from the merge intermediate becomes a compile error.
- Drop the now-redundant defensive coercion (`toRecord`, `normalizeOptionalPositiveInt`, `normalizeOptionalStringArray`) from `normalizePermissionSystemConfig` — that work already happens at the JSON boundary in `normalizeUnifiedConfig`.
- Add one full-pipeline seam test in a new `test/config-pipeline.test.ts` that writes a temp `config.json`, runs it through `loadAndMergeConfigs``normalizePermissionSystemConfig`, and asserts a runtime knob and a preview-length field both survive end to end.
This change is **not breaking**: observable runtime behavior is unchanged.
The two production call sites already feed typed objects through `normalizeUnifiedConfig` first, so the removed coercion is dead code for production; only test-only garbage injection is affected.
## Non-Goals
- The secondary `saveExtensionConfig` behavior from [#332] (length fields not written back) is out of scope — `ConfigStore.save` already preserves existing file fields via `...existing.config`, and the modal only edits the three booleans.
`config-store.ts` is not modified by this plan.
- No changes to `normalizeUnifiedConfig`, `mergeUnifiedConfigs`, or the boundary parsing — they already do the defensive work correctly.
- No new config fields, schema entries, or example-config changes.
- No narrowing of the parameter to `Omit<UnifiedPermissionConfig, "permission">` (see Design Overview — accepted minor ISP slack).
## Background
Relevant modules:
- `src/extension-config.ts` — declares `PermissionSystemExtensionConfig`, `DEFAULT_EXTENSION_CONFIG`, and `normalizePermissionSystemConfig`.
The function currently does `toRecord(raw)` then reads each field defensively (`record.debugLog === true`, `normalizeOptionalPositiveInt(record.toolInputPreviewMaxLength)`, etc.).
- `src/config-loader.ts` — declares `UnifiedPermissionConfig` (all-optional intermediate) and `normalizeUnifiedConfig`, which already does the full defensive parse at the JSON boundary: `normalizeOptionalBoolean` for the three booleans, `normalizeOptionalPositiveInt` for the two length fields, `normalizeOptionalStringArray` for `piInfrastructureReadPaths`.
- `src/config-store.ts` — the two production call sites.
`refresh` passes `mergeResult.merged` (a `UnifiedPermissionConfig`); `save` passes `next` (a `PermissionSystemExtensionConfig`).
- `src/common.ts` — exports `toRecord`, `normalizeOptionalPositiveInt`, `normalizeOptionalStringArray`.
Applicable constraint from the package skill (`package-pi-permission-system`): "Treat any declared config field not read at runtime as a maintenance trap" and "A field on the runtime type but not the merge intermediate is silently dropped before runtime (the [#332] / [#347] bug class)."
This plan makes that trap a compile error.
Existing boundary coverage in `test/config-loader.test.ts` already exercises the defensive parse the retype removes from `normalizePermissionSystemConfig`:
- Lines 188199 — non-boolean values (`debugLog: "yes"`, `permissionReviewLog: 1`, `yoloMode: null`) are dropped to `undefined`.
- Lines 156172 — `debugLog` present/false/missing.
- Lines 262325 — both length fields: valid positive integer parsed, absent omitted, and invalid values (`0`, `-1`, fractional, string, boolean) dropped.
This is why the redundant cases in `test/extension-config.test.ts` can be deleted rather than relocated.
## Design Overview
### Type model
After the change, `normalizePermissionSystemConfig` converts a typed `UnifiedPermissionConfig` into a `PermissionSystemExtensionConfig` by applying defaults to the three required booleans and passing through the optional fields:
```typescript
export function normalizePermissionSystemConfig(
raw: UnifiedPermissionConfig,
): PermissionSystemExtensionConfig {
const result: PermissionSystemExtensionConfig = {
debugLog: raw.debugLog === true,
permissionReviewLog: raw.permissionReviewLog !== false,
yoloMode: raw.yoloMode === true,
};
if (raw.piInfrastructureReadPaths !== undefined) {
result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
}
if (raw.toolInputPreviewMaxLength !== undefined) {
result.toolInputPreviewMaxLength = raw.toolInputPreviewMaxLength;
}
if (raw.toolTextSummaryMaxLength !== undefined) {
result.toolTextSummaryMaxLength = raw.toolTextSummaryMaxLength;
}
return result;
}
```
Behavior is preserved exactly: undefined booleans default the same way (`=== true``false`, `!== false``true`), and the optional fields are passed through unchanged.
The function no longer imports `toRecord`, `normalizeOptionalPositiveInt`, or `normalizeOptionalStringArray` — those imports become unused and are removed.
### Why this achieves the safety goal
The point of the retype is the omission-becomes-compile-error property.
Keeping `toRecord` would defeat it: `toRecord` returns `Record<string, unknown>`, so `record.toolInputPreviewMaxLength` is `unknown` and the type checker cannot see a missing field on the input.
Reading `raw.toolInputPreviewMaxLength` directly against a typed `UnifiedPermissionConfig` means that if a future field is declared on `PermissionSystemExtensionConfig`, read here, but never added to `UnifiedPermissionConfig`, the field access is a compile error.
### Call-site compatibility
Both production call sites already feed assignable types — no `config-store.ts` change is needed:
```typescript
// ConfigStore.refresh — mergeResult.merged is already UnifiedPermissionConfig
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
// ConfigStore.save — next is PermissionSystemExtensionConfig, structurally
// assignable to UnifiedPermissionConfig (every field present, all optional there)
const normalized = normalizePermissionSystemConfig(next);
```
`PermissionSystemExtensionConfig` (required booleans + optional extras) is assignable to `UnifiedPermissionConfig` (all-optional superset including `permission?`), so `save` compiles unchanged.
### ISP note (accepted slack)
`normalizePermissionSystemConfig` reads 6 of `UnifiedPermissionConfig`'s 7 fields — it never reads `permission`.
A strict ISP reading would narrow the parameter to `Omit<UnifiedPermissionConfig, "permission">`.
We accept the single unused optional field instead: `UnifiedPermissionConfig` is the natural merged-config domain object and the exact type of `mergeResult.merged`, the issue prescribes typing the parameter as `UnifiedPermissionConfig`, and the compile-error safety property holds either way.
Adding a narrowing alias would be speculative surface for negligible benefit.
### Design-review checklist result
| Check | Finding |
| ---------------- | ---------------------------------------------------------------------------------- |
| Dependency width | Param reads 6/7 fields; one unused optional (`permission`) accepted (see ISP note) |
| Law of Demeter | No chained access introduced |
| Output arguments | None — function returns a fresh object |
| Scattered resets | N/A |
| Parameter relay | N/A |
| Test mock depth | Removes test-only `as unknown` casts in `config-modal.test.ts` (improvement) |
No structural smells introduced; the change removes redundant code and a test-only cast.
## Module-Level Changes
- `src/extension-config.ts`
- Change `normalizePermissionSystemConfig(raw: unknown)` to `(raw: UnifiedPermissionConfig)`; read fields directly; drop the `toRecord` call and the `normalizeOptionalPositiveInt` / `normalizeOptionalStringArray` coercion.
- Add `import type { UnifiedPermissionConfig } from "./config-loader";`.
- Remove the now-unused imports `normalizeOptionalPositiveInt`, `normalizeOptionalStringArray`, `toRecord` from the `./common` import (verify `toRecord` has no other use in the file — `detectMisplacedPermissionKeys` takes a `Record` directly, so it does not).
- `test/extension-config.test.ts`
- Delete the four redundant garbage-input cases (now uncompilable against the typed parameter and already covered at the boundary in `config-loader.test.ts`): "coerces non-boolean values to their defaults", "handles null/undefined input gracefully", "omits toolInputPreviewMaxLength for invalid values", "omits toolTextSummaryMaxLength for invalid values".
- Keep the valid-input cases (valid config, the three boolean defaults via `{}`, includes/omits for both length fields) — they pass typed objects and still compile.
- `test/config-modal.test.ts`
- Fix the two call sites that pass `JSON.parse(readFileSync(configPath, "utf-8")) as unknown` to `normalizePermissionSystemConfig` (lines ~147 and ~156): route them through `loadUnifiedConfig(configPath).config` (reads + normalizes the file into a `UnifiedPermissionConfig`, mirroring production) and import `loadUnifiedConfig` from `#src/config-loader`.
- The other two call sites pass typed values (`config`, `next`) and need no change.
- `test/config-pipeline.test.ts` (new)
- Add the full-pipeline seam test (change 2).
No `docs/architecture/`, schema, example-config, README, or `docs/configuration.md` updates — no config surface changes.
The package skill's bug-class note already describes this trap; no skill edit required.
## Test Impact Analysis
What the change enables and affects:
1. New coverage enabled — the seam test in `test/config-pipeline.test.ts` is the first test that runs config through the real `loadAndMergeConfigs``normalizePermissionSystemConfig` path on disk, catching any future mid-pipeline drop (not just the two length fields).
2. Tests that become redundant — the four garbage-input cases in `test/extension-config.test.ts` duplicate boundary coverage already present in `test/config-loader.test.ts` (lines 188199 for booleans, 296325 for invalid length values).
They are deleted, not relocated, because the boundary already tests the identical behavior.
3. Tests that must stay as-is — the valid-input cases in `test/extension-config.test.ts` (default application, length-field passthrough) genuinely exercise `normalizePermissionSystemConfig`'s remaining responsibility (defaults + passthrough) and are kept.
All of `test/config-loader.test.ts` stays — it owns the boundary defensive-parse contract.
## TDD Order
1. Add the full-pipeline seam test (change 2).
Surface: new `test/config-pipeline.test.ts`.
Covers: write a temp `config.json` with a runtime knob (e.g. `debugLog: true`) and a preview-length field (e.g. `toolInputPreviewMaxLength: 1000`); run `loadAndMergeConfigs(agentDir, cwd, extensionRoot)`; pass `.merged` to `normalizePermissionSystemConfig`; assert both values survive end to end.
This passes immediately (the [#332] loader fix is already in place) — it is a regression guard documenting the seam, established before the refactor as a safety net.
Commit: `test: add full-pipeline config seam regression test (#356)`.
2. Retype `normalizePermissionSystemConfig` and migrate its tests (change 1).
Surface: `src/extension-config.ts`, `test/extension-config.test.ts`, `test/config-modal.test.ts`.
This is a single atomic commit: changing the parameter type breaks the four garbage-input test cases and the two `as unknown` call sites at the type level, so the production change, the test deletions, and the `config-modal.test.ts` call-site updates must land together.
Run `pnpm run check` immediately after — the retype is the one interface-shape change in this plan, and `pnpm --filter @gotgenes/pi-permission-system exec vitest run` to confirm the seam test (step 1) and the surviving unit tests stay green.
Commit: `refactor: type normalizePermissionSystemConfig parameter as UnifiedPermissionConfig (#356)`.
## Risks and Mitigations
- Risk: a hidden production caller passes genuinely untrusted `unknown` to `normalizePermissionSystemConfig`, relying on the defensive coercion.
Mitigation: confirmed via grep — only two production call sites (`ConfigStore.refresh`, `ConfigStore.save`), both feeding typed objects already normalized upstream; no untrusted path exists.
- Risk: deleting the garbage-input tests loses coverage of invalid-value handling.
Mitigation: that behavior is fully covered at the JSON boundary in `test/config-loader.test.ts` (booleans lines 188199, length fields 296325); the deletions are duplicates, not unique coverage.
- Risk: `config-modal.test.ts` call-site rewrite changes what the test asserts.
Mitigation: `loadUnifiedConfig(configPath).config` reads and normalizes the same file the test wrote, mirroring the production load path more faithfully than the previous `JSON.parse(...) as unknown` cast — the asserted round-trip is preserved.
## Open Questions
None blocking.
A future follow-up could narrow the parameter to `Omit<UnifiedPermissionConfig, "permission">` if more unused fields accumulate, but that is not warranted now.
[#332]: https://github.com/gotgenes/pi-packages/issues/332
[#347]: https://github.com/gotgenes/pi-packages/issues/347