Skip to content

Commit 50111c1

Browse files
committed
feat: US-068 - Fix Pi package asset discovery after SDK import bootstrap
1 parent 9032bf5 commit 50111c1

8 files changed

Lines changed: 214 additions & 6 deletions

File tree

.agent/contracts/node-permissions.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ When a kernel is available, the kernel's `wrapFileSystem` deny-by-default permis
8484
### Requirement: Projected Node-Modules Paths MUST Be Read-Only
8585
When driver-managed node_modules overlay/projection is active (including always-on `/app/node_modules` overlay), projected sandbox module paths (including `/app/node_modules` and descendants) MUST be treated as read-only runtime state.
8686

87+
#### Scenario: Host-absolute package asset reads stay inside the projected closure
88+
- **WHEN** sandboxed package code derives a host-absolute path for its own projected module files from `import.meta.url`, `__filename`, or `realpath()` and then reads sibling assets such as `package.json`, `README.md`, or bundled theme/template files
89+
- **THEN** those read operations MUST succeed when the canonical path still resolves inside the configured projected `node_modules` closure
90+
- **AND** the same host-absolute projected paths MUST remain read-only for write, rename, mkdir, unlink, and rmdir operations
91+
8792
#### Scenario: Sandboxed write targets projected module file
8893
- **WHEN** sandboxed code attempts `writeFile`, `unlink`, or `rename` for a path under projected `/app/node_modules`
8994
- **THEN** the operation MUST be denied with `EACCES` regardless of broader filesystem allow rules
@@ -102,4 +107,3 @@ Node-modules overlay access SHALL NOT grant implicit read access to non-overlay
102107
#### Scenario: Overlay availability does not auto-allow unrelated host reads
103108
- **WHEN** always-on `/app/node_modules` overlay is available and sandboxed code attempts to read `/etc/passwd` without explicit fs permission allowance
104109
- **THEN** runtime MUST deny the read with `EACCES`
105-

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@
147147
- bridge exports that userland constructs with `new` must be assigned as constructable function properties, not object-literal method shorthands; shorthand methods like `createReadStream() {}` are not constructable and vendored fs coverage calls `new fs.createReadStream(...)`
148148
- `/proc/sys/kernel/hostname` conformance hits both kernel-backed and standalone NodeRuntime paths; a procfs fix that only lands in the kernel layer still leaves `createTestNodeRuntime()` fs/FileHandle coverage red
149149
- require-transformed ESM must not rely on the CommonJS wrapper's `__filename` / `__dirname` parameter names; keep wrapper internals on private names, synthesize local CJS bindings only for plain CommonJS sources, and compute transformed `import.meta.url` from `pathToFileURL(__secureExecFilename).href`
150+
- `ModuleAccessFileSystem` must treat host-absolute package asset paths derived from `import.meta.url`, `__filename`, or `realpath()` as part of the same read-only projected `node_modules` closure when they canonicalize inside the configured overlay; Pi and similar SDKs walk to sibling `package.json`/README/theme assets that way
150151

151152
## Virtual Kernel Architecture
152153

packages/nodejs/src/module-access.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ function collectOverlayAllowedRoots(hostNodeModulesRoot: string): string[] {
153153
*/
154154
export class ModuleAccessFileSystem implements VirtualFileSystem {
155155
private readonly baseFileSystem?: VirtualFileSystem;
156+
private readonly configuredNodeModulesRoot: string;
156157
private readonly hostNodeModulesRoot: string | null;
157158
private readonly overlayAllowedRoots: string[];
158159

@@ -169,6 +170,7 @@ export class ModuleAccessFileSystem implements VirtualFileSystem {
169170

170171
const cwd = path.resolve(cwdInput);
171172
const nodeModulesPath = path.join(cwd, "node_modules");
173+
this.configuredNodeModulesRoot = nodeModulesPath;
172174
try {
173175
this.hostNodeModulesRoot = fsSync.realpathSync(nodeModulesPath);
174176
this.overlayAllowedRoots = collectOverlayAllowedRoots(this.hostNodeModulesRoot);
@@ -204,7 +206,10 @@ export class ModuleAccessFileSystem implements VirtualFileSystem {
204206
}
205207

206208
private isReadOnlyProjectionPath(virtualPath: string): boolean {
207-
return startsWithPath(virtualPath, SANDBOX_NODE_MODULES_ROOT);
209+
return (
210+
startsWithPath(virtualPath, SANDBOX_NODE_MODULES_ROOT) ||
211+
this.isProjectedHostPath(virtualPath)
212+
);
208213
}
209214

210215
private shouldMergeBase(pathValue: string): boolean {
@@ -234,6 +239,35 @@ export class ModuleAccessFileSystem implements VirtualFileSystem {
234239
return path.join(this.hostNodeModulesRoot, ...relative.split("/"));
235240
}
236241

242+
private isProjectedHostPath(pathValue: string): boolean {
243+
if (!path.isAbsolute(pathValue)) {
244+
return false;
245+
}
246+
247+
const resolved = path.resolve(pathValue);
248+
if (isWithinPath(resolved, this.configuredNodeModulesRoot)) {
249+
return true;
250+
}
251+
if (
252+
this.hostNodeModulesRoot &&
253+
isWithinPath(resolved, this.hostNodeModulesRoot)
254+
) {
255+
return true;
256+
}
257+
return this.overlayAllowedRoots.some((root) => isWithinPath(resolved, root));
258+
}
259+
260+
private getOverlayHostPathCandidate(pathValue: string): string | null {
261+
const overlayPath = this.overlayHostPathFor(pathValue);
262+
if (overlayPath) {
263+
return overlayPath;
264+
}
265+
if (!this.isProjectedHostPath(pathValue)) {
266+
return null;
267+
}
268+
return path.resolve(pathValue);
269+
}
270+
237271
prepareOpenSync(pathValue: string, flags: number): boolean {
238272
const virtualPath = normalizeOverlayPath(pathValue);
239273
if (this.isReadOnlyProjectionPath(virtualPath)) {
@@ -274,7 +308,7 @@ export class ModuleAccessFileSystem implements VirtualFileSystem {
274308
);
275309
}
276310

277-
const hostPath = this.overlayHostPathFor(virtualPath);
311+
const hostPath = this.getOverlayHostPathCandidate(virtualPath);
278312
if (!hostPath) {
279313
return null;
280314
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { existsSync } from "node:fs";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import {
6+
NodeRuntime,
7+
allowAll,
8+
createNodeDriver,
9+
createNodeRuntimeDriverFactory,
10+
} from "../../src/index.js";
11+
12+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
13+
const SECURE_EXEC_ROOT = path.resolve(__dirname, "../..");
14+
const PI_CONFIG_ENTRY = path.resolve(
15+
SECURE_EXEC_ROOT,
16+
"node_modules/@mariozechner/pi-coding-agent/dist/config.js",
17+
);
18+
19+
function skipUnlessPiInstalled(): string | false {
20+
return existsSync(PI_CONFIG_ENTRY)
21+
? false
22+
: "@mariozechner/pi-coding-agent not installed";
23+
}
24+
25+
function parseLastJsonLine(stdout: string): Record<string, unknown> {
26+
const line = stdout
27+
.trim()
28+
.split("\n")
29+
.map((entry) => entry.trim())
30+
.filter(Boolean)
31+
.at(-1);
32+
33+
if (!line) {
34+
throw new Error(`sandbox produced no JSON output: ${JSON.stringify(stdout)}`);
35+
}
36+
37+
return JSON.parse(line) as Record<string, unknown>;
38+
}
39+
40+
describe.skipIf(skipUnlessPiInstalled())("Pi SDK bootstrap in NodeRuntime", () => {
41+
let runtime: NodeRuntime | undefined;
42+
43+
afterEach(async () => {
44+
await runtime?.terminate();
45+
runtime = undefined;
46+
});
47+
48+
it("resolves Pi package assets from the package root after config bootstrap", async () => {
49+
const stdout: string[] = [];
50+
const stderr: string[] = [];
51+
52+
runtime = new NodeRuntime({
53+
onStdio: (event) => {
54+
if (event.channel === "stdout") stdout.push(event.message);
55+
if (event.channel === "stderr") stderr.push(event.message);
56+
},
57+
systemDriver: createNodeDriver({
58+
moduleAccess: { cwd: SECURE_EXEC_ROOT },
59+
permissions: allowAll,
60+
}),
61+
runtimeDriverFactory: createNodeRuntimeDriverFactory(),
62+
});
63+
64+
const result = await runtime.exec(
65+
`
66+
const fs = require("node:fs");
67+
(async () => {
68+
try {
69+
const config = await import(${JSON.stringify(PI_CONFIG_ENTRY)});
70+
const packageJsonPath = config.getPackageJsonPath();
71+
const readmePath = config.getReadmePath();
72+
const themesDir = config.getThemesDir();
73+
console.log(JSON.stringify({
74+
ok: true,
75+
appName: config.APP_NAME,
76+
version: config.VERSION,
77+
packageJsonPath,
78+
packageJsonExists: fs.existsSync(packageJsonPath),
79+
readmePath,
80+
readmeExists: fs.existsSync(readmePath),
81+
themesDir,
82+
themesDirExists: fs.existsSync(themesDir),
83+
}));
84+
} catch (error) {
85+
console.log(JSON.stringify({
86+
ok: false,
87+
error: String(error),
88+
stack: error && typeof error === "object" && "stack" in error ? error.stack : undefined,
89+
}));
90+
process.exitCode = 1;
91+
}
92+
})();
93+
`,
94+
{ cwd: SECURE_EXEC_ROOT },
95+
);
96+
97+
expect(result.code, stderr.join("")).toBe(0);
98+
99+
const payload = parseLastJsonLine(stdout.join(""));
100+
expect(payload.ok).toBe(true);
101+
expect(payload.appName).toBe("pi");
102+
expect(payload.version).toBe("0.60.0");
103+
expect(payload.packageJsonExists).toBe(true);
104+
expect(String(payload.packageJsonPath)).toMatch(
105+
/node_modules\/@mariozechner\/pi-coding-agent\/package\.json$/,
106+
);
107+
expect(String(payload.packageJsonPath)).not.toMatch(/\/dist\/package\.json$/);
108+
expect(payload.readmeExists).toBe(true);
109+
expect(String(payload.readmePath)).toMatch(
110+
/node_modules\/@mariozechner\/pi-coding-agent\/README\.md$/,
111+
);
112+
expect(payload.themesDirExists).toBe(true);
113+
expect(String(payload.themesDir)).toMatch(
114+
/node_modules\/@mariozechner\/pi-coding-agent\/dist\/modes\/interactive\/theme$/,
115+
);
116+
});
117+
});

packages/secure-exec/tests/cli-tools/pi-sdk-real-provider.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,8 @@ describe.skipIf(skipReason)('Pi SDK real-provider E2E (sandbox VM)', () => {
196196
const error = String(payload.error ?? '');
197197
expect(payload.ok).toBe(false);
198198
expect(error).toContain('@mariozechner/pi-coding-agent');
199-
expect(error).toContain('dist/config.js');
200-
expect(error).toContain("Identifier '__filename' has already been declared");
199+
expect(error).not.toContain("Identifier '__filename' has already been declared");
200+
expect(error).not.toContain('/dist/package.json');
201201
},
202202
55_000,
203203
);

packages/secure-exec/tests/runtime-driver/node/module-access.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ async function writePackage(
6262
options: {
6363
main?: string;
6464
dependencies?: Record<string, string>;
65+
packageJsonFields?: Record<string, unknown>;
6566
files: PackageFiles;
6667
},
6768
): Promise<string> {
@@ -75,6 +76,7 @@ async function writePackage(
7576
name: packageName,
7677
main: options.main ?? "index.js",
7778
dependencies: options.dependencies,
79+
...options.packageJsonFields,
7880
};
7981
await writeFile(
8082
path.join(packageDir, "package.json"),
@@ -216,6 +218,44 @@ describe("moduleAccess overlay", () => {
216218
expect(capture.stdout()).toBe("42:host-file\n");
217219
});
218220

221+
it("allows sync fs access to host absolute paths within the projected module tree", async () => {
222+
const projectDir = await createTempProject();
223+
tempDirs.push(projectDir);
224+
const entryPath = path.join(
225+
projectDir,
226+
"node_modules",
227+
"asset-probe",
228+
"dist",
229+
"config.js",
230+
);
231+
const packageJsonPath = path.join(
232+
projectDir,
233+
"node_modules",
234+
"asset-probe",
235+
"package.json",
236+
);
237+
238+
await writePackage(projectDir, "asset-probe", {
239+
files: {
240+
"dist/config.js": "module.exports = { ok: true };",
241+
},
242+
});
243+
244+
const driver = createModuleAccessDriver({
245+
moduleAccess: {
246+
cwd: projectDir,
247+
},
248+
});
249+
const filesystem = driver.filesystem!;
250+
251+
expect(await filesystem.exists(entryPath)).toBe(true);
252+
expect(await filesystem.realpath(entryPath)).toBe(entryPath);
253+
expect(await filesystem.exists(packageJsonPath)).toBe(true);
254+
expect(await filesystem.readTextFile(packageJsonPath)).toContain(
255+
'"name": "asset-probe"',
256+
);
257+
});
258+
219259
it("keeps projected node_modules read-only", async () => {
220260
const projectDir = await createTempProject();
221261
tempDirs.push(projectDir);

scripts/ralph/prd.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1141,7 +1141,7 @@
11411141
"Typecheck passes"
11421142
],
11431143
"priority": 53.2,
1144-
"passes": false,
1144+
"passes": true,
11451145
"notes": "Next blocker exposed by US-067. Exact repro as of 2026-03-26: after the `__filename` collision fix, importing `@mariozechner/pi-coding-agent@0.60.0` through NodeRuntime reaches `dist/config.js` and then fails with `ENOENT: no such file or directory, open '<repo>/node_modules/.pnpm/.../@mariozechner/pi-coding-agent/dist/package.json'`. Proposed unblock path: inspect how the sandboxed FS / path resolution is handling Pi's package-root detection and why `getPackageDir()` stops at `dist/` instead of the package root."
11461146
},
11471147
{

scripts/ralph/progress.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
## Codebase Patterns
2+
- `ModuleAccessFileSystem` must allow read-only host-absolute package asset paths derived from `import.meta.url`, `__filename`, or `realpath()` when they canonicalize back into the configured `node_modules` overlay; real SDKs like Pi walk to sibling `package.json`, README, and theme/template assets that way.
23
- Real-provider tool-integration coverage should stay opt-in via a dedicated env flag (for example `SECURE_EXEC_PI_REAL_PROVIDER_E2E=1`) and load secrets at runtime from `process.env` with `~/misc/env.txt` as a local fallback; never commit credentials or silently swap the live provider path back to a mock redirect.
34
- Kernel-backed `/proc/self` tests must run through a process-scoped runtime such as `createNodeRuntime()` + `kernel.spawn('node', ...)` (or `createProcessScopedFileSystem`); raw kernel `vfs` calls have no caller PID context, and standalone NodeRuntime intentionally keeps `/proc/self/environ` denied.
45
- `AF_UNIX` sockets are local IPC, not host networking: kernel `SocketTable` bind/listen/connect for path sockets must stay fully in-kernel, bypass `permissions.network`, and use only the listener registry plus VFS socket-file state.
@@ -733,3 +734,14 @@
733734
- `import.meta.url` compatibility cannot be implemented by rewriting to the wrapper `__filename`; it must stay a file URL string, which in this runtime means deriving it from `pathToFileURL(__secureExecFilename).href`.
734735
- Once the duplicate-binding bootstrap bug is removed, Pi v0.60.0 immediately hits package-root discovery (`dist/package.json` ENOENT), so the next investigation should focus on sandboxed filesystem/path resolution rather than provider traffic.
735736
---
737+
## [2026-03-26 13:44 PDT] - US-068
738+
- Fixed `ModuleAccessFileSystem` so host-absolute package asset paths inside the projected `node_modules` closure stay readable and read-only after `import.meta.url` / `__filename` / `realpath()` resolution, instead of disappearing outside the overlay.
739+
- Added deterministic coverage for host-absolute projected-path reads in `packages/secure-exec/tests/runtime-driver/node/module-access.test.ts`, added a Pi-specific bootstrap smoke in `packages/secure-exec/tests/cli-tools/pi-sdk-bootstrap.test.ts`, and updated the opt-in real-provider harness so it no longer expects the resolved `dist/package.json` blocker.
740+
- Verified the original Pi blocker is gone: importing `packages/secure-exec/node_modules/@mariozechner/pi-coding-agent/dist/config.js` through `NodeRuntime` now resolves package metadata/assets from the package root, and a follow-up manual probe of `dist/index.js` now advances to the next concrete blocker, `Cannot find module '#ansi-styles'` from `chalk/source/index.js`.
741+
- Verified with `pnpm --filter @secure-exec/nodejs build`, `pnpm vitest run packages/secure-exec/tests/cli-tools/pi-sdk-bootstrap.test.ts`, `pnpm vitest run packages/secure-exec/tests/runtime-driver/node/module-access.test.ts -t 'allows sync fs access to host absolute paths within the projected module tree'`, `pnpm vitest run packages/secure-exec/tests/cli-tools/pi-sdk-real-provider.test.ts`, `pnpm --filter @secure-exec/nodejs check-types`, `pnpm --filter @secure-exec/core check-types`, and `pnpm --filter secure-exec check-types`.
742+
- Files changed: `CLAUDE.md`, `.agent/contracts/node-permissions.md`, `packages/nodejs/src/module-access.ts`, `packages/secure-exec/tests/runtime-driver/node/module-access.test.ts`, `packages/secure-exec/tests/cli-tools/pi-sdk-bootstrap.test.ts`, `packages/secure-exec/tests/cli-tools/pi-sdk-real-provider.test.ts`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`
743+
- **Learnings for future iterations:**
744+
- Pi-style package bootstraps can escape `/root/node_modules` string paths even when the module itself was projected correctly; the overlay has to recognize host-absolute paths that canonicalize back into the configured package roots.
745+
- The quickest proof that a projected-package asset bug is fixed is a direct `dist/config.js` smoke through `NodeRuntime`; it isolates package-root detection before later SDK dependencies muddy the failure.
746+
- After this fix, the next Pi SDK blocker is no longer filesystem discovery: importing `dist/index.js` now dies in Chalk import-map resolution (`#ansi-styles`), so follow-up work should stay in module resolution rather than package asset access.
747+
---

0 commit comments

Comments
 (0)