Skip to content

Commit cf1561b

Browse files
committed
feat: US-063 - Make Pi PTY mode pass end-to-end with real-provider tokens
1 parent 7f41564 commit cf1561b

23 files changed

Lines changed: 1401 additions & 140 deletions

.agent/contracts/node-bridge.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,19 @@ Bridge/runtime bootstrap SHALL expose the modern Web API and worker-thread compa
134134
- **AND** compatibility helpers like `worker_threads.markAsUncloneable` and `stream.Readable.fromWeb()` MUST be reachable during that same bootstrap path
135135
- **AND** the runtime MUST satisfy that dependency chain through generic runtime/bootstrap behavior rather than a package-specific redirect or mock
136136

137+
### Requirement: Bridged `process.kill()` Preserves Self-Signal Semantics
138+
The process bridge SHALL preserve Node-compatible self-signal behavior for `process.kill(process.pid, signal)` so interactive TUIs and signal handlers can refresh state without spuriously terminating the sandbox.
139+
140+
#### Scenario: Unhandled self `SIGWINCH` is ignored
141+
- **WHEN** sandboxed code calls `process.kill(process.pid, 'SIGWINCH')` without a registered `SIGWINCH` listener
142+
- **THEN** the runtime MUST return `true`
143+
- **AND** execution MUST continue instead of exiting with `128 + 28`
144+
145+
#### Scenario: Registered self signal handlers run in-process
146+
- **WHEN** sandboxed code registers `process.on('SIGTERM', handler)` or another signal listener and then calls `process.kill(process.pid, signal)`
147+
- **THEN** the bridge MUST emit that signal event to the registered handlers
148+
- **AND** the process MUST remain alive unless user code exits explicitly from the handler
149+
137150
### Requirement: Cryptographic Randomness Bridge Uses Host CSPRNG
138151
Bridge-provided randomness for global `crypto` APIs MUST delegate to host `node:crypto` primitives and MUST NOT use isolate-local pseudo-random fallbacks such as `Math.random()`.
139152

.agent/contracts/node-runtime.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,18 @@ The `__dynamicImport` bridge function SHALL return a Promise that resolves to th
173173
- **WHEN** user code calls `await import("./nonexistent")`
174174
- **THEN** the returned Promise MUST reject with an error indicating the module cannot be resolved
175175

176+
### Requirement: JavaScript Module Loading Preserves Node Shebang Semantics
177+
JavaScript entrypoints and dependency files that begin with a Node-style shebang (`#!...`) SHALL load through both CommonJS and ESM sandbox paths without surfacing a syntax error from the host-side wrapper or transform stages.
178+
179+
#### Scenario: CommonJS wrapper loads a shebang-bearing ESM CLI entrypoint
180+
- **WHEN** sandboxed code reaches `await import("/pkg/dist/cli.js")` from an exec-mode CommonJS entrypoint and `/pkg/dist/cli.js` begins with `#!/usr/bin/env node`
181+
- **THEN** the runtime MUST normalize the shebang before any CommonJS wrapper compilation step
182+
- **AND** the module MUST continue loading through the normal transform/evaluation path instead of failing with `SyntaxError: Invalid or unexpected token`
183+
184+
#### Scenario: ESM loader reads a BOM-prefixed shebang-bearing module
185+
- **WHEN** the sandbox loads a JavaScript module whose first bytes are UTF-8 BOM followed by a Node-style shebang and ESM syntax
186+
- **THEN** module-syntax detection and ESM evaluation MUST still succeed without the shebang line being treated as executable JavaScript
187+
176188
### Requirement: ESM Top-Level Await Completes Before Execution Finalization
177189
When sandboxed ESM execution uses top-level `await`, the runtime SHALL keep the entry-module evaluation promise alive until it settles instead of finalizing execution early.
178190

@@ -188,6 +200,22 @@ When sandboxed ESM execution uses top-level `await`, the runtime SHALL keep the
188200
- **WHEN** sandboxed code executes `await import("./mod.mjs")` and `./mod.mjs` contains top-level `await`
189201
- **THEN** the import Promise MUST not resolve until the imported module's async evaluation has completed and its namespace is ready
190202

