19 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 334 | Inject a single PermissionManager into PermissionSession (configure once at session_start) |
Inject a single PermissionManager into PermissionSession
Problem Statement
PermissionSession constructs its own PermissionManager by calling the free function createPermissionManagerForCwd(...) in three places — the constructor, resetForNewSession(), and reload().
The manager is never injected, which is a Dependency Inversion (DIP) violation: the session cannot be built with a test double.
The cost lands in permission-session.test.ts, which must vi.mock("../src/runtime") to stub the factory and route a {...} as unknown as PermissionManager mock through it.
The per-call reconstruction implies the project cwd can change across a session.
It cannot: the issue verified against Pi core that AgentSession._cwd and ExtensionRunner.cwd are each assigned once and never reassigned, and /reload re-emits session_start with the same cwd.
The instance-swapping is dead generality — the extension simply does not learn the cwd until session_start.
This is Phase 4 Step 1 (Track A: Injection foundation) from docs/architecture/architecture.md.
It is behavior-preserving and unblocks the session-split work in Steps 6-7.
Goals
- Inject one
PermissionManagerinstance intoPermissionSession, constructed inindex.ts. - Add
PermissionManager.configureForCwd(cwd)that rebuilds itsFilePolicyLoaderfor the cwd-derived config paths and clears the resolved-permissions cache. resetForNewSession(ctx)callsmanager.configureForCwd(ctx.cwd)once;reload()calls it with the current (unchanged) cwd, preserving today's refresh semantics.- Remove
createPermissionManagerForCwd(and its sole helperderivePiProjectPaths) — the cwd→paths derivation moves onto a thin pure helper owned bypermission-manager.ts. - Make the injected dependency a narrow interface so the test mock needs no
as unknown as PermissionManagercast. - Behavior-preserving: no observable change to permission decisions in production.
Non-Goals
- Unifying the session's
PermissionManagerwithruntime.permissionManager(the split-brain the RPC/command/service path reads from). That split is intentional here and is fixed in Step 4 (#337, dissolveExtensionRuntime). - Removing the belt-and-suspenders
reload()rebuild (theFilePolicyLoaderalready does mtime-based cache invalidation). The issue flags this for Step 4, not here. - Touching
permissions-service.ts,permission-event-rpc.ts, orconfig-modal.tswiring. - Any
PermissionSessiongod-object decomposition (Steps 6-8).
Background
Relevant modules:
src/permission-session.ts— the god object. Fieldprivate permissionManager: PermissionManageris assigned bycreatePermissionManagerForCwd(...)in the constructor (cwdundefined),resetForNewSession(cwdctx.cwd), andreload(cwdthis.context?.cwd). It uses the manager forcheckPermission,getToolPermission,getConfigIssues,getPolicyCacheStamp.src/permission-manager.ts—PermissionManagerowns aPolicyLoader(defaultFilePolicyLoader) and aresolvedPermissionsCache: Map. Constructor:this.loader = options.policyLoader ?? new FilePolicyLoader(options).src/runtime.ts— definesderivePiProjectPaths(cwd)andcreatePermissionManagerForCwd(agentDir, cwd). The latter is called by the session (3×) and bycreateExtensionRuntime()(1×, for the separateruntime.permissionManager).src/policy-loader.ts—FilePolicyLoaderreads config fromglobalConfigPath/projectGlobalConfigPath/projectAgentsDir/agentsDir, with mtime-based caching.PolicyLoaderOptionscarries those paths.src/config-paths.ts—getGlobalConfigPath(agentDir),getProjectConfigPath(cwd).src/index.ts— the composition root. Constructsruntime(which holdsruntime.permissionManager) andnew PermissionSession(runtime, logger, forwarding, runtimeDeps).
Constraints from AGENTS.md / package skill:
- DIP: inject the new collaborator even though the class still constructs others internally (existing internal construction is the smell being removed).
- Use a narrow interface type — not the concrete class — for the injected collaborator, so test mocks need no cast (concrete class types leak private fields to TypeScript's structural checker).
- Do not read
process.env/getAgentDir()inside library functions — accept the value (hereagentDir) as a parameter. The currentcreatePermissionManagerForCwdpartially honors this; the new derivation honors it fully. - Keep schema/example/docs aligned only if config surface changes — it does not here.
Why the split-brain stays
runtime.permissionManager (read by the RPC check, config-modal, and LocalPermissionsService) is a different instance from the one PermissionSession builds (Finding 3 in the roadmap).
runtime.permissionManager is constructed with cwd undefined and never reconfigured, so it stays global-only; the session's manager is cwd-scoped.
This Step keeps that split exactly as-is — Step 4 (#337) points all consumers at the same manager.
Unifying them now would change what the RPC/command/service path resolves (global-only → cwd-scoped), which is out of scope for a behavior-preserving Step 1.
Design Overview
configureForCwd and the cwd→paths derivation
PermissionManager gains an agentDir option and a configureForCwd method.
The cwd→PolicyLoaderOptions derivation becomes a thin pure helper owned by permission-manager.ts (replacing derivePiProjectPaths + the body of createPermissionManagerForCwd):
function derivePolicyLoaderOptions(
agentDir: string,
cwd: string | undefined | null,
): PolicyLoaderOptions {
return {
globalConfigPath: getGlobalConfigPath(agentDir),
agentsDir: join(agentDir, "agents"),
projectGlobalConfigPath: cwd ? getProjectConfigPath(cwd) : undefined,
projectAgentsDir: cwd ? join(cwd, ".pi", "agent", "agents") : undefined,
};
}
Note: this sets agentsDir explicitly from agentDir.
Today createPermissionManagerForCwd leaves agentsDir unset, so FilePolicyLoader falls back to join(getAgentDir(), "agents") — a hidden getAgentDir() env read.
In production agentDir === getAgentDir(), so the value is identical; deriving it from the passed agentDir is production-behavior-preserving and removes the hidden env dependency (and makes the new unit test deterministic under a temp agentDir).
The manager stores agentDir and rebuilds its loader on demand:
export interface PermissionManagerOptions extends PolicyLoaderOptions {
policyLoader?: PolicyLoader;
agentDir?: string;
}
constructor(options: PermissionManagerOptions = {}) {
this.agentDir = options.agentDir;
this.loader =
options.policyLoader ??
new FilePolicyLoader(
options.agentDir !== undefined
? derivePolicyLoaderOptions(options.agentDir, undefined)
: options,
);
}
configureForCwd(cwd: string | undefined | null): void {
if (this.agentDir !== undefined) {
this.loader = new FilePolicyLoader(
derivePolicyLoaderOptions(this.agentDir, cwd),
);
}
this.resolvedPermissionsCache.clear();
}
- Construction with
{ agentDir }yields the global-only loader — identical to today'screatePermissionManagerForCwd(agentDir, undefined). configureForCwd(cwd)re-derives the loader for the cwd and clears the cache.- When
agentDiris undefined (test managers built with an injectedpolicyLoaderor explicit paths viacreateManager),configureForCwdonly clears the cache and leaves the injected loader intact — those tests never call it, but the no-op keeps the contract safe.
Narrow injected interface
PermissionSession depends on a narrow interface — the five methods it actually uses — not the concrete class.
This is the seam that lets the test mock drop its cast.
// permission-manager.ts
export interface ScopedPermissionManager {
configureForCwd(cwd: string | undefined | null): void;
checkPermission(
toolName: string,
input: unknown,
agentName?: string,
sessionRules?: Ruleset,
): PermissionCheckResult;
getToolPermission(toolName: string, agentName?: string): PermissionState;
getConfigIssues(agentName?: string): string[];
getPolicyCacheStamp(agentName?: string): string;
}
export class PermissionManager implements ScopedPermissionManager { … }
ISP check: PermissionSession is the sole consumer and uses all five members together (it owns the manager's per-session lifecycle: configure on reset/reload, query on demand), so one cohesive interface is correct here — not over-fragmented.
getComposedConfigRules and getResolvedPolicyPaths are deliberately excluded: only the runtime.permissionManager path uses those, and that path keeps the concrete PermissionManager type.
Consumer call site (index.ts)
const runtime = createExtensionRuntime();
// ... existing wiring ...
const sessionManager = new PermissionManager({ agentDir: runtime.agentDir });
const session = new PermissionSession(
runtime,
createSessionLogger(runtime),
new ForwardingManager(runtime.subagentSessionsDir, forwarder, subagentRegistry),
sessionManager,
{ /* runtimeDeps unchanged */ },
);
sessionManager is global-only at construction; lifecycle.handleSessionStart → session.resetForNewSession(ctx) → sessionManager.configureForCwd(ctx.cwd) scopes it once.
This is Tell-Don't-Ask: the session tells the manager to reconfigure rather than rebuilding it.
Extracted-module upstream check
derivePolicyLoaderOptions lives in permission-manager.ts and imports only getGlobalConfigPath / getProjectConfigPath (config-paths.ts) and join (node:path).
config-paths.ts does not import permission-manager.ts, so there is no import cycle — unlike keeping the helper in runtime.ts (which imports permission-manager.ts).
No output-argument mutation or reverse-search patterns are carried over; the helper is a pure value producer.
Edge cases
cwdnull/undefined/empty-string → global-only loader (matchesderivePiProjectPaths' falsy guard).reload()before activation (this.context === null) →configureForCwd(undefined)→ global-only; same as today'screatePermissionManagerForCwd(agentDir, undefined).- Resolved-permissions cache:
configureForCwdclears it so a config change between sessions is observed even when mtimes look stale.
Module-Level Changes
src/permission-manager.ts- Add
agentDir?: stringtoPermissionManagerOptions; storethis.agentDir. - Add the
derivePolicyLoaderOptions(agentDir, cwd)pure helper (importsgetGlobalConfigPath,getProjectConfigPath,join). - Constructor: derive the loader from
agentDirwhen provided and nopolicyLoader. - Add
configureForCwd(cwd)method. - Add and export the
ScopedPermissionManagerinterface;class PermissionManager implements ScopedPermissionManager.
- Add
src/permission-session.ts- Add constructor param
permissionManager: ScopedPermissionManager(inserted afterforwarding, beforeruntimeDeps); field becomesprivate readonly permissionManager: ScopedPermissionManager. - Remove
import { createPermissionManagerForCwd } from "./runtime"and the constructor body that builds the manager. resetForNewSession(ctx): replace the rebuild withthis.permissionManager.configureForCwd(ctx.cwd).reload(): replace the rebuild withthis.permissionManager.configureForCwd(this.context?.cwd).- Update the
type { PermissionManager }import totype { ScopedPermissionManager }.
- Add constructor param
src/index.ts- Construct
const sessionManager = new PermissionManager({ agentDir: runtime.agentDir })and pass it intonew PermissionSession(...). PermissionManageris already imported indirectly? No — add thePermissionManagerimport (the class) for the explicit construction.
- Construct
src/runtime.tscreateExtensionRuntime: replacecreatePermissionManagerForCwd(agentDir, undefined)withnew PermissionManager({ agentDir }).- Delete
createPermissionManagerForCwdandderivePiProjectPaths. - Remove the now-unused
getProjectConfigPathimport (keepgetGlobalConfigPath, still used bysaveExtensionConfig/logResolvedConfigPaths).
test/permission-manager-unified.test.ts- Add a
configureForCwd/agentDirdescribe block (filesystem-backed via a tempagentDir).
- Add a
test/permission-session.test.ts- Remove
vi.mock("../src/runtime")and themockCreatePermissionManagerForCwdhoisted stub. makePermissionManagerreturns aScopedPermissionManager(addconfigureForCwd: vi.fn(), dropgetComposedConfigRules/getResolvedPolicyPathsand theas unknown as PermissionManagercast).createSessionbuilds a manager mock and injects it.resetForNewSessiontest: assertpm.configureForCwdcalled withctx.cwd(was:createPermissionManagerForCwdcalled withagentDir,cwd).reloadtest: assertpm.configureForCwdcalled with the current context cwd.
- Remove
test/runtime.test.ts- Remove the
createPermissionManagerForCwdandderivePiProjectPathsimports and their twodescribeblocks. - Remove the now-unused
getProjectConfigPathimport if it is used only by those blocks.
- Remove the
No symbol referenced from these removed exports survives elsewhere (grep confirms createPermissionManagerForCwd / derivePiProjectPaths appear only in runtime.ts, permission-session.ts, permission-session.test.ts, runtime.test.ts).
The package skill (.pi/skills/package-pi-permission-system/SKILL.md) does not reference either symbol.
No doc updates required: docs/architecture/architecture.md references createPermissionManagerForCwd only in the descriptive Step 1 narrative (a record of intent, not a stale code reference); the constructibility metrics table is a snapshot, not a live count.
Test Impact Analysis
- New unit tests enabled by the change:
PermissionManager.configureForCwdis now directly testable: constructnew PermissionManager({ agentDir }), write a global config and a cwd-scoped project config, assert thatconfigureForCwd(cwd)makes the project policy take effect (last-match-wins) and thatconfigureForCwd(undefined)reverts to global-only — proving both the loader rebuild and the cache clear.PermissionSessionbecomes constructable with a plain mock collaborator (no module mock, no cast), so its delegation tests assert directly on the injected double.
- Tests that become redundant:
runtime.test.ts'screatePermissionManagerForCwdandderivePiProjectPathsblocks — the function under test is deleted; its behavior is covered by the newconfigureForCwdunit tests at the layer that now owns the derivation.
- Tests that must stay as-is:
permission-session.test.tsdelegation tests (checkPermission,getToolPermission,resolve, session-rules, lifecycle) — they exercise the session's delegation contract, which is unchanged; only their fixture wiring (inject vs. mock-factory) moves.permission-manager-unified.test.tsexisting tests using thecreateManagerharness (explicitglobalConfigPath/agentsDir, noagentDir) — unaffected; they hit the non-agentDirconstructor branch.
TDD Order
-
Add
configureForCwd+agentDirtoPermissionManager—feat:- Red: in
test/permission-manager-unified.test.ts, add tests:{ agentDir }construction reads global config fromgetGlobalConfigPath(agentDir);configureForCwd(cwd)applies project config fromgetProjectConfigPath(cwd)and clears the cache;configureForCwd(undefined)reverts to global-only. - Green: add the
agentDiroption, thederivePolicyLoaderOptionshelper, theconfigureForCwdmethod, and theScopedPermissionManagerinterface withimplements. - Purely additive — no existing caller changes.
Run
pnpm run checkand the manager test file. - Note:
ScopedPermissionManagergains its production consumer in Step 2; that is fine within the same plan (not a speculative export). - Commit:
feat: add PermissionManager.configureForCwd and agentDir option.
- Red: in
-
Inject the manager into
PermissionSession—refactor:- This is a coupled step: the constructor-signature change has a single production call site (
index.ts) and the test helper (createSession), so the session change, theindex.tsupdate, and thepermission-session.test.tsupdate must land together. - Red: update
permission-session.test.ts— removevi.mock("../src/runtime")andmockCreatePermissionManagerForCwd;makePermissionManagerreturns aScopedPermissionManager(addconfigureForCwd, drop the two unused methods and the cast);createSessioninjects the manager; rewrite theresetForNewSessionandreloadassertions to checkpm.configureForCwd. - Green: change the
PermissionSessionconstructor to accept and store the injectedScopedPermissionManager; remove thecreatePermissionManagerForCwdimport and the three internal builds;resetForNewSession/reloadcallconfigureForCwd. Updateindex.tsto constructnew PermissionManager({ agentDir: runtime.agentDir })and inject it. - Run
pnpm run check(shared-interface change) and the full suite. - Commit:
refactor: inject PermissionManager into PermissionSession.
- This is a coupled step: the constructor-signature change has a single production call site (
-
Remove the
createPermissionManagerForCwdfactory —refactor:- Now that the session no longer calls it, point
createExtensionRuntimeatnew PermissionManager({ agentDir }), then deletecreatePermissionManagerForCwdandderivePiProjectPathsand theirruntime.test.tsblocks; drop the unusedgetProjectConfigPathimports. - Run
pnpm run check, the full suite, andpnpm fallow dead-codeto confirm no orphaned exports. - Commit:
refactor: remove createPermissionManagerForCwd factory.
- Now that the session no longer calls it, point
Risks and Mitigations
- Risk: the
agentsDirderivation change (explicit vs. env-default) alters which agents directory is read. Mitigation: in productionagentDir === getAgentDir(), sojoin(agentDir, "agents")equals the former default; the change only removes a hidden env read and is covered by the new deterministic unit test. - Risk: a consumer relies on the session and
runtime.permissionManagerbeing the same instance. Mitigation: they are already different instances today; this Step preserves that exactly (split-brain fix is Step 4 / #337). Called out in Non-Goals. - Risk: dropping the
as unknown as PermissionManagercast surfaces incomplete mock return values that the cast previously masked. Mitigation: the session-test mocks already return fullPermissionCheckResultshapes via explicit literals; the narrow interface only requires the five methods, all stubbed. - Risk: removing
createPermissionManagerForCwdbreaks an importer not found by grep. Mitigation: grep acrosssrc/andtest/is clean; Step 3 runspnpm fallow dead-codeas a backstop.
Open Questions
- Should the session's manager and
runtime.permissionManagerbe unified (one instance for gates, RPC, command, and service)? Deferred to Step 4 (#337); tracked there, not here. - Should
reload()'s loader rebuild be dropped in favor ofFilePolicyLoader's mtime invalidation alone? Deferred to Step 4 per the issue note; kept here for behavior preservation.