Skip to content

Commit 5c81981

Browse files
committed
feat: US-062 - Make Pi headless mode pass end-to-end with real-provider tokens
1 parent 50111c1 commit 5c81981

28 files changed

Lines changed: 1576 additions & 218 deletions

.agent/contracts/node-bridge.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ This hardening policy MUST NOT force Node stdlib globals to non-writable/non-con
7979
- **WHEN** bridge setup exposes a Node stdlib global surface (for example `process`, timers, `Buffer`, `URL`, `fetch`, or `console`)
8080
- **THEN** the bridge MUST preserve Node-compatible behavior and MUST NOT require non-writable/non-configurable descriptors for that stdlib global due to this policy alone
8181

82+
#### Scenario: Bridge exposes Node global alias
83+
- **WHEN** sandboxed code or bridged dependencies access `global`
84+
- **THEN** the bridge/runtime bootstrap MUST expose `global` as an alias of `globalThis`
85+
- **AND** Node globals such as `process` and `Buffer` MUST remain reachable through that alias
86+
8287
### Requirement: WHATWG URL Bridge Preserves Node Validation And Scalar-Value Semantics
8388
Bridge-provided `URL` and `URLSearchParams` globals SHALL preserve the Node-observable validation, coercion, and inspection behavior that vendored conformance tests assert.
8489

.agent/contracts/node-permissions.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ When driver-managed node_modules overlay/projection is active (including always-
8989
- **THEN** those read operations MUST succeed when the canonical path still resolves inside the configured projected `node_modules` closure
9090
- **AND** the same host-absolute projected paths MUST remain read-only for write, rename, mkdir, unlink, and rmdir operations
9191

92+
#### Scenario: Pnpm virtual-store dependency targets stay inside the projected closure
93+
- **WHEN** a projected package resolves a transitive dependency through pnpm virtual-store symlinks (for example package-internal `imports` such as Chalk resolving `#ansi-styles` to another package directory under `.pnpm/.../node_modules/...`)
94+
- **THEN** the module overlay MUST treat those canonical dependency paths as part of the same read-only projected closure
95+
- **AND** sandboxed reads of those host-absolute dependency files MUST succeed without granting access to unrelated host filesystem paths outside the reachable package closure
96+
9297
#### Scenario: Sandboxed write targets projected module file
9398
- **WHEN** sandboxed code attempts `writeFile`, `unlink`, or `rename` for a path under projected `/app/node_modules`
9499
- **THEN** the operation MUST be denied with `EACCES` regardless of broader filesystem allow rules

.agent/contracts/node-stdlib.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ Builtin module resolution through helper APIs MUST return builtin identifiers di
184184
- **WHEN** sandboxed code calls `createRequire("/app/entry.js").resolve("path")`
185185
- **THEN** the call MUST succeed and return a builtin identifier for `path` (for example `"path"` or `"node:path"`)
186186

187+
#### Scenario: CommonJS builtin fallback stays CommonJS-safe
188+
- **WHEN** CommonJS package code loads a built-in such as `v8` through a fallback file-loading path instead of a pre-populated cache hit
189+
- **THEN** the runtime MUST still provide CommonJS-compatible source or exports for that builtin
190+
- **AND** the loader MUST NOT hand a CommonJS `require()` path an ESM wrapper that fails on `export` syntax
191+
187192
### Requirement: Bridged Builtins Support ESM Default and Named Imports
188193
For bridged built-in modules exposed to ESM, the runtime MUST provide both default export access and named-import access for supported APIs.
189194

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
- tests that validate sandbox behavior MUST run code through the secure-exec sandbox (NodeRuntime/proc.exec()), never directly on the host
2020
- CLI tool tests (Pi, Claude Code, OpenCode) must execute inside the sandbox: Pi runs as JS in the VM, Claude Code and OpenCode spawn their binaries via the sandbox's child_process.spawn bridge
2121
- real-provider CLI/SDK tool-integration tests must stay opt-in via an explicit env flag and load credentials at runtime from exported env vars or `~/misc/env.txt`; never commit secrets or replace the live provider path with a mock redirect when the story requires real traffic
22+
- real-provider NodeRuntime CLI/tool tests that need a mutable temp worktree must pair `moduleAccess` with a real host-backed base filesystem such as `new NodeFileSystem()`; `moduleAccess` alone makes projected packages readable but leaves sandbox tools unable to touch `/tmp` working files
2223
- e2e-docker fixtures connect to real Docker containers (Postgres, MySQL, Redis, SSH/SFTP) — skip gracefully via `skipUnlessDocker()` when Docker is unavailable
2324
- interactive/PTY tests must use `kernel.openShell()` with `@xterm/headless`, not host PTY via `script -qefc`
2425
- kernel blocking-I/O regressions should be proven through `packages/core/test/kernel/kernel-integration.test.ts` using real process-owned FDs via `KernelInterface` (`fdWrite`, `flock`, `fdPollWait`) rather than only manager-level unit tests
@@ -131,6 +132,8 @@
131132
- keep it up to date when adding, removing, or significantly changing components
132133
- keep host bootstrap polyfills in `packages/nodejs/src/execution-driver.ts` aligned with isolate bootstrap polyfills in `packages/core/isolate-runtime/src/inject/require-setup.ts`; drift in shared globals like `AbortController` causes sandbox-only behavior gaps that source-level tests can miss
133134
- WHATWG globals that sandbox code touches before any bridge module loads (`TextDecoder`, `TextEncoder`, `Event`, `CustomEvent`, `EventTarget`) must be fixed in both bootstrap layers and `packages/nodejs/src/bridge/polyfills.ts`; bridge-only fixes do not change the globals seen by direct `runtime.run()` / `runtime.exec()` code
135+
- bridged `fetch()` request serialization must normalize `Headers` instances before crossing the JSON bridge; passing the host a raw `Headers` object silently drops auth and SDK-specific headers because it stringifies to `{}`
136+
- sandbox stdout/stderr write bridges must preserve Node's callback semantics even for empty writes like `process.stdout.write('', cb)`; headless CLI tools use that zero-byte callback as a flush barrier before clean exit
134137
- When a builtin or `internal/*` module needs sandbox-specific behavior but still has to work through CommonJS `require()`, add it under `packages/nodejs/src/polyfills/` and register it in `packages/nodejs/src/polyfills.ts` `CUSTOM_POLYFILL_ENTRY_POINTS`; that keeps esbuild bundling it to CJS instead of letting the isolate loader choke on raw ESM `export` syntax
135138
- vendored fs abort tests deep-freeze option bags via `common.mustNotMutateObjectDeep()`, so sandbox `AbortSignal` state must live outside writable instance properties; freezing `{ signal }` must not break later `controller.abort()`
136139
- vendored `common.mustNotMutateObjectDeep()` helpers must skip populated typed-array/DataView instances; `Object.freeze(new Uint8Array([1]))` throws before the runtime under test executes, which turns option-bag immutability coverage into a harness failure
@@ -148,6 +151,7 @@
148151
- `/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
149152
- 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`
150153
- `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
154+
- `ModuleAccessFileSystem` also has to include pnpm virtual-store dependency symlink targets reachable from projected packages; package-internal `imports` like Chalk's `#ansi-styles` resolve into those sibling `.pnpm/*/node_modules/*` targets rather than staying under the top-level package root
151155

152156
## Virtual Kernel Architecture
153157

native/v8-runtime/src/execution.rs

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,7 @@ struct ModuleResolveState {
564564
bridge_ctx: *const BridgeCallContext,
565565
/// identity_hash → resource_name for referrer lookup
566566
module_names: HashMap<NonZeroI32, String>,
567-
/// resolved_path → Global<Module> cache
567+
/// resolved_path and referrer-qualified request keys → Global<Module> cache
568568
module_cache: HashMap<String, v8::Global<v8::Module>>,
569569
}
570570

@@ -594,6 +594,10 @@ thread_local! {
594594
static PENDING_MODULE_EVALUATION: RefCell<Option<PendingModuleEvaluation>> = const { RefCell::new(None) };
595595
}
596596

597+
fn module_request_cache_key(specifier: &str, referrer_name: &str) -> String {
598+
format!("{}\0{}", referrer_name, specifier)
599+
}
600+
597601
#[cfg_attr(test, allow(dead_code))]
598602
pub fn clear_module_state() {
599603
MODULE_RESOLVE_STATE.with(|cell| {
@@ -936,12 +940,13 @@ fn extract_uncached_imports(
936940
let data = requests.get(scope, i).unwrap();
937941
let request: v8::Local<v8::ModuleRequest> = data.cast();
938942
let specifier = request.get_specifier().to_rust_string_lossy(scope);
943+
let cache_key = module_request_cache_key(&specifier, referrer_name);
939944

940-
// Skip if already cached (by specifier or resolved path)
945+
// Skip if already cached for this referrer-qualified request.
941946
let already_cached = MODULE_RESOLVE_STATE.with(|cell| {
942947
let borrow = cell.borrow();
943948
let state = borrow.as_ref().unwrap();
944-
state.module_cache.contains_key(&specifier)
949+
state.module_cache.contains_key(&cache_key)
945950
});
946951
if !already_cached {
947952
uncached.push((specifier, referrer_name.to_string()));
@@ -973,8 +978,8 @@ fn prefetch_module_imports(
973978
let local_mod = v8::Local::new(scope, global_mod);
974979
let imports = extract_uncached_imports(scope, local_mod, referrer);
975980
for (spec, ref_name) in imports {
976-
// Deduplicate within this batch
977-
if !batch.iter().any(|(s, _)| s == &spec) {
981+
// Deduplicate within this batch by the full request identity.
982+
if !batch.iter().any(|(s, r)| s == &spec && r == &ref_name) {
978983
batch.push((spec, ref_name));
979984
}
980985
}
@@ -1048,7 +1053,10 @@ fn prefetch_module_imports(
10481053
.insert(resolved_path.clone(), global.clone());
10491054
state
10501055
.module_cache
1051-
.insert(batch[i].0.clone(), global.clone());
1056+
.insert(
1057+
module_request_cache_key(&batch[i].0, &batch[i].1),
1058+
global.clone(),
1059+
);
10521060
}
10531061
});
10541062

@@ -1065,11 +1073,13 @@ fn resolve_or_compile_module<'s>(
10651073
specifier_str: &str,
10661074
referrer_name: &str,
10671075
) -> Option<v8::Local<'s, v8::Module>> {
1068-
// Phase 1: Check cache by specifier.
1076+
let request_cache_key = module_request_cache_key(specifier_str, referrer_name);
1077+
1078+
// Phase 1: Check cache by referrer-qualified request.
10691079
let cached_global = MODULE_RESOLVE_STATE.with(|cell| {
10701080
let borrow = cell.borrow();
10711081
let state = borrow.as_ref()?;
1072-
state.module_cache.get(specifier_str).cloned()
1082+
state.module_cache.get(&request_cache_key).cloned()
10731083
});
10741084
if let Some(cached) = cached_global {
10751085
return Some(v8::Local::new(scope, &cached));
@@ -1130,7 +1140,7 @@ fn resolve_or_compile_module<'s>(
11301140
let global = v8::Global::new(scope, module);
11311141
state
11321142
.module_cache
1133-
.insert(specifier_str.to_string(), global.clone());
1143+
.insert(request_cache_key.clone(), global.clone());
11341144
state.module_cache.insert(resolved_path, global);
11351145
}
11361146
});

packages/core/isolate-runtime/src/inject/require-setup.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,60 @@
1313
});
1414
};
1515

16+
if (typeof globalThis.global === 'undefined') {
17+
globalThis.global = globalThis;
18+
}
19+
20+
if (typeof globalThis.RegExp === 'function' && !globalThis.RegExp.__secureExecRgiEmojiCompat) {
21+
const NativeRegExp = globalThis.RegExp;
22+
const RGI_EMOJI_PATTERN = '^\\p{RGI_Emoji}$';
23+
const RGI_EMOJI_BASE_CLASS = '[\\u{00A9}\\u{00AE}\\u{203C}\\u{2049}\\u{2122}\\u{2139}\\u{2194}-\\u{21AA}\\u{231A}-\\u{23FF}\\u{24C2}\\u{25AA}-\\u{27BF}\\u{2934}-\\u{2935}\\u{2B05}-\\u{2B55}\\u{3030}\\u{303D}\\u{3297}\\u{3299}\\u{1F000}-\\u{1FAFF}]';
24+
const RGI_EMOJI_KEYCAP = '[#*0-9]\\uFE0F?\\u20E3';
25+
const RGI_EMOJI_FALLBACK_SOURCE =
26+
'^(?:' +
27+
RGI_EMOJI_KEYCAP +
28+
'|\\p{Regional_Indicator}{2}|' +
29+
RGI_EMOJI_BASE_CLASS +
30+
'(?:\\uFE0F|\\u200D(?:' +
31+
RGI_EMOJI_KEYCAP +
32+
'|' +
33+
RGI_EMOJI_BASE_CLASS +
34+
')|[\\u{1F3FB}-\\u{1F3FF}])*)$';
35+
try {
36+
new NativeRegExp(RGI_EMOJI_PATTERN, 'v');
37+
} catch (error) {
38+
if (String(error && error.message || error).includes('RGI_Emoji')) {
39+
function CompatRegExp(pattern, flags) {
40+
const normalizedPattern =
41+
pattern instanceof NativeRegExp && flags === undefined
42+
? pattern.source
43+
: String(pattern);
44+
const normalizedFlags =
45+
flags === undefined
46+
? (pattern instanceof NativeRegExp ? pattern.flags : '')
47+
: String(flags);
48+
try {
49+
return new NativeRegExp(pattern, flags);
50+
} catch (innerError) {
51+
if (normalizedPattern === RGI_EMOJI_PATTERN && normalizedFlags === 'v') {
52+
return new NativeRegExp(RGI_EMOJI_FALLBACK_SOURCE, 'u');
53+
}
54+
throw innerError;
55+
}
56+
}
57+
Object.setPrototypeOf(CompatRegExp, NativeRegExp);
58+
CompatRegExp.prototype = NativeRegExp.prototype;
59+
Object.defineProperty(CompatRegExp.prototype, 'constructor', {
60+
value: CompatRegExp,
61+
writable: true,
62+
configurable: true,
63+
});
64+
CompatRegExp.__secureExecRgiEmojiCompat = true;
65+
globalThis.RegExp = CompatRegExp;
66+
}
67+
}
68+
}
69+
1670
if (
1771
typeof globalThis.AbortController === 'undefined' ||
1872
typeof globalThis.AbortSignal === 'undefined' ||
@@ -1603,6 +1657,31 @@
16031657
};
16041658
}
16051659

1660+
if (typeof _cryptoRandomUUID !== 'undefined' && typeof result.randomUUID !== 'function') {
1661+
result.randomUUID = function randomUUID(options) {
1662+
if (options !== undefined) {
1663+
if (options === null || typeof options !== 'object') {
1664+
throw createInvalidArgTypeError('options', 'of type object', options);
1665+
}
1666+
if (
1667+
Object.prototype.hasOwnProperty.call(options, 'disableEntropyCache') &&
1668+
typeof options.disableEntropyCache !== 'boolean'
1669+
) {
1670+
throw createInvalidArgTypeError(
1671+
'options.disableEntropyCache',
1672+
'of type boolean',
1673+
options.disableEntropyCache,
1674+
);
1675+
}
1676+
}
1677+
var uuid = _cryptoRandomUUID.applySync(undefined, []);
1678+
if (typeof uuid !== 'string') {
1679+
throw new Error('invalid host uuid');
1680+
}
1681+
return uuid;
1682+
};
1683+
}
1684+
16061685
// Overlay host-backed pbkdf2/pbkdf2Sync
16071686
if (typeof _cryptoPbkdf2 !== 'undefined') {
16081687
function createPbkdf2ArgTypeError(name, value) {
@@ -3772,6 +3851,16 @@
37723851
return globalThis.process;
37733852
}
37743853

3854+
// Special handling for v8. Some CommonJS dependencies require it
3855+
// before the mutable module cache has been copied into the local cache.
3856+
if (name === 'v8') {
3857+
if (__internalModuleCache['v8']) return __internalModuleCache['v8'];
3858+
const v8Module = globalThis._moduleCache?.v8 || {};
3859+
__internalModuleCache['v8'] = v8Module;
3860+
_debugRequire('loaded', name, 'v8-special');
3861+
return v8Module;
3862+
}
3863+
37753864
// Special handling for async_hooks.
37763865
// This provides the minimum API surface needed by tracing libraries.
37773866
if (name === 'async_hooks') {

packages/core/src/generated/isolate-runtime.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/nodejs/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,9 @@
108108
"dependencies": {
109109
"@secure-exec/core": "workspace:*",
110110
"@secure-exec/v8": "workspace:*",
111-
"esbuild": "^0.27.1",
111+
"cjs-module-lexer": "^2.1.0",
112112
"es-module-lexer": "^1.7.0",
113+
"esbuild": "^0.27.1",
113114
"node-stdlib-browser": "^1.3.1"
114115
},
115116
"devDependencies": {

0 commit comments

Comments
 (0)