12 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 93 | Infrastructure read bypass fails in local development checkout |
Fix infrastructure read bypass in local development checkout
Problem Statement
discoverGlobalNodeModulesRoot() walks up from the extension's own import.meta.url to find a node_modules ancestor directory.
When the extension is globally installed this works — the walk finds e.g. /opt/homebrew/lib/node_modules.
When running from a local development checkout (e.g. /Users/chris/development/pi/pi-permission-system), there is no node_modules ancestor, so the function returns null.
This causes piInfrastructureDirs to omit the global node_modules root, and skill file reads trigger unexpected external-directory permission prompts.
Goals
- Make
discoverGlobalNodeModulesRoot()find the globalnode_modulesroot even when the extension itself is not installed inside it. - Eliminate spurious external-directory prompts for skill file reads during local development.
- Keep the existing walk-up-from-self as the primary strategy (zero subprocess overhead for production installs).
Non-Goals
- Upstream API request for
getGlobalNpmRoot()— useful but orthogonal (tracked in #48 discussion). - Changing
piInfrastructureReadPathsconfig semantics — the manual workaround stays as-is. - Multi-package-manager detection — Pi defaults to
npmfor package installation; users with customnpmCommandinsettings.jsoncan usepiInfrastructureReadPathsfor non-npm global roots.
Background
Relevant modules
src/external-directory.ts—discoverGlobalNodeModulesRoot()(the broken function),isPiInfrastructureRead().src/runtime.ts—createExtensionRuntime()callsdiscoverGlobalNodeModulesRoot()once at construction and stores the result inpiInfrastructureDirs.src/handlers/tool-call.ts— combinespiInfrastructureDirswithconfig.piInfrastructureReadPathsand passes them toisPiInfrastructureRead().
Permission surface
special.external_directory — the external-directory gate for path-bearing tools.
Existing workaround
Users can add the global node_modules path to piInfrastructureReadPaths in their config, but this is non-obvious and machine-specific.
Why the original createRequire fallback plan was wrong
The initial plan proposed using createRequire(import.meta.url) to resolve @mariozechner/pi-coding-agent and walk up from that path.
This fails because from a dev checkout, createRequire resolves to the local node_modules/.pnpm/... (the devDependency copy), not the global root.
Walking up from that path finds pnpm's internal node_modules, not /opt/homebrew/lib/node_modules.
Verified empirically:
import.meta.resolve('@mariozechner/pi-coding-agent')
→ file:///.../pi-permission-system/node_modules/.pnpm/@mariozechner+pi-coding-agent@0.72.1_.../node_modules/@mariozechner/pi-coding-agent/dist/index.js
Walk-up finds: .../node_modules/.pnpm/.../node_modules (WRONG — pnpm internal)
Need: /opt/homebrew/lib/node_modules (RIGHT — global root)
Design Overview
Strategy: npm root -g subprocess fallback
When the walk-up-from-self strategy returns null (no node_modules ancestor), fall back to npm root -g to discover the global node_modules root.
export function discoverGlobalNodeModulesRoot(
fromUrl = import.meta.url,
): string | null {
// Strategy 1: walk up from own location (covers global installs).
const fromSelf = walkUpToNodeModules(fromUrl);
if (fromSelf) return fromSelf;
// Strategy 2: ask npm for the global root (covers dev checkouts).
return discoverGlobalNodeModulesViaSubprocess();
}
The walk-up loop is extracted to a private walkUpToNodeModules(fromUrl) helper.
The subprocess fallback is a separate private function with its own error handling.
Why npm root -g?
- Pi defaults to
npmfor package installation (getNpmCommand()returns{ command: "npm", args: [] }unless overridden insettings.json). Skills and extensions installed by Pi live undernpm root -g. npmis always available when Node.js is available — it ships with Node.- The subprocess only runs when the walk-up fails (dev checkout only), so production installs pay zero cost.
- The result is cached in the existing
discoverGlobalNodeModulesRoot()call (called once atcreateExtensionRuntime()construction).
Why not multi-PM detection?
Pi's config.ts has a detectInstallMethod() function with getGlobalPackageRoots() that handles npm/pnpm/yarn/bun, but those are not exported and the detection logic relies on __dirname being inside node_modules.
Replicating that detection is fragile and unnecessary:
npm root -gcovers the default Pi installation method.- Users who override
npmCommandinsettings.jsonto use pnpm/bun already have a non-default setup and can use the existingpiInfrastructureReadPathsconfig field. - The Bun binary case has no global
node_modulestree — extensions are bundled.
Subprocess implementation
function discoverGlobalNodeModulesViaSubprocess(): string | null {
try {
const result = spawnSync("npm", ["root", "-g"], {
encoding: "utf-8",
timeout: 5000,
stdio: ["ignore", "pipe", "ignore"],
});
const root = result.stdout?.trim();
if (result.status === 0 && root && existsSync(root)) {
return root;
}
return null;
} catch {
return null;
}
}
Key details:
timeout: 5000— 5 second timeout prevents hanging if npm is broken.stdio: ["ignore", "pipe", "ignore"]— only capture stdout; discard stdin and stderr.existsSync(root)— sanity-check the returned path actually exists.- All failures return
null— same graceful degradation as today.
Edge cases
- npm not installed:
spawnSyncthrowsENOENT→ caught → returnsnull. - npm root -g returns a non-existent path:
existsSynccheck → returnsnull. - Bun binary runtime: walk-up fails (virtual filesystem), npm may not be available → subprocess fails → returns
null. Acceptable — Bun binary bundles extensions. - Windows:
npm root -gworks on Windows.spawnSynchandles cross-platform. - NVM / fnm:
npm root -greturns the correct root for the active Node version. - Custom npm prefix:
npm root -grespects the configured prefix.
Module-Level Changes
src/external-directory.ts
- Extract the walk-up loop body into a private
walkUpToNodeModules(fromUrl: string): string | nullhelper. - Add private
discoverGlobalNodeModulesViaSubprocess(): string | nullfunction. - Update
discoverGlobalNodeModulesRoot()to try walk-up first, then subprocess fallback. - Add imports:
spawnSyncfromnode:child_process,existsSyncfromnode:fs.
tests/external-directory.test.ts
- Add tests for the subprocess fallback path:
- Walk-up-from-self succeeds → returns result without invoking subprocess.
- Walk-up-from-self fails,
npm root -greturns a valid path → returns that path. - Walk-up-from-self fails,
npm root -gfails → returnsnull. - Walk-up-from-self fails,
npm root -greturns a non-existent path → returnsnull.
tests/runtime.test.ts
- No changes needed — the existing mock of
discoverGlobalNodeModulesRootcovers the runtime's consumption of the return value. The new fallback logic is internal todiscoverGlobalNodeModulesRootand tested inexternal-directory.test.ts.
No changes needed
src/runtime.ts— no API change; it already callsdiscoverGlobalNodeModulesRoot()and handlesnull.src/handlers/tool-call.ts— no change; it already combinespiInfrastructureDirswith config paths.schemas/permissions.schema.json— no config field changes.config/config.example.json— no config field changes.docs/architecture/— no architecture doc describesdiscoverGlobalNodeModulesRootin detail.
TDD Order
-
test: cover
npm root -gfallback indiscoverGlobalNodeModulesRootAdd tests intests/external-directory.test.ts:- Walk-up-from-self succeeds → returns result without invoking subprocess.
- Walk-up-from-self fails, subprocess returns valid path → returns that path.
- Walk-up-from-self fails, subprocess fails (non-zero exit / throws) → returns
null. - Walk-up-from-self fails, subprocess returns non-existent path → returns
null. MockspawnSyncto control subprocess behavior without actually spawning. Commit:test: cover npm root -g fallback for global node_modules discovery
-
feat: add
npm root -gfallback todiscoverGlobalNodeModulesRootExtractwalkUpToNodeModuleshelper, adddiscoverGlobalNodeModulesViaSubprocess, wire intodiscoverGlobalNodeModulesRoot. Commit:fix: discover global node_modules root from dev checkout via npm root -g fallback -
docs: note the fallback in README The README already documents
piInfrastructureReadPathsas the manual workaround. Add a brief note that the automatic discovery now works from dev checkouts vianpm root -gfallback. Commit:docs: note npm root -g fallback for dev checkout infrastructure reads
Risks and Mitigations
| Risk | Mitigation |
|---|---|
npm root -g returns an unexpected path, widening the auto-allow set |
existsSync check validates the path exists. The auto-allow is restricted to READ_ONLY_PATH_BEARING_TOOLS via isPiInfrastructureRead. Writes are never bypassed. |
| Could this silently weaken a permission? | No. The change only affects which directories are added to piInfrastructureDirs, and only for read-only tools. The directory added is the npm global root — the same directory that production installs already auto-allow via the walk-up. |
| Subprocess hangs or is slow | 5-second timeout. Only runs when walk-up fails (dev checkout only). Production installs never hit this path. |
| npm not available (Bun binary, restricted env) | catch returns null, identical to current behavior. No regression. |
#48 rejected npm root -g |
#48 rejected it as the primary strategy because the walk-up-from-self approach was zero-cost for production. Here it's a fallback that only fires from dev checkouts where the walk-up fails. The production path is unchanged. |
Open Questions
- Should we log a debug message when the subprocess fallback is used? This would help diagnose issues but adds noise. Leaning yes — it's a dev-only path and the debug log is opt-in.