203+
### Requirement: Intl Segmentation APIs Stay Operational In The Sandbox
204+
The Node runtime SHALL initialize the underlying V8/ICU internationalization data needed for `Intl` segmentation APIs so Unicode-aware third-party code can execute without tearing down the runtime.
205+
206+
#### Scenario: Intl.Segmenter segments ASCII and non-ASCII graphemes
207+
- **WHEN** sandboxed code constructs `new Intl.Segmenter(undefined, { granularity: "grapheme" })` and iterates `segment()` results for strings such as `"abc thinking off"` and `"abc • thinking off"`
208+
- **THEN** the runtime MUST return the expected grapheme segments
209+
- **AND** execution MUST remain alive instead of failing with a runtime-process crash or IPC disconnect
210+
211+
### Requirement: PTY Raw Mode Preserves Carriage Return Input
212+
When sandboxed code enables PTY raw mode through `process.stdin.setRawMode(true)`, the runtime SHALL disable canonical translations such as `ICRNL` so interactive TUIs receive the original carriage-return byte for Enter.
213+
214+
#### Scenario: Raw-mode stdin receives CR without newline translation
215+
- **WHEN** sandboxed PTY-backed code calls `process.stdin.setRawMode(true)` and the PTY master writes `\r`
216+
- **THEN** `process.stdin` listeners MUST receive byte `13` / `"\r"` rather than translated newline input
217+
- **AND** restoring cooked mode with `process.stdin.setRawMode(false)` MUST restore the default translated line discipline
218+
191219
### Requirement: Configurable CPU Time Limit for Node Runtime Execution
192220
The Node runtime MUST support an optional `cpuTimeLimitMs` execution budget for sandboxed code and MUST enforce it as a shared per-execution deadline across runtime calls that execute user-controlled code.
193221

native/v8-runtime/build.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
use std::env;
2+
use std::fs;
3+
use std::path::{Path, PathBuf};
4+
5+
fn cargo_home() -> PathBuf {
6+
if let Some(home) = env::var_os("CARGO_HOME") {
7+
return PathBuf::from(home);
8+
}
9+
10+
let home = env::var_os("HOME").expect("HOME must be set when CARGO_HOME is unset");
11+
PathBuf::from(home).join(".cargo")
12+
}
13+
14+
fn read_v8_version(lock_path: &Path) -> String {
15+
let lock = fs::read_to_string(lock_path)
16+
.unwrap_or_else(|error| panic!("failed to read {}: {}", lock_path.display(), error));
17+
18+
let mut in_v8_package = false;
19+
for line in lock.lines() {
20+
match line.trim() {
21+
"[[package]]" => in_v8_package = false,
22+
"name = \"v8\"" => in_v8_package = true,
23+
_ if in_v8_package && line.trim_start().starts_with("version = \"") => {
24+
let version = line
25+
.trim()
26+
.trim_start_matches("version = \"")
27+
.trim_end_matches('"');
28+
return version.to_owned();
29+
}
30+
_ => {}
31+
}
32+
}
33+
34+
panic!("failed to locate v8 version in {}", lock_path.display());
35+
}
36+
37+
fn find_v8_icu_data(v8_version: &str) -> PathBuf {
38+
let registry_src = cargo_home().join("registry").join("src");
39+
let candidates = [
40+
Path::new("third_party/icu/common/icudtl.dat"),
41+
Path::new("third_party/icu/flutter_desktop/icudtl.dat"),
42+
Path::new("third_party/icu/chromecast_video/icudtl.dat"),
43+
];
44+
45+
let entries = fs::read_dir(&registry_src).unwrap_or_else(|error| {
46+
panic!("failed to read cargo registry src {}: {}", registry_src.display(), error)
47+
});
48+
49+
for entry in entries {
50+
let entry = entry.unwrap_or_else(|error| panic!("failed to inspect cargo registry entry: {}", error));
51+
let crate_root = entry.path().join(format!("v8-{}", v8_version));
52+
for relative in candidates {
53+
let candidate = crate_root.join(relative);
54+
if candidate.exists() {
55+
return candidate;
56+
}
57+
}
58+
}
59+
60+
panic!(
61+
"failed to locate ICU data for v8-{} under {}",
62+
v8_version,
63+
registry_src.display(),
64+
);
65+
}
66+
67+
fn main() {
68+
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set"));
69+
let lock_path = manifest_dir.join("Cargo.lock");
70+
let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR must be set"));
71+
72+
println!("cargo:rerun-if-changed={}", lock_path.display());
73+
println!("cargo:rerun-if-changed=build.rs");
74+
75+
let v8_version = read_v8_version(&lock_path);
76+
let icu_data = find_v8_icu_data(&v8_version);
77+
let dest_path = out_dir.join("icudtl.dat");
78+
79+
fs::copy(&icu_data, &dest_path).unwrap_or_else(|error| {
80+
panic!(
81+
"failed to copy ICU data from {} to {}: {}",
82+
icu_data.display(),
83+
dest_path.display(),
84+
error,
85+
)
86+
});
87+
}

native/v8-runtime/src/execution.rs

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -356,22 +356,26 @@ pub fn execute_script(
356356
}
357357
};
358358

359+
// Flush microtasks once after every exec()-style script so process.nextTick()
360+
// and zero-delay bridge callbacks run before we decide whether more event-loop
361+
// work is pending.
362+
tc.perform_microtask_checkpoint();
363+
364+
if let Some(exception) = tc.exception() {
365+
let (c, err) = exception_to_result(tc, exception);
366+
return (c, Some(err));
367+
}
368+
369+
if let Some(state) = tc.get_slot_mut::<crate::isolate::PromiseRejectState>() {
370+
if let Some((_, err)) = state.unhandled.drain().next() {
371+
return (1, Some(err));
372+
}
373+
}
374+
359375
// Surface rejected async completions for exec()-style scripts that
360376
// return a Promise (for example an async IIFE ending in await import()).
361377
if completion.is_promise() {
362378
let promise = v8::Local::<v8::Promise>::try_from(completion).unwrap();
363-
tc.perform_microtask_checkpoint();
364-
365-
if let Some(exception) = tc.exception() {
366-
let (c, err) = exception_to_result(tc, exception);
367-
return (c, Some(err));
368-
}
369-
370-
if let Some(state) = tc.get_slot_mut::<crate::isolate::PromiseRejectState>() {
371-
if let Some((_, err)) = state.unhandled.drain().next() {
372-
return (1, Some(err));
373-
}
374-
}
375379

376380
if promise.state() == v8::PromiseState::Rejected {
377381
let rejection = promise.result(tc);
@@ -415,7 +419,7 @@ pub fn extract_process_exit_code(
415419
/// Extract error info and exit code from a V8 exception.
416420
/// For ProcessExitError (detected via _isProcessExit sentinel), returns the error's exit code.
417421
/// For other errors, returns exit code 1.
418-
fn exception_to_result(
422+
pub(crate) fn exception_to_result(
419423
scope: &mut v8::HandleScope,
420424
exception: v8::Local<v8::Value>,
421425
) -> (i32, ExecutionError) {
@@ -640,7 +644,9 @@ fn set_pending_module_evaluation(
640644
});
641645
}
642646

643-
fn take_unhandled_promise_rejection(scope: &mut v8::HandleScope) -> Option<ExecutionError> {
647+
pub(crate) fn take_unhandled_promise_rejection(
648+
scope: &mut v8::HandleScope,
649+
) -> Option<ExecutionError> {
644650
scope
645651
.get_slot_mut::<crate::isolate::PromiseRejectState>()
646652
.and_then(|state| state.unhandled.drain().next().map(|(_, err)| err))

native/v8-runtime/src/isolate.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ use crate::ipc::ExecutionError;
77

88
static V8_INIT: Once = Once::new();
99

10+
#[repr(align(16))]
11+
struct AlignedBytes<const N: usize>([u8; N]);
12+
13+
static ICU_COMMON_DATA: AlignedBytes<{ include_bytes!(concat!(env!("OUT_DIR"), "/icudtl.dat")).len() }> =
14+
AlignedBytes(*include_bytes!(concat!(env!("OUT_DIR"), "/icudtl.dat")));
15+
1016
#[derive(Default)]
1117
pub struct PromiseRejectState {
1218
pub unhandled: HashMap<i32, ExecutionError>,
@@ -46,6 +52,8 @@ pub fn configure_isolate(isolate: &mut v8::OwnedIsolate) {
4652
/// Safe to call multiple times; only the first call takes effect.
4753
pub fn init_v8_platform() {
4854
V8_INIT.call_once(|| {
55+
v8::icu::set_common_data_74(&ICU_COMMON_DATA.0)
56+
.expect("failed to initialize V8 ICU common data");
4957
let platform = v8::new_default_platform(0, false).make_shared();
5058
v8::V8::initialize_platform(platform);
5159
v8::V8::initialize();

0 commit comments

Comments
 (0)