1use crate::error::{Error, Result};
27use crate::hostcall_io_uring_lane::{
28 HostcallCapabilityClass, HostcallIoHint, IoUringLaneDecisionInput,
29};
30use crate::hostcall_queue::{
31 HOSTCALL_FAST_RING_CAPACITY, HOSTCALL_OVERFLOW_CAPACITY, HostcallQueueEnqueueResult,
32 HostcallQueueTelemetry, HostcallRequestQueue, QueueTenant,
33};
34use crate::scheduler::{Clock as SchedulerClock, HostcallOutcome, Scheduler, WallClock};
35use base64::Engine as _;
36use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
37use rquickjs::function::{Func, Opt};
38use rquickjs::loader::{Loader as JsModuleLoader, Resolver as JsModuleResolver};
39use rquickjs::module::Declared as JsModuleDeclared;
40use rquickjs::{
41 AsyncContext, AsyncRuntime, Coerced, Ctx, Exception, FromJs, Function, IntoJs, Module, Object,
42 Value,
43};
44use sha2::{Digest, Sha256};
45use std::cell::RefCell;
46use std::cmp::Ordering;
47use std::collections::{BTreeSet, BinaryHeap, HashMap, HashSet, VecDeque};
48use std::fmt::Write as _;
49use std::rc::Rc;
50use std::sync::Arc;
51use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
52use std::time::{SystemTime, UNIX_EPOCH};
53use std::{fs, path::Path, path::PathBuf};
54use swc_common::{FileName, GLOBALS, Globals, Mark, SourceMap, sync::Lrc};
55use swc_ecma_ast::{Module as SwcModule, Pass, Program as SwcProgram};
56use swc_ecma_codegen::{Emitter, text_writer::JsWriter};
57use swc_ecma_parser::{Parser as SwcParser, StringInput, Syntax, TsSyntax};
58use swc_ecma_transforms_base::resolver;
59use swc_ecma_transforms_typescript::strip;
60
61use crate::extensions::{
66 ExecMediationResult, ExtensionPolicy, ExtensionPolicyMode, SecretBrokerPolicy,
67 evaluate_exec_mediation,
68};
69
70fn check_exec_capability(policy: &ExtensionPolicy, extension_id: Option<&str>) -> bool {
72 let cap = "exec";
73
74 if let Some(id) = extension_id {
76 if let Some(override_config) = policy.per_extension.get(id) {
77 if override_config.deny.iter().any(|c| c == cap) {
78 return false;
79 }
80 if override_config.allow.iter().any(|c| c == cap) {
81 return true;
82 }
83 if let Some(mode) = override_config.mode {
84 return match mode {
85 ExtensionPolicyMode::Permissive => true,
86 ExtensionPolicyMode::Strict | ExtensionPolicyMode::Prompt => false, };
88 }
89 }
90 }
91
92 if policy.deny_caps.iter().any(|c| c == cap) {
94 return false;
95 }
96
97 if policy.default_caps.iter().any(|c| c == cap) {
99 return true;
100 }
101
102 match policy.mode {
104 ExtensionPolicyMode::Permissive => true,
105 ExtensionPolicyMode::Strict | ExtensionPolicyMode::Prompt => false, }
107}
108
109pub fn is_env_var_allowed(key: &str) -> bool {
114 let policy = SecretBrokerPolicy::default();
115 !policy.is_secret(key)
118}
119
120fn parse_truthy_flag(value: &str) -> bool {
121 matches!(
122 value.trim().to_ascii_lowercase().as_str(),
123 "1" | "true" | "yes" | "on"
124 )
125}
126
127fn is_global_compat_scan_mode() -> bool {
128 cfg!(feature = "ext-conformance")
129 || std::env::var("PI_EXT_COMPAT_SCAN").is_ok_and(|value| parse_truthy_flag(&value))
130}
131
132fn is_compat_scan_mode(env: &HashMap<String, String>) -> bool {
133 is_global_compat_scan_mode()
134 || env
135 .get("PI_EXT_COMPAT_SCAN")
136 .is_some_and(|value| parse_truthy_flag(value))
137}
138
139fn compat_env_fallback_value(key: &str, env: &HashMap<String, String>) -> Option<String> {
144 if !is_compat_scan_mode(env) {
145 return None;
146 }
147
148 let upper = key.to_ascii_uppercase();
149 if upper.ends_with("_API_KEY") {
150 return Some(format!("pi-compat-{}", upper.to_ascii_lowercase()));
151 }
152 if upper == "PI_SEMANTIC_LEGACY" {
153 return Some("1".to_string());
154 }
155
156 None
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum HostcallKind {
166 Tool { name: String },
168 Exec { cmd: String },
170 Http,
172 Session { op: String },
174 Ui { op: String },
176 Events { op: String },
178 Log,
180}
181
182#[derive(Debug, Clone)]
184pub struct HostcallRequest {
185 pub call_id: String,
187 pub kind: HostcallKind,
189 pub payload: serde_json::Value,
191 pub trace_id: u64,
193 pub extension_id: Option<String>,
195}
196
197impl QueueTenant for HostcallRequest {
198 fn tenant_key(&self) -> Option<&str> {
199 self.extension_id.as_deref()
200 }
201}
202
203#[allow(clippy::derive_partial_eq_without_eq)]
205#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
206pub struct ExtensionToolDef {
207 pub name: String,
208 #[serde(default)]
209 pub label: Option<String>,
210 pub description: String,
211 pub parameters: serde_json::Value,
212}
213
214fn hostcall_params_hash(method: &str, params: &serde_json::Value) -> String {
216 crate::extensions::hostcall_params_hash(method, params)
217}
218
219fn canonical_exec_params(cmd: &str, payload: &serde_json::Value) -> serde_json::Value {
220 let mut obj = match payload {
221 serde_json::Value::Object(map) => {
222 let mut out = map.clone();
223 out.remove("command");
224 out
225 }
226 serde_json::Value::Null => serde_json::Map::new(),
227 other => {
228 let mut out = serde_json::Map::new();
229 out.insert("payload".to_string(), other.clone());
230 out
231 }
232 };
233
234 obj.insert(
235 "cmd".to_string(),
236 serde_json::Value::String(cmd.to_string()),
237 );
238 serde_json::Value::Object(obj)
239}
240
241fn canonical_op_params(op: &str, payload: &serde_json::Value) -> serde_json::Value {
242 if payload.is_null() {
245 return serde_json::json!({ "op": op });
246 }
247
248 let mut obj = match payload {
249 serde_json::Value::Object(map) => map.clone(),
250 other => {
251 let mut out = serde_json::Map::new();
252 out.insert("payload".to_string(), other.clone());
254 out
255 }
256 };
257
258 obj.insert("op".to_string(), serde_json::Value::String(op.to_string()));
260 serde_json::Value::Object(obj)
261}
262
263fn builtin_tool_required_capability(name: &str) -> &'static str {
264 let name = name.trim();
265 if name.eq_ignore_ascii_case("read")
266 || name.eq_ignore_ascii_case("grep")
267 || name.eq_ignore_ascii_case("find")
268 || name.eq_ignore_ascii_case("ls")
269 {
270 "read"
271 } else if name.eq_ignore_ascii_case("write") || name.eq_ignore_ascii_case("edit") {
272 "write"
273 } else if name.eq_ignore_ascii_case("bash") {
274 "exec"
275 } else {
276 "tool"
277 }
278}
279
280impl HostcallRequest {
281 #[must_use]
282 pub const fn method(&self) -> &'static str {
283 match self.kind {
284 HostcallKind::Tool { .. } => "tool",
285 HostcallKind::Exec { .. } => "exec",
286 HostcallKind::Http => "http",
287 HostcallKind::Session { .. } => "session",
288 HostcallKind::Ui { .. } => "ui",
289 HostcallKind::Events { .. } => "events",
290 HostcallKind::Log => "log",
291 }
292 }
293
294 #[must_use]
295 pub fn required_capability(&self) -> &'static str {
296 match &self.kind {
297 HostcallKind::Tool { name } => builtin_tool_required_capability(name),
298 HostcallKind::Exec { .. } => "exec",
299 HostcallKind::Http => "http",
300 HostcallKind::Session { .. } => "session",
301 HostcallKind::Ui { .. } => "ui",
302 HostcallKind::Events { .. } => "events",
303 HostcallKind::Log => "log",
304 }
305 }
306
307 #[must_use]
308 pub fn io_uring_capability_class(&self) -> HostcallCapabilityClass {
309 HostcallCapabilityClass::from_capability(self.required_capability())
310 }
311
312 #[must_use]
313 pub fn io_uring_io_hint(&self) -> HostcallIoHint {
314 match &self.kind {
315 HostcallKind::Http => HostcallIoHint::IoHeavy,
316 HostcallKind::Exec { .. } => HostcallIoHint::CpuBound,
317 HostcallKind::Tool { name } => {
318 let name = name.trim();
319 if name.eq_ignore_ascii_case("read")
320 || name.eq_ignore_ascii_case("write")
321 || name.eq_ignore_ascii_case("edit")
322 || name.eq_ignore_ascii_case("grep")
323 || name.eq_ignore_ascii_case("find")
324 || name.eq_ignore_ascii_case("ls")
325 {
326 HostcallIoHint::IoHeavy
327 } else if name.eq_ignore_ascii_case("bash") {
328 HostcallIoHint::CpuBound
329 } else {
330 HostcallIoHint::Unknown
331 }
332 }
333 HostcallKind::Session { .. }
334 | HostcallKind::Ui { .. }
335 | HostcallKind::Events { .. }
336 | HostcallKind::Log => HostcallIoHint::Unknown,
337 }
338 }
339
340 #[must_use]
341 pub fn io_uring_lane_input(
342 &self,
343 queue_depth: usize,
344 force_compat_lane: bool,
345 ) -> IoUringLaneDecisionInput {
346 IoUringLaneDecisionInput {
347 capability: self.io_uring_capability_class(),
348 io_hint: self.io_uring_io_hint(),
349 queue_depth,
350 force_compat_lane,
351 }
352 }
353
354 #[must_use]
365 pub fn params_for_hash(&self) -> serde_json::Value {
366 match &self.kind {
367 HostcallKind::Tool { name } => {
368 serde_json::json!({ "name": name, "input": self.payload.clone() })
369 }
370 HostcallKind::Exec { cmd } => canonical_exec_params(cmd, &self.payload),
371 HostcallKind::Http | HostcallKind::Log => self.payload.clone(),
372 HostcallKind::Session { op }
373 | HostcallKind::Ui { op }
374 | HostcallKind::Events { op } => canonical_op_params(op, &self.payload),
375 }
376 }
377
378 #[must_use]
379 pub fn params_hash(&self) -> String {
380 hostcall_params_hash(self.method(), &self.params_for_hash())
381 }
382}
383
384const MAX_JSON_DEPTH: usize = 64;
385const MAX_JOBS_PER_TICK: usize = 10_000;
386
387#[allow(clippy::option_if_let_else)]
389pub(crate) fn json_to_js<'js>(
390 ctx: &Ctx<'js>,
391 value: &serde_json::Value,
392) -> rquickjs::Result<Value<'js>> {
393 json_to_js_inner(ctx, value, 0)
394}
395
396fn json_to_js_inner<'js>(
397 ctx: &Ctx<'js>,
398 value: &serde_json::Value,
399 depth: usize,
400) -> rquickjs::Result<Value<'js>> {
401 if depth > MAX_JSON_DEPTH {
402 return Err(rquickjs::Error::new_into_js_message(
403 "json",
404 "parse",
405 "JSON object too deep",
406 ));
407 }
408
409 match value {
410 serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
411 serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
412 serde_json::Value::Number(n) => n.as_i64().and_then(|i| i32::try_from(i).ok()).map_or_else(
413 || {
414 n.as_f64().map_or_else(
415 || Ok(Value::new_null(ctx.clone())),
416 |f| Ok(Value::new_float(ctx.clone(), f)),
417 )
418 },
419 |i| Ok(Value::new_int(ctx.clone(), i)),
420 ),
421 serde_json::Value::String(s) => s.as_str().into_js(ctx),
423 serde_json::Value::Array(arr) => {
424 let js_arr = rquickjs::Array::new(ctx.clone())?;
425 for (i, v) in arr.iter().enumerate() {
426 let js_v = json_to_js_inner(ctx, v, depth + 1)?;
427 js_arr.set(i, js_v)?;
428 }
429 Ok(js_arr.into_value())
430 }
431 serde_json::Value::Object(obj) => {
432 let js_obj = Object::new(ctx.clone())?;
433 for (k, v) in obj {
434 let js_v = json_to_js_inner(ctx, v, depth + 1)?;
435 js_obj.set(k.as_str(), js_v)?;
436 }
437 Ok(js_obj.into_value())
438 }
439 }
440}
441
442pub(crate) fn js_to_json(value: &Value<'_>) -> rquickjs::Result<serde_json::Value> {
444 js_to_json_inner(value, 0)
445}
446
447fn js_to_json_inner(value: &Value<'_>, depth: usize) -> rquickjs::Result<serde_json::Value> {
448 if depth > MAX_JSON_DEPTH {
449 return Err(rquickjs::Error::new_into_js_message(
450 "json",
451 "stringify",
452 "Object too deep or contains cycles",
453 ));
454 }
455
456 if value.is_null() || value.is_undefined() {
457 return Ok(serde_json::Value::Null);
458 }
459 if let Some(b) = value.as_bool() {
460 return Ok(serde_json::Value::Bool(b));
461 }
462 if let Some(i) = value.as_int() {
463 return Ok(serde_json::json!(i));
464 }
465 if let Some(f) = value.as_float() {
466 return Ok(serde_json::json!(f));
467 }
468 if let Some(s) = value.as_string() {
469 let s = s.to_string()?;
470 return Ok(serde_json::Value::String(s));
471 }
472 if let Some(arr) = value.as_array() {
473 let len = arr.len();
474 if len > 100_000 {
475 return Err(rquickjs::Error::new_into_js_message(
476 "json",
477 "stringify",
478 format!("Array length ({len}) exceeds maximum allowed limit of 100,000"),
479 ));
480 }
481 let mut result = Vec::with_capacity(std::cmp::min(len, 1024));
482 for i in 0..len {
483 let v: Value<'_> = arr.get(i)?;
484 result.push(js_to_json_inner(&v, depth + 1)?);
485 }
486 return Ok(serde_json::Value::Array(result));
487 }
488 if let Some(obj) = value.as_object() {
489 let mut result = serde_json::Map::new();
490 for (count, item) in obj.props::<String, Value<'_>>().enumerate() {
491 if count >= 100_000 {
492 return Err(rquickjs::Error::new_into_js_message(
493 "json",
494 "stringify",
495 "Object properties count exceeds maximum allowed limit of 100,000",
496 ));
497 }
498 let (k, v) = item?;
499 if v.is_undefined() || v.is_function() || v.is_symbol() {
500 continue;
501 }
502 result.insert(k, js_to_json_inner(&v, depth + 1)?);
503 }
504 return Ok(serde_json::Value::Object(result));
505 }
506 Ok(serde_json::Value::Null)
508}
509
510pub type HostcallQueue = Rc<RefCell<HostcallRequestQueue<HostcallRequest>>>;
511
512pub trait Clock: Send + Sync {
517 fn now_ms(&self) -> u64;
518}
519
520#[derive(Clone)]
521pub struct ClockHandle(Arc<dyn Clock>);
522
523impl ClockHandle {
524 pub fn new(clock: Arc<dyn Clock>) -> Self {
525 Self(clock)
526 }
527}
528
529impl Clock for ClockHandle {
530 fn now_ms(&self) -> u64 {
531 self.0.now_ms()
532 }
533}
534
535pub struct SystemClock;
536
537impl Clock for SystemClock {
538 fn now_ms(&self) -> u64 {
539 let now = SystemTime::now()
540 .duration_since(UNIX_EPOCH)
541 .unwrap_or_default();
542 u64::try_from(now.as_millis()).unwrap_or(u64::MAX)
543 }
544}
545
546#[derive(Debug)]
547pub struct ManualClock {
548 now_ms: AtomicU64,
549}
550
551impl ManualClock {
552 pub const fn new(start_ms: u64) -> Self {
553 Self {
554 now_ms: AtomicU64::new(start_ms),
555 }
556 }
557
558 pub fn set(&self, ms: u64) {
559 self.now_ms.store(ms, AtomicOrdering::SeqCst);
560 }
561
562 pub fn advance(&self, delta_ms: u64) {
563 self.now_ms.fetch_add(delta_ms, AtomicOrdering::SeqCst);
564 }
565}
566
567impl Clock for ManualClock {
568 fn now_ms(&self) -> u64 {
569 self.now_ms.load(AtomicOrdering::SeqCst)
570 }
571}
572
573#[derive(Debug, Clone, PartialEq, Eq)]
574pub enum MacrotaskKind {
575 TimerFired { timer_id: u64 },
576 HostcallComplete { call_id: String },
577 InboundEvent { event_id: String },
578}
579
580#[derive(Debug, Clone, PartialEq, Eq)]
581pub struct Macrotask {
582 pub seq: u64,
583 pub trace_id: u64,
584 pub kind: MacrotaskKind,
585}
586
587#[derive(Debug, Clone, PartialEq, Eq)]
588struct MacrotaskEntry {
589 seq: u64,
590 trace_id: u64,
591 kind: MacrotaskKind,
592}
593
594impl Ord for MacrotaskEntry {
595 fn cmp(&self, other: &Self) -> Ordering {
596 self.seq.cmp(&other.seq)
597 }
598}
599
600impl PartialOrd for MacrotaskEntry {
601 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
602 Some(self.cmp(other))
603 }
604}
605
606#[derive(Debug, Clone, PartialEq, Eq)]
607struct TimerEntry {
608 deadline_ms: u64,
609 order_seq: u64,
610 timer_id: u64,
611 trace_id: u64,
612}
613
614impl Ord for TimerEntry {
615 fn cmp(&self, other: &Self) -> Ordering {
616 (self.deadline_ms, self.order_seq, self.timer_id).cmp(&(
617 other.deadline_ms,
618 other.order_seq,
619 other.timer_id,
620 ))
621 }
622}
623
624impl PartialOrd for TimerEntry {
625 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
626 Some(self.cmp(other))
627 }
628}
629
630#[derive(Debug, Clone, PartialEq, Eq)]
631struct PendingMacrotask {
632 trace_id: u64,
633 kind: MacrotaskKind,
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637pub struct TickResult {
638 pub ran_macrotask: bool,
639 pub microtasks_drained: usize,
640}
641
642pub struct PiEventLoop {
643 clock: ClockHandle,
644 seq: u64,
645 next_timer_id: u64,
646 pending: VecDeque<PendingMacrotask>,
647 macro_queue: BinaryHeap<std::cmp::Reverse<MacrotaskEntry>>,
648 timers: BinaryHeap<std::cmp::Reverse<TimerEntry>>,
649 cancelled_timers: HashSet<u64>,
650}
651
652impl PiEventLoop {
653 pub fn new(clock: ClockHandle) -> Self {
654 Self {
655 clock,
656 seq: 0,
657 next_timer_id: 1,
658 pending: VecDeque::new(),
659 macro_queue: BinaryHeap::new(),
660 timers: BinaryHeap::new(),
661 cancelled_timers: HashSet::new(),
662 }
663 }
664
665 pub fn enqueue_hostcall_completion(&mut self, call_id: impl Into<String>) {
666 let trace_id = self.next_seq();
667 self.pending.push_back(PendingMacrotask {
668 trace_id,
669 kind: MacrotaskKind::HostcallComplete {
670 call_id: call_id.into(),
671 },
672 });
673 }
674
675 pub fn enqueue_inbound_event(&mut self, event_id: impl Into<String>) {
676 let trace_id = self.next_seq();
677 self.pending.push_back(PendingMacrotask {
678 trace_id,
679 kind: MacrotaskKind::InboundEvent {
680 event_id: event_id.into(),
681 },
682 });
683 }
684
685 pub fn set_timeout(&mut self, delay_ms: u64) -> u64 {
686 let timer_id = self.next_timer_id;
687 self.next_timer_id = self.next_timer_id.saturating_add(1);
688 let order_seq = self.next_seq();
689 let deadline_ms = self.clock.now_ms().saturating_add(delay_ms);
690 self.timers.push(std::cmp::Reverse(TimerEntry {
691 deadline_ms,
692 order_seq,
693 timer_id,
694 trace_id: order_seq,
695 }));
696 timer_id
697 }
698
699 pub fn clear_timeout(&mut self, timer_id: u64) -> bool {
700 let pending = self.timers.iter().any(|entry| entry.0.timer_id == timer_id)
701 && !self.cancelled_timers.contains(&timer_id);
702
703 if pending {
704 self.cancelled_timers.insert(timer_id)
705 } else {
706 false
707 }
708 }
709
710 pub fn tick(
711 &mut self,
712 mut on_macrotask: impl FnMut(Macrotask),
713 mut drain_microtasks: impl FnMut() -> bool,
714 ) -> TickResult {
715 self.ingest_pending();
716 self.enqueue_due_timers();
717
718 let mut ran_macrotask = false;
719 if let Some(task) = self.pop_next_macrotask() {
720 ran_macrotask = true;
721 on_macrotask(task);
722 }
723
724 let mut microtasks_drained = 0;
725 if ran_macrotask {
726 while drain_microtasks() {
727 microtasks_drained += 1;
728 }
729 }
730
731 TickResult {
732 ran_macrotask,
733 microtasks_drained,
734 }
735 }
736
737 fn ingest_pending(&mut self) {
738 while let Some(pending) = self.pending.pop_front() {
739 self.enqueue_macrotask(pending.trace_id, pending.kind);
740 }
741 }
742
743 fn enqueue_due_timers(&mut self) {
744 let now = self.clock.now_ms();
745 while let Some(std::cmp::Reverse(entry)) = self.timers.peek().cloned() {
746 if entry.deadline_ms > now {
747 break;
748 }
749 let _ = self.timers.pop();
750 if self.cancelled_timers.remove(&entry.timer_id) {
751 continue;
752 }
753 self.enqueue_macrotask(
754 entry.trace_id,
755 MacrotaskKind::TimerFired {
756 timer_id: entry.timer_id,
757 },
758 );
759 }
760 }
761
762 fn enqueue_macrotask(&mut self, trace_id: u64, kind: MacrotaskKind) {
763 let seq = self.next_seq();
764 self.macro_queue.push(std::cmp::Reverse(MacrotaskEntry {
765 seq,
766 trace_id,
767 kind,
768 }));
769 }
770
771 fn pop_next_macrotask(&mut self) -> Option<Macrotask> {
772 self.macro_queue.pop().map(|entry| {
773 let entry = entry.0;
774 Macrotask {
775 seq: entry.seq,
776 trace_id: entry.trace_id,
777 kind: entry.kind,
778 }
779 })
780 }
781
782 const fn next_seq(&mut self) -> u64 {
783 let current = self.seq;
784 self.seq = self.seq.saturating_add(1);
785 current
786 }
787}
788
789fn map_js_error(err: &rquickjs::Error) -> Error {
790 Error::extension(format!("QuickJS: {err:?}"))
791}
792
793fn format_quickjs_exception<'js>(ctx: &Ctx<'js>, caught: Value<'js>) -> String {
794 if let Ok(obj) = caught.clone().try_into_object() {
795 if let Some(exception) = Exception::from_object(obj) {
796 if let Some(message) = exception.message() {
797 if let Some(stack) = exception.stack() {
798 return format!("{message}\n{stack}");
799 }
800 return message;
801 }
802 if let Some(stack) = exception.stack() {
803 return stack;
804 }
805 }
806 }
807
808 match Coerced::<String>::from_js(ctx, caught) {
809 Ok(value) => value.0,
810 Err(err) => format!("(failed to stringify QuickJS exception: {err})"),
811 }
812}
813
814#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
820pub enum RepairPattern {
821 DistToSrc,
824 MissingAsset,
827 MonorepoEscape,
830 MissingNpmDep,
833 ExportShape,
836 ManifestNormalization,
839 ApiMigration,
842}
843
844#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
851pub enum RepairRisk {
852 Safe,
854 Aggressive,
856}
857
858impl RepairPattern {
859 pub const fn risk(self) -> RepairRisk {
861 match self {
862 Self::DistToSrc | Self::MissingAsset | Self::ManifestNormalization => RepairRisk::Safe,
864 Self::MonorepoEscape | Self::MissingNpmDep | Self::ExportShape | Self::ApiMigration => {
866 RepairRisk::Aggressive
867 }
868 }
869 }
870
871 pub const fn is_allowed_by(self, mode: RepairMode) -> bool {
873 match self.risk() {
874 RepairRisk::Safe => mode.should_apply(),
875 RepairRisk::Aggressive => mode.allows_aggressive(),
876 }
877 }
878}
879
880impl std::fmt::Display for RepairPattern {
881 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
882 match self {
883 Self::DistToSrc => write!(f, "dist_to_src"),
884 Self::MissingAsset => write!(f, "missing_asset"),
885 Self::MonorepoEscape => write!(f, "monorepo_escape"),
886 Self::MissingNpmDep => write!(f, "missing_npm_dep"),
887 Self::ExportShape => write!(f, "export_shape"),
888 Self::ManifestNormalization => write!(f, "manifest_normalization"),
889 Self::ApiMigration => write!(f, "api_migration"),
890 }
891 }
892}
893
894#[derive(Debug, Clone)]
897pub struct ExtensionRepairEvent {
898 pub extension_id: String,
900 pub pattern: RepairPattern,
902 pub original_error: String,
904 pub repair_action: String,
906 pub success: bool,
908 pub timestamp_ms: u64,
910}
911
912#[derive(Debug, Clone)]
918pub struct RepairRule {
919 pub id: &'static str,
921 pub version: &'static str,
923 pub pattern: RepairPattern,
925 pub description: &'static str,
927}
928
929impl RepairRule {
930 pub const fn risk(&self) -> RepairRisk {
932 self.pattern.risk()
933 }
934
935 pub const fn is_allowed_by(&self, mode: RepairMode) -> bool {
937 self.pattern.is_allowed_by(mode)
938 }
939}
940
941pub static REPAIR_RULES: &[RepairRule] = &[
947 RepairRule {
948 id: "dist_to_src_v1",
949 pattern: RepairPattern::DistToSrc,
950 version: "1.0.0",
951 description: "Remap ./dist/X.js to ./src/X.ts when build output is missing",
952 },
953 RepairRule {
954 id: "missing_asset_v1",
955 pattern: RepairPattern::MissingAsset,
956 version: "1.0.0",
957 description: "Return empty string for missing bundled asset reads",
958 },
959 RepairRule {
960 id: "monorepo_escape_v1",
961 pattern: RepairPattern::MonorepoEscape,
962 version: "1.0.0",
963 description: "Stub monorepo sibling imports (../../shared) with empty module",
964 },
965 RepairRule {
966 id: "missing_npm_dep_v1",
967 pattern: RepairPattern::MissingNpmDep,
968 version: "1.0.0",
969 description: "Provide proxy-based stub for unresolvable npm bare specifiers",
970 },
971 RepairRule {
972 id: "export_shape_v1",
973 pattern: RepairPattern::ExportShape,
974 version: "1.0.0",
975 description: "Try alternative lifecycle exports (CJS default, named activate)",
976 },
977 RepairRule {
979 id: "manifest_schema_v1",
980 pattern: RepairPattern::ManifestNormalization,
981 version: "1.0.0",
982 description: "Migrate deprecated manifest fields to current schema",
983 },
984 RepairRule {
986 id: "api_migration_v1",
987 pattern: RepairPattern::ApiMigration,
988 version: "1.0.0",
989 description: "Rewrite known deprecated API calls to current equivalents",
990 },
991];
992
993pub fn applicable_rules(mode: RepairMode) -> Vec<&'static RepairRule> {
995 REPAIR_RULES
996 .iter()
997 .filter(|rule| rule.is_allowed_by(mode))
998 .collect()
999}
1000
1001pub fn rule_by_id(id: &str) -> Option<&'static RepairRule> {
1003 REPAIR_RULES.iter().find(|r| r.id == id)
1004}
1005
1006pub const REPAIR_REGISTRY_VERSION: &str = "1.1.0";
1008
1009#[derive(Debug, Clone, PartialEq, Eq)]
1019pub enum PatchOp {
1020 ReplaceModulePath { from: String, to: String },
1023 AddExport {
1025 module_path: String,
1026 export_name: String,
1027 export_value: String,
1028 },
1029 RemoveImport {
1031 module_path: String,
1032 specifier: String,
1033 },
1034 InjectStub {
1036 virtual_path: String,
1037 source: String,
1038 },
1039 RewriteRequire {
1041 module_path: String,
1042 from_specifier: String,
1043 to_specifier: String,
1044 },
1045}
1046
1047impl PatchOp {
1048 pub const fn risk(&self) -> RepairRisk {
1050 match self {
1051 Self::ReplaceModulePath { .. } | Self::RewriteRequire { .. } => RepairRisk::Safe,
1053 Self::AddExport { .. } | Self::RemoveImport { .. } | Self::InjectStub { .. } => {
1055 RepairRisk::Aggressive
1056 }
1057 }
1058 }
1059
1060 pub const fn tag(&self) -> &'static str {
1062 match self {
1063 Self::ReplaceModulePath { .. } => "replace_module_path",
1064 Self::AddExport { .. } => "add_export",
1065 Self::RemoveImport { .. } => "remove_import",
1066 Self::InjectStub { .. } => "inject_stub",
1067 Self::RewriteRequire { .. } => "rewrite_require",
1068 }
1069 }
1070}
1071
1072#[derive(Debug, Clone)]
1078pub struct PatchProposal {
1079 pub rule_id: String,
1081 pub ops: Vec<PatchOp>,
1083 pub rationale: String,
1085 pub confidence: Option<f64>,
1087}
1088
1089impl PatchProposal {
1090 pub fn max_risk(&self) -> RepairRisk {
1092 if self
1093 .ops
1094 .iter()
1095 .any(|op| op.risk() == RepairRisk::Aggressive)
1096 {
1097 RepairRisk::Aggressive
1098 } else {
1099 RepairRisk::Safe
1100 }
1101 }
1102
1103 pub fn is_allowed_by(&self, mode: RepairMode) -> bool {
1105 match self.max_risk() {
1106 RepairRisk::Safe => mode.should_apply(),
1107 RepairRisk::Aggressive => mode.allows_aggressive(),
1108 }
1109 }
1110
1111 pub fn op_count(&self) -> usize {
1113 self.ops.len()
1114 }
1115}
1116
1117#[derive(Debug, Clone, PartialEq, Eq)]
1123pub enum ConflictKind {
1124 None,
1126 SameModulePath(String),
1128 SameVirtualPath(String),
1130}
1131
1132impl ConflictKind {
1133 pub const fn is_clear(&self) -> bool {
1135 matches!(self, Self::None)
1136 }
1137}
1138
1139pub fn detect_conflict(a: &PatchProposal, b: &PatchProposal) -> ConflictKind {
1145 for op_a in &a.ops {
1146 for op_b in &b.ops {
1147 if let Some(conflict) = ops_conflict(op_a, op_b) {
1148 return conflict;
1149 }
1150 }
1151 }
1152 ConflictKind::None
1153}
1154
1155fn ops_conflict(a: &PatchOp, b: &PatchOp) -> Option<ConflictKind> {
1157 match (a, b) {
1158 (
1159 PatchOp::ReplaceModulePath { from: fa, .. },
1160 PatchOp::ReplaceModulePath { from: fb, .. },
1161 ) if fa == fb => Some(ConflictKind::SameModulePath(fa.clone())),
1162
1163 (
1164 PatchOp::AddExport {
1165 module_path: pa, ..
1166 },
1167 PatchOp::AddExport {
1168 module_path: pb, ..
1169 },
1170 ) if pa == pb => Some(ConflictKind::SameModulePath(pa.clone())),
1171
1172 (
1173 PatchOp::RemoveImport {
1174 module_path: pa, ..
1175 },
1176 PatchOp::RemoveImport {
1177 module_path: pb, ..
1178 },
1179 ) if pa == pb => Some(ConflictKind::SameModulePath(pa.clone())),
1180
1181 (
1182 PatchOp::InjectStub {
1183 virtual_path: va, ..
1184 },
1185 PatchOp::InjectStub {
1186 virtual_path: vb, ..
1187 },
1188 ) if va == vb => Some(ConflictKind::SameVirtualPath(va.clone())),
1189
1190 (
1191 PatchOp::RewriteRequire {
1192 module_path: pa,
1193 from_specifier: sa,
1194 ..
1195 },
1196 PatchOp::RewriteRequire {
1197 module_path: pb,
1198 from_specifier: sb,
1199 ..
1200 },
1201 ) if pa == pb && sa == sb => Some(ConflictKind::SameModulePath(pa.clone())),
1202
1203 _ => Option::None,
1204 }
1205}
1206
1207pub fn select_best_candidate(
1218 candidates: &[PatchProposal],
1219 mode: RepairMode,
1220) -> Option<&PatchProposal> {
1221 candidates
1222 .iter()
1223 .filter(|p| p.is_allowed_by(mode))
1224 .min_by(|a, b| compare_proposals(a, b))
1225}
1226
1227fn compare_proposals(a: &PatchProposal, b: &PatchProposal) -> std::cmp::Ordering {
1229 let risk_ord = risk_rank(a.max_risk()).cmp(&risk_rank(b.max_risk()));
1231 if risk_ord != std::cmp::Ordering::Equal {
1232 return risk_ord;
1233 }
1234
1235 let ops_ord = a.op_count().cmp(&b.op_count());
1237 if ops_ord != std::cmp::Ordering::Equal {
1238 return ops_ord;
1239 }
1240
1241 let conf_a = a.confidence.unwrap_or(0.0);
1243 let conf_b = b.confidence.unwrap_or(0.0);
1244 let conf_ord = conf_b
1246 .partial_cmp(&conf_a)
1247 .unwrap_or(std::cmp::Ordering::Equal);
1248 if conf_ord != std::cmp::Ordering::Equal {
1249 return conf_ord;
1250 }
1251
1252 a.rule_id.cmp(&b.rule_id)
1254}
1255
1256const fn risk_rank(risk: RepairRisk) -> u8 {
1258 match risk {
1259 RepairRisk::Safe => 0,
1260 RepairRisk::Aggressive => 1,
1261 }
1262}
1263
1264pub fn resolve_conflicts(proposals: &[PatchProposal]) -> Vec<&PatchProposal> {
1269 if proposals.is_empty() {
1270 return vec![];
1271 }
1272
1273 let mut indexed: Vec<(usize, &PatchProposal)> = proposals.iter().enumerate().collect();
1275 indexed.sort_by(|(_, a), (_, b)| compare_proposals(a, b));
1276
1277 let mut accepted: Vec<&PatchProposal> = Vec::new();
1278 for (_, candidate) in indexed {
1279 let conflicts_with_accepted = accepted
1280 .iter()
1281 .any(|acc| !detect_conflict(acc, candidate).is_clear());
1282 if !conflicts_with_accepted {
1283 accepted.push(candidate);
1284 }
1285 }
1286
1287 accepted
1288}
1289
1290#[derive(Debug, Clone)]
1300pub struct RepairContext {
1301 pub extension_id: String,
1303 pub gating: GatingVerdict,
1305 pub intent: IntentGraph,
1307 pub parse: TolerantParseResult,
1309 pub mode: RepairMode,
1311 pub diagnostics: Vec<String>,
1313 pub allowed_op_tags: Vec<&'static str>,
1315}
1316
1317impl RepairContext {
1318 pub fn new(
1320 extension_id: String,
1321 gating: GatingVerdict,
1322 intent: IntentGraph,
1323 parse: TolerantParseResult,
1324 mode: RepairMode,
1325 diagnostics: Vec<String>,
1326 ) -> Self {
1327 let allowed_op_tags = allowed_op_tags_for_mode(mode);
1328 Self {
1329 extension_id,
1330 gating,
1331 intent,
1332 parse,
1333 mode,
1334 diagnostics,
1335 allowed_op_tags,
1336 }
1337 }
1338}
1339
1340pub fn allowed_op_tags_for_mode(mode: RepairMode) -> Vec<&'static str> {
1342 let mut tags = Vec::new();
1343 if mode.should_apply() {
1344 tags.extend_from_slice(&["replace_module_path", "rewrite_require"]);
1346 }
1347 if mode.allows_aggressive() {
1348 tags.extend_from_slice(&["add_export", "remove_import", "inject_stub"]);
1350 }
1351 tags
1352}
1353
1354#[derive(Debug, Clone, PartialEq, Eq)]
1360pub enum ProposalValidationError {
1361 EmptyProposal,
1363 DisallowedOp { tag: String },
1365 RiskExceedsMode { risk: RepairRisk, mode: RepairMode },
1367 UnknownRule { rule_id: String },
1369 MonotonicityViolation { path: String },
1371}
1372
1373impl std::fmt::Display for ProposalValidationError {
1374 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1375 match self {
1376 Self::EmptyProposal => write!(f, "proposal has no operations"),
1377 Self::DisallowedOp { tag } => write!(f, "op '{tag}' not allowed in current mode"),
1378 Self::RiskExceedsMode { risk, mode } => {
1379 write!(f, "{risk:?} risk not allowed in {mode:?} mode")
1380 }
1381 Self::UnknownRule { rule_id } => write!(f, "unknown rule: {rule_id}"),
1382 Self::MonotonicityViolation { path } => {
1383 write!(f, "path escapes extension root: {path}")
1384 }
1385 }
1386 }
1387}
1388
1389pub fn validate_proposal(
1398 proposal: &PatchProposal,
1399 mode: RepairMode,
1400 extension_root: Option<&Path>,
1401) -> Vec<ProposalValidationError> {
1402 let mut errors = Vec::new();
1403
1404 if proposal.ops.is_empty() {
1406 errors.push(ProposalValidationError::EmptyProposal);
1407 return errors;
1408 }
1409
1410 let allowed = allowed_op_tags_for_mode(mode);
1412 for op in &proposal.ops {
1413 if !allowed.contains(&op.tag()) {
1414 errors.push(ProposalValidationError::DisallowedOp {
1415 tag: op.tag().to_string(),
1416 });
1417 }
1418 }
1419
1420 if !proposal.is_allowed_by(mode) {
1422 errors.push(ProposalValidationError::RiskExceedsMode {
1423 risk: proposal.max_risk(),
1424 mode,
1425 });
1426 }
1427
1428 if !proposal.rule_id.is_empty() && rule_by_id(&proposal.rule_id).is_none() {
1430 errors.push(ProposalValidationError::UnknownRule {
1431 rule_id: proposal.rule_id.clone(),
1432 });
1433 }
1434
1435 if let Some(root) = extension_root {
1437 for op in &proposal.ops {
1438 let mut paths_to_check = vec![op_target_path(op)];
1439 if let PatchOp::ReplaceModulePath { from, .. } = op {
1440 paths_to_check.push(from.clone());
1441 }
1442
1443 for path_str in paths_to_check {
1444 let target = Path::new(&path_str);
1445 let resolved = if target.is_absolute() {
1446 target.to_path_buf()
1447 } else {
1448 root.join(target)
1449 };
1450 let verdict = verify_repair_monotonicity(root, root, &resolved);
1451 if !verdict.is_safe() {
1452 errors.push(ProposalValidationError::MonotonicityViolation { path: path_str });
1453 }
1454 }
1455 }
1456 }
1457
1458 errors
1459}
1460
1461fn op_target_path(op: &PatchOp) -> String {
1463 match op {
1464 PatchOp::ReplaceModulePath { to, .. } => to.clone(),
1465 PatchOp::AddExport { module_path, .. }
1466 | PatchOp::RemoveImport { module_path, .. }
1467 | PatchOp::RewriteRequire { module_path, .. } => module_path.clone(),
1468 PatchOp::InjectStub { virtual_path, .. } => virtual_path.clone(),
1469 }
1470}
1471
1472#[derive(Debug, Clone)]
1474pub struct ApplicationResult {
1475 pub success: bool,
1477 pub ops_applied: usize,
1479 pub summary: String,
1481}
1482
1483pub fn apply_proposal(
1489 proposal: &PatchProposal,
1490 mode: RepairMode,
1491 extension_root: Option<&Path>,
1492) -> std::result::Result<ApplicationResult, Vec<ProposalValidationError>> {
1493 let errors = validate_proposal(proposal, mode, extension_root);
1494 if !errors.is_empty() {
1495 return Err(errors);
1496 }
1497
1498 Ok(ApplicationResult {
1499 success: true,
1500 ops_applied: proposal.ops.len(),
1501 summary: format!(
1502 "Applied {} op(s) from rule '{}'",
1503 proposal.ops.len(),
1504 proposal.rule_id
1505 ),
1506 })
1507}
1508
1509#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1515pub enum ApprovalRequirement {
1516 AutoApproved,
1518 RequiresApproval,
1520}
1521
1522impl ApprovalRequirement {
1523 pub const fn needs_approval(&self) -> bool {
1525 matches!(self, Self::RequiresApproval)
1526 }
1527}
1528
1529impl std::fmt::Display for ApprovalRequirement {
1530 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1531 match self {
1532 Self::AutoApproved => write!(f, "auto_approved"),
1533 Self::RequiresApproval => write!(f, "requires_approval"),
1534 }
1535 }
1536}
1537
1538#[derive(Debug, Clone)]
1540pub struct ApprovalRequest {
1541 pub extension_id: String,
1543 pub proposal: PatchProposal,
1545 pub risk: RepairRisk,
1547 pub confidence_score: f64,
1549 pub rationale: String,
1551 pub op_summaries: Vec<String>,
1553}
1554
1555#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1557pub enum ApprovalResponse {
1558 Approved,
1560 Rejected,
1562}
1563
1564pub fn check_approval_requirement(
1572 proposal: &PatchProposal,
1573 confidence_score: f64,
1574) -> ApprovalRequirement {
1575 if proposal.max_risk() == RepairRisk::Aggressive {
1576 return ApprovalRequirement::RequiresApproval;
1577 }
1578 if confidence_score < 0.5 {
1579 return ApprovalRequirement::RequiresApproval;
1580 }
1581 if proposal.ops.len() >= 3 {
1582 return ApprovalRequirement::RequiresApproval;
1583 }
1584 ApprovalRequirement::AutoApproved
1585}
1586
1587pub fn build_approval_request(
1589 extension_id: &str,
1590 proposal: &PatchProposal,
1591 confidence_score: f64,
1592) -> ApprovalRequest {
1593 let op_summaries = proposal
1594 .ops
1595 .iter()
1596 .map(|op| format!("[{}] {}", op.tag(), op_target_path(op)))
1597 .collect();
1598
1599 ApprovalRequest {
1600 extension_id: extension_id.to_string(),
1601 proposal: proposal.clone(),
1602 risk: proposal.max_risk(),
1603 confidence_score,
1604 rationale: proposal.rationale.clone(),
1605 op_summaries,
1606 }
1607}
1608
1609#[derive(Debug, Clone, PartialEq, Eq)]
1615pub enum StructuralVerdict {
1616 Valid,
1618 Unreadable { path: PathBuf, reason: String },
1620 UnsupportedExtension { path: PathBuf, extension: String },
1622 ParseError { path: PathBuf, message: String },
1624}
1625
1626impl StructuralVerdict {
1627 pub const fn is_valid(&self) -> bool {
1629 matches!(self, Self::Valid)
1630 }
1631}
1632
1633impl std::fmt::Display for StructuralVerdict {
1634 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1635 match self {
1636 Self::Valid => write!(f, "valid"),
1637 Self::Unreadable { path, reason } => {
1638 write!(f, "unreadable: {} ({})", path.display(), reason)
1639 }
1640 Self::UnsupportedExtension { path, extension } => {
1641 write!(
1642 f,
1643 "unsupported extension: {} (.{})",
1644 path.display(),
1645 extension
1646 )
1647 }
1648 Self::ParseError { path, message } => {
1649 write!(f, "parse error: {} ({})", path.display(), message)
1650 }
1651 }
1652 }
1653}
1654
1655pub fn validate_repaired_artifact(path: &Path) -> StructuralVerdict {
1664 let source = match fs::read_to_string(path) {
1666 Ok(s) => s,
1667 Err(err) => {
1668 return StructuralVerdict::Unreadable {
1669 path: path.to_path_buf(),
1670 reason: err.to_string(),
1671 };
1672 }
1673 };
1674
1675 let ext = path
1677 .extension()
1678 .and_then(|e| e.to_str())
1679 .unwrap_or("")
1680 .to_ascii_lowercase();
1681
1682 match ext.as_str() {
1683 "ts" | "tsx" => validate_typescript_parse(path, &source, &ext),
1684 "js" | "mjs" => {
1685 StructuralVerdict::Valid
1688 }
1689 "json" => validate_json_parse(path, &source),
1690 _ => StructuralVerdict::UnsupportedExtension {
1691 path: path.to_path_buf(),
1692 extension: ext,
1693 },
1694 }
1695}
1696
1697fn validate_typescript_parse(path: &Path, source: &str, ext: &str) -> StructuralVerdict {
1699 use swc_common::{FileName, GLOBALS, Globals};
1700 use swc_ecma_parser::{Parser as SwcParser, StringInput, Syntax, TsSyntax};
1701
1702 let globals = Globals::new();
1703 GLOBALS.set(&globals, || {
1704 let cm: swc_common::sync::Lrc<swc_common::SourceMap> = swc_common::sync::Lrc::default();
1705 let fm = cm.new_source_file(
1706 FileName::Custom(path.display().to_string()).into(),
1707 source.to_string(),
1708 );
1709 let syntax = Syntax::Typescript(TsSyntax {
1710 tsx: ext == "tsx",
1711 decorators: true,
1712 ..Default::default()
1713 });
1714 let mut parser = SwcParser::new(syntax, StringInput::from(&*fm), None);
1715 match parser.parse_module() {
1716 Ok(_) => StructuralVerdict::Valid,
1717 Err(err) => StructuralVerdict::ParseError {
1718 path: path.to_path_buf(),
1719 message: format!("{err:?}"),
1720 },
1721 }
1722 })
1723}
1724
1725fn validate_json_parse(path: &Path, source: &str) -> StructuralVerdict {
1727 match serde_json::from_str::<serde_json::Value>(source) {
1728 Ok(_) => StructuralVerdict::Valid,
1729 Err(err) => StructuralVerdict::ParseError {
1730 path: path.to_path_buf(),
1731 message: err.to_string(),
1732 },
1733 }
1734}
1735
1736#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1742pub enum AmbiguitySignal {
1743 DynamicEval,
1745 DynamicFunction,
1747 DynamicImport,
1749 StarReExport,
1751 DynamicRequire,
1753 ProxyUsage,
1755 WithStatement,
1757 RecoverableParseErrors { count: usize },
1759}
1760
1761impl AmbiguitySignal {
1762 pub fn weight(&self) -> f64 {
1764 match self {
1765 Self::DynamicEval | Self::DynamicFunction => 0.9,
1766 Self::ProxyUsage | Self::WithStatement => 0.7,
1767 Self::DynamicImport | Self::DynamicRequire => 0.5,
1768 Self::StarReExport => 0.3,
1769 Self::RecoverableParseErrors { count } => {
1770 (f64::from(u32::try_from(*count).unwrap_or(u32::MAX)) * 0.2).min(1.0)
1772 }
1773 }
1774 }
1775
1776 pub const fn tag(&self) -> &'static str {
1778 match self {
1779 Self::DynamicEval => "dynamic_eval",
1780 Self::DynamicFunction => "dynamic_function",
1781 Self::DynamicImport => "dynamic_import",
1782 Self::StarReExport => "star_reexport",
1783 Self::DynamicRequire => "dynamic_require",
1784 Self::ProxyUsage => "proxy_usage",
1785 Self::WithStatement => "with_statement",
1786 Self::RecoverableParseErrors { .. } => "recoverable_parse_errors",
1787 }
1788 }
1789}
1790
1791impl std::fmt::Display for AmbiguitySignal {
1792 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1793 match self {
1794 Self::RecoverableParseErrors { count } => {
1795 write!(f, "{}({})", self.tag(), count)
1796 }
1797 _ => write!(f, "{}", self.tag()),
1798 }
1799 }
1800}
1801
1802#[derive(Debug, Clone)]
1804pub struct TolerantParseResult {
1805 pub parsed_ok: bool,
1807 pub statement_count: usize,
1809 pub import_export_count: usize,
1811 pub ambiguities: Vec<AmbiguitySignal>,
1813}
1814
1815impl TolerantParseResult {
1816 pub fn ambiguity_score(&self) -> f64 {
1818 if self.ambiguities.is_empty() {
1819 return 0.0;
1820 }
1821 self.ambiguities
1823 .iter()
1824 .map(AmbiguitySignal::weight)
1825 .fold(0.0_f64, f64::max)
1826 }
1827
1828 pub fn is_legible(&self) -> bool {
1830 self.parsed_ok && self.ambiguity_score() < 0.8
1831 }
1832}
1833
1834pub fn tolerant_parse(source: &str, filename: &str) -> TolerantParseResult {
1841 let ext = Path::new(filename)
1842 .extension()
1843 .and_then(|e| e.to_str())
1844 .unwrap_or("")
1845 .to_ascii_lowercase();
1846
1847 let (parsed_ok, statement_count, import_export_count, parse_errors) = match ext.as_str() {
1848 "ts" | "tsx" | "js" | "mjs" => try_swc_parse(source, filename, &ext),
1849 _ => (false, 0, 0, 0),
1850 };
1851
1852 let mut ambiguities = detect_ambiguity_patterns(source);
1853 if parse_errors > 0 {
1854 ambiguities.push(AmbiguitySignal::RecoverableParseErrors {
1855 count: parse_errors,
1856 });
1857 }
1858
1859 let mut seen = std::collections::HashSet::new();
1861 ambiguities.retain(|s| seen.insert(s.clone()));
1862
1863 TolerantParseResult {
1864 parsed_ok,
1865 statement_count,
1866 import_export_count,
1867 ambiguities,
1868 }
1869}
1870
1871fn try_swc_parse(source: &str, filename: &str, ext: &str) -> (bool, usize, usize, usize) {
1873 use swc_common::{FileName, GLOBALS, Globals};
1874 use swc_ecma_parser::{Parser as SwcParser, StringInput, Syntax, TsSyntax};
1875
1876 let globals = Globals::new();
1877 GLOBALS.set(&globals, || {
1878 let cm: swc_common::sync::Lrc<swc_common::SourceMap> = swc_common::sync::Lrc::default();
1879 let fm = cm.new_source_file(
1880 FileName::Custom(filename.to_string()).into(),
1881 source.to_string(),
1882 );
1883 let is_ts = ext == "ts" || ext == "tsx";
1884 let syntax = if is_ts {
1885 Syntax::Typescript(TsSyntax {
1886 tsx: ext == "tsx",
1887 decorators: true,
1888 ..Default::default()
1889 })
1890 } else {
1891 Syntax::Es(swc_ecma_parser::EsSyntax {
1892 jsx: true,
1893 ..Default::default()
1894 })
1895 };
1896 let mut parser = SwcParser::new(syntax, StringInput::from(&*fm), None);
1897 if let Ok(module) = parser.parse_module() {
1898 let errors = parser.take_errors();
1899 let stmts = module.body.len();
1900 let imports = module
1901 .body
1902 .iter()
1903 .filter(|item| {
1904 matches!(
1905 item,
1906 swc_ecma_ast::ModuleItem::ModuleDecl(
1907 swc_ecma_ast::ModuleDecl::Import(_)
1908 | swc_ecma_ast::ModuleDecl::ExportAll(_)
1909 | swc_ecma_ast::ModuleDecl::ExportNamed(_)
1910 | swc_ecma_ast::ModuleDecl::ExportDefaultDecl(_)
1911 | swc_ecma_ast::ModuleDecl::ExportDefaultExpr(_)
1912 | swc_ecma_ast::ModuleDecl::ExportDecl(_)
1913 )
1914 )
1915 })
1916 .count();
1917 (true, stmts, imports, errors.len())
1918 } else {
1919 let errors = parser.take_errors();
1920 (false, 0, 0, errors.len() + 1)
1922 }
1923 })
1924}
1925
1926fn detect_ambiguity_patterns(source: &str) -> Vec<AmbiguitySignal> {
1928 use std::sync::OnceLock;
1929
1930 static PATTERNS: OnceLock<Vec<(regex::Regex, AmbiguitySignal)>> = OnceLock::new();
1931 static DYN_REQUIRE: OnceLock<regex::Regex> = OnceLock::new();
1932
1933 let patterns = PATTERNS.get_or_init(|| {
1934 vec![
1935 (
1936 regex::Regex::new(r"\beval\s*\(").expect("regex"),
1937 AmbiguitySignal::DynamicEval,
1938 ),
1939 (
1940 regex::Regex::new(r"\bnew\s+Function\s*\(").expect("regex"),
1941 AmbiguitySignal::DynamicFunction,
1942 ),
1943 (
1944 regex::Regex::new(r"\bimport\s*\(").expect("regex"),
1945 AmbiguitySignal::DynamicImport,
1946 ),
1947 (
1948 regex::Regex::new(r"export\s+\*\s+from\b").expect("regex"),
1949 AmbiguitySignal::StarReExport,
1950 ),
1951 (
1952 regex::Regex::new(r"\bnew\s+Proxy\s*\(").expect("regex"),
1953 AmbiguitySignal::ProxyUsage,
1954 ),
1955 (
1956 regex::Regex::new(r"\bwith\s*\(").expect("regex"),
1957 AmbiguitySignal::WithStatement,
1958 ),
1959 ]
1960 });
1961
1962 let dyn_require = DYN_REQUIRE
1963 .get_or_init(|| regex::Regex::new(r#"\brequire\s*\(\s*[^"'`\s)]"#).expect("regex"));
1964
1965 let mut signals = Vec::new();
1966 for (re, signal) in patterns {
1967 if re.is_match(source) {
1968 signals.push(signal.clone());
1969 }
1970 }
1971 if dyn_require.is_match(source) {
1972 signals.push(AmbiguitySignal::DynamicRequire);
1973 }
1974
1975 signals
1976}
1977
1978#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1984pub enum IntentSignal {
1985 RegistersTool(String),
1987 RegistersCommand(String),
1989 RegistersShortcut(String),
1991 RegistersFlag(String),
1993 RegistersProvider(String),
1995 HooksEvent(String),
1997 RequiresCapability(String),
1999 RegistersRenderer(String),
2001}
2002
2003impl IntentSignal {
2004 pub const fn category(&self) -> &'static str {
2006 match self {
2007 Self::RegistersTool(_) => "tool",
2008 Self::RegistersCommand(_) => "command",
2009 Self::RegistersShortcut(_) => "shortcut",
2010 Self::RegistersFlag(_) => "flag",
2011 Self::RegistersProvider(_) => "provider",
2012 Self::HooksEvent(_) => "event_hook",
2013 Self::RequiresCapability(_) => "capability",
2014 Self::RegistersRenderer(_) => "renderer",
2015 }
2016 }
2017
2018 pub fn name(&self) -> &str {
2020 match self {
2021 Self::RegistersTool(n)
2022 | Self::RegistersCommand(n)
2023 | Self::RegistersShortcut(n)
2024 | Self::RegistersFlag(n)
2025 | Self::RegistersProvider(n)
2026 | Self::HooksEvent(n)
2027 | Self::RequiresCapability(n)
2028 | Self::RegistersRenderer(n) => n,
2029 }
2030 }
2031}
2032
2033impl std::fmt::Display for IntentSignal {
2034 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2035 write!(f, "{}:{}", self.category(), self.name())
2036 }
2037}
2038
2039#[derive(Debug, Clone, Default)]
2045pub struct IntentGraph {
2046 pub extension_id: String,
2048 pub signals: Vec<IntentSignal>,
2050}
2051
2052impl IntentGraph {
2053 pub fn from_register_payload(
2055 extension_id: &str,
2056 payload: &serde_json::Value,
2057 capabilities: &[String],
2058 ) -> Self {
2059 let mut signals = Vec::new();
2060
2061 if let Some(tools) = payload.get("tools").and_then(|v| v.as_array()) {
2063 for tool in tools {
2064 if let Some(name) = tool.get("name").and_then(|n| n.as_str()) {
2065 signals.push(IntentSignal::RegistersTool(name.to_string()));
2066 }
2067 }
2068 }
2069
2070 if let Some(cmds) = payload.get("slash_commands").and_then(|v| v.as_array()) {
2072 for cmd in cmds {
2073 if let Some(name) = cmd.get("name").and_then(|n| n.as_str()) {
2074 signals.push(IntentSignal::RegistersCommand(name.to_string()));
2075 }
2076 }
2077 }
2078
2079 if let Some(shortcuts) = payload.get("shortcuts").and_then(|v| v.as_array()) {
2081 for sc in shortcuts {
2082 let label = sc
2083 .get("name")
2084 .or_else(|| sc.get("key"))
2085 .and_then(|n| n.as_str())
2086 .unwrap_or("unknown");
2087 signals.push(IntentSignal::RegistersShortcut(label.to_string()));
2088 }
2089 }
2090
2091 if let Some(flags) = payload.get("flags").and_then(|v| v.as_array()) {
2093 for flag in flags {
2094 if let Some(name) = flag.get("name").and_then(|n| n.as_str()) {
2095 signals.push(IntentSignal::RegistersFlag(name.to_string()));
2096 }
2097 }
2098 }
2099
2100 if let Some(hooks) = payload.get("event_hooks").and_then(|v| v.as_array()) {
2102 for hook in hooks {
2103 if let Some(name) = hook.as_str() {
2104 signals.push(IntentSignal::HooksEvent(name.to_string()));
2105 }
2106 }
2107 }
2108
2109 for cap in capabilities {
2111 signals.push(IntentSignal::RequiresCapability(cap.clone()));
2112 }
2113
2114 let mut seen = std::collections::HashSet::new();
2116 signals.retain(|s| seen.insert(s.clone()));
2117
2118 Self {
2119 extension_id: extension_id.to_string(),
2120 signals,
2121 }
2122 }
2123
2124 pub fn signals_by_category(&self, category: &str) -> Vec<&IntentSignal> {
2126 self.signals
2127 .iter()
2128 .filter(|s| s.category() == category)
2129 .collect()
2130 }
2131
2132 pub fn category_count(&self) -> usize {
2134 let cats: std::collections::HashSet<&str> =
2135 self.signals.iter().map(IntentSignal::category).collect();
2136 cats.len()
2137 }
2138
2139 pub fn is_empty(&self) -> bool {
2141 self.signals.is_empty()
2142 }
2143
2144 pub fn signal_count(&self) -> usize {
2146 self.signals.len()
2147 }
2148}
2149
2150#[derive(Debug, Clone)]
2156pub struct ConfidenceReason {
2157 pub code: String,
2159 pub explanation: String,
2161 pub delta: f64,
2163}
2164
2165#[derive(Debug, Clone)]
2167pub struct ConfidenceReport {
2168 pub score: f64,
2170 pub reasons: Vec<ConfidenceReason>,
2172}
2173
2174impl ConfidenceReport {
2175 pub fn is_repairable(&self) -> bool {
2177 self.score >= 0.5
2178 }
2179
2180 pub fn is_suggestable(&self) -> bool {
2182 self.score >= 0.2
2183 }
2184}
2185
2186#[allow(clippy::too_many_lines)]
2202pub fn compute_confidence(intent: &IntentGraph, parse: &TolerantParseResult) -> ConfidenceReport {
2203 let mut score: f64 = 0.5;
2204 let mut reasons = Vec::new();
2205
2206 if parse.parsed_ok {
2208 let delta = 0.15;
2209 score += delta;
2210 reasons.push(ConfidenceReason {
2211 code: "parsed_ok".to_string(),
2212 explanation: "Source parsed without fatal errors".to_string(),
2213 delta,
2214 });
2215 } else {
2216 let delta = -0.3;
2217 score += delta;
2218 reasons.push(ConfidenceReason {
2219 code: "parse_failed".to_string(),
2220 explanation: "Source failed to parse".to_string(),
2221 delta,
2222 });
2223 }
2224
2225 if parse.statement_count == 0 && parse.parsed_ok {
2227 let delta = -0.1;
2228 score += delta;
2229 reasons.push(ConfidenceReason {
2230 code: "empty_module".to_string(),
2231 explanation: "Module has no statements".to_string(),
2232 delta,
2233 });
2234 }
2235
2236 if parse.import_export_count > 0 {
2238 let delta = 0.05;
2239 score += delta;
2240 reasons.push(ConfidenceReason {
2241 code: "has_imports_exports".to_string(),
2242 explanation: format!(
2243 "{} import/export declarations found",
2244 parse.import_export_count
2245 ),
2246 delta,
2247 });
2248 }
2249
2250 for ambiguity in &parse.ambiguities {
2252 let weight = ambiguity.weight();
2253 let delta = -weight * 0.3;
2254 score += delta;
2255 reasons.push(ConfidenceReason {
2256 code: format!("ambiguity_{}", ambiguity.tag()),
2257 explanation: format!("Ambiguity detected: {ambiguity} (weight={weight:.1})"),
2258 delta,
2259 });
2260 }
2261
2262 let tool_count = intent.signals_by_category("tool").len();
2264 if tool_count > 0 {
2265 let delta = 0.1;
2266 score += delta;
2267 reasons.push(ConfidenceReason {
2268 code: "has_tools".to_string(),
2269 explanation: format!("{tool_count} tool(s) registered"),
2270 delta,
2271 });
2272 }
2273
2274 let hook_count = intent.signals_by_category("event_hook").len();
2275 if hook_count > 0 {
2276 let delta = 0.05;
2277 score += delta;
2278 reasons.push(ConfidenceReason {
2279 code: "has_event_hooks".to_string(),
2280 explanation: format!("{hook_count} event hook(s) registered"),
2281 delta,
2282 });
2283 }
2284
2285 let categories = intent.category_count();
2286 if categories >= 3 {
2287 let delta = 0.1;
2288 score += delta;
2289 reasons.push(ConfidenceReason {
2290 code: "multi_category".to_string(),
2291 explanation: format!("{categories} distinct intent categories"),
2292 delta,
2293 });
2294 }
2295
2296 if intent.is_empty() && parse.parsed_ok {
2297 let delta = -0.15;
2298 score += delta;
2299 reasons.push(ConfidenceReason {
2300 code: "no_registrations".to_string(),
2301 explanation: "No tools, commands, or hooks registered".to_string(),
2302 delta,
2303 });
2304 }
2305
2306 score = score.clamp(0.0, 1.0);
2308
2309 ConfidenceReport { score, reasons }
2310}
2311
2312#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2318pub enum GatingDecision {
2319 Allow,
2321 Suggest,
2323 Deny,
2325}
2326
2327impl GatingDecision {
2328 pub const fn label(&self) -> &'static str {
2330 match self {
2331 Self::Allow => "allow",
2332 Self::Suggest => "suggest",
2333 Self::Deny => "deny",
2334 }
2335 }
2336}
2337
2338impl std::fmt::Display for GatingDecision {
2339 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2340 f.write_str(self.label())
2341 }
2342}
2343
2344#[derive(Debug, Clone, PartialEq, Eq)]
2346pub struct GatingReasonCode {
2347 pub code: String,
2349 pub remediation: String,
2351}
2352
2353#[derive(Debug, Clone)]
2355pub struct GatingVerdict {
2356 pub decision: GatingDecision,
2358 pub confidence: ConfidenceReport,
2360 pub reason_codes: Vec<GatingReasonCode>,
2362}
2363
2364impl GatingVerdict {
2365 pub fn allows_repair(&self) -> bool {
2367 self.decision == GatingDecision::Allow
2368 }
2369
2370 pub const fn allows_suggestion(&self) -> bool {
2372 matches!(
2373 self.decision,
2374 GatingDecision::Allow | GatingDecision::Suggest
2375 )
2376 }
2377}
2378
2379pub fn compute_gating_verdict(intent: &IntentGraph, parse: &TolerantParseResult) -> GatingVerdict {
2389 let confidence = compute_confidence(intent, parse);
2390 let decision = if confidence.is_repairable() {
2391 GatingDecision::Allow
2392 } else if confidence.is_suggestable() {
2393 GatingDecision::Suggest
2394 } else {
2395 GatingDecision::Deny
2396 };
2397
2398 let reason_codes = if decision == GatingDecision::Allow {
2399 vec![]
2400 } else {
2401 build_reason_codes(&confidence, parse)
2402 };
2403
2404 GatingVerdict {
2405 decision,
2406 confidence,
2407 reason_codes,
2408 }
2409}
2410
2411fn build_reason_codes(
2413 confidence: &ConfidenceReport,
2414 parse: &TolerantParseResult,
2415) -> Vec<GatingReasonCode> {
2416 let mut codes = Vec::new();
2417
2418 if !parse.parsed_ok {
2419 codes.push(GatingReasonCode {
2420 code: "parse_failed".to_string(),
2421 remediation: "Fix syntax errors in the extension source code".to_string(),
2422 });
2423 }
2424
2425 for ambiguity in &parse.ambiguities {
2426 if ambiguity.weight() >= 0.7 {
2427 codes.push(GatingReasonCode {
2428 code: format!("high_ambiguity_{}", ambiguity.tag()),
2429 remediation: format!(
2430 "Remove or refactor {} usage to improve repair safety",
2431 ambiguity.tag().replace('_', " ")
2432 ),
2433 });
2434 }
2435 }
2436
2437 if confidence.score < 0.2 {
2438 codes.push(GatingReasonCode {
2439 code: "very_low_confidence".to_string(),
2440 remediation: "Extension is too opaque for automated analysis; \
2441 add explicit tool/hook registrations and remove dynamic constructs"
2442 .to_string(),
2443 });
2444 }
2445
2446 codes
2447}
2448
2449#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2451pub struct PiJsTickStats {
2452 pub ran_macrotask: bool,
2454 pub microtask_drains: usize,
2456 pub jobs_drained: usize,
2458 pub pending_hostcalls: usize,
2460 pub hostcalls_total: u64,
2462 pub hostcalls_timed_out: u64,
2464 pub memory_used_bytes: u64,
2466 pub peak_memory_used_bytes: u64,
2468 pub repairs_total: u64,
2470 pub module_cache_hits: u64,
2472 pub module_cache_misses: u64,
2474 pub module_cache_invalidations: u64,
2476 pub module_cache_entries: u64,
2478 pub module_disk_cache_hits: u64,
2480}
2481
2482#[derive(Debug, Clone, Default)]
2483pub struct PiJsRuntimeLimits {
2484 pub memory_limit_bytes: Option<usize>,
2486 pub max_stack_bytes: Option<usize>,
2488 pub interrupt_budget: Option<u64>,
2493 pub hostcall_timeout_ms: Option<u64>,
2495 pub hostcall_fast_queue_capacity: usize,
2499 pub hostcall_overflow_queue_capacity: usize,
2503}
2504
2505#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2510pub enum RepairMode {
2511 Off,
2513 Suggest,
2516 #[default]
2519 AutoSafe,
2520 AutoStrict,
2524}
2525
2526impl RepairMode {
2527 pub const fn should_apply(self) -> bool {
2529 matches!(self, Self::AutoSafe | Self::AutoStrict)
2530 }
2531
2532 pub const fn is_active(self) -> bool {
2534 !matches!(self, Self::Off)
2535 }
2536
2537 pub const fn allows_aggressive(self) -> bool {
2539 matches!(self, Self::AutoStrict)
2540 }
2541
2542 pub fn from_str_lossy(s: &str) -> Self {
2544 match s.trim().to_ascii_lowercase().as_str() {
2545 "off" | "none" | "disabled" | "false" | "0" => Self::Off,
2546 "suggest" | "log" | "dry-run" | "dry_run" => Self::Suggest,
2547 "auto-strict" | "auto_strict" | "strict" | "all" => Self::AutoStrict,
2548 _ => Self::AutoSafe,
2550 }
2551 }
2552}
2553
2554impl std::fmt::Display for RepairMode {
2555 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2556 match self {
2557 Self::Off => write!(f, "off"),
2558 Self::Suggest => write!(f, "suggest"),
2559 Self::AutoSafe => write!(f, "auto-safe"),
2560 Self::AutoStrict => write!(f, "auto-strict"),
2561 }
2562 }
2563}
2564
2565#[derive(Debug, Clone, PartialEq, Eq)]
2571pub enum MonotonicityVerdict {
2572 Safe,
2574 EscapesRoot {
2576 extension_root: PathBuf,
2577 resolved: PathBuf,
2578 },
2579 CrossExtension {
2581 original_extension: String,
2582 resolved: PathBuf,
2583 },
2584}
2585
2586impl MonotonicityVerdict {
2587 pub const fn is_safe(&self) -> bool {
2588 matches!(self, Self::Safe)
2589 }
2590}
2591
2592pub fn verify_repair_monotonicity(
2602 extension_root: &Path,
2603 _original_path: &Path,
2604 resolved_path: &Path,
2605) -> MonotonicityVerdict {
2606 let canonical_root = crate::extensions::safe_canonicalize(extension_root);
2609
2610 let canonical_resolved = crate::extensions::safe_canonicalize(resolved_path);
2611
2612 if !canonical_resolved.starts_with(&canonical_root) {
2614 return MonotonicityVerdict::EscapesRoot {
2615 extension_root: canonical_root,
2616 resolved: canonical_resolved,
2617 };
2618 }
2619
2620 MonotonicityVerdict::Safe
2621}
2622
2623#[derive(Debug, Clone, PartialEq, Eq)]
2629pub enum CapabilityDelta {
2630 Retained(IntentSignal),
2632 Removed(IntentSignal),
2634 Added(IntentSignal),
2636}
2637
2638impl CapabilityDelta {
2639 pub const fn is_escalation(&self) -> bool {
2641 matches!(self, Self::Added(_))
2642 }
2643
2644 pub const fn is_retained(&self) -> bool {
2646 matches!(self, Self::Retained(_))
2647 }
2648
2649 pub const fn is_removed(&self) -> bool {
2651 matches!(self, Self::Removed(_))
2652 }
2653
2654 pub const fn label(&self) -> &'static str {
2656 match self {
2657 Self::Retained(_) => "retained",
2658 Self::Removed(_) => "removed",
2659 Self::Added(_) => "added",
2660 }
2661 }
2662
2663 pub const fn signal(&self) -> &IntentSignal {
2665 match self {
2666 Self::Retained(s) | Self::Removed(s) | Self::Added(s) => s,
2667 }
2668 }
2669}
2670
2671impl std::fmt::Display for CapabilityDelta {
2672 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2673 write!(f, "{}: {}", self.label(), self.signal())
2674 }
2675}
2676
2677#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2679pub enum CapabilityMonotonicityVerdict {
2680 Monotonic,
2682 Escalation,
2684}
2685
2686impl CapabilityMonotonicityVerdict {
2687 pub const fn is_safe(&self) -> bool {
2689 matches!(self, Self::Monotonic)
2690 }
2691}
2692
2693impl std::fmt::Display for CapabilityMonotonicityVerdict {
2694 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2695 match self {
2696 Self::Monotonic => write!(f, "monotonic"),
2697 Self::Escalation => write!(f, "escalation"),
2698 }
2699 }
2700}
2701
2702#[derive(Debug, Clone)]
2708pub struct CapabilityProofReport {
2709 pub extension_id: String,
2711 pub verdict: CapabilityMonotonicityVerdict,
2713 pub deltas: Vec<CapabilityDelta>,
2715 pub retained_count: usize,
2717 pub removed_count: usize,
2719 pub added_count: usize,
2721}
2722
2723impl CapabilityProofReport {
2724 pub const fn is_safe(&self) -> bool {
2726 self.verdict.is_safe()
2727 }
2728
2729 pub fn escalations(&self) -> Vec<&CapabilityDelta> {
2731 self.deltas.iter().filter(|d| d.is_escalation()).collect()
2732 }
2733}
2734
2735pub fn compute_capability_proof(
2743 before: &IntentGraph,
2744 after: &IntentGraph,
2745) -> CapabilityProofReport {
2746 use std::collections::HashSet;
2747
2748 let before_set: HashSet<&IntentSignal> = before.signals.iter().collect();
2749 let after_set: HashSet<&IntentSignal> = after.signals.iter().collect();
2750
2751 let mut deltas = Vec::new();
2752
2753 for signal in &before.signals {
2755 if after_set.contains(signal) {
2756 deltas.push(CapabilityDelta::Retained(signal.clone()));
2757 } else {
2758 deltas.push(CapabilityDelta::Removed(signal.clone()));
2759 }
2760 }
2761
2762 for signal in &after.signals {
2764 if !before_set.contains(signal) {
2765 deltas.push(CapabilityDelta::Added(signal.clone()));
2766 }
2767 }
2768
2769 let retained_count = deltas.iter().filter(|d| d.is_retained()).count();
2770 let removed_count = deltas.iter().filter(|d| d.is_removed()).count();
2771 let added_count = deltas.iter().filter(|d| d.is_escalation()).count();
2772
2773 let verdict = if added_count == 0 {
2774 CapabilityMonotonicityVerdict::Monotonic
2775 } else {
2776 CapabilityMonotonicityVerdict::Escalation
2777 };
2778
2779 CapabilityProofReport {
2780 extension_id: before.extension_id.clone(),
2781 verdict,
2782 deltas,
2783 retained_count,
2784 removed_count,
2785 added_count,
2786 }
2787}
2788
2789#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2795pub enum HostcallCategory {
2796 Events(String),
2798 Session(String),
2800 Register,
2802 Tool(String),
2804 ModuleResolution(String),
2806}
2807
2808impl HostcallCategory {
2809 pub fn tag(&self) -> String {
2811 match self {
2812 Self::Events(op) => format!("events:{op}"),
2813 Self::Session(op) => format!("session:{op}"),
2814 Self::Register => "register".to_string(),
2815 Self::Tool(op) => format!("tool:{op}"),
2816 Self::ModuleResolution(spec) => format!("module:{spec}"),
2817 }
2818 }
2819}
2820
2821impl std::fmt::Display for HostcallCategory {
2822 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2823 f.write_str(&self.tag())
2824 }
2825}
2826
2827#[derive(Debug, Clone, PartialEq, Eq)]
2829pub enum HostcallDelta {
2830 Retained(HostcallCategory),
2832 Removed(HostcallCategory),
2834 Added(HostcallCategory),
2836}
2837
2838impl HostcallDelta {
2839 pub const fn is_expansion(&self) -> bool {
2841 matches!(self, Self::Added(_))
2842 }
2843
2844 pub const fn label(&self) -> &'static str {
2846 match self {
2847 Self::Retained(_) => "retained",
2848 Self::Removed(_) => "removed",
2849 Self::Added(_) => "added",
2850 }
2851 }
2852
2853 pub const fn category(&self) -> &HostcallCategory {
2855 match self {
2856 Self::Retained(c) | Self::Removed(c) | Self::Added(c) => c,
2857 }
2858 }
2859}
2860
2861impl std::fmt::Display for HostcallDelta {
2862 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2863 write!(f, "{}: {}", self.label(), self.category())
2864 }
2865}
2866
2867#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
2869pub enum SemanticDriftSeverity {
2870 None,
2872 Low,
2874 Medium,
2876 High,
2878}
2879
2880impl SemanticDriftSeverity {
2881 pub const fn is_acceptable(&self) -> bool {
2883 matches!(self, Self::None | Self::Low)
2884 }
2885}
2886
2887impl std::fmt::Display for SemanticDriftSeverity {
2888 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2889 match self {
2890 Self::None => write!(f, "none"),
2891 Self::Low => write!(f, "low"),
2892 Self::Medium => write!(f, "medium"),
2893 Self::High => write!(f, "high"),
2894 }
2895 }
2896}
2897
2898#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2900pub enum SemanticParityVerdict {
2901 Equivalent,
2903 AcceptableDrift,
2905 Divergent,
2907}
2908
2909impl SemanticParityVerdict {
2910 pub const fn is_safe(&self) -> bool {
2912 matches!(self, Self::Equivalent | Self::AcceptableDrift)
2913 }
2914}
2915
2916impl std::fmt::Display for SemanticParityVerdict {
2917 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2918 match self {
2919 Self::Equivalent => write!(f, "equivalent"),
2920 Self::AcceptableDrift => write!(f, "acceptable_drift"),
2921 Self::Divergent => write!(f, "divergent"),
2922 }
2923 }
2924}
2925
2926#[derive(Debug, Clone)]
2928pub struct SemanticParityReport {
2929 pub extension_id: String,
2931 pub verdict: SemanticParityVerdict,
2933 pub hostcall_deltas: Vec<HostcallDelta>,
2935 pub drift_severity: SemanticDriftSeverity,
2937 pub expanded_count: usize,
2939 pub removed_count: usize,
2941 pub retained_count: usize,
2943 pub notes: Vec<String>,
2945}
2946
2947impl SemanticParityReport {
2948 pub const fn is_safe(&self) -> bool {
2950 self.verdict.is_safe()
2951 }
2952
2953 pub fn expansions(&self) -> Vec<&HostcallDelta> {
2955 self.hostcall_deltas
2956 .iter()
2957 .filter(|d| d.is_expansion())
2958 .collect()
2959 }
2960}
2961
2962pub fn extract_hostcall_surface(
2967 intent: &IntentGraph,
2968) -> std::collections::HashSet<HostcallCategory> {
2969 let mut surface = std::collections::HashSet::new();
2970
2971 for signal in &intent.signals {
2972 match signal {
2973 IntentSignal::RegistersTool(_)
2974 | IntentSignal::RegistersCommand(_)
2975 | IntentSignal::RegistersShortcut(_)
2976 | IntentSignal::RegistersFlag(_)
2977 | IntentSignal::RegistersProvider(_)
2978 | IntentSignal::RegistersRenderer(_) => {
2979 surface.insert(HostcallCategory::Register);
2980 }
2981 IntentSignal::HooksEvent(name) => {
2982 surface.insert(HostcallCategory::Events(name.clone()));
2983 }
2984 IntentSignal::RequiresCapability(cap) => {
2985 if cap == "session" {
2986 surface.insert(HostcallCategory::Session("*".to_string()));
2987 } else if cap == "tool" {
2988 surface.insert(HostcallCategory::Tool("*".to_string()));
2989 }
2990 }
2991 }
2992 }
2993
2994 surface
2995}
2996
2997pub fn compute_semantic_parity(
3003 before: &IntentGraph,
3004 after: &IntentGraph,
3005 patch_ops: &[PatchOp],
3006) -> SemanticParityReport {
3007 let before_surface = extract_hostcall_surface(before);
3008 let after_surface = extract_hostcall_surface(after);
3009
3010 let mut hostcall_deltas = Vec::new();
3011
3012 for cat in &before_surface {
3014 if after_surface.contains(cat) {
3015 hostcall_deltas.push(HostcallDelta::Retained(cat.clone()));
3016 } else {
3017 hostcall_deltas.push(HostcallDelta::Removed(cat.clone()));
3018 }
3019 }
3020
3021 for cat in &after_surface {
3023 if !before_surface.contains(cat) {
3024 hostcall_deltas.push(HostcallDelta::Added(cat.clone()));
3025 }
3026 }
3027
3028 let expanded_count = hostcall_deltas.iter().filter(|d| d.is_expansion()).count();
3029 let removed_count = hostcall_deltas
3030 .iter()
3031 .filter(|d| matches!(d, HostcallDelta::Removed(_)))
3032 .count();
3033 let retained_count = hostcall_deltas
3034 .iter()
3035 .filter(|d| matches!(d, HostcallDelta::Retained(_)))
3036 .count();
3037
3038 let mut notes = Vec::new();
3040 let drift_severity = assess_drift(patch_ops, expanded_count, removed_count, &mut notes);
3041
3042 let verdict = if expanded_count == 0 && drift_severity.is_acceptable() {
3043 if removed_count == 0 {
3044 SemanticParityVerdict::Equivalent
3045 } else {
3046 notes.push(format!(
3047 "{removed_count} hostcall(s) removed — acceptable reduction"
3048 ));
3049 SemanticParityVerdict::AcceptableDrift
3050 }
3051 } else {
3052 if expanded_count > 0 {
3053 notes.push(format!(
3054 "{expanded_count} new hostcall surface(s) introduced"
3055 ));
3056 }
3057 SemanticParityVerdict::Divergent
3058 };
3059
3060 SemanticParityReport {
3061 extension_id: before.extension_id.clone(),
3062 verdict,
3063 hostcall_deltas,
3064 drift_severity,
3065 expanded_count,
3066 removed_count,
3067 retained_count,
3068 notes,
3069 }
3070}
3071
3072fn assess_drift(
3074 patch_ops: &[PatchOp],
3075 expanded_hostcalls: usize,
3076 _removed_hostcalls: usize,
3077 notes: &mut Vec<String>,
3078) -> SemanticDriftSeverity {
3079 if expanded_hostcalls > 0 {
3081 notes.push("new hostcall surface detected".to_string());
3082 return SemanticDriftSeverity::High;
3083 }
3084
3085 let mut has_aggressive = false;
3086 let mut stub_count = 0_usize;
3087
3088 for op in patch_ops {
3089 match op {
3090 PatchOp::InjectStub { .. } => {
3091 stub_count += 1;
3092 has_aggressive = true;
3093 }
3094 PatchOp::AddExport { .. } | PatchOp::RemoveImport { .. } => {
3095 has_aggressive = true;
3096 }
3097 PatchOp::ReplaceModulePath { .. } | PatchOp::RewriteRequire { .. } => {}
3098 }
3099 }
3100
3101 if stub_count > 2 {
3102 notes.push(format!("{stub_count} stubs injected — medium drift"));
3103 return SemanticDriftSeverity::Medium;
3104 }
3105
3106 if has_aggressive {
3107 notes.push("aggressive ops present — low drift".to_string());
3108 return SemanticDriftSeverity::Low;
3109 }
3110
3111 SemanticDriftSeverity::None
3112}
3113
3114pub type ArtifactChecksum = String;
3120
3121pub fn compute_artifact_checksum(content: &[u8]) -> ArtifactChecksum {
3123 use sha2::{Digest, Sha256};
3124 let hash = Sha256::digest(content);
3125 format!("{hash:x}")
3126}
3127
3128#[derive(Debug, Clone, PartialEq, Eq)]
3130pub struct ChecksumEntry {
3131 pub relative_path: String,
3133 pub checksum: ArtifactChecksum,
3135 pub size_bytes: u64,
3137}
3138
3139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3141pub enum ConformanceReplayVerdict {
3142 Pass,
3144 Fail,
3146 NoFixtures,
3148}
3149
3150impl ConformanceReplayVerdict {
3151 pub const fn is_acceptable(&self) -> bool {
3153 matches!(self, Self::Pass | Self::NoFixtures)
3154 }
3155}
3156
3157impl std::fmt::Display for ConformanceReplayVerdict {
3158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3159 match self {
3160 Self::Pass => write!(f, "pass"),
3161 Self::Fail => write!(f, "fail"),
3162 Self::NoFixtures => write!(f, "no_fixtures"),
3163 }
3164 }
3165}
3166
3167#[derive(Debug, Clone)]
3169pub struct ConformanceFixture {
3170 pub name: String,
3172 pub expected: String,
3174 pub actual: Option<String>,
3176 pub passed: bool,
3178}
3179
3180#[derive(Debug, Clone)]
3182pub struct ConformanceReplayReport {
3183 pub extension_id: String,
3185 pub verdict: ConformanceReplayVerdict,
3187 pub fixtures: Vec<ConformanceFixture>,
3189 pub passed_count: usize,
3191 pub total_count: usize,
3193}
3194
3195impl ConformanceReplayReport {
3196 pub const fn is_acceptable(&self) -> bool {
3198 self.verdict.is_acceptable()
3199 }
3200}
3201
3202pub fn replay_conformance_fixtures(
3208 extension_id: &str,
3209 fixtures: &[ConformanceFixture],
3210) -> ConformanceReplayReport {
3211 if fixtures.is_empty() {
3212 return ConformanceReplayReport {
3213 extension_id: extension_id.to_string(),
3214 verdict: ConformanceReplayVerdict::NoFixtures,
3215 fixtures: Vec::new(),
3216 passed_count: 0,
3217 total_count: 0,
3218 };
3219 }
3220
3221 let passed_count = fixtures.iter().filter(|f| f.passed).count();
3222 let total_count = fixtures.len();
3223 let verdict = if passed_count == total_count {
3224 ConformanceReplayVerdict::Pass
3225 } else {
3226 ConformanceReplayVerdict::Fail
3227 };
3228
3229 ConformanceReplayReport {
3230 extension_id: extension_id.to_string(),
3231 verdict,
3232 fixtures: fixtures.to_vec(),
3233 passed_count,
3234 total_count,
3235 }
3236}
3237
3238#[derive(Debug, Clone)]
3244pub struct GoldenChecksumManifest {
3245 pub extension_id: String,
3247 pub entries: Vec<ChecksumEntry>,
3249 pub generated_at_ms: u64,
3251}
3252
3253impl GoldenChecksumManifest {
3254 pub fn artifact_count(&self) -> usize {
3256 self.entries.len()
3257 }
3258
3259 pub fn verify_entry(&self, relative_path: &str, content: &[u8]) -> Option<bool> {
3261 self.entries
3262 .iter()
3263 .find(|e| e.relative_path == relative_path)
3264 .map(|e| e.checksum == compute_artifact_checksum(content))
3265 }
3266}
3267
3268pub fn build_golden_manifest(
3273 extension_id: &str,
3274 artifacts: &[(&str, &[u8])],
3275 timestamp_ms: u64,
3276) -> GoldenChecksumManifest {
3277 let mut entries: Vec<ChecksumEntry> = artifacts
3278 .iter()
3279 .map(|(path, content)| ChecksumEntry {
3280 relative_path: (*path).to_string(),
3281 checksum: compute_artifact_checksum(content),
3282 size_bytes: content.len() as u64,
3283 })
3284 .collect();
3285
3286 entries.sort_by(|a, b| {
3287 a.relative_path
3288 .cmp(&b.relative_path)
3289 .then_with(|| a.checksum.cmp(&b.checksum))
3290 .then_with(|| a.size_bytes.cmp(&b.size_bytes))
3291 });
3292
3293 GoldenChecksumManifest {
3294 extension_id: extension_id.to_string(),
3295 entries,
3296 generated_at_ms: timestamp_ms,
3297 }
3298}
3299
3300#[derive(Debug, Clone)]
3306pub struct VerificationBundle {
3307 pub extension_id: String,
3309 pub structural: StructuralVerdict,
3311 pub capability_proof: CapabilityProofReport,
3313 pub semantic_proof: SemanticParityReport,
3315 pub conformance: ConformanceReplayReport,
3317 pub checksum_manifest: GoldenChecksumManifest,
3319}
3320
3321impl VerificationBundle {
3322 pub const fn is_verified(&self) -> bool {
3324 self.structural.is_valid()
3325 && self.capability_proof.is_safe()
3326 && self.semantic_proof.is_safe()
3327 && self.conformance.is_acceptable()
3328 }
3329
3330 pub fn failure_reasons(&self) -> Vec<String> {
3332 let mut reasons = Vec::new();
3333 if !self.structural.is_valid() {
3334 reasons.push(format!("structural: {}", self.structural));
3335 }
3336 if !self.capability_proof.is_safe() {
3337 reasons.push(format!(
3338 "capability: {} ({} escalation(s))",
3339 self.capability_proof.verdict, self.capability_proof.added_count
3340 ));
3341 }
3342 if !self.semantic_proof.is_safe() {
3343 reasons.push(format!(
3344 "semantic: {} (drift={})",
3345 self.semantic_proof.verdict, self.semantic_proof.drift_severity
3346 ));
3347 }
3348 if !self.conformance.is_acceptable() {
3349 reasons.push(format!(
3350 "conformance: {} ({}/{} passed)",
3351 self.conformance.verdict,
3352 self.conformance.passed_count,
3353 self.conformance.total_count
3354 ));
3355 }
3356 reasons
3357 }
3358}
3359
3360#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3366pub enum OverlayState {
3367 Staged,
3369 Canary,
3371 Stable,
3373 RolledBack,
3375 Superseded,
3377}
3378
3379impl OverlayState {
3380 pub const fn is_active(&self) -> bool {
3382 matches!(self, Self::Canary | Self::Stable)
3383 }
3384
3385 pub const fn is_terminal(&self) -> bool {
3387 matches!(self, Self::RolledBack | Self::Superseded)
3388 }
3389}
3390
3391impl std::fmt::Display for OverlayState {
3392 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3393 match self {
3394 Self::Staged => write!(f, "staged"),
3395 Self::Canary => write!(f, "canary"),
3396 Self::Stable => write!(f, "stable"),
3397 Self::RolledBack => write!(f, "rolled_back"),
3398 Self::Superseded => write!(f, "superseded"),
3399 }
3400 }
3401}
3402
3403#[derive(Debug, Clone)]
3408pub struct OverlayArtifact {
3409 pub overlay_id: String,
3411 pub extension_id: String,
3413 pub extension_version: String,
3415 pub original_checksum: ArtifactChecksum,
3417 pub repaired_checksum: ArtifactChecksum,
3419 pub state: OverlayState,
3421 pub rule_id: String,
3423 pub repair_mode: RepairMode,
3425 pub verification_passed: bool,
3427 pub created_at_ms: u64,
3429 pub updated_at_ms: u64,
3431}
3432
3433impl OverlayArtifact {
3434 pub const fn is_active(&self) -> bool {
3436 self.state.is_active()
3437 }
3438}
3439
3440#[derive(Debug, Clone, PartialEq, Eq)]
3442pub enum OverlayTransitionError {
3443 InvalidTransition {
3445 from: OverlayState,
3446 to: OverlayState,
3447 },
3448 VerificationRequired,
3450}
3451
3452impl std::fmt::Display for OverlayTransitionError {
3453 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3454 match self {
3455 Self::InvalidTransition { from, to } => {
3456 write!(f, "invalid transition: {from} → {to}")
3457 }
3458 Self::VerificationRequired => {
3459 write!(f, "verification must pass before deployment")
3460 }
3461 }
3462 }
3463}
3464
3465pub fn transition_overlay(
3475 artifact: &mut OverlayArtifact,
3476 target: OverlayState,
3477 now_ms: u64,
3478) -> std::result::Result<(), OverlayTransitionError> {
3479 let valid = matches!(
3480 (artifact.state, target),
3481 (
3482 OverlayState::Staged,
3483 OverlayState::Canary | OverlayState::RolledBack
3484 ) | (
3485 OverlayState::Canary,
3486 OverlayState::Stable | OverlayState::RolledBack
3487 ) | (
3488 OverlayState::Stable,
3489 OverlayState::RolledBack | OverlayState::Superseded
3490 )
3491 );
3492
3493 if !valid {
3494 return Err(OverlayTransitionError::InvalidTransition {
3495 from: artifact.state,
3496 to: target,
3497 });
3498 }
3499
3500 if target == OverlayState::Canary && !artifact.verification_passed {
3502 return Err(OverlayTransitionError::VerificationRequired);
3503 }
3504
3505 artifact.state = target;
3506 artifact.updated_at_ms = now_ms;
3507 Ok(())
3508}
3509
3510#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3516pub enum CanaryRoute {
3517 Original,
3519 Overlay,
3521}
3522
3523impl std::fmt::Display for CanaryRoute {
3524 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3525 match self {
3526 Self::Original => write!(f, "original"),
3527 Self::Overlay => write!(f, "overlay"),
3528 }
3529 }
3530}
3531
3532#[derive(Debug, Clone)]
3534pub struct CanaryConfig {
3535 pub extension_id: String,
3537 pub extension_version: String,
3539 pub overlay_percent: u8,
3541 pub enabled: bool,
3543}
3544
3545impl CanaryConfig {
3546 pub const fn route(&self, hash_bucket: u8) -> CanaryRoute {
3548 if self.enabled && hash_bucket < self.overlay_percent {
3549 CanaryRoute::Overlay
3550 } else {
3551 CanaryRoute::Original
3552 }
3553 }
3554
3555 pub const fn is_full_rollout(&self) -> bool {
3557 self.enabled && self.overlay_percent >= 100
3558 }
3559}
3560
3561pub fn compute_canary_bucket(extension_id: &str, environment: &str) -> u8 {
3563 use sha2::{Digest, Sha256};
3564 let mut hasher = Sha256::new();
3565 hasher.update(extension_id.as_bytes());
3566 hasher.update(b":");
3567 hasher.update(environment.as_bytes());
3568 let hash = hasher.finalize();
3569 let val = u16::from_be_bytes([hash[0], hash[1]]);
3571 (val % 100) as u8
3572}
3573
3574#[derive(Debug, Clone)]
3580pub struct HealthSignal {
3581 pub name: String,
3583 pub value: f64,
3585 pub threshold: f64,
3587}
3588
3589impl HealthSignal {
3590 pub fn is_healthy(&self) -> bool {
3592 self.value <= self.threshold
3593 }
3594}
3595
3596#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3598pub enum SloVerdict {
3599 Healthy,
3601 Violated,
3603}
3604
3605impl SloVerdict {
3606 pub const fn is_healthy(&self) -> bool {
3608 matches!(self, Self::Healthy)
3609 }
3610}
3611
3612impl std::fmt::Display for SloVerdict {
3613 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3614 match self {
3615 Self::Healthy => write!(f, "healthy"),
3616 Self::Violated => write!(f, "violated"),
3617 }
3618 }
3619}
3620
3621#[derive(Debug, Clone)]
3623pub struct HealthReport {
3624 pub extension_id: String,
3626 pub verdict: SloVerdict,
3628 pub signals: Vec<HealthSignal>,
3630 pub violations: Vec<String>,
3632}
3633
3634impl HealthReport {
3635 pub const fn is_healthy(&self) -> bool {
3637 self.verdict.is_healthy()
3638 }
3639}
3640
3641pub fn evaluate_health(extension_id: &str, signals: &[HealthSignal]) -> HealthReport {
3643 let violations: Vec<String> = signals
3644 .iter()
3645 .filter(|s| !s.is_healthy())
3646 .map(|s| format!("{}: {:.3} > {:.3}", s.name, s.value, s.threshold))
3647 .collect();
3648
3649 let verdict = if violations.is_empty() {
3650 SloVerdict::Healthy
3651 } else {
3652 SloVerdict::Violated
3653 };
3654
3655 HealthReport {
3656 extension_id: extension_id.to_string(),
3657 verdict,
3658 signals: signals.to_vec(),
3659 violations,
3660 }
3661}
3662
3663pub const fn should_auto_rollback(health: &HealthReport) -> bool {
3665 !health.is_healthy()
3666}
3667
3668#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3674pub enum PromotionDecision {
3675 Promote,
3677 Hold,
3679 Rollback,
3681}
3682
3683impl std::fmt::Display for PromotionDecision {
3684 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3685 match self {
3686 Self::Promote => write!(f, "promote"),
3687 Self::Hold => write!(f, "hold"),
3688 Self::Rollback => write!(f, "rollback"),
3689 }
3690 }
3691}
3692
3693pub const fn decide_promotion(
3700 health: &HealthReport,
3701 canary_start_ms: u64,
3702 now_ms: u64,
3703 canary_window_ms: u64,
3704) -> PromotionDecision {
3705 if !health.is_healthy() {
3706 return PromotionDecision::Rollback;
3707 }
3708 if now_ms.saturating_sub(canary_start_ms) >= canary_window_ms {
3709 return PromotionDecision::Promote;
3710 }
3711 PromotionDecision::Hold
3712}
3713
3714pub fn execute_promotion(
3716 artifact: &mut OverlayArtifact,
3717 now_ms: u64,
3718) -> std::result::Result<(), OverlayTransitionError> {
3719 transition_overlay(artifact, OverlayState::Stable, now_ms)
3720}
3721
3722pub fn execute_rollback(
3724 artifact: &mut OverlayArtifact,
3725 now_ms: u64,
3726) -> std::result::Result<(), OverlayTransitionError> {
3727 transition_overlay(artifact, OverlayState::RolledBack, now_ms)
3728}
3729
3730#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3736pub enum AuditEntryKind {
3737 Analysis,
3739 GatingDecision,
3741 ProposalGenerated,
3743 ProposalValidated,
3745 VerificationEvaluated,
3747 ApprovalRequested,
3749 ApprovalResponse,
3751 Activated,
3753 RolledBack,
3755 Promoted,
3757 Superseded,
3759}
3760
3761impl std::fmt::Display for AuditEntryKind {
3762 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3763 match self {
3764 Self::Analysis => write!(f, "analysis"),
3765 Self::GatingDecision => write!(f, "gating_decision"),
3766 Self::ProposalGenerated => write!(f, "proposal_generated"),
3767 Self::ProposalValidated => write!(f, "proposal_validated"),
3768 Self::VerificationEvaluated => write!(f, "verification_evaluated"),
3769 Self::ApprovalRequested => write!(f, "approval_requested"),
3770 Self::ApprovalResponse => write!(f, "approval_response"),
3771 Self::Activated => write!(f, "activated"),
3772 Self::RolledBack => write!(f, "rolled_back"),
3773 Self::Promoted => write!(f, "promoted"),
3774 Self::Superseded => write!(f, "superseded"),
3775 }
3776 }
3777}
3778
3779#[derive(Debug, Clone)]
3781pub struct AuditEntry {
3782 pub sequence: u64,
3784 pub timestamp_ms: u64,
3786 pub extension_id: String,
3788 pub kind: AuditEntryKind,
3790 pub summary: String,
3792 pub details: Vec<(String, String)>,
3794}
3795
3796#[derive(Debug, Clone, Default)]
3801pub struct AuditLedger {
3802 entries: Vec<AuditEntry>,
3803 next_sequence: u64,
3804}
3805
3806impl AuditLedger {
3807 pub const fn new() -> Self {
3809 Self {
3810 entries: Vec::new(),
3811 next_sequence: 0,
3812 }
3813 }
3814
3815 pub fn append(
3817 &mut self,
3818 timestamp_ms: u64,
3819 extension_id: &str,
3820 kind: AuditEntryKind,
3821 summary: String,
3822 details: Vec<(String, String)>,
3823 ) -> u64 {
3824 let seq = self.next_sequence;
3825 self.entries.push(AuditEntry {
3826 sequence: seq,
3827 timestamp_ms,
3828 extension_id: extension_id.to_string(),
3829 kind,
3830 summary,
3831 details,
3832 });
3833 self.next_sequence = self.next_sequence.saturating_add(1);
3834 seq
3835 }
3836
3837 pub fn len(&self) -> usize {
3839 self.entries.len()
3840 }
3841
3842 pub fn is_empty(&self) -> bool {
3844 self.entries.is_empty()
3845 }
3846
3847 pub fn get(&self, sequence: u64) -> Option<&AuditEntry> {
3849 self.entries.iter().find(|e| e.sequence == sequence)
3850 }
3851
3852 pub fn entries_for_extension(&self, extension_id: &str) -> Vec<&AuditEntry> {
3854 self.entries
3855 .iter()
3856 .filter(|e| e.extension_id == extension_id)
3857 .collect()
3858 }
3859
3860 pub fn entries_by_kind(&self, kind: AuditEntryKind) -> Vec<&AuditEntry> {
3862 self.entries.iter().filter(|e| e.kind == kind).collect()
3863 }
3864
3865 pub fn all_entries(&self) -> &[AuditEntry] {
3867 &self.entries
3868 }
3869}
3870
3871#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3877pub enum TelemetryMetric {
3878 RepairAttempted,
3880 RepairEligible,
3882 RepairDenied,
3884 VerificationFailed,
3886 OverlayRolledBack,
3888 OverlayPromoted,
3890 ApprovalLatencyMs,
3892}
3893
3894impl std::fmt::Display for TelemetryMetric {
3895 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3896 match self {
3897 Self::RepairAttempted => write!(f, "repair.attempted"),
3898 Self::RepairEligible => write!(f, "repair.eligible"),
3899 Self::RepairDenied => write!(f, "repair.denied"),
3900 Self::VerificationFailed => write!(f, "verification.failed"),
3901 Self::OverlayRolledBack => write!(f, "overlay.rolled_back"),
3902 Self::OverlayPromoted => write!(f, "overlay.promoted"),
3903 Self::ApprovalLatencyMs => write!(f, "approval.latency_ms"),
3904 }
3905 }
3906}
3907
3908#[derive(Debug, Clone)]
3910pub struct TelemetryPoint {
3911 pub metric: TelemetryMetric,
3913 pub value: f64,
3915 pub timestamp_ms: u64,
3917 pub tags: Vec<(String, String)>,
3919}
3920
3921#[derive(Debug, Clone, Default)]
3923pub struct TelemetryCollector {
3924 points: Vec<TelemetryPoint>,
3925}
3926
3927impl TelemetryCollector {
3928 pub const fn new() -> Self {
3930 Self { points: Vec::new() }
3931 }
3932
3933 pub fn record(
3935 &mut self,
3936 metric: TelemetryMetric,
3937 value: f64,
3938 timestamp_ms: u64,
3939 tags: Vec<(String, String)>,
3940 ) {
3941 self.points.push(TelemetryPoint {
3942 metric,
3943 value,
3944 timestamp_ms,
3945 tags,
3946 });
3947 }
3948
3949 pub fn increment(
3951 &mut self,
3952 metric: TelemetryMetric,
3953 timestamp_ms: u64,
3954 tags: Vec<(String, String)>,
3955 ) {
3956 self.record(metric, 1.0, timestamp_ms, tags);
3957 }
3958
3959 pub fn count(&self, metric: TelemetryMetric) -> usize {
3961 self.points.iter().filter(|p| p.metric == metric).count()
3962 }
3963
3964 pub fn sum(&self, metric: TelemetryMetric) -> f64 {
3966 self.points
3967 .iter()
3968 .filter(|p| p.metric == metric)
3969 .map(|p| p.value)
3970 .sum()
3971 }
3972
3973 pub fn all_points(&self) -> &[TelemetryPoint] {
3975 &self.points
3976 }
3977
3978 pub fn len(&self) -> usize {
3980 self.points.len()
3981 }
3982
3983 pub fn is_empty(&self) -> bool {
3985 self.points.is_empty()
3986 }
3987}
3988
3989#[derive(Debug, Clone)]
3995pub struct InspectionRecord {
3996 pub extension_id: String,
3998 pub timeline: Vec<String>,
4000 pub gating_summary: String,
4002 pub overlay_state: Option<String>,
4004 pub verification_summary: String,
4006}
4007
4008pub fn build_inspection(
4010 extension_id: &str,
4011 ledger: &AuditLedger,
4012 overlay_state: Option<OverlayState>,
4013 verification_passed: bool,
4014) -> InspectionRecord {
4015 let entries = ledger.entries_for_extension(extension_id);
4016 let timeline: Vec<String> = entries
4017 .iter()
4018 .map(|e| format!("[seq={}] {} — {}", e.sequence, e.kind, e.summary))
4019 .collect();
4020
4021 let gating_entries = entries
4022 .iter()
4023 .filter(|e| e.kind == AuditEntryKind::GatingDecision)
4024 .collect::<Vec<_>>();
4025 let gating_summary = gating_entries.last().map_or_else(
4026 || "no gating decision recorded".to_string(),
4027 |e| e.summary.clone(),
4028 );
4029
4030 let verification_summary = if verification_passed {
4031 "all proofs passed".to_string()
4032 } else {
4033 "one or more proofs failed".to_string()
4034 };
4035
4036 InspectionRecord {
4037 extension_id: extension_id.to_string(),
4038 timeline,
4039 gating_summary,
4040 overlay_state: overlay_state.map(|s| s.to_string()),
4041 verification_summary,
4042 }
4043}
4044
4045pub fn explain_gating(verdict: &GatingVerdict) -> Vec<String> {
4047 let mut lines = Vec::new();
4048 lines.push(format!(
4049 "Decision: {} (confidence: {:.2})",
4050 verdict.decision, verdict.confidence.score
4051 ));
4052 for reason in &verdict.confidence.reasons {
4053 lines.push(format!(
4054 " [{:+.2}] {} — {}",
4055 reason.delta, reason.code, reason.explanation
4056 ));
4057 }
4058 for code in &verdict.reason_codes {
4059 lines.push(format!(" REASON: {} — {}", code.code, code.remediation));
4060 }
4061 lines
4062}
4063
4064pub fn format_proposal_diff(proposal: &PatchProposal) -> Vec<String> {
4066 let mut lines = Vec::new();
4067 lines.push(format!(
4068 "Rule: {} ({} op(s), risk: {:?})",
4069 proposal.rule_id,
4070 proposal.op_count(),
4071 proposal.max_risk()
4072 ));
4073 if !proposal.rationale.is_empty() {
4074 lines.push(format!("Rationale: {}", proposal.rationale));
4075 }
4076 for (i, op) in proposal.ops.iter().enumerate() {
4077 lines.push(format!(
4078 " Op {}: [{}] {}",
4079 i + 1,
4080 op.tag(),
4081 op_target_path(op)
4082 ));
4083 }
4084 lines
4085}
4086
4087#[derive(Debug, Clone)]
4096pub struct ForensicBundle {
4097 pub extension_id: String,
4099 pub overlay: Option<OverlayArtifact>,
4101 pub verification: Option<VerificationBundle>,
4103 pub audit_entries: Vec<AuditEntry>,
4105 pub telemetry_points: Vec<TelemetryPoint>,
4107 pub health_report: Option<HealthReport>,
4109 pub checksum_manifest: Option<GoldenChecksumManifest>,
4111 pub exported_at_ms: u64,
4113}
4114
4115impl ForensicBundle {
4116 pub fn audit_count(&self) -> usize {
4118 self.audit_entries.len()
4119 }
4120
4121 pub const fn has_verification(&self) -> bool {
4123 self.verification.is_some()
4124 }
4125
4126 pub const fn has_health_data(&self) -> bool {
4128 self.health_report.is_some()
4129 }
4130}
4131
4132#[allow(clippy::too_many_arguments)]
4134pub fn build_forensic_bundle(
4135 extension_id: &str,
4136 overlay: Option<&OverlayArtifact>,
4137 verification: Option<&VerificationBundle>,
4138 ledger: &AuditLedger,
4139 collector: &TelemetryCollector,
4140 health_report: Option<&HealthReport>,
4141 checksum_manifest: Option<&GoldenChecksumManifest>,
4142 exported_at_ms: u64,
4143) -> ForensicBundle {
4144 let audit_entries = ledger
4145 .entries_for_extension(extension_id)
4146 .into_iter()
4147 .cloned()
4148 .collect();
4149
4150 let telemetry_points = collector
4151 .all_points()
4152 .iter()
4153 .filter(|p| {
4154 p.tags
4155 .iter()
4156 .any(|(k, v)| k == "extension_id" && v == extension_id)
4157 })
4158 .cloned()
4159 .collect();
4160
4161 ForensicBundle {
4162 extension_id: extension_id.to_string(),
4163 overlay: overlay.cloned(),
4164 verification: verification.cloned(),
4165 audit_entries,
4166 telemetry_points,
4167 health_report: health_report.cloned(),
4168 checksum_manifest: checksum_manifest.cloned(),
4169 exported_at_ms,
4170 }
4171}
4172
4173pub struct LisrAdr;
4183
4184impl LisrAdr {
4185 pub const ID: &'static str = "ADR-LISR-001";
4187
4188 pub const TITLE: &'static str =
4190 "Dynamic Secure Extension Repair with Intent-Legible Self-Healing";
4191
4192 pub const CONTEXT: &'static str = "\
4194Extensions frequently break during updates when build artifacts (dist/) \
4195diverge from source (src/). Manual repair is slow, error-prone, and blocks \
4196the agent workflow. LISR provides automated repair within strict safety \
4197boundaries to restore extension functionality without human intervention.";
4198
4199 pub const DECISION: &'static str = "\
4201Adopt a layered repair pipeline with fail-closed defaults: \
4202(1) security policy framework bounds all repairs, \
4203(2) intent legibility analysis gates repair eligibility, \
4204(3) deterministic rules execute safe repairs, \
4205(4) model-assisted repairs are constrained to whitelisted primitives, \
4206(5) all repairs require structural + capability + semantic proof, \
4207(6) overlay deployment uses canary routing with health rollback, \
4208(7) every action is recorded in an append-only audit ledger, \
4209(8) governance checks are codified in the release process.";
4210
4211 pub const THREATS: &'static [&'static str] = &[
4213 "T1: Privilege escalation via repair adding new capabilities",
4214 "T2: Code injection via model-generated repair proposals",
4215 "T3: Supply-chain compromise via path traversal beyond extension root",
4216 "T4: Silent behavioral drift from opaque automated repairs",
4217 "T5: Loss of auditability preventing incident forensics",
4218 "T6: Governance decay from undocumented safety invariants",
4219 ];
4220
4221 pub const FAIL_CLOSED_RATIONALE: &'static str = "\
4223Any uncertainty in repair safety defaults to denial. A broken extension \
4224that remains broken is safer than a repaired extension that silently \
4225escalates privileges or introduces semantic drift. The cost of a false \
4226negative (missed repair) is low; the cost of a false positive (unsafe \
4227repair applied) is catastrophic.";
4228
4229 pub const INVARIANTS: &'static [&'static str] = &[
4231 "I1: Repairs never add capabilities absent from the original extension",
4232 "I2: All file paths stay within the extension root (monotonicity)",
4233 "I3: Model proposals are restricted to whitelisted PatchOp primitives",
4234 "I4: Structural validity is verified via SWC parse before activation",
4235 "I5: Every repair decision is recorded in the append-only audit ledger",
4236 "I6: Canary rollback triggers automatically on SLO violation",
4237 ];
4238}
4239
4240pub struct OperatorPlaybook;
4246
4247impl OperatorPlaybook {
4248 pub const MODE_GUIDANCE: &'static [(&'static str, &'static str)] = &[
4250 (
4251 "Off",
4252 "Disable all automated repairs. Use when investigating a repair-related incident.",
4253 ),
4254 (
4255 "Suggest",
4256 "Log repair suggestions without applying. Use during initial rollout or audit.",
4257 ),
4258 (
4259 "AutoSafe",
4260 "Apply only safe (path-remap) repairs automatically. Default for production.",
4261 ),
4262 (
4263 "AutoStrict",
4264 "Apply both safe and aggressive repairs. Use only with explicit approval.",
4265 ),
4266 ];
4267
4268 pub const CANARY_PROCEDURE: &'static [&'static str] = &[
4270 "1. Create overlay artifact from repair pipeline",
4271 "2. Verify all proofs pass (structural, capability, semantic, conformance)",
4272 "3. Transition to Canary state with initial overlay_percent (e.g., 10%)",
4273 "4. Monitor health signals for canary_window_ms (default: 300_000)",
4274 "5. If SLO violated → automatic rollback",
4275 "6. If canary window passes → promote to Stable",
4276 "7. Record all transitions in audit ledger",
4277 ];
4278
4279 pub const INCIDENT_RESPONSE: &'static [&'static str] = &[
4281 "1. Set repair_mode to Off immediately",
4282 "2. Export forensic bundle for the affected extension",
4283 "3. Review audit ledger for the repair timeline",
4284 "4. Check verification bundle for proof failures",
4285 "5. Inspect health signals that triggered rollback",
4286 "6. Root-cause the repair rule or model proposal",
4287 "7. File ADR amendment if safety invariant was violated",
4288 ];
4289}
4290
4291pub struct DeveloperGuide;
4297
4298impl DeveloperGuide {
4299 pub const ADD_RULE_CHECKLIST: &'static [&'static str] = &[
4301 "1. Define a RepairPattern variant with clear trigger semantics",
4302 "2. Define a RepairRule with: id, name, pattern, description, risk, ops",
4303 "3. Risk must be Safe unless the rule modifies code (then Aggressive)",
4304 "4. Add the rule to REPAIR_RULES static registry",
4305 "5. Implement matching logic in the extension loader",
4306 "6. Add unit tests covering: match, no-match, edge cases",
4307 "7. Add integration test with real extension fixture",
4308 "8. Verify monotonicity: rule must not escape extension root",
4309 "9. Verify capability monotonicity: rule must not add capabilities",
4310 "10. Run full conformance suite to check for regressions",
4311 ];
4312
4313 pub const ANTI_PATTERNS: &'static [(&'static str, &'static str)] = &[
4315 (
4316 "Unconstrained path rewriting",
4317 "Always validate target paths are within extension root via verify_repair_monotonicity()",
4318 ),
4319 (
4320 "Model-generated code execution",
4321 "Model proposals must use PatchOp primitives only — never eval or Function()",
4322 ),
4323 (
4324 "Skipping verification",
4325 "Every repair must pass the full VerificationBundle gate before activation",
4326 ),
4327 (
4328 "Mutable audit entries",
4329 "AuditLedger is append-only — never expose delete or update methods",
4330 ),
4331 (
4332 "Implicit capability grants",
4333 "compute_capability_proof() must show no Added deltas for the repair to pass",
4334 ),
4335 ];
4336
4337 pub const TESTING_EXPECTATIONS: &'static [&'static str] = &[
4339 "Unit test: rule matches intended pattern and rejects non-matching input",
4340 "Unit test: generated PatchOps have correct risk classification",
4341 "Integration test: repair applied to real extension fixture succeeds",
4342 "Monotonicity test: repaired path stays within extension root",
4343 "Capability test: compute_capability_proof returns Monotonic",
4344 "Semantic test: compute_semantic_parity returns Equivalent or AcceptableDrift",
4345 "Conformance test: extension still passes conformance replay after repair",
4346 ];
4347}
4348
4349#[derive(Debug, Clone)]
4355pub struct GovernanceCheck {
4356 pub id: String,
4358 pub description: String,
4360 pub passed: bool,
4362 pub detail: String,
4364}
4365
4366#[derive(Debug, Clone)]
4368pub struct GovernanceReport {
4369 pub checks: Vec<GovernanceCheck>,
4371 pub passed_count: usize,
4373 pub total_count: usize,
4375}
4376
4377impl GovernanceReport {
4378 pub const fn all_passed(&self) -> bool {
4380 self.passed_count == self.total_count
4381 }
4382
4383 pub fn failures(&self) -> Vec<&GovernanceCheck> {
4385 self.checks.iter().filter(|c| !c.passed).collect()
4386 }
4387}
4388
4389pub fn run_governance_checklist() -> GovernanceReport {
4399 let mut checks = Vec::new();
4400
4401 checks.push(GovernanceCheck {
4403 id: "GOV-001".to_string(),
4404 description: "Repair registry contains at least one rule".to_string(),
4405 passed: !REPAIR_RULES.is_empty(),
4406 detail: if REPAIR_RULES.is_empty() {
4407 "REPAIR_RULES is empty".to_string()
4408 } else {
4409 String::new()
4410 },
4411 });
4412
4413 let empty_ids: Vec<_> = REPAIR_RULES
4415 .iter()
4416 .filter(|r| r.id.is_empty())
4417 .map(|r| r.description)
4418 .collect();
4419 checks.push(GovernanceCheck {
4420 id: "GOV-002".to_string(),
4421 description: "All repair rules have non-empty IDs".to_string(),
4422 passed: empty_ids.is_empty(),
4423 detail: if empty_ids.is_empty() {
4424 String::new()
4425 } else {
4426 format!("Rules with empty IDs: {empty_ids:?}")
4427 },
4428 });
4429
4430 checks.push(GovernanceCheck {
4432 id: "GOV-003".to_string(),
4433 description: "Architecture ADR is defined".to_string(),
4434 passed: !LisrAdr::INVARIANTS.is_empty(),
4435 detail: String::new(),
4436 });
4437
4438 checks.push(GovernanceCheck {
4440 id: "GOV-004".to_string(),
4441 description: "Threat model is documented".to_string(),
4442 passed: !LisrAdr::THREATS.is_empty(),
4443 detail: String::new(),
4444 });
4445
4446 let invariant_count = LisrAdr::INVARIANTS.len();
4448 checks.push(GovernanceCheck {
4449 id: "GOV-005".to_string(),
4450 description: "Safety invariants cover all critical areas (>=6)".to_string(),
4451 passed: invariant_count >= 6,
4452 detail: if invariant_count < 6 {
4453 format!("Only {invariant_count} invariants defined (need >=6)")
4454 } else {
4455 String::new()
4456 },
4457 });
4458
4459 checks.push(GovernanceCheck {
4461 id: "GOV-006".to_string(),
4462 description: "Developer testing expectations are documented".to_string(),
4463 passed: !DeveloperGuide::TESTING_EXPECTATIONS.is_empty(),
4464 detail: String::new(),
4465 });
4466
4467 let passed_count = checks.iter().filter(|c| c.passed).count();
4468 let total_count = checks.len();
4469
4470 GovernanceReport {
4471 checks,
4472 passed_count,
4473 total_count,
4474 }
4475}
4476
4477#[derive(Debug, Clone)]
4478pub struct PiJsRuntimeConfig {
4479 pub cwd: String,
4480 pub args: Vec<String>,
4481 pub env: HashMap<String, String>,
4482 pub limits: PiJsRuntimeLimits,
4483 pub repair_mode: RepairMode,
4485 pub allow_unsafe_sync_exec: bool,
4491 pub deny_env: bool,
4494 pub disk_cache_dir: Option<PathBuf>,
4501}
4502
4503impl PiJsRuntimeConfig {
4504 pub const fn auto_repair_enabled(&self) -> bool {
4506 self.repair_mode.should_apply()
4507 }
4508}
4509
4510impl Default for PiJsRuntimeConfig {
4511 fn default() -> Self {
4512 Self {
4513 cwd: ".".to_string(),
4514 args: Vec::new(),
4515 env: HashMap::new(),
4516 limits: PiJsRuntimeLimits::default(),
4517 repair_mode: RepairMode::default(),
4518 allow_unsafe_sync_exec: false,
4519 deny_env: true,
4520 disk_cache_dir: runtime_disk_cache_dir(),
4521 }
4522 }
4523}
4524
4525fn runtime_disk_cache_dir() -> Option<PathBuf> {
4530 if let Some(raw) = std::env::var_os("PIJS_MODULE_CACHE_DIR") {
4531 return if raw.is_empty() {
4532 None
4533 } else {
4534 Some(PathBuf::from(raw))
4535 };
4536 }
4537 dirs::home_dir().map(|home| home.join(".pi").join("agent").join("cache").join("modules"))
4538}
4539
4540#[derive(Debug)]
4541struct InterruptBudget {
4542 configured: Option<u64>,
4543 remaining: std::cell::Cell<Option<u64>>,
4544 tripped: std::cell::Cell<bool>,
4545}
4546
4547impl InterruptBudget {
4548 const fn new(configured: Option<u64>) -> Self {
4549 Self {
4550 configured,
4551 remaining: std::cell::Cell::new(None),
4552 tripped: std::cell::Cell::new(false),
4553 }
4554 }
4555
4556 fn reset(&self) {
4557 self.remaining.set(self.configured);
4558 self.tripped.set(false);
4559 }
4560
4561 fn on_interrupt(&self) -> bool {
4562 let Some(remaining) = self.remaining.get() else {
4563 return false;
4564 };
4565 if remaining == 0 {
4566 self.tripped.set(true);
4567 return true;
4568 }
4569 self.remaining.set(Some(remaining - 1));
4570 false
4571 }
4572
4573 fn did_trip(&self) -> bool {
4574 self.tripped.get()
4575 }
4576
4577 fn clear_trip(&self) {
4578 self.tripped.set(false);
4579 }
4580}
4581
4582#[derive(Debug, Default)]
4583struct HostcallTracker {
4584 pending: HashSet<String>,
4585 cancelled: HashSet<String>,
4586 call_to_timer: HashMap<String, u64>,
4587 timer_to_call: HashMap<u64, String>,
4588 enqueued_at_ms: HashMap<String, u64>,
4589 stream_last_seq: HashMap<String, u64>,
4590}
4591
4592enum HostcallCompletion {
4593 Delivered {
4594 #[allow(dead_code)]
4595 timer_id: Option<u64>,
4596 },
4597 Unknown,
4598}
4599
4600impl HostcallTracker {
4601 fn clear(&mut self) {
4602 self.pending.clear();
4603 self.cancelled.clear();
4604 self.call_to_timer.clear();
4605 self.timer_to_call.clear();
4606 self.enqueued_at_ms.clear();
4607 self.stream_last_seq.clear();
4608 }
4609
4610 fn register(&mut self, call_id: String, timer_id: Option<u64>, enqueued_at_ms: u64) {
4611 self.pending.insert(call_id.clone());
4612 self.cancelled.remove(&call_id);
4613 self.stream_last_seq.remove(&call_id);
4614 if let Some(timer_id) = timer_id {
4615 self.call_to_timer.insert(call_id.clone(), timer_id);
4616 self.timer_to_call.insert(timer_id, call_id.clone());
4617 }
4618 self.enqueued_at_ms.insert(call_id, enqueued_at_ms);
4620 }
4621
4622 fn pending_count(&self) -> usize {
4623 self.pending.len()
4624 }
4625
4626 fn is_pending(&self, call_id: &str) -> bool {
4627 self.pending.contains(call_id)
4628 }
4629
4630 fn is_active(&self, call_id: &str) -> bool {
4631 self.pending.contains(call_id) && !self.cancelled.contains(call_id)
4632 }
4633
4634 fn cancel(&mut self, call_id: &str) -> Option<u64> {
4635 if !self.pending.contains(call_id) {
4636 return None;
4637 }
4638 self.cancelled.insert(call_id.to_string());
4639 let timer_id = self.call_to_timer.remove(call_id);
4640 if let Some(timer_id) = timer_id {
4641 self.timer_to_call.remove(&timer_id);
4642 }
4643 timer_id
4644 }
4645
4646 fn record_stream_seq(&mut self, call_id: &str, sequence: u64) {
4647 if !self.pending.contains(call_id) {
4648 return;
4649 }
4650 let entry = self
4651 .stream_last_seq
4652 .entry(call_id.to_string())
4653 .or_insert(sequence);
4654 if sequence > *entry {
4655 *entry = sequence;
4656 }
4657 }
4658
4659 fn stream_next_seq(&self, call_id: &str) -> Option<u64> {
4660 if !self.pending.contains(call_id) {
4661 return None;
4662 }
4663 Some(
4664 self.stream_last_seq
4665 .get(call_id)
4666 .copied()
4667 .map_or(0, |seq| seq.saturating_add(1)),
4668 )
4669 }
4670
4671 fn queue_wait_ms(&self, call_id: &str, now_ms: u64) -> Option<u64> {
4672 self.enqueued_at_ms
4673 .get(call_id)
4674 .copied()
4675 .map(|enqueued| now_ms.saturating_sub(enqueued))
4676 }
4677
4678 fn on_complete(&mut self, call_id: &str) -> HostcallCompletion {
4679 if !self.pending.remove(call_id) {
4680 return HostcallCompletion::Unknown;
4681 }
4682
4683 let timer_id = self.call_to_timer.remove(call_id);
4684 self.enqueued_at_ms.remove(call_id);
4685 self.cancelled.remove(call_id);
4686 self.stream_last_seq.remove(call_id);
4687 if let Some(timer_id) = timer_id {
4688 self.timer_to_call.remove(&timer_id);
4689 }
4690
4691 HostcallCompletion::Delivered { timer_id }
4692 }
4693
4694 fn take_timed_out_call(&mut self, timer_id: u64) -> Option<String> {
4695 let call_id = self.timer_to_call.remove(&timer_id)?;
4696 self.call_to_timer.remove(&call_id);
4697 self.enqueued_at_ms.remove(&call_id);
4698 self.cancelled.remove(&call_id);
4699 self.stream_last_seq.remove(&call_id);
4700 if !self.pending.remove(&call_id) {
4701 return None;
4702 }
4703 Some(call_id)
4704 }
4705}
4706
4707fn enqueue_hostcall_request_with_backpressure<C: SchedulerClock>(
4708 queue: &HostcallQueue,
4709 tracker: &Rc<RefCell<HostcallTracker>>,
4710 scheduler: &Rc<RefCell<Scheduler<C>>>,
4711 request: HostcallRequest,
4712) {
4713 let call_id = request.call_id.clone();
4714 let trace_id = request.trace_id;
4715 let extension_id = request.extension_id.clone();
4716 match queue.borrow_mut().push_back(request) {
4717 HostcallQueueEnqueueResult::FastPath { depth } => {
4718 tracing::trace!(
4719 event = "pijs.hostcall.queue.fast_path",
4720 call_id = %call_id,
4721 trace_id,
4722 extension_id = ?extension_id,
4723 depth,
4724 "Hostcall queued on fast-path ring"
4725 );
4726 }
4727 HostcallQueueEnqueueResult::OverflowPath {
4728 depth,
4729 overflow_depth,
4730 } => {
4731 tracing::debug!(
4732 event = "pijs.hostcall.queue.overflow_path",
4733 call_id = %call_id,
4734 trace_id,
4735 extension_id = ?extension_id,
4736 depth,
4737 overflow_depth,
4738 "Hostcall spilled to overflow queue"
4739 );
4740 }
4741 HostcallQueueEnqueueResult::Rejected {
4742 depth,
4743 overflow_depth,
4744 } => {
4745 let completion = tracker.borrow_mut().on_complete(&call_id);
4746 if let HostcallCompletion::Delivered { timer_id } = completion {
4747 if let Some(timer_id) = timer_id {
4748 let _ = scheduler.borrow_mut().clear_timeout(timer_id);
4749 }
4750 scheduler.borrow_mut().enqueue_hostcall_complete(
4751 call_id.clone(),
4752 HostcallOutcome::Error {
4753 code: "overloaded".to_string(),
4754 message: format!(
4755 "Hostcall queue overloaded (depth={depth}, overflow_depth={overflow_depth})"
4756 ),
4757 },
4758 );
4759 }
4760 tracing::warn!(
4761 event = "pijs.hostcall.queue.rejected",
4762 call_id = %call_id,
4763 trace_id,
4764 extension_id = ?extension_id,
4765 depth,
4766 overflow_depth,
4767 "Hostcall rejected by queue backpressure policy"
4768 );
4769 }
4770 }
4771}
4772
4773#[derive(Debug)]
4778struct PiJsModuleState {
4779 static_virtual_modules: Arc<HashMap<String, String>>,
4781 dynamic_virtual_modules: HashMap<String, String>,
4783 dynamic_virtual_named_exports: HashMap<String, BTreeSet<String>>,
4785 compiled_sources: HashMap<String, CompiledModuleCacheEntry>,
4786 module_cache_counters: ModuleCacheCounters,
4787 repair_mode: RepairMode,
4790 extension_roots: Vec<PathBuf>,
4793 canonical_extension_roots: Vec<PathBuf>,
4795 extension_root_tiers: HashMap<PathBuf, ProxyStubSourceTier>,
4798 extension_root_scopes: HashMap<PathBuf, String>,
4801 extension_roots_by_id: HashMap<String, Vec<PathBuf>>,
4805 extension_roots_without_id: Vec<PathBuf>,
4809 repair_events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>>,
4811 disk_cache_dir: Option<PathBuf>,
4813}
4814
4815#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4816enum ProxyStubSourceTier {
4817 Official,
4818 Community,
4819 Unknown,
4820}
4821
4822#[derive(Debug, Clone)]
4823struct CompiledModuleCacheEntry {
4824 cache_key: Option<String>,
4825 source: Arc<[u8]>,
4826}
4827
4828#[derive(Debug, Clone, Copy, Default)]
4829struct ModuleCacheCounters {
4830 hits: u64,
4831 misses: u64,
4832 invalidations: u64,
4833 disk_hits: u64,
4834}
4835
4836impl PiJsModuleState {
4837 fn new() -> Self {
4838 Self {
4839 static_virtual_modules: default_virtual_modules_shared(),
4840 dynamic_virtual_modules: HashMap::new(),
4841 dynamic_virtual_named_exports: HashMap::new(),
4842 compiled_sources: HashMap::new(),
4843 module_cache_counters: ModuleCacheCounters::default(),
4844 repair_mode: RepairMode::default(),
4845 extension_roots: Vec::new(),
4846 canonical_extension_roots: Vec::new(),
4847 extension_root_tiers: HashMap::new(),
4848 extension_root_scopes: HashMap::new(),
4849 extension_roots_by_id: HashMap::new(),
4850 extension_roots_without_id: Vec::new(),
4851 repair_events: Arc::new(std::sync::Mutex::new(Vec::new())),
4852 disk_cache_dir: None,
4853 }
4854 }
4855
4856 const fn with_repair_mode(mut self, mode: RepairMode) -> Self {
4857 self.repair_mode = mode;
4858 self
4859 }
4860
4861 fn with_repair_events(
4862 mut self,
4863 events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>>,
4864 ) -> Self {
4865 self.repair_events = events;
4866 self
4867 }
4868
4869 fn with_disk_cache_dir(mut self, dir: Option<PathBuf>) -> Self {
4870 self.disk_cache_dir = dir;
4871 self
4872 }
4873}
4874
4875fn current_extension_id(ctx: &Ctx<'_>) -> Option<String> {
4876 ctx.globals()
4877 .get::<_, Option<String>>("__pi_current_extension_id")
4878 .ok()
4879 .flatten()
4880 .map(|value| value.trim().to_string())
4881 .filter(|value| !value.is_empty())
4882}
4883
4884fn extension_roots_for_fs_access(
4885 extension_id: Option<&str>,
4886 module_state: &Rc<RefCell<PiJsModuleState>>,
4887 fallback_roots: &Arc<std::sync::Mutex<Vec<PathBuf>>>,
4888) -> Vec<PathBuf> {
4889 if let Some(extension_id) = extension_id {
4890 let state = module_state.borrow();
4891 let mut roots = state.extension_roots_without_id.clone();
4892 if let Some(scoped_roots) = state.extension_roots_by_id.get(extension_id) {
4893 for root in scoped_roots {
4894 if !roots.contains(root) {
4895 roots.push(root.clone());
4896 }
4897 }
4898 }
4899 return roots;
4900 }
4901
4902 fallback_roots
4903 .lock()
4904 .map(|roots| roots.clone())
4905 .unwrap_or_default()
4906}
4907
4908fn path_is_in_allowed_extension_root(
4909 path: &Path,
4910 extension_id: Option<&str>,
4911 module_state: &Rc<RefCell<PiJsModuleState>>,
4912 fallback_roots: &Arc<std::sync::Mutex<Vec<PathBuf>>>,
4913) -> bool {
4914 extension_roots_for_fs_access(extension_id, module_state, fallback_roots)
4915 .iter()
4916 .any(|root| path.starts_with(root))
4917}
4918
4919#[derive(Clone, Debug)]
4920struct PiJsResolver {
4921 state: Rc<RefCell<PiJsModuleState>>,
4922}
4923
4924fn canonical_node_builtin(spec: &str) -> Option<&'static str> {
4925 match spec {
4926 "fs" | "node:fs" => Some("node:fs"),
4927 "fs/promises" | "node:fs/promises" => Some("node:fs/promises"),
4928 "path" | "node:path" => Some("node:path"),
4929 "os" | "node:os" => Some("node:os"),
4930 "child_process" | "node:child_process" => Some("node:child_process"),
4931 "crypto" | "node:crypto" => Some("node:crypto"),
4932 "http" | "node:http" => Some("node:http"),
4933 "https" | "node:https" => Some("node:https"),
4934 "http2" | "node:http2" => Some("node:http2"),
4935 "timers" | "node:timers" => Some("node:timers"),
4936 "util" | "node:util" => Some("node:util"),
4937 "readline" | "node:readline" => Some("node:readline"),
4938 "readline/promises" | "node:readline/promises" => Some("node:readline/promises"),
4939 "url" | "node:url" => Some("node:url"),
4940 "net" | "node:net" => Some("node:net"),
4941 "events" | "node:events" => Some("node:events"),
4942 "buffer" | "node:buffer" => Some("node:buffer"),
4943 "assert" | "node:assert" => Some("node:assert"),
4944 "assert/strict" | "node:assert/strict" => Some("node:assert/strict"),
4945 "test" | "node:test" => Some("node:test"),
4946 "stream" | "node:stream" => Some("node:stream"),
4947 "stream/web" | "node:stream/web" => Some("node:stream/web"),
4948 "module" | "node:module" => Some("node:module"),
4949 "string_decoder" | "node:string_decoder" => Some("node:string_decoder"),
4950 "querystring" | "node:querystring" => Some("node:querystring"),
4951 "process" | "node:process" => Some("node:process"),
4952 "stream/promises" | "node:stream/promises" => Some("node:stream/promises"),
4953 "constants" | "node:constants" => Some("node:constants"),
4954 "tls" | "node:tls" => Some("node:tls"),
4955 "tty" | "node:tty" => Some("node:tty"),
4956 "zlib" | "node:zlib" => Some("node:zlib"),
4957 "perf_hooks" | "node:perf_hooks" => Some("node:perf_hooks"),
4958 "vm" | "node:vm" => Some("node:vm"),
4959 "v8" | "node:v8" => Some("node:v8"),
4960 "worker_threads" | "node:worker_threads" => Some("node:worker_threads"),
4961 _ => None,
4962 }
4963}
4964
4965fn is_network_specifier(spec: &str) -> bool {
4966 spec.starts_with("http://")
4967 || spec.starts_with("https://")
4968 || spec.starts_with("http:")
4969 || spec.starts_with("https:")
4970}
4971
4972fn is_bare_package_specifier(spec: &str) -> bool {
4973 if spec.starts_with("./")
4974 || spec.starts_with("../")
4975 || spec.starts_with('/')
4976 || spec.starts_with("file://")
4977 || spec.starts_with("node:")
4978 {
4979 return false;
4980 }
4981 !spec.contains(':')
4982}
4983
4984fn unsupported_module_specifier_message(spec: &str) -> String {
4985 if is_network_specifier(spec) {
4986 return format!("Network module imports are not supported in PiJS: {spec}");
4987 }
4988 if is_bare_package_specifier(spec) {
4989 return format!("Package module specifiers are not supported in PiJS: {spec}");
4990 }
4991 format!("Unsupported module specifier: {spec}")
4992}
4993
4994fn split_scoped_package(spec: &str) -> Option<(&str, &str)> {
4995 if !spec.starts_with('@') {
4996 return None;
4997 }
4998 let mut parts = spec.split('/');
4999 let scope = parts.next()?;
5000 let package = parts.next()?;
5001 Some((scope, package))
5002}
5003
5004fn package_scope(spec: &str) -> Option<&str> {
5005 split_scoped_package(spec).map(|(scope, _)| scope)
5006}
5007
5008fn read_extension_package_scope(root: &Path) -> Option<String> {
5009 let package_json = root.join("package.json");
5010 let raw = fs::read_to_string(package_json).ok()?;
5011 let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
5012 let name = parsed.get("name").and_then(serde_json::Value::as_str)?;
5013 let (scope, _) = split_scoped_package(name.trim())?;
5014 Some(scope.to_string())
5015}
5016
5017fn root_path_hint_tier(root: &Path) -> ProxyStubSourceTier {
5018 let normalized = root
5019 .to_string_lossy()
5020 .replace('\\', "/")
5021 .to_ascii_lowercase();
5022 let community_hints = [
5023 "/community/",
5024 "/npm/",
5025 "/agents-",
5026 "/third-party",
5027 "/third_party",
5028 "/plugins-community/",
5029 ];
5030 if community_hints.iter().any(|hint| normalized.contains(hint)) {
5031 return ProxyStubSourceTier::Community;
5032 }
5033
5034 let official_hints = ["/official-pi-mono/", "/plugins-official/", "/official/"];
5035 if official_hints.iter().any(|hint| normalized.contains(hint)) {
5036 return ProxyStubSourceTier::Official;
5037 }
5038
5039 ProxyStubSourceTier::Unknown
5040}
5041
5042fn classify_proxy_stub_source_tier(extension_id: &str, root: &Path) -> ProxyStubSourceTier {
5043 let id = extension_id.trim().to_ascii_lowercase();
5044 if id.starts_with("community/")
5045 || id.starts_with("npm/")
5046 || id.starts_with("agents-")
5047 || id.starts_with("plugins-community/")
5048 || id.starts_with("third-party")
5049 || id.starts_with("third_party")
5050 {
5051 return ProxyStubSourceTier::Community;
5052 }
5053
5054 if id.starts_with("plugins-official/") {
5055 return ProxyStubSourceTier::Official;
5056 }
5057
5058 root_path_hint_tier(root)
5059}
5060
5061fn resolve_extension_root_for_base<'a>(base: &str, roots: &'a [PathBuf]) -> Option<&'a PathBuf> {
5062 let base_path = Path::new(base);
5063 let canonical_base = crate::extensions::safe_canonicalize(base_path);
5064 roots
5065 .iter()
5066 .filter(|root| {
5067 let canonical_root = crate::extensions::safe_canonicalize(root);
5068 canonical_base.starts_with(&canonical_root)
5069 })
5070 .max_by_key(|root| root.components().count())
5071}
5072
5073fn is_proxy_blocklisted_package(spec: &str) -> bool {
5074 if spec.starts_with("node:") {
5075 return true;
5076 }
5077
5078 let top = spec.split('/').next().unwrap_or(spec);
5079 matches!(
5080 top,
5081 "fs" | "path"
5082 | "child_process"
5083 | "net"
5084 | "http"
5085 | "https"
5086 | "crypto"
5087 | "tls"
5088 | "dgram"
5089 | "dns"
5090 | "vm"
5091 | "worker_threads"
5092 | "cluster"
5093 | "module"
5094 | "os"
5095 | "process"
5096 )
5097}
5098
5099fn is_proxy_allowlisted_package(spec: &str) -> bool {
5100 const ALLOWLIST_SCOPES: &[&str] = &["@sourcegraph", "@marckrenn", "@aliou"];
5101 const ALLOWLIST_PACKAGES: &[&str] = &[
5102 "openai",
5103 "adm-zip",
5104 "linkedom",
5105 "p-limit",
5106 "unpdf",
5107 "node-pty",
5108 "chokidar",
5109 "jsdom",
5110 "turndown",
5111 "beautiful-mermaid",
5112 ];
5113
5114 if ALLOWLIST_PACKAGES.contains(&spec) {
5115 return true;
5116 }
5117
5118 if let Some((scope, package)) = split_scoped_package(spec) {
5119 if ALLOWLIST_SCOPES.contains(&scope) {
5120 return true;
5121 }
5122
5123 if package.starts_with("pi-") {
5125 return true;
5126 }
5127 }
5128
5129 false
5130}
5131
5132fn capture_with_max_buffer(
5133 mut reader: impl std::io::Read,
5134 limit_bytes: usize,
5135 limit_exceeded: &std::sync::atomic::AtomicBool,
5136 stream_name: &'static str,
5137) -> (Vec<u8>, Option<String>) {
5138 let mut buf = Vec::new();
5139 let mut chunk = [0u8; 8192];
5140 let mut overflowed = false;
5141 loop {
5142 let n = match reader.read(&mut chunk) {
5143 Ok(n) => n,
5144 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
5145 Err(e) => return (buf, Some(e.to_string())),
5146 };
5147 if n == 0 {
5148 break;
5149 }
5150
5151 let remaining = limit_bytes.saturating_sub(buf.len());
5152 if remaining > 0 {
5153 let keep = remaining.min(n);
5154 buf.extend_from_slice(&chunk[..keep]);
5155 }
5156
5157 if n > remaining {
5158 overflowed = true;
5159 limit_exceeded.store(true, AtomicOrdering::Relaxed);
5160 }
5161 }
5162
5163 let error = overflowed.then(|| format!("ENOBUFS: {stream_name} maxBuffer length exceeded"));
5164 (buf, error)
5165}
5166
5167const MAX_MODULE_SOURCE_BYTES: u64 = 1024 * 1024 * 1024;
5169
5170fn should_auto_stub_package(
5171 spec: &str,
5172 base: &str,
5173 extension_roots: &[PathBuf],
5174 extension_root_tiers: &HashMap<PathBuf, ProxyStubSourceTier>,
5175 extension_root_scopes: &HashMap<PathBuf, String>,
5176) -> bool {
5177 if !is_bare_package_specifier(spec) || is_proxy_blocklisted_package(spec) {
5178 return false;
5179 }
5180
5181 let (tier, root_for_scope) = resolve_extension_root_for_base(base, extension_roots).map_or(
5182 (ProxyStubSourceTier::Unknown, None),
5183 |root| {
5184 (
5185 extension_root_tiers
5186 .get(root)
5187 .copied()
5188 .unwrap_or(ProxyStubSourceTier::Unknown),
5189 Some(root),
5190 )
5191 },
5192 );
5193
5194 let same_scope = if let Some(spec_scope) = package_scope(spec)
5195 && let Some(root) = root_for_scope
5196 && let Some(extension_scope) = extension_root_scopes.get(root)
5197 {
5198 extension_scope == spec_scope
5199 } else {
5200 false
5201 };
5202
5203 if is_proxy_allowlisted_package(spec) {
5204 return true;
5205 }
5206
5207 if same_scope {
5208 return true;
5209 }
5210
5211 tier != ProxyStubSourceTier::Official
5218}
5219
5220fn is_valid_js_export_name(name: &str) -> bool {
5221 let mut chars = name.chars();
5222 let Some(first) = chars.next() else {
5223 return false;
5224 };
5225 let is_start = first == '_' || first == '$' || first.is_ascii_alphabetic();
5226 if !is_start {
5227 return false;
5228 }
5229 chars.all(|c| c == '_' || c == '$' || c.is_ascii_alphanumeric())
5230}
5231
5232fn generate_proxy_stub_module(spec: &str, named_exports: &BTreeSet<String>) -> String {
5233 let spec_literal = serde_json::to_string(spec).unwrap_or_else(|_| "\"<unknown>\"".to_string());
5234 let mut source = format!(
5235 r"// Auto-generated npm proxy stub (Pattern 4) for {spec_literal}
5236const __pkg = {spec_literal};
5237const __handler = {{
5238 get(_target, prop) {{
5239 if (typeof prop === 'symbol') {{
5240 if (prop === Symbol.toPrimitive) return () => '';
5241 return undefined;
5242 }}
5243 if (prop === '__esModule') return true;
5244 if (prop === 'default') return __stub;
5245 if (prop === 'toString') return () => '';
5246 if (prop === 'valueOf') return () => '';
5247 if (prop === 'name') return __pkg;
5248 // Promise assimilation guard: do not pretend to be then-able.
5249 if (prop === 'then') return undefined;
5250 return __stub;
5251 }},
5252 apply() {{ return __stub; }},
5253 construct() {{ return __stub; }},
5254 has() {{ return false; }},
5255 ownKeys() {{ return []; }},
5256 getOwnPropertyDescriptor() {{
5257 return {{ configurable: true, enumerable: false }};
5258 }},
5259}};
5260const __stub = new Proxy(function __pijs_noop() {{}}, __handler);
5261"
5262 );
5263
5264 for name in named_exports {
5265 if name == "default" || name == "__esModule" || !is_valid_js_export_name(name) {
5266 continue;
5267 }
5268 let _ = writeln!(source, "export const {name} = __stub;");
5269 }
5270
5271 source.push_str("export default __stub;\n");
5272 source.push_str("export const __pijs_proxy_stub = __stub;\n");
5273 source.push_str("export const __esModule = true;\n");
5274 source
5275}
5276
5277fn builtin_specifier_aliases(spec: &str, canonical: &str) -> Vec<String> {
5278 let mut aliases = Vec::new();
5279 let mut seen = HashSet::new();
5280 let mut push_alias = |candidate: &str| {
5281 if candidate.is_empty() {
5282 return;
5283 }
5284 if seen.insert(candidate.to_string()) {
5285 aliases.push(candidate.to_string());
5286 }
5287 };
5288
5289 push_alias(spec);
5290 push_alias(canonical);
5291
5292 if let Some(bare) = spec.strip_prefix("node:") {
5293 push_alias(bare);
5294 }
5295 if let Some(bare) = canonical.strip_prefix("node:") {
5296 push_alias(bare);
5297 }
5298
5299 aliases
5300}
5301
5302fn extract_builtin_import_names(source: &str, spec: &str, canonical: &str) -> BTreeSet<String> {
5303 let mut names = BTreeSet::new();
5304 for alias in builtin_specifier_aliases(spec, canonical) {
5305 for name in extract_import_names(source, &alias) {
5306 if name == "default" || name == "__esModule" {
5307 continue;
5308 }
5309 if is_valid_js_export_name(&name) {
5310 names.insert(name);
5311 }
5312 }
5313 }
5314 names
5315}
5316
5317fn generate_builtin_compat_overlay_module(
5318 canonical: &str,
5319 named_exports: &BTreeSet<String>,
5320) -> String {
5321 let spec_literal =
5322 serde_json::to_string(canonical).unwrap_or_else(|_| "\"node:unknown\"".to_string());
5323 let mut source = format!(
5324 r"// Auto-generated Node builtin compatibility overlay for {canonical}
5325import * as __pijs_builtin_ns from {spec_literal};
5326const __pijs_builtin_default =
5327 __pijs_builtin_ns.default !== undefined ? __pijs_builtin_ns.default : __pijs_builtin_ns;
5328export default __pijs_builtin_default;
5329"
5330 );
5331
5332 for name in named_exports {
5333 if !is_valid_js_export_name(name) || name == "default" || name == "__esModule" {
5334 continue;
5335 }
5336 let _ = writeln!(
5337 source,
5338 "export const {name} = __pijs_builtin_ns.{name} !== undefined ? __pijs_builtin_ns.{name} : (__pijs_builtin_default && __pijs_builtin_default.{name});"
5339 );
5340 }
5341
5342 source.push_str("export const __esModule = true;\n");
5343 source
5344}
5345
5346fn builtin_overlay_module_key(base: &str, canonical: &str) -> String {
5347 let mut hasher = Sha256::new();
5348 hasher.update(base.as_bytes());
5349 let digest = format!("{:x}", hasher.finalize());
5350 let short = &digest[..16];
5351 format!("pijs-compat://builtin/{canonical}/{short}")
5352}
5353
5354fn read_source_for_import_extraction(path: &str) -> Option<String> {
5357 use std::io::Read;
5358 let file = std::fs::File::open(path).ok()?;
5359 let mut handle = file.take(1024 * 1024); let mut buffer = Vec::new();
5361 handle.read_to_end(&mut buffer).ok()?;
5362 Some(String::from_utf8_lossy(&buffer).into_owned())
5363}
5364
5365fn maybe_register_builtin_compat_overlay(
5366 state: &mut PiJsModuleState,
5367 base: &str,
5368 spec: &str,
5369 canonical: &str,
5370) -> Option<String> {
5371 if !canonical.starts_with("node:") {
5372 return None;
5373 }
5374
5375 let source = read_source_for_import_extraction(base)?;
5376 let extracted_names = extract_builtin_import_names(&source, spec, canonical);
5377 if extracted_names.is_empty() {
5378 return None;
5379 }
5380
5381 let overlay_key = builtin_overlay_module_key(base, canonical);
5382 let needs_rebuild = state
5383 .dynamic_virtual_named_exports
5384 .get(&overlay_key)
5385 .is_none_or(|existing| existing != &extracted_names)
5386 || !state.dynamic_virtual_modules.contains_key(&overlay_key);
5387
5388 if needs_rebuild {
5389 state
5390 .dynamic_virtual_named_exports
5391 .insert(overlay_key.clone(), extracted_names.clone());
5392 let overlay = generate_builtin_compat_overlay_module(canonical, &extracted_names);
5393 state
5394 .dynamic_virtual_modules
5395 .insert(overlay_key.clone(), overlay);
5396 if state.compiled_sources.remove(&overlay_key).is_some() {
5397 state.module_cache_counters.invalidations =
5398 state.module_cache_counters.invalidations.saturating_add(1);
5399 }
5400 }
5401
5402 Some(overlay_key)
5403}
5404
5405impl JsModuleResolver for PiJsResolver {
5406 #[allow(clippy::too_many_lines)]
5407 fn resolve(&mut self, _ctx: &Ctx<'_>, base: &str, name: &str) -> rquickjs::Result<String> {
5408 let spec = name.trim();
5409 if spec.is_empty() {
5410 return Err(rquickjs::Error::new_resolving(base, name));
5411 }
5412
5413 let canonical = canonical_node_builtin(spec).unwrap_or(spec);
5415 let compat_scan_mode = is_global_compat_scan_mode();
5416
5417 let repair_mode = {
5418 let mut state = self.state.borrow_mut();
5419 if state.dynamic_virtual_modules.contains_key(canonical)
5420 || state.static_virtual_modules.contains_key(canonical)
5421 {
5422 if compat_scan_mode
5423 && let Some(overlay_key) =
5424 maybe_register_builtin_compat_overlay(&mut state, base, spec, canonical)
5425 {
5426 tracing::debug!(
5427 event = "pijs.compat.builtin_overlay",
5428 base = %base,
5429 specifier = %spec,
5430 canonical = %canonical,
5431 overlay = %overlay_key,
5432 "compat overlay for builtin named imports"
5433 );
5434 return Ok(overlay_key);
5435 }
5436 return Ok(canonical.to_string());
5437 }
5438 state.repair_mode
5439 };
5440
5441 let canonical_roots = {
5442 let state = self.state.borrow();
5443 state.canonical_extension_roots.clone()
5444 };
5445 if let Some(path) = resolve_module_path(base, spec, repair_mode, &canonical_roots) {
5446 let canonical = crate::extensions::safe_canonicalize(&path);
5449
5450 let is_safe = canonical_roots
5451 .iter()
5452 .any(|canonical_root| canonical.starts_with(canonical_root));
5453
5454 if !is_safe {
5455 tracing::warn!(
5456 event = "pijs.resolve.escape",
5457 base = %base,
5458 specifier = %spec,
5459 resolved = %canonical.display(),
5460 "import resolved to path outside extension roots"
5461 );
5462 return Err(rquickjs::Error::new_resolving(base, name));
5463 }
5464
5465 return Ok(canonical.to_string_lossy().replace('\\', "/"));
5466 }
5467
5468 if spec.starts_with('.') && repair_mode.allows_aggressive() {
5473 let state = self.state.borrow();
5474 let canonical_roots = state.canonical_extension_roots.clone();
5475 drop(state);
5476
5477 if let Some(escaped_path) = detect_monorepo_escape(base, spec, &canonical_roots) {
5478 let source = read_source_for_import_extraction(base).unwrap_or_default();
5480 let names = extract_import_names(&source, spec);
5481
5482 let stub = generate_monorepo_stub(&names);
5483 let virtual_key = format!("pijs-repair://monorepo/{}", escaped_path.display());
5484
5485 tracing::info!(
5486 event = "pijs.repair.monorepo_escape",
5487 base = %base,
5488 specifier = %spec,
5489 resolved = %escaped_path.display(),
5490 exports = ?names,
5491 "auto-repair: generated monorepo escape stub"
5492 );
5493
5494 let state = self.state.borrow();
5496 if let Ok(mut events) = state.repair_events.lock() {
5497 events.push(ExtensionRepairEvent {
5498 extension_id: String::new(),
5499 pattern: RepairPattern::MonorepoEscape,
5500 original_error: format!(
5501 "monorepo escape: {} from {base}",
5502 escaped_path.display()
5503 ),
5504 repair_action: format!(
5505 "generated stub with {} exports: {virtual_key}",
5506 names.len()
5507 ),
5508 success: true,
5509 timestamp_ms: 0,
5510 });
5511 }
5512 drop(state);
5513
5514 let mut state = self.state.borrow_mut();
5516 state
5517 .dynamic_virtual_modules
5518 .insert(virtual_key.clone(), stub);
5519 return Ok(virtual_key);
5520 }
5521 }
5522
5523 if is_bare_package_specifier(spec) && (repair_mode.allows_aggressive() || compat_scan_mode)
5531 {
5532 let state = self.state.borrow();
5533 let roots = state.extension_roots.clone();
5534 let tiers = state.extension_root_tiers.clone();
5535 let scopes = state.extension_root_scopes.clone();
5536 drop(state);
5537
5538 if should_auto_stub_package(spec, base, &roots, &tiers, &scopes) {
5539 tracing::info!(
5540 event = "pijs.repair.missing_npm_dep",
5541 base = %base,
5542 specifier = %spec,
5543 "auto-repair: generated proxy stub for missing npm dependency"
5544 );
5545
5546 let source = read_source_for_import_extraction(base).unwrap_or_default();
5547 let extracted_names = extract_import_names(&source, spec);
5548 let mut state = self.state.borrow_mut();
5549 let entry_key = spec.to_string();
5550 let mut exports_changed = false;
5551 {
5552 let exports = state
5553 .dynamic_virtual_named_exports
5554 .entry(entry_key.clone())
5555 .or_default();
5556 for name in extracted_names {
5557 exports_changed |= exports.insert(name);
5558 }
5559 }
5560
5561 let export_names = state
5562 .dynamic_virtual_named_exports
5563 .get(&entry_key)
5564 .cloned()
5565 .unwrap_or_default();
5566 if exports_changed || !state.dynamic_virtual_modules.contains_key(spec) {
5567 let stub = generate_proxy_stub_module(spec, &export_names);
5568 state.dynamic_virtual_modules.insert(entry_key, stub);
5569 if state.compiled_sources.remove(spec).is_some() {
5570 state.module_cache_counters.invalidations =
5571 state.module_cache_counters.invalidations.saturating_add(1);
5572 }
5573 }
5574
5575 if let Ok(mut events) = state.repair_events.lock() {
5576 events.push(ExtensionRepairEvent {
5577 extension_id: String::new(),
5578 pattern: RepairPattern::MissingNpmDep,
5579 original_error: format!("missing npm dependency: {spec} from {base}"),
5580 repair_action: format!(
5581 "generated proxy stub for package '{spec}' with {} named export(s)",
5582 export_names.len()
5583 ),
5584 success: true,
5585 timestamp_ms: 0,
5586 });
5587 }
5588
5589 return Ok(spec.to_string());
5590 }
5591 }
5592
5593 let canonical_roots = {
5594 let state = self.state.borrow();
5595 state.canonical_extension_roots.clone()
5596 };
5597 if let Some(escaped_path) = detect_monorepo_escape(base, spec, &canonical_roots) {
5598 return Err(rquickjs::Error::new_resolving_message(
5599 base,
5600 name,
5601 format!(
5602 "Module path escapes extension root: {}",
5603 escaped_path.display()
5604 ),
5605 ));
5606 }
5607
5608 Err(rquickjs::Error::new_resolving_message(
5609 base,
5610 name,
5611 unsupported_module_specifier_message(spec),
5612 ))
5613 }
5614}
5615
5616#[derive(Clone, Debug)]
5617struct PiJsLoader {
5618 state: Rc<RefCell<PiJsModuleState>>,
5619}
5620
5621impl JsModuleLoader for PiJsLoader {
5622 fn load<'js>(
5623 &mut self,
5624 ctx: &Ctx<'js>,
5625 name: &str,
5626 ) -> rquickjs::Result<Module<'js, JsModuleDeclared>> {
5627 let source = {
5628 let mut state = self.state.borrow_mut();
5629 load_compiled_module_source(&mut state, name)?
5630 };
5631
5632 Module::declare(ctx.clone(), name, source)
5633 }
5634}
5635
5636fn compile_module_source(
5637 static_virtual_modules: &HashMap<String, String>,
5638 dynamic_virtual_modules: &HashMap<String, String>,
5639 name: &str,
5640) -> rquickjs::Result<Vec<u8>> {
5641 if let Some(source) = dynamic_virtual_modules
5642 .get(name)
5643 .or_else(|| static_virtual_modules.get(name))
5644 {
5645 return Ok(prefix_import_meta_url(name, source));
5646 }
5647
5648 let path = Path::new(name);
5649 if !path.is_file() {
5650 return Err(rquickjs::Error::new_loading_message(
5651 name,
5652 "Module is not a file",
5653 ));
5654 }
5655
5656 let metadata = fs::metadata(path)
5657 .map_err(|err| rquickjs::Error::new_loading_message(name, format!("metadata: {err}")))?;
5658 if metadata.len() > MAX_MODULE_SOURCE_BYTES {
5659 return Err(rquickjs::Error::new_loading_message(
5660 name,
5661 format!(
5662 "Module source exceeds size limit: {} > {}",
5663 metadata.len(),
5664 MAX_MODULE_SOURCE_BYTES
5665 ),
5666 ));
5667 }
5668
5669 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
5670 let file = fs::File::open(path)
5671 .map_err(|err| rquickjs::Error::new_loading_message(name, format!("open: {err}")))?;
5672 let mut handle = std::io::Read::take(file, MAX_MODULE_SOURCE_BYTES + 1);
5673 let mut raw = String::new();
5674 std::io::Read::read_to_string(&mut handle, &mut raw)
5675 .map_err(|err| rquickjs::Error::new_loading_message(name, format!("read: {err}")))?;
5676
5677 if raw.len() as u64 > MAX_MODULE_SOURCE_BYTES {
5678 return Err(rquickjs::Error::new_loading_message(
5679 name,
5680 format!(
5681 "Module source exceeds size limit: {} > {}",
5682 raw.len(),
5683 MAX_MODULE_SOURCE_BYTES
5684 ),
5685 ));
5686 }
5687
5688 let compiled = match extension {
5689 "ts" | "tsx" | "cts" | "mts" | "jsx" => {
5690 let transpiled = transpile_typescript_module(&raw, name).map_err(|message| {
5691 rquickjs::Error::new_loading_message(name, format!("transpile: {message}"))
5692 })?;
5693 rewrite_legacy_private_identifiers(&maybe_cjs_to_esm(&transpiled))
5694 }
5695 "js" | "mjs" | "cjs" => rewrite_legacy_private_identifiers(&maybe_cjs_to_esm(&raw)),
5696 "json" => json_module_to_esm(&raw, name).map_err(|message| {
5697 rquickjs::Error::new_loading_message(name, format!("json: {message}"))
5698 })?,
5699 other => {
5700 return Err(rquickjs::Error::new_loading_message(
5701 name,
5702 format!("Unsupported module extension: {other}"),
5703 ));
5704 }
5705 };
5706
5707 Ok(prefix_import_meta_url(name, &compiled))
5708}
5709
5710fn module_cache_key(
5711 static_virtual_modules: &HashMap<String, String>,
5712 dynamic_virtual_modules: &HashMap<String, String>,
5713 name: &str,
5714) -> Option<String> {
5715 if let Some(source) = dynamic_virtual_modules
5716 .get(name)
5717 .or_else(|| static_virtual_modules.get(name))
5718 {
5719 let mut hasher = Sha256::new();
5720 hasher.update(b"virtual\0");
5721 hasher.update(name.as_bytes());
5722 hasher.update(b"\0");
5723 hasher.update(source.as_bytes());
5724 return Some(format!("v:{:x}", hasher.finalize()));
5725 }
5726
5727 let path = Path::new(name);
5728 if !path.is_file() {
5729 return None;
5730 }
5731
5732 let metadata = fs::metadata(path).ok()?;
5733 let modified_nanos = metadata
5734 .modified()
5735 .ok()
5736 .and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
5737 .map_or(0, |duration| duration.as_nanos());
5738
5739 Some(format!("f:{name}:{}:{modified_nanos}", metadata.len()))
5740}
5741
5742fn disk_cache_path(cache_dir: &Path, cache_key: &str) -> PathBuf {
5751 let mut hasher = Sha256::new();
5752 hasher.update(cache_key.as_bytes());
5753 let hex = format!("{:x}", hasher.finalize());
5754 let prefix = &hex[..2];
5755 cache_dir.join(prefix).join(format!("{hex}.js"))
5756}
5757
5758fn try_load_from_disk_cache(cache_dir: &Path, cache_key: &str) -> Option<Vec<u8>> {
5760 let path = disk_cache_path(cache_dir, cache_key);
5761 fs::read(path).ok()
5762}
5763
5764fn store_to_disk_cache(cache_dir: &Path, cache_key: &str, source: &[u8]) {
5766 let path = disk_cache_path(cache_dir, cache_key);
5767 if let Some(parent) = path.parent() {
5768 if let Err(err) = fs::create_dir_all(parent) {
5769 tracing::debug!(event = "pijs.module_cache.disk.mkdir_failed", path = %parent.display(), %err);
5770 return;
5771 }
5772 }
5773
5774 let temp_path = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4().simple()));
5775 if let Err(err) = fs::write(&temp_path, source) {
5776 tracing::debug!(event = "pijs.module_cache.disk.write_failed", path = %temp_path.display(), %err);
5777 return;
5778 }
5779
5780 if let Err(err) = fs::rename(&temp_path, &path) {
5781 tracing::debug!(event = "pijs.module_cache.disk.rename_failed", from = %temp_path.display(), to = %path.display(), %err);
5782 let _ = fs::remove_file(&temp_path);
5783 }
5784}
5785
5786fn load_compiled_module_source(
5787 state: &mut PiJsModuleState,
5788 name: &str,
5789) -> rquickjs::Result<Vec<u8>> {
5790 let cache_key = module_cache_key(
5791 &state.static_virtual_modules,
5792 &state.dynamic_virtual_modules,
5793 name,
5794 );
5795
5796 if let Some(cached) = state.compiled_sources.get(name) {
5798 if cached.cache_key == cache_key {
5799 state.module_cache_counters.hits = state.module_cache_counters.hits.saturating_add(1);
5800 return Ok(cached.source.to_vec());
5801 }
5802
5803 state.module_cache_counters.invalidations =
5804 state.module_cache_counters.invalidations.saturating_add(1);
5805 }
5806
5807 if let Some(cache_key_str) = cache_key.as_deref()
5809 && let Some(cache_dir) = state.disk_cache_dir.as_deref()
5810 && let Some(disk_cached) = try_load_from_disk_cache(cache_dir, cache_key_str)
5811 {
5812 state.module_cache_counters.disk_hits =
5813 state.module_cache_counters.disk_hits.saturating_add(1);
5814 let source: Arc<[u8]> = disk_cached.into();
5815 state.compiled_sources.insert(
5816 name.to_string(),
5817 CompiledModuleCacheEntry {
5818 cache_key,
5819 source: Arc::clone(&source),
5820 },
5821 );
5822 return Ok(source.to_vec());
5823 }
5824
5825 state.module_cache_counters.misses = state.module_cache_counters.misses.saturating_add(1);
5827 let compiled = compile_module_source(
5828 &state.static_virtual_modules,
5829 &state.dynamic_virtual_modules,
5830 name,
5831 )?;
5832 let source: Arc<[u8]> = compiled.into();
5833 state.compiled_sources.insert(
5834 name.to_string(),
5835 CompiledModuleCacheEntry {
5836 cache_key: cache_key.clone(),
5837 source: Arc::clone(&source),
5838 },
5839 );
5840
5841 if let Some(cache_key_str) = cache_key.as_deref()
5843 && let Some(cache_dir) = state.disk_cache_dir.as_deref()
5844 {
5845 store_to_disk_cache(cache_dir, cache_key_str, &source);
5846 }
5847
5848 Ok(source.to_vec())
5849}
5850
5851#[derive(Debug, Clone)]
5871pub struct WarmIsolatePool {
5872 template: PiJsRuntimeConfig,
5874 created_count: Arc<AtomicU64>,
5876 reset_count: Arc<AtomicU64>,
5878}
5879
5880impl WarmIsolatePool {
5881 pub fn new(template: PiJsRuntimeConfig) -> Self {
5883 Self {
5884 template,
5885 created_count: Arc::new(AtomicU64::new(0)),
5886 reset_count: Arc::new(AtomicU64::new(0)),
5887 }
5888 }
5889
5890 pub fn make_config(&self) -> PiJsRuntimeConfig {
5892 self.created_count.fetch_add(1, AtomicOrdering::Relaxed);
5893 self.template.clone()
5894 }
5895
5896 pub fn record_reset(&self) {
5898 self.reset_count.fetch_add(1, AtomicOrdering::Relaxed);
5899 }
5900
5901 pub fn created_count(&self) -> u64 {
5903 self.created_count.load(AtomicOrdering::Relaxed)
5904 }
5905
5906 pub fn reset_count(&self) -> u64 {
5908 self.reset_count.load(AtomicOrdering::Relaxed)
5909 }
5910}
5911
5912impl Default for WarmIsolatePool {
5913 fn default() -> Self {
5914 Self::new(PiJsRuntimeConfig::default())
5915 }
5916}
5917
5918fn prefix_import_meta_url(module_name: &str, body: &str) -> Vec<u8> {
5919 let url = if module_name.starts_with('/') {
5920 format!("file://{module_name}")
5921 } else if module_name.starts_with("file://") {
5922 module_name.to_string()
5923 } else if module_name.len() > 2
5924 && module_name.as_bytes()[1] == b':'
5925 && (module_name.as_bytes()[2] == b'/' || module_name.as_bytes()[2] == b'\\')
5926 {
5927 format!("file:///{module_name}")
5929 } else {
5930 format!("pi://{module_name}")
5931 };
5932 let url_literal = serde_json::to_string(&url).unwrap_or_else(|_| "\"\"".to_string());
5933 format!("import.meta.url = {url_literal};\n{body}").into_bytes()
5934}
5935
5936#[allow(clippy::too_many_lines)]
5937fn resolve_module_path(
5938 base: &str,
5939 specifier: &str,
5940 repair_mode: RepairMode,
5941 canonical_roots: &[PathBuf],
5942) -> Option<PathBuf> {
5943 let specifier = specifier.trim();
5944 if specifier.is_empty() {
5945 return None;
5946 }
5947
5948 if let Some(path) = specifier.strip_prefix("file://") {
5949 if canonical_roots.is_empty() {
5950 return None;
5951 }
5952 let path_buf = PathBuf::from(path);
5953 let canonical = crate::extensions::safe_canonicalize(&path_buf);
5954 let allowed = canonical_roots
5955 .iter()
5956 .any(|canonical_root| canonical.starts_with(canonical_root));
5957 if !allowed {
5958 tracing::warn!(
5959 event = "pijs.resolve.monotonicity_violation",
5960 original = %path_buf.display(),
5961 "resolution blocked: file:// path escapes extension root"
5962 );
5963 return None;
5964 }
5965
5966 let resolved = resolve_existing_file(path_buf)?;
5967
5968 let canonical_resolved = crate::extensions::safe_canonicalize(&resolved);
5970 let allowed_resolved = canonical_roots
5971 .iter()
5972 .any(|canonical_root| canonical_resolved.starts_with(canonical_root));
5973
5974 if !allowed_resolved {
5975 tracing::warn!(
5976 event = "pijs.resolve.monotonicity_violation",
5977 resolved = %resolved.display(),
5978 "resolution blocked: resolved file:// path escapes extension root"
5979 );
5980 return None;
5981 }
5982 return Some(resolved);
5983 }
5984
5985 let path = if specifier.starts_with('/') {
5986 PathBuf::from(specifier)
5987 } else if specifier.len() > 2
5988 && specifier.as_bytes()[1] == b':'
5989 && (specifier.as_bytes()[2] == b'/' || specifier.as_bytes()[2] == b'\\')
5990 {
5991 PathBuf::from(specifier)
5993 } else if specifier.starts_with('.') {
5994 let base_path = Path::new(base);
5995 let base_dir = base_path.parent()?;
5996 base_dir.join(specifier)
5997 } else {
5998 return None;
5999 };
6000
6001 if canonical_roots.is_empty() {
6005 return None;
6006 }
6007 let canonical = crate::extensions::safe_canonicalize(&path);
6008 let allowed = canonical_roots
6009 .iter()
6010 .any(|canonical_root| canonical.starts_with(canonical_root));
6011
6012 if !allowed {
6013 return None;
6014 }
6015
6016 if let Some(resolved) = resolve_existing_module_candidate(path.clone()) {
6017 if canonical_roots.is_empty() {
6021 return None;
6022 }
6023 let canonical_resolved = crate::extensions::safe_canonicalize(&resolved);
6024 let allowed = canonical_roots
6025 .iter()
6026 .any(|canonical_root| canonical_resolved.starts_with(canonical_root));
6027
6028 if !allowed {
6029 tracing::warn!(
6030 event = "pijs.resolve.monotonicity_violation",
6031 original = %path.display(),
6032 resolved = %resolved.display(),
6033 "resolution blocked: resolved path escapes extension root"
6034 );
6035 return None;
6036 }
6037 return Some(resolved);
6038 }
6039
6040 let fallback_resolved = if repair_mode.should_apply() {
6044 try_dist_to_src_fallback(&path)
6045 } else {
6046 if repair_mode == RepairMode::Suggest {
6047 if let Some(resolved) = try_dist_to_src_fallback(&path) {
6049 tracing::info!(
6050 event = "pijs.repair.suggest",
6051 pattern = "dist_to_src",
6052 original = %path.display(),
6053 resolved = %resolved.display(),
6054 "repair suggestion: would resolve dist/ → src/ (mode=suggest)"
6055 );
6056 }
6057 }
6058 None
6059 };
6060
6061 if let Some(resolved) = fallback_resolved {
6062 let canonical_resolved = crate::extensions::safe_canonicalize(&resolved);
6063 let allowed = canonical_roots
6064 .iter()
6065 .any(|canonical_root| canonical_resolved.starts_with(canonical_root));
6066
6067 if !allowed {
6068 tracing::warn!(
6069 event = "pijs.resolve.monotonicity_violation",
6070 original = %path.display(),
6071 resolved = %resolved.display(),
6072 "resolution blocked: repaired path escapes extension root"
6073 );
6074 return None;
6075 }
6076 return Some(resolved);
6077 }
6078
6079 None
6080}
6081
6082fn try_dist_to_src_fallback(path: &Path) -> Option<PathBuf> {
6087 let path_str = path.to_string_lossy();
6088
6089 let normalized = path_str.replace('\\', "/");
6091 let idx = normalized.find("/dist/")?;
6092
6093 let extension_root = PathBuf::from(&path_str[..idx]);
6095
6096 let sep = std::path::MAIN_SEPARATOR;
6097 let src_path = format!("{}{sep}src{sep}{}", &path_str[..idx], &path_str[idx + 6..]);
6098
6099 let candidate = PathBuf::from(&src_path);
6100
6101 if let Some(resolved) = resolve_existing_module_candidate(candidate) {
6102 let verdict = verify_repair_monotonicity(&extension_root, path, &resolved);
6105 if !verdict.is_safe() {
6106 tracing::warn!(
6107 event = "pijs.repair.monotonicity_violation",
6108 original = %path_str,
6109 resolved = %resolved.display(),
6110 verdict = ?verdict,
6111 "repair blocked: resolved path escapes extension root"
6112 );
6113 return None;
6114 }
6115
6116 let structural = validate_repaired_artifact(&resolved);
6119 if !structural.is_valid() {
6120 tracing::warn!(
6121 event = "pijs.repair.structural_validation_failed",
6122 original = %path_str,
6123 resolved = %resolved.display(),
6124 verdict = %structural,
6125 "repair blocked: resolved artifact failed structural validation"
6126 );
6127 return None;
6128 }
6129
6130 tracing::info!(
6131 event = "pijs.repair.dist_to_src",
6132 original = %path_str,
6133 resolved = %resolved.display(),
6134 "auto-repair: resolved dist/ → src/ fallback"
6135 );
6136 return Some(resolved);
6137 }
6138
6139 None
6140}
6141
6142fn resolve_existing_file(path: PathBuf) -> Option<PathBuf> {
6143 if path.is_file() {
6144 return Some(path);
6145 }
6146 None
6147}
6148
6149fn resolve_existing_module_candidate(path: PathBuf) -> Option<PathBuf> {
6150 if path.is_file() {
6151 return Some(path);
6152 }
6153
6154 if path.is_dir() {
6155 for candidate in [
6156 "index.ts",
6157 "index.tsx",
6158 "index.jsx",
6159 "index.cts",
6160 "index.mts",
6161 "index.js",
6162 "index.mjs",
6163 "index.cjs",
6164 "index.json",
6165 ] {
6166 let full = path.join(candidate);
6167 if full.is_file() {
6168 return Some(full);
6169 }
6170 }
6171 return None;
6172 }
6173
6174 let extension = path.extension().and_then(|ext| ext.to_str());
6175 match extension {
6176 Some("js" | "mjs" | "cjs" | "jsx") => {
6177 for ext in ["ts", "tsx", "cts", "mts"] {
6178 let fallback = path.with_extension(ext);
6179 if fallback.is_file() {
6180 return Some(fallback);
6181 }
6182 }
6183 }
6184 None => {
6185 for ext in ["ts", "tsx", "jsx", "cts", "mts", "js", "mjs", "cjs", "json"] {
6186 let candidate = path.with_extension(ext);
6187 if candidate.is_file() {
6188 return Some(candidate);
6189 }
6190 }
6191 }
6192 _ => {}
6193 }
6194
6195 None
6196}
6197
6198static IMPORT_NAMES_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6205
6206fn import_names_regex() -> &'static regex::Regex {
6207 IMPORT_NAMES_RE.get_or_init(|| {
6208 regex::Regex::new(r#"(?ms)import\s+(?:[^{};]*?,\s*)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#)
6209 .expect("import names regex")
6210 })
6211}
6212
6213static REQUIRE_DESTRUCTURE_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6216
6217fn require_destructure_regex() -> &'static regex::Regex {
6218 REQUIRE_DESTRUCTURE_RE.get_or_init(|| {
6219 regex::Regex::new(
6220 r#"(?m)(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]"#,
6221 )
6222 .expect("require destructure regex")
6223 })
6224}
6225
6226fn detect_monorepo_escape(
6229 base: &str,
6230 specifier: &str,
6231 canonical_extension_roots: &[PathBuf],
6232) -> Option<PathBuf> {
6233 if !specifier.starts_with('.') {
6234 return None;
6235 }
6236 let base_dir = Path::new(base).parent()?;
6237 let resolved = base_dir.join(specifier);
6238
6239 let effective = crate::extensions::safe_canonicalize(&resolved);
6242
6243 for canonical_root in canonical_extension_roots {
6244 if effective.starts_with(canonical_root) {
6245 return None; }
6247 }
6248
6249 Some(resolved)
6250}
6251
6252pub fn extract_import_names(source: &str, specifier: &str) -> Vec<String> {
6258 let mut names = Vec::new();
6259 let re_esm = import_names_regex();
6260 let re_cjs = require_destructure_regex();
6261
6262 for cap in re_esm.captures_iter(source) {
6263 let spec_in_source = &cap[2];
6264 if spec_in_source != specifier {
6265 continue;
6266 }
6267 parse_import_list(&cap[1], &mut names);
6268 }
6269
6270 for cap in re_cjs.captures_iter(source) {
6271 let spec_in_source = &cap[2];
6272 if spec_in_source != specifier {
6273 continue;
6274 }
6275 parse_import_list(&cap[1], &mut names);
6276 }
6277
6278 names.sort();
6279 names.dedup();
6280 names
6281}
6282
6283fn parse_import_list(raw: &str, out: &mut Vec<String>) {
6285 for token in raw.split(',') {
6286 let token = token.trim();
6287 if token.is_empty() {
6288 continue;
6289 }
6290 if token.starts_with("type ") || token.starts_with("type\t") {
6292 continue;
6293 }
6294 let name = token.split_whitespace().next().unwrap_or(token).trim();
6296 if !name.is_empty() {
6297 out.push(name.to_string());
6298 }
6299 }
6300}
6301
6302pub fn generate_monorepo_stub(names: &[String]) -> String {
6312 let mut lines = Vec::with_capacity(names.len() + 1);
6313 lines.push("// Auto-generated monorepo escape stub (Pattern 3)".to_string());
6314
6315 for name in names {
6316 if !is_valid_js_export_name(name) {
6317 continue;
6318 }
6319
6320 let export = if name == "default" {
6321 "export default () => {};".to_string()
6322 } else if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && !name.is_empty() {
6323 format!("export const {name} = [];")
6325 } else if name.starts_with("is") || name.starts_with("has") || name.starts_with("check") {
6326 format!("export const {name} = () => false;")
6327 } else if name.starts_with("get")
6328 || name.starts_with("detect")
6329 || name.starts_with("find")
6330 || name.starts_with("create")
6331 || name.starts_with("make")
6332 {
6333 format!("export const {name} = () => ({{}});")
6334 } else if name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
6335 format!("export class {name} {{}}")
6337 } else {
6338 format!("export const {name} = () => {{}};")
6340 };
6341 lines.push(export);
6342 }
6343
6344 lines.join("\n")
6345}
6346
6347#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
6348enum DeclState {
6349 #[default]
6350 None,
6351 AfterExport,
6352 AfterAsync,
6353 AfterDeclKeyword,
6354}
6355
6356#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
6357enum BindingLexMode {
6358 #[default]
6359 Normal,
6360 SingleQuoted,
6361 DoubleQuoted,
6362 Template,
6363 LineComment,
6364 BlockComment,
6365}
6366
6367#[derive(Debug, Default)]
6368struct BindingScanner {
6369 mode: BindingLexMode,
6370 escaped: bool,
6371 state: DeclState,
6372 brace_depth: usize,
6373}
6374
6375impl BindingScanner {
6376 fn consume_context(&mut self, b: u8, next: Option<u8>, index: &mut usize) -> bool {
6377 match self.mode {
6378 BindingLexMode::Normal => false,
6379 BindingLexMode::LineComment => {
6380 if b == b'\n' {
6381 self.mode = BindingLexMode::Normal;
6382 }
6383 *index += 1;
6384 true
6385 }
6386 BindingLexMode::BlockComment => {
6387 if b == b'*' && next == Some(b'/') {
6388 *index += 2;
6389 self.mode = BindingLexMode::Normal;
6390 } else {
6391 *index += 1;
6392 }
6393 true
6394 }
6395 BindingLexMode::SingleQuoted => {
6396 consume_quoted_context(&mut self.mode, &mut self.escaped, b, b'\'');
6397 *index += 1;
6398 true
6399 }
6400 BindingLexMode::DoubleQuoted => {
6401 consume_quoted_context(&mut self.mode, &mut self.escaped, b, b'"');
6402 *index += 1;
6403 true
6404 }
6405 BindingLexMode::Template => {
6406 consume_quoted_context(&mut self.mode, &mut self.escaped, b, b'`');
6407 *index += 1;
6408 true
6409 }
6410 }
6411 }
6412
6413 fn enter_context(&mut self, b: u8, next: Option<u8>, index: &mut usize) -> bool {
6414 if b == b'/' && next == Some(b'/') {
6415 self.mode = BindingLexMode::LineComment;
6416 *index += 2;
6417 return true;
6418 }
6419
6420 if b == b'/' && next == Some(b'*') {
6421 self.mode = BindingLexMode::BlockComment;
6422 *index += 2;
6423 return true;
6424 }
6425
6426 if b == b'\'' {
6427 self.mode = BindingLexMode::SingleQuoted;
6428 *index += 1;
6429 return true;
6430 }
6431
6432 if b == b'"' {
6433 self.mode = BindingLexMode::DoubleQuoted;
6434 *index += 1;
6435 return true;
6436 }
6437
6438 if b == b'`' {
6439 self.mode = BindingLexMode::Template;
6440 *index += 1;
6441 return true;
6442 }
6443
6444 false
6445 }
6446
6447 fn advance_state(&mut self, token: &str, name: &str) -> bool {
6448 self.state = match self.state {
6449 DeclState::None => match token {
6450 "export" => DeclState::AfterExport,
6451 "const" | "let" | "var" | "function" | "class" => DeclState::AfterDeclKeyword,
6452 _ => DeclState::None,
6453 },
6454 DeclState::AfterExport => match token {
6455 "const" | "let" | "var" | "function" | "class" => DeclState::AfterDeclKeyword,
6456 "async" => DeclState::AfterAsync,
6457 _ => DeclState::None,
6458 },
6459 DeclState::AfterAsync => {
6460 if token == "function" {
6461 DeclState::AfterDeclKeyword
6462 } else {
6463 DeclState::None
6464 }
6465 }
6466 DeclState::AfterDeclKeyword => {
6467 if token == name {
6468 return true;
6469 }
6470 DeclState::None
6471 }
6472 };
6473
6474 false
6475 }
6476}
6477
6478const fn consume_quoted_context(
6479 mode: &mut BindingLexMode,
6480 escaped: &mut bool,
6481 b: u8,
6482 terminator: u8,
6483) {
6484 if *escaped {
6485 *escaped = false;
6486 } else if b == b'\\' {
6487 *escaped = true;
6488 } else if b == terminator {
6489 *mode = BindingLexMode::Normal;
6490 }
6491}
6492
6493fn consume_js_identifier<'a>(source: &'a str, bytes: &[u8], index: &mut usize) -> &'a str {
6494 let start = *index;
6495 *index += 1;
6496 while *index < bytes.len() && is_js_ident_continue(bytes[*index]) {
6497 *index += 1;
6498 }
6499 &source[start..*index]
6500}
6501
6502fn source_declares_binding(source: &str, name: &str) -> bool {
6503 if name.is_empty() || !name.is_ascii() {
6504 return false;
6505 }
6506
6507 let bytes = source.as_bytes();
6508 let mut i = 0usize;
6509 let mut scanner = BindingScanner::default();
6510
6511 while i < bytes.len() {
6512 let b = bytes[i];
6513 let next = bytes.get(i + 1).copied();
6514
6515 if scanner.consume_context(b, next, &mut i) || scanner.enter_context(b, next, &mut i) {
6516 continue;
6517 }
6518
6519 if b.is_ascii_whitespace() {
6520 i += 1;
6521 continue;
6522 }
6523
6524 if b == b'{' {
6525 scanner.brace_depth = scanner.brace_depth.saturating_add(1);
6526 scanner.state = DeclState::None;
6527 i += 1;
6528 continue;
6529 }
6530
6531 if b == b'}' {
6532 scanner.brace_depth = scanner.brace_depth.saturating_sub(1);
6533 scanner.state = DeclState::None;
6534 i += 1;
6535 continue;
6536 }
6537
6538 if is_js_ident_start(b) {
6539 let token = consume_js_identifier(source, bytes, &mut i);
6540 if scanner.brace_depth == 0 && scanner.advance_state(token, name) {
6541 return true;
6542 }
6543 if scanner.brace_depth > 0 {
6544 scanner.state = DeclState::None;
6545 }
6546 continue;
6547 }
6548
6549 if scanner.state == DeclState::AfterDeclKeyword && b == b'*' {
6550 i += 1;
6551 continue;
6552 }
6553
6554 scanner.state = DeclState::None;
6555 i += 1;
6556 }
6557
6558 false
6559}
6560
6561#[allow(clippy::too_many_lines)]
6567fn extract_static_require_specifiers(source: &str) -> Vec<String> {
6568 const REQUIRE: &[u8] = b"require";
6569
6570 let bytes = source.as_bytes();
6571 let mut out = Vec::new();
6572 let mut seen = HashSet::new();
6573
6574 let mut i = 0usize;
6575 let mut in_line_comment = false;
6576 let mut in_block_comment = false;
6577 let mut in_single = false;
6578 let mut in_double = false;
6579 let mut in_template = false;
6580 let mut escaped = false;
6581
6582 while i < bytes.len() {
6583 let b = bytes[i];
6584
6585 if in_line_comment {
6586 if b == b'\n' {
6587 in_line_comment = false;
6588 }
6589 i += 1;
6590 continue;
6591 }
6592
6593 if in_block_comment {
6594 if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
6595 in_block_comment = false;
6596 i += 2;
6597 } else {
6598 i += 1;
6599 }
6600 continue;
6601 }
6602
6603 if in_single {
6604 if escaped {
6605 escaped = false;
6606 } else if b == b'\\' {
6607 escaped = true;
6608 } else if b == b'\'' {
6609 in_single = false;
6610 }
6611 i += 1;
6612 continue;
6613 }
6614
6615 if in_double {
6616 if escaped {
6617 escaped = false;
6618 } else if b == b'\\' {
6619 escaped = true;
6620 } else if b == b'"' {
6621 in_double = false;
6622 }
6623 i += 1;
6624 continue;
6625 }
6626
6627 if in_template {
6628 if escaped {
6629 escaped = false;
6630 } else if b == b'\\' {
6631 escaped = true;
6632 } else if b == b'`' {
6633 in_template = false;
6634 }
6635 i += 1;
6636 continue;
6637 }
6638
6639 if b == b'/' && i + 1 < bytes.len() {
6640 match bytes[i + 1] {
6641 b'/' => {
6642 in_line_comment = true;
6643 i += 2;
6644 continue;
6645 }
6646 b'*' => {
6647 in_block_comment = true;
6648 i += 2;
6649 continue;
6650 }
6651 _ => {}
6652 }
6653 }
6654
6655 if b == b'\'' {
6656 in_single = true;
6657 i += 1;
6658 continue;
6659 }
6660 if b == b'"' {
6661 in_double = true;
6662 i += 1;
6663 continue;
6664 }
6665 if b == b'`' {
6666 in_template = true;
6667 i += 1;
6668 continue;
6669 }
6670
6671 if i + REQUIRE.len() <= bytes.len() && &bytes[i..i + REQUIRE.len()] == REQUIRE {
6672 let has_ident_before = i > 0 && is_js_ident_continue(bytes[i - 1]);
6673 let after_ident_idx = i + REQUIRE.len();
6674 let has_ident_after =
6675 after_ident_idx < bytes.len() && is_js_ident_continue(bytes[after_ident_idx]);
6676 if has_ident_before || has_ident_after {
6677 i += 1;
6678 continue;
6679 }
6680
6681 let mut j = after_ident_idx;
6682 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
6683 j += 1;
6684 }
6685 if j >= bytes.len() || bytes[j] != b'(' {
6686 i += 1;
6687 continue;
6688 }
6689
6690 j += 1;
6691 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
6692 j += 1;
6693 }
6694 if j >= bytes.len() || (bytes[j] != b'"' && bytes[j] != b'\'') {
6695 i += 1;
6696 continue;
6697 }
6698
6699 let quote = bytes[j];
6700 let spec_start = j + 1;
6701 j += 1;
6702 let mut lit_escaped = false;
6703 while j < bytes.len() {
6704 let c = bytes[j];
6705 if lit_escaped {
6706 lit_escaped = false;
6707 j += 1;
6708 continue;
6709 }
6710 if c == b'\\' {
6711 lit_escaped = true;
6712 j += 1;
6713 continue;
6714 }
6715 if c == quote {
6716 break;
6717 }
6718 j += 1;
6719 }
6720 if j >= bytes.len() {
6721 break;
6722 }
6723
6724 let spec = &source[spec_start..j];
6725 j += 1;
6726 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
6727 j += 1;
6728 }
6729 if j < bytes.len() && bytes[j] == b')' && seen.insert(spec.to_string()) {
6730 out.push(spec.to_string());
6731 i = j + 1;
6732 continue;
6733 }
6734 }
6735
6736 i += 1;
6737 }
6738
6739 out
6740}
6741
6742#[allow(clippy::too_many_lines)]
6751fn maybe_cjs_to_esm(source: &str) -> String {
6752 let has_require = source.contains("require(");
6753 let has_module_exports = source.contains("module.exports")
6754 || source.contains("module[\"exports\"]")
6755 || source.contains("module['exports']");
6756 let has_exports_usage = source.contains("exports.") || source.contains("exports[");
6757 let has_filename_refs = source.contains("__filename");
6758 let has_dirname_refs = source.contains("__dirname");
6759
6760 if !has_require
6761 && !has_module_exports
6762 && !has_exports_usage
6763 && !has_filename_refs
6764 && !has_dirname_refs
6765 {
6766 return source.to_string();
6767 }
6768
6769 let has_esm = source.lines().any(|line| {
6770 let trimmed = line.trim();
6771 (trimmed.starts_with("import ") || trimmed.starts_with("export "))
6772 && !trimmed.starts_with("//")
6773 });
6774 let has_export_default = source.contains("export default");
6775
6776 let specifiers = extract_static_require_specifiers(source);
6778
6779 if specifiers.is_empty()
6780 && !has_module_exports
6781 && !has_exports_usage
6782 && !has_filename_refs
6783 && !has_dirname_refs
6784 {
6785 return source.to_string();
6786 }
6787 if specifiers.is_empty()
6788 && has_esm
6789 && !has_module_exports
6790 && !has_exports_usage
6791 && !has_filename_refs
6792 && !has_dirname_refs
6793 {
6794 return source.to_string();
6795 }
6796
6797 let mut output = String::with_capacity(source.len() + 512);
6798
6799 for (i, spec) in specifiers.iter().enumerate() {
6801 let _ = writeln!(output, "import * as __cjs_req_{i} from {spec:?};");
6802 }
6803
6804 let has_require_binding = source_declares_binding(source, "require");
6806 if !specifiers.is_empty() && !has_require_binding {
6807 output.push_str("const __cjs_req_map = {");
6808 for (i, spec) in specifiers.iter().enumerate() {
6809 if i > 0 {
6810 output.push(',');
6811 }
6812 let _ = write!(output, "\n {spec:?}: __cjs_req_{i}");
6813 }
6814 output.push_str("\n};\n");
6815 output.push_str(
6816 "function require(s) {\n\
6817 \x20 const m = __cjs_req_map[s];\n\
6818 \x20 if (!m) throw new Error('Cannot find module: ' + s);\n\
6819 \x20 return m.default !== undefined && typeof m.default === 'object' \
6820 ? m.default : m;\n\
6821 }\n",
6822 );
6823 }
6824
6825 let has_filename_binding = source_declares_binding(source, "__filename");
6826 let has_dirname_binding = source_declares_binding(source, "__dirname");
6827 let has_module_binding = source_declares_binding(source, "module");
6828 let has_exports_binding = source_declares_binding(source, "exports");
6829 let needs_filename = has_filename_refs && !has_filename_binding;
6830 let needs_dirname = has_dirname_refs && !has_dirname_binding;
6831 let needs_module = (has_module_exports || has_exports_usage) && !has_module_binding;
6832 let needs_exports = (has_module_exports || has_exports_usage) && !has_exports_binding;
6833
6834 if needs_filename || needs_dirname || needs_module || needs_exports {
6835 if needs_filename {
6837 output.push_str(
6838 "const __filename = (() => {\n\
6839 \x20 try { return new URL(import.meta.url).pathname || ''; } catch { return ''; }\n\
6840 })();\n",
6841 );
6842 }
6843 if needs_dirname {
6844 output.push_str(
6845 "const __dirname = (() => {\n\
6846 \x20 try {\n\
6847 \x20\x20 const __pi_pathname = new URL(import.meta.url).pathname || '';\n\
6848 \x20\x20 return __pi_pathname ? __pi_pathname.replace(/[/\\\\][^/\\\\]*$/, '') : '.';\n\
6849 \x20 } catch { return '.'; }\n\
6850 })();\n",
6851 );
6852 }
6853 if needs_module {
6854 output.push_str("const module = { exports: {} };\n");
6855 }
6856 if needs_exports {
6857 output.push_str("const exports = module.exports;\n");
6858 }
6859 }
6860
6861 output.push_str(source);
6862 output.push('\n');
6863
6864 if !has_export_default && (!has_esm || has_module_exports || has_exports_usage) {
6865 output.push_str("export default module.exports;\n");
6867 }
6868
6869 output
6870}
6871
6872const fn is_js_ident_start(byte: u8) -> bool {
6873 (byte as char).is_ascii_alphabetic() || byte == b'_' || byte == b'$'
6874}
6875
6876const fn is_js_ident_continue(byte: u8) -> bool {
6877 is_js_ident_start(byte) || (byte as char).is_ascii_digit()
6878}
6879
6880#[allow(clippy::too_many_lines)]
6884fn rewrite_legacy_private_identifiers(source: &str) -> String {
6885 if !source.contains('#') || !source.is_ascii() {
6886 return source.to_string();
6887 }
6888
6889 let bytes = source.as_bytes();
6890 let mut out = String::with_capacity(source.len() + 32);
6891 let mut i = 0usize;
6892 let mut in_single = false;
6893 let mut in_double = false;
6894 let mut in_template = false;
6895 let mut escaped = false;
6896 let mut line_comment = false;
6897 let mut block_comment = false;
6898
6899 while i < bytes.len() {
6900 let b = bytes[i];
6901 let next = bytes.get(i + 1).copied();
6902
6903 if line_comment {
6904 out.push(b as char);
6905 if b == b'\n' {
6906 line_comment = false;
6907 }
6908 i += 1;
6909 continue;
6910 }
6911
6912 if block_comment {
6913 if b == b'*' && next == Some(b'/') {
6914 out.push('*');
6915 out.push('/');
6916 i += 2;
6917 block_comment = false;
6918 continue;
6919 }
6920 out.push(b as char);
6921 i += 1;
6922 continue;
6923 }
6924
6925 if in_single {
6926 out.push(b as char);
6927 if escaped {
6928 escaped = false;
6929 } else if b == b'\\' {
6930 escaped = true;
6931 } else if b == b'\'' {
6932 in_single = false;
6933 }
6934 i += 1;
6935 continue;
6936 }
6937
6938 if in_double {
6939 out.push(b as char);
6940 if escaped {
6941 escaped = false;
6942 } else if b == b'\\' {
6943 escaped = true;
6944 } else if b == b'"' {
6945 in_double = false;
6946 }
6947 i += 1;
6948 continue;
6949 }
6950
6951 if in_template {
6952 out.push(b as char);
6953 if escaped {
6954 escaped = false;
6955 } else if b == b'\\' {
6956 escaped = true;
6957 } else if b == b'`' {
6958 in_template = false;
6959 }
6960 i += 1;
6961 continue;
6962 }
6963
6964 if b == b'/' && next == Some(b'/') {
6965 line_comment = true;
6966 out.push('/');
6967 i += 1;
6968 continue;
6969 }
6970 if b == b'/' && next == Some(b'*') {
6971 block_comment = true;
6972 out.push('/');
6973 i += 1;
6974 continue;
6975 }
6976 if b == b'\'' {
6977 in_single = true;
6978 out.push('\'');
6979 i += 1;
6980 continue;
6981 }
6982 if b == b'"' {
6983 in_double = true;
6984 out.push('"');
6985 i += 1;
6986 continue;
6987 }
6988 if b == b'`' {
6989 in_template = true;
6990 out.push('`');
6991 i += 1;
6992 continue;
6993 }
6994
6995 if b == b'#' && next.is_some_and(is_js_ident_start) {
6996 let prev_is_ident = i > 0 && is_js_ident_continue(bytes[i - 1]);
6997 if !prev_is_ident {
6998 out.push_str("__pijs_private_");
6999 i += 1;
7000 while i < bytes.len() && is_js_ident_continue(bytes[i]) {
7001 out.push(bytes[i] as char);
7002 i += 1;
7003 }
7004 continue;
7005 }
7006 }
7007
7008 out.push(b as char);
7009 i += 1;
7010 }
7011
7012 out
7013}
7014
7015fn json_module_to_esm(raw: &str, name: &str) -> std::result::Result<String, String> {
7016 let value: serde_json::Value =
7017 serde_json::from_str(raw).map_err(|err| format!("parse {name}: {err}"))?;
7018 let literal = serde_json::to_string(&value).map_err(|err| format!("encode {name}: {err}"))?;
7019 Ok(format!("export default {literal};\n"))
7020}
7021
7022fn transpile_typescript_module(source: &str, name: &str) -> std::result::Result<String, String> {
7023 let globals = Globals::new();
7024 GLOBALS.set(&globals, || {
7025 let cm: Lrc<SourceMap> = Lrc::default();
7026 let fm = cm.new_source_file(
7027 FileName::Custom(name.to_string()).into(),
7028 source.to_string(),
7029 );
7030
7031 let syntax = Syntax::Typescript(TsSyntax {
7032 tsx: Path::new(name).extension().is_some_and(|ext| {
7033 ext.eq_ignore_ascii_case("tsx") || ext.eq_ignore_ascii_case("jsx")
7034 }),
7035 decorators: true,
7036 ..Default::default()
7037 });
7038
7039 let mut parser = SwcParser::new(syntax, StringInput::from(&*fm), None);
7040 let module: SwcModule = parser
7041 .parse_module()
7042 .map_err(|err| format!("parse {name}: {err:?}"))?;
7043
7044 let unresolved_mark = Mark::new();
7045 let top_level_mark = Mark::new();
7046 let mut program = SwcProgram::Module(module);
7047 {
7048 let mut pass = resolver(unresolved_mark, top_level_mark, false);
7049 pass.process(&mut program);
7050 }
7051 {
7052 let mut pass = strip(unresolved_mark, top_level_mark);
7053 pass.process(&mut program);
7054 }
7055 let SwcProgram::Module(module) = program else {
7056 return Err(format!("transpile {name}: expected module"));
7057 };
7058
7059 let mut buf = Vec::new();
7060 {
7061 let mut emitter = Emitter {
7062 cfg: swc_ecma_codegen::Config::default(),
7063 comments: None,
7064 cm: cm.clone(),
7065 wr: JsWriter::new(cm, "\n", &mut buf, None),
7066 };
7067 emitter
7068 .emit_module(&module)
7069 .map_err(|err| format!("emit {name}: {err}"))?;
7070 }
7071
7072 String::from_utf8(buf).map_err(|err| format!("utf8 {name}: {err}"))
7073 })
7074}
7075
7076#[allow(clippy::too_many_lines)]
7080fn build_node_os_module() -> String {
7081 let node_platform = match std::env::consts::OS {
7083 "macos" => "darwin",
7084 "windows" => "win32",
7085 other => other, };
7087 let node_arch = match std::env::consts::ARCH {
7088 "x86_64" => "x64",
7089 "aarch64" => "arm64",
7090 "x86" => "ia32",
7091 "arm" => "arm",
7092 other => other,
7093 };
7094 let node_type = match std::env::consts::OS {
7095 "linux" => "Linux",
7096 "macos" => "Darwin",
7097 "windows" => "Windows_NT",
7098 other => other,
7099 };
7100 let tmpdir = std::env::temp_dir()
7102 .display()
7103 .to_string()
7104 .replace('\\', "\\\\");
7105 let homedir = std::env::var("HOME")
7106 .or_else(|_| std::env::var("USERPROFILE"))
7107 .unwrap_or_else(|_| "/home/unknown".to_string())
7108 .replace('\\', "\\\\");
7109 let hostname = std::fs::read_to_string("/etc/hostname")
7111 .ok()
7112 .map(|s| s.trim().to_string())
7113 .filter(|s| !s.is_empty())
7114 .or_else(|| std::env::var("HOSTNAME").ok())
7115 .or_else(|| std::env::var("COMPUTERNAME").ok())
7116 .unwrap_or_else(|| "localhost".to_string());
7117 let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
7118 let eol = if cfg!(windows) { "\\r\\n" } else { "\\n" };
7119 let dev_null = if cfg!(windows) {
7120 "\\\\\\\\.\\\\NUL"
7121 } else {
7122 "/dev/null"
7123 };
7124 let username = std::env::var("USER")
7125 .or_else(|_| std::env::var("USERNAME"))
7126 .unwrap_or_else(|_| "unknown".to_string());
7127 let shell = std::env::var("SHELL").unwrap_or_else(|_| {
7128 if cfg!(windows) {
7129 "cmd.exe".to_string()
7130 } else {
7131 "/bin/sh".to_string()
7132 }
7133 });
7134 let (uid, gid) = read_proc_uid_gid().unwrap_or((1000, 1000));
7136
7137 format!(
7141 r#"
7142const _platform = "{node_platform}";
7143const _arch = "{node_arch}";
7144const _type = "{node_type}";
7145const _tmpdir = "{tmpdir}";
7146const _homedir = "{homedir}";
7147const _hostname = "{hostname}";
7148const _eol = "{eol}";
7149const _devNull = "{dev_null}";
7150const _uid = {uid};
7151const _gid = {gid};
7152const _username = "{username}";
7153const _shell = "{shell}";
7154const _numCpus = {num_cpus};
7155const _cpus = [];
7156for (let i = 0; i < _numCpus; i++) _cpus.push({{ model: "cpu", speed: 2400, times: {{ user: 0, nice: 0, sys: 0, idle: 0, irq: 0 }} }});
7157
7158export function homedir() {{
7159 const env_home =
7160 globalThis.pi && globalThis.pi.env && typeof globalThis.pi.env.get === "function"
7161 ? globalThis.pi.env.get("HOME")
7162 : undefined;
7163 return env_home || _homedir;
7164}}
7165export function tmpdir() {{ return _tmpdir; }}
7166export function hostname() {{ return _hostname; }}
7167export function platform() {{ return _platform; }}
7168export function arch() {{ return _arch; }}
7169export function type() {{ return _type; }}
7170export function release() {{ return "6.0.0"; }}
7171export function cpus() {{ return _cpus; }}
7172export function totalmem() {{ return 8 * 1024 * 1024 * 1024; }}
7173export function freemem() {{ return 4 * 1024 * 1024 * 1024; }}
7174export function uptime() {{ return Math.floor(Date.now() / 1000); }}
7175export function loadavg() {{ return [0.0, 0.0, 0.0]; }}
7176export function networkInterfaces() {{ return {{}}; }}
7177export function userInfo(_options) {{
7178 return {{
7179 uid: _uid,
7180 gid: _gid,
7181 username: _username,
7182 homedir: homedir(),
7183 shell: _shell,
7184 }};
7185}}
7186export function endianness() {{ return "LE"; }}
7187export const EOL = _eol;
7188export const devNull = _devNull;
7189export const constants = {{
7190 signals: {{}},
7191 errno: {{}},
7192 priority: {{ PRIORITY_LOW: 19, PRIORITY_BELOW_NORMAL: 10, PRIORITY_NORMAL: 0, PRIORITY_ABOVE_NORMAL: -7, PRIORITY_HIGH: -14, PRIORITY_HIGHEST: -20 }},
7193}};
7194export default {{ homedir, tmpdir, hostname, platform, arch, type, release, cpus, totalmem, freemem, uptime, loadavg, networkInterfaces, userInfo, endianness, EOL, devNull, constants }};
7195"#
7196 )
7197 .trim()
7198 .to_string()
7199}
7200
7201fn read_proc_uid_gid() -> Option<(u32, u32)> {
7204 let status = std::fs::read_to_string("/proc/self/status").ok()?;
7205 let mut uid = None;
7206 let mut gid = None;
7207 for line in status.lines() {
7208 if let Some(rest) = line.strip_prefix("Uid:") {
7209 uid = rest.split_whitespace().next().and_then(|v| v.parse().ok());
7210 } else if let Some(rest) = line.strip_prefix("Gid:") {
7211 gid = rest.split_whitespace().next().and_then(|v| v.parse().ok());
7212 }
7213 if uid.is_some() && gid.is_some() {
7214 break;
7215 }
7216 }
7217 Some((uid?, gid?))
7218}
7219
7220#[allow(clippy::too_many_lines)]
7221fn default_virtual_modules() -> HashMap<String, String> {
7222 let mut modules = HashMap::new();
7223
7224 modules.insert(
7225 "@sinclair/typebox".to_string(),
7226 r#"
7227export const Type = {
7228 String: (opts = {}) => ({ type: "string", ...opts }),
7229 Number: (opts = {}) => ({ type: "number", ...opts }),
7230 Boolean: (opts = {}) => ({ type: "boolean", ...opts }),
7231 Array: (items, opts = {}) => ({ type: "array", items, ...opts }),
7232 Object: (props = {}, opts = {}) => {
7233 const required = [];
7234 const properties = {};
7235 for (const [k, v] of Object.entries(props)) {
7236 if (v && typeof v === "object" && v.__pi_optional) {
7237 properties[k] = v.schema;
7238 } else {
7239 properties[k] = v;
7240 required.push(k);
7241 }
7242 }
7243 const out = { type: "object", properties, ...opts };
7244 if (required.length) out.required = required;
7245 return out;
7246 },
7247 Optional: (schema) => ({ __pi_optional: true, schema }),
7248 Literal: (value, opts = {}) => ({ const: value, ...opts }),
7249 Any: (opts = {}) => ({ ...opts }),
7250 Union: (schemas, opts = {}) => ({ anyOf: schemas, ...opts }),
7251 Enum: (values, opts = {}) => ({ enum: values, ...opts }),
7252 Integer: (opts = {}) => ({ type: "integer", ...opts }),
7253 Null: (opts = {}) => ({ type: "null", ...opts }),
7254 Unknown: (opts = {}) => ({ ...opts }),
7255 Tuple: (items, opts = {}) => ({ type: "array", items, minItems: items.length, maxItems: items.length, ...opts }),
7256 Record: (keySchema, valueSchema, opts = {}) => ({ type: "object", additionalProperties: valueSchema, ...opts }),
7257 Ref: (ref, opts = {}) => ({ $ref: ref, ...opts }),
7258 Intersect: (schemas, opts = {}) => ({ allOf: schemas, ...opts }),
7259};
7260export default { Type };
7261"#
7262 .trim()
7263 .to_string(),
7264 );
7265
7266 modules.insert(
7267 "@mariozechner/pi-ai".to_string(),
7268 r#"
7269export function StringEnum(values, opts = {}) {
7270 const list = Array.isArray(values) ? values.map((v) => String(v)) : [];
7271 return { type: "string", enum: list, ...opts };
7272}
7273
7274export function calculateCost(model, usage) {
7275 const usageObj = usage && typeof usage === 'object' ? usage : {};
7276 const cost = usageObj.cost && typeof usageObj.cost === 'object' ? usageObj.cost : {};
7277 const modelCost = model && typeof model === 'object' ? (model.cost || {}) : {};
7278
7279 const inputTokens = Number(usageObj.input ?? usageObj.inputTokens ?? usageObj.input_tokens ?? 0);
7280 const outputTokens = Number(usageObj.output ?? usageObj.outputTokens ?? usageObj.output_tokens ?? 0);
7281 const cacheReadTokens = Number(usageObj.cacheRead ?? usageObj.cache_read ?? 0);
7282 const cacheWriteTokens = Number(usageObj.cacheWrite ?? usageObj.cache_write ?? 0);
7283
7284 const inputRate = Number(modelCost.input ?? 0);
7285 const outputRate = Number(modelCost.output ?? 0);
7286 const cacheReadRate = Number(modelCost.cacheRead ?? modelCost.cache_read ?? 0);
7287 const cacheWriteRate = Number(modelCost.cacheWrite ?? modelCost.cache_write ?? 0);
7288
7289 cost.input = (inputRate / 1000000) * inputTokens;
7290 cost.output = (outputRate / 1000000) * outputTokens;
7291 cost.cacheRead = (cacheReadRate / 1000000) * cacheReadTokens;
7292 cost.cacheWrite = (cacheWriteRate / 1000000) * cacheWriteTokens;
7293 cost.total = cost.input + cost.output + cost.cacheRead + cost.cacheWrite;
7294
7295 usageObj.cost = cost;
7296 if (!Number.isFinite(Number(usageObj.totalTokens))) {
7297 usageObj.totalTokens = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
7298 }
7299
7300 return cost;
7301}
7302
7303function getEnvValue(name) {
7304 if (globalThis.pi && globalThis.pi.env && typeof globalThis.pi.env.get === "function") {
7305 const value = globalThis.pi.env.get(name);
7306 if (value !== undefined && value !== null) {
7307 return String(value);
7308 }
7309 }
7310 if (typeof process !== "undefined" && process.env) {
7311 return process.env[name];
7312 }
7313 return undefined;
7314}
7315
7316export function getEnvApiKey(provider) {
7317 const p = String(provider ?? "").trim();
7318 if (!p) return undefined;
7319
7320 if (p === "github-copilot") {
7321 return (
7322 getEnvValue("COPILOT_GITHUB_TOKEN") ||
7323 getEnvValue("GH_TOKEN") ||
7324 getEnvValue("GITHUB_TOKEN")
7325 );
7326 }
7327
7328 if (p === "anthropic") {
7329 return getEnvValue("ANTHROPIC_OAUTH_TOKEN") || getEnvValue("ANTHROPIC_API_KEY");
7330 }
7331
7332 if (p === "google-vertex") {
7333 const hasCredentials = !!getEnvValue("GOOGLE_APPLICATION_CREDENTIALS");
7334 const hasProject = !!(getEnvValue("GOOGLE_CLOUD_PROJECT") || getEnvValue("GCLOUD_PROJECT"));
7335 const hasLocation = !!getEnvValue("GOOGLE_CLOUD_LOCATION");
7336 if (hasCredentials && (hasProject || hasLocation)) {
7337 return "<authenticated>";
7338 }
7339 if (hasProject && hasLocation) {
7340 return "<authenticated>";
7341 }
7342 }
7343
7344 if (p === "amazon-bedrock") {
7345 if (
7346 getEnvValue("AWS_PROFILE") ||
7347 (getEnvValue("AWS_ACCESS_KEY_ID") && getEnvValue("AWS_SECRET_ACCESS_KEY")) ||
7348 getEnvValue("AWS_BEARER_TOKEN_BEDROCK") ||
7349 getEnvValue("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") ||
7350 getEnvValue("AWS_CONTAINER_CREDENTIALS_FULL_URI") ||
7351 getEnvValue("AWS_WEB_IDENTITY_TOKEN_FILE")
7352 ) {
7353 return "<authenticated>";
7354 }
7355 }
7356
7357 const envMap = {
7358 openai: "OPENAI_API_KEY",
7359 "azure-openai-responses": "AZURE_OPENAI_API_KEY",
7360 google: "GEMINI_API_KEY",
7361 groq: "GROQ_API_KEY",
7362 cerebras: "CEREBRAS_API_KEY",
7363 xai: "XAI_API_KEY",
7364 openrouter: "OPENROUTER_API_KEY",
7365 "vercel-ai-gateway": "AI_GATEWAY_API_KEY",
7366 zai: "ZAI_API_KEY",
7367 mistral: "MISTRAL_API_KEY",
7368 minimax: "MINIMAX_API_KEY",
7369 "minimax-cn": "MINIMAX_CN_API_KEY",
7370 huggingface: "HF_TOKEN",
7371 opencode: "OPENCODE_API_KEY",
7372 "kimi-coding": "KIMI_API_KEY",
7373 };
7374
7375 const envVar = envMap[p];
7376 return envVar ? getEnvValue(envVar) : undefined;
7377}
7378
7379export function createAssistantMessageEventStream() {
7380 return {
7381 push: () => {},
7382 end: () => {},
7383 };
7384}
7385
7386export function streamSimpleAnthropic() {
7387 throw new Error("@mariozechner/pi-ai.streamSimpleAnthropic is not available in PiJS");
7388}
7389
7390export function streamSimpleOpenAIResponses() {
7391 throw new Error("@mariozechner/pi-ai.streamSimpleOpenAIResponses is not available in PiJS");
7392}
7393
7394export function streamSimpleOpenAICompletions() {
7395 throw new Error("@mariozechner/pi-ai.streamSimpleOpenAICompletions is not available in PiJS");
7396}
7397
7398export async function complete(_model, _messages, _opts = {}) {
7399 // Return a minimal completion response stub
7400 return { content: "", model: _model ?? "unknown", usage: { input_tokens: 0, output_tokens: 0 } };
7401}
7402
7403// Stub: completeSimple returns a simple text completion without streaming
7404export async function completeSimple(_model, _prompt, _opts = {}) {
7405 // Return an empty string completion
7406 return "";
7407}
7408
7409export function getModel() {
7410 // Return a default model identifier
7411 return "claude-sonnet-4-5";
7412}
7413
7414export function getApiProvider() {
7415 // Return a default provider identifier
7416 return "anthropic";
7417}
7418
7419export function getModels() {
7420 // Return a list of available model identifiers
7421 return ["claude-sonnet-4-5", "claude-haiku-3-5"];
7422}
7423
7424export async function loginOpenAICodex(_opts = {}) {
7425 return { accessToken: "", refreshToken: "", expiresAt: Date.now() + 3600000 };
7426}
7427
7428export async function refreshOpenAICodexToken(_refreshToken) {
7429 return { accessToken: "", refreshToken: "", expiresAt: Date.now() + 3600000 };
7430}
7431
7432export default { StringEnum, calculateCost, createAssistantMessageEventStream, streamSimpleAnthropic, streamSimpleOpenAIResponses, streamSimpleOpenAICompletions, complete, completeSimple, getModel, getApiProvider, getModels, loginOpenAICodex, refreshOpenAICodexToken };
7433"#
7434 .trim()
7435 .to_string(),
7436 );
7437
7438 modules.insert(
7439 "@mariozechner/pi-tui".to_string(),
7440 r#"
7441export function matchesKey(_data, _key) {
7442 return false;
7443}
7444
7445export function truncateToWidth(text, width) {
7446 const s = String(text ?? "");
7447 const w = Number(width ?? 0);
7448 if (!w || w <= 0) return "";
7449 return s.length <= w ? s : s.slice(0, w);
7450}
7451
7452export class Text {
7453 constructor(text, x = 0, y = 0) {
7454 this.text = String(text ?? "");
7455 this.x = x;
7456 this.y = y;
7457 }
7458}
7459
7460export class TruncatedText extends Text {
7461 constructor(text, width = 80, x = 0, y = 0) {
7462 super(text, x, y);
7463 this.width = Number(width ?? 80);
7464 }
7465}
7466
7467export class Container {
7468 constructor(..._args) {}
7469}
7470
7471export class Markdown {
7472 constructor(..._args) {}
7473}
7474
7475export class Spacer {
7476 constructor(..._args) {}
7477}
7478
7479export function visibleWidth(str) {
7480 return String(str ?? "").length;
7481}
7482
7483export function wrapTextWithAnsi(text, _width) {
7484 return String(text ?? "");
7485}
7486
7487export class Editor {
7488 constructor(_opts = {}) {
7489 this.value = "";
7490 }
7491}
7492
7493export const CURSOR_MARKER = "▌";
7494
7495export function isKeyRelease(_data) {
7496 return false;
7497}
7498
7499export function parseKey(key) {
7500 return { key: String(key ?? "") };
7501}
7502
7503export class Box {
7504 constructor(_padX = 0, _padY = 0, _styleFn = null) {
7505 this.children = [];
7506 }
7507
7508 addChild(child) {
7509 this.children.push(child);
7510 }
7511}
7512
7513export class SelectList {
7514 constructor(items = [], _opts = {}) {
7515 this.items = Array.isArray(items) ? items : [];
7516 this.selected = 0;
7517 }
7518
7519 setItems(items) {
7520 this.items = Array.isArray(items) ? items : [];
7521 }
7522
7523 select(index) {
7524 const i = Number(index ?? 0);
7525 this.selected = Number.isFinite(i) ? i : 0;
7526 }
7527}
7528
7529export class Input {
7530 constructor(_opts = {}) {
7531 this.value = "";
7532 }
7533}
7534
7535export class ProcessTerminal {
7536 constructor(_proc, _opts = {}) {
7537 this.proc = _proc;
7538 }
7539 on(_event, _handler) { return this; }
7540 write(_data) {}
7541 resize(_cols, _rows) {}
7542 destroy() {}
7543}
7544
7545export const Key = {
7546 // Special keys
7547 escape: "escape",
7548 esc: "esc",
7549 enter: "enter",
7550 tab: "tab",
7551 space: "space",
7552 backspace: "backspace",
7553 delete: "delete",
7554 home: "home",
7555 end: "end",
7556 pageUp: "pageUp",
7557 pageDown: "pageDown",
7558 up: "up",
7559 down: "down",
7560 left: "left",
7561 right: "right",
7562 // Single modifiers
7563 ctrl: (key) => `ctrl+${key}`,
7564 shift: (key) => `shift+${key}`,
7565 alt: (key) => `alt+${key}`,
7566 // Combined modifiers
7567 ctrlShift: (key) => `ctrl+shift+${key}`,
7568 shiftCtrl: (key) => `shift+ctrl+${key}`,
7569 ctrlAlt: (key) => `ctrl+alt+${key}`,
7570 altCtrl: (key) => `alt+ctrl+${key}`,
7571 shiftAlt: (key) => `shift+alt+${key}`,
7572 altShift: (key) => `alt+shift+${key}`,
7573 ctrlAltShift: (key) => `ctrl+alt+shift+${key}`,
7574};
7575
7576export class DynamicBorder {
7577 constructor(_styleFn = null) {
7578 this.styleFn = _styleFn;
7579 }
7580}
7581
7582export class SettingsList {
7583 constructor(_opts = {}) {
7584 this.items = [];
7585 }
7586
7587 setItems(items) {
7588 this.items = Array.isArray(items) ? items : [];
7589 }
7590}
7591
7592// Fuzzy string matching for filtering lists
7593export function fuzzyMatch(query, text, _opts = {}) {
7594 const q = String(query ?? '').toLowerCase();
7595 const t = String(text ?? '').toLowerCase();
7596 if (!q) return { match: true, score: 0, positions: [] };
7597 if (!t) return { match: false, score: 0, positions: [] };
7598
7599 const positions = [];
7600 let qi = 0;
7601 for (let ti = 0; ti < t.length && qi < q.length; ti++) {
7602 if (t[ti] === q[qi]) {
7603 positions.push(ti);
7604 qi++;
7605 }
7606 }
7607
7608 const match = qi === q.length;
7609 const score = match ? (q.length / t.length) * 100 : 0;
7610 return { match, score, positions };
7611}
7612
7613// Get editor keybindings configuration
7614export function getEditorKeybindings() {
7615 return {
7616 save: 'ctrl+s',
7617 quit: 'ctrl+q',
7618 copy: 'ctrl+c',
7619 paste: 'ctrl+v',
7620 undo: 'ctrl+z',
7621 redo: 'ctrl+y',
7622 find: 'ctrl+f',
7623 replace: 'ctrl+h',
7624 };
7625}
7626
7627// Filter an array of items using fuzzy matching
7628export function fuzzyFilter(query, items, _opts = {}) {
7629 const q = String(query ?? '').toLowerCase();
7630 if (!q) return items;
7631 if (!Array.isArray(items)) return [];
7632 return items.filter(item => {
7633 const text = typeof item === 'string' ? item : String(item?.label ?? item?.name ?? item);
7634 return fuzzyMatch(q, text).match;
7635 });
7636}
7637
7638// Cancellable loader widget - shows loading state with optional cancel
7639export class CancellableLoader {
7640 constructor(message = 'Loading...', opts = {}) {
7641 this.message = String(message ?? 'Loading...');
7642 this.cancelled = false;
7643 this.onCancel = opts.onCancel ?? null;
7644 }
7645
7646 cancel() {
7647 this.cancelled = true;
7648 if (typeof this.onCancel === 'function') {
7649 this.onCancel();
7650 }
7651 }
7652
7653 render() {
7654 return this.cancelled ? [] : [this.message];
7655 }
7656}
7657
7658export class Image {
7659 constructor(src, _opts = {}) {
7660 this.src = String(src ?? "");
7661 this.width = 0;
7662 this.height = 0;
7663 }
7664}
7665
7666export default { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, Text, TruncatedText, Container, Markdown, Spacer, Editor, Box, SelectList, Input, ProcessTerminal, Image, CURSOR_MARKER, isKeyRelease, parseKey, Key, DynamicBorder, SettingsList, fuzzyMatch, getEditorKeybindings, fuzzyFilter, CancellableLoader };
7667"#
7668 .trim()
7669 .to_string(),
7670 );
7671
7672 modules.insert(
7673 "@mariozechner/pi-coding-agent".to_string(),
7674 r#"
7675export const VERSION = "0.0.0";
7676
7677export const DEFAULT_MAX_LINES = 2000;
7678export const DEFAULT_MAX_BYTES = 1_000_000;
7679
7680export function formatSize(bytes) {
7681 const b = Number(bytes ?? 0);
7682 const KB = 1024;
7683 const MB = 1024 * 1024;
7684 if (b >= MB) return `${(b / MB).toFixed(1)}MB`;
7685 if (b >= KB) return `${(b / KB).toFixed(1)}KB`;
7686 return `${Math.trunc(b)}B`;
7687}
7688
7689function jsBytes(value) {
7690 return String(value ?? "").length;
7691}
7692
7693export function truncateHead(text, opts = {}) {
7694 const raw = String(text ?? "");
7695 const maxLines = Number(opts.maxLines ?? DEFAULT_MAX_LINES);
7696 const maxBytes = Number(opts.maxBytes ?? DEFAULT_MAX_BYTES);
7697
7698 const lines = raw.split("\n");
7699 const totalLines = lines.length;
7700 const totalBytes = jsBytes(raw);
7701
7702 const out = [];
7703 let outBytes = 0;
7704 let truncatedBy = null;
7705
7706 for (const line of lines) {
7707 if (out.length >= maxLines) {
7708 truncatedBy = "lines";
7709 break;
7710 }
7711
7712 const candidate = out.length ? `\n${line}` : line;
7713 const candidateBytes = jsBytes(candidate);
7714 if (outBytes + candidateBytes > maxBytes) {
7715 truncatedBy = "bytes";
7716 break;
7717 }
7718 out.push(line);
7719 outBytes += candidateBytes;
7720 }
7721
7722 const content = out.join("\n");
7723 return {
7724 content,
7725 truncated: truncatedBy != null,
7726 truncatedBy,
7727 totalLines,
7728 totalBytes,
7729 outputLines: out.length,
7730 outputBytes: jsBytes(content),
7731 lastLinePartial: false,
7732 firstLineExceedsLimit: false,
7733 maxLines,
7734 maxBytes,
7735 };
7736}
7737
7738export function truncateTail(text, opts = {}) {
7739 const raw = String(text ?? "");
7740 const maxLines = Number(opts.maxLines ?? DEFAULT_MAX_LINES);
7741 const maxBytes = Number(opts.maxBytes ?? DEFAULT_MAX_BYTES);
7742
7743 const lines = raw.split("\n");
7744 const totalLines = lines.length;
7745 const totalBytes = jsBytes(raw);
7746
7747 const out = [];
7748 let outBytes = 0;
7749 let truncatedBy = null;
7750
7751 for (let i = lines.length - 1; i >= 0; i--) {
7752 if (out.length >= maxLines) {
7753 truncatedBy = "lines";
7754 break;
7755 }
7756 const line = lines[i];
7757 const candidate = out.length ? `${line}\n` : line;
7758 const candidateBytes = jsBytes(candidate);
7759 if (outBytes + candidateBytes > maxBytes) {
7760 truncatedBy = "bytes";
7761 break;
7762 }
7763 out.unshift(line);
7764 outBytes += candidateBytes;
7765 }
7766
7767 const content = out.join("\n");
7768 return {
7769 content,
7770 truncated: truncatedBy != null,
7771 truncatedBy,
7772 totalLines,
7773 totalBytes,
7774 outputLines: out.length,
7775 outputBytes: jsBytes(content),
7776 lastLinePartial: false,
7777 firstLineExceedsLimit: false,
7778 maxLines,
7779 maxBytes,
7780 };
7781}
7782
7783export function parseSessionEntries(text) {
7784 const raw = String(text ?? "");
7785 const out = [];
7786 for (const line of raw.split(/\r?\n/)) {
7787 const trimmed = line.trim();
7788 if (!trimmed) continue;
7789 try {
7790 out.push(JSON.parse(trimmed));
7791 } catch {
7792 // ignore malformed lines
7793 }
7794 }
7795 return out;
7796}
7797
7798export function convertToLlm(entries) {
7799 return entries;
7800}
7801
7802export function serializeConversation(entries) {
7803 try {
7804 return JSON.stringify(entries ?? []);
7805 } catch {
7806 return String(entries ?? "");
7807 }
7808}
7809
7810export function buildSessionContext(entries = [], _leafId = null, _byId = null) {
7811 const list = Array.isArray(entries) ? entries.slice() : [];
7812 return {
7813 messages: list,
7814 thinkingLevel: null,
7815 model: null,
7816 };
7817}
7818
7819export function parseFrontmatter(text) {
7820 const raw = String(text ?? "");
7821 if (!raw.startsWith("---")) return { frontmatter: {}, body: raw };
7822 const end = raw.indexOf("\n---", 3);
7823 if (end === -1) return { frontmatter: {}, body: raw };
7824
7825 const header = raw.slice(3, end).trim();
7826 const body = raw.slice(end + 4).replace(/^\n/, "");
7827 const frontmatter = {};
7828 for (const line of header.split(/\r?\n/)) {
7829 const idx = line.indexOf(":");
7830 if (idx === -1) continue;
7831 const key = line.slice(0, idx).trim();
7832 const val = line.slice(idx + 1).trim();
7833 if (!key) continue;
7834 frontmatter[key] = val;
7835 }
7836 return { frontmatter, body };
7837}
7838
7839export function getMarkdownTheme() {
7840 return {};
7841}
7842
7843export function getSettingsListTheme() {
7844 return {};
7845}
7846
7847export function getSelectListTheme() {
7848 return {};
7849}
7850
7851export class DynamicBorder {
7852 constructor(..._args) {}
7853}
7854
7855export class BorderedLoader {
7856 constructor(..._args) {}
7857}
7858
7859export class CustomEditor {
7860 constructor(_opts = {}) {
7861 this.value = "";
7862 }
7863
7864 handleInput(_data) {}
7865
7866 render(_width) {
7867 return [];
7868 }
7869}
7870
7871export function createBashTool(_cwd, _opts = {}) {
7872 return {
7873 name: "bash",
7874 label: "bash",
7875 description: "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 1MB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.",
7876 parameters: {
7877 type: "object",
7878 properties: {
7879 command: { type: "string", description: "The bash command to execute" },
7880 timeout: { type: "number", description: "Optional timeout in seconds" },
7881 },
7882 required: ["command"],
7883 },
7884 async execute(_id, params) {
7885 return { content: [{ type: "text", text: String(params?.command ?? "") }], details: {} };
7886 },
7887 };
7888}
7889
7890export function createReadTool(_cwd, _opts = {}) {
7891 return {
7892 name: "read",
7893 label: "read",
7894 description: "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 1MB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.",
7895 parameters: {
7896 type: "object",
7897 properties: {
7898 path: { type: "string", description: "The path to the file to read" },
7899 offset: { type: "number", description: "Line offset to start reading from (0-indexed)" },
7900 limit: { type: "number", description: "Maximum number of lines to read" },
7901 },
7902 required: ["path"],
7903 },
7904 async execute(_id, _params) {
7905 return { content: [{ type: "text", text: "" }], details: {} };
7906 },
7907 };
7908}
7909
7910export function createLsTool(_cwd, _opts = {}) {
7911 return {
7912 name: "ls",
7913 label: "ls",
7914 description: "List files and directories. Returns names, sizes, and metadata.",
7915 parameters: {
7916 type: "object",
7917 properties: {
7918 path: { type: "string", description: "The path to list" },
7919 },
7920 required: ["path"],
7921 },
7922 async execute(_id, _params) {
7923 return { content: [{ type: "text", text: "" }], details: {} };
7924 },
7925 };
7926}
7927
7928export function createGrepTool(_cwd, _opts = {}) {
7929 return {
7930 name: "grep",
7931 label: "grep",
7932 description: "Search file contents using regular expressions.",
7933 parameters: {
7934 type: "object",
7935 properties: {
7936 pattern: { type: "string", description: "The regex pattern to search for" },
7937 path: { type: "string", description: "The path to search in" },
7938 },
7939 required: ["pattern"],
7940 },
7941 async execute(_id, _params) {
7942 return { content: [{ type: "text", text: "" }], details: {} };
7943 },
7944 };
7945}
7946
7947export function createWriteTool(_cwd, _opts = {}) {
7948 return {
7949 name: "write",
7950 label: "write",
7951 description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
7952 parameters: {
7953 type: "object",
7954 properties: {
7955 path: { type: "string", description: "The path to the file to write" },
7956 content: { type: "string", description: "The content to write to the file" },
7957 },
7958 required: ["path", "content"],
7959 },
7960 async execute(_id, _params) {
7961 return { content: [{ type: "text", text: "" }], details: {} };
7962 },
7963 };
7964}
7965
7966export function createEditTool(_cwd, _opts = {}) {
7967 return {
7968 name: "edit",
7969 label: "edit",
7970 description: "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
7971 parameters: {
7972 type: "object",
7973 properties: {
7974 path: { type: "string", description: "The path to the file to edit" },
7975 oldText: { type: "string", minLength: 1, description: "The exact text to find and replace" },
7976 newText: { type: "string", description: "The text to replace oldText with" },
7977 },
7978 required: ["path", "oldText", "newText"],
7979 },
7980 async execute(_id, _params) {
7981 return { content: [{ type: "text", text: "" }], details: {} };
7982 },
7983 };
7984}
7985
7986export function copyToClipboard(_text) {
7987 return;
7988}
7989
7990export function getAgentDir() {
7991 const home =
7992 globalThis.pi && globalThis.pi.env && typeof globalThis.pi.env.get === "function"
7993 ? globalThis.pi.env.get("HOME")
7994 : undefined;
7995 return home ? `${home}/.pi/agent` : "/home/unknown/.pi/agent";
7996}
7997
7998// Stub: keyHint returns a keyboard shortcut hint string for UI display
7999export function keyHint(action, fallback = "") {
8000 // Map action names to default key bindings
8001 const keyMap = {
8002 expandTools: "Ctrl+E",
8003 copy: "Ctrl+C",
8004 paste: "Ctrl+V",
8005 save: "Ctrl+S",
8006 quit: "Ctrl+Q",
8007 help: "?",
8008 };
8009 return keyMap[action] || fallback || action;
8010}
8011
8012export function rawKeyHint(action, fallback = "") {
8013 return keyHint(action, fallback);
8014}
8015
8016// Stub: compact performs conversation compaction via LLM
8017export async function compact(_preparation, _model, _apiKey, _customInstructions, _signal) {
8018 // Return a minimal compaction result
8019 return {
8020 summary: "Conversation summary placeholder",
8021 firstKeptEntryId: null,
8022 tokensBefore: 0,
8023 tokensAfter: 0,
8024 };
8025}
8026
8027/// Stub: AssistantMessageComponent for rendering assistant messages
8028export class AssistantMessageComponent {
8029 constructor(message, editable = false) {
8030 this.message = message;
8031 this.editable = editable;
8032 }
8033
8034 render() {
8035 return [];
8036 }
8037}
8038
8039// Stub: ToolExecutionComponent for rendering tool executions
8040export class ToolExecutionComponent {
8041 constructor(toolName, args, opts = {}, result, ui) {
8042 this.toolName = toolName;
8043 this.args = args;
8044 this.opts = opts;
8045 this.result = result;
8046 this.ui = ui;
8047 }
8048
8049 render() {
8050 return [];
8051 }
8052}
8053
8054// Stub: UserMessageComponent for rendering user messages
8055export class UserMessageComponent {
8056 constructor(text) {
8057 this.text = text;
8058 }
8059
8060 render() {
8061 return [];
8062 }
8063}
8064
8065export class ModelSelectorComponent {
8066 constructor(_opts = {}) {
8067 this.opts = _opts;
8068 }
8069
8070 render() {
8071 return [];
8072 }
8073}
8074
8075export class SessionManager {
8076 constructor() {}
8077 static inMemory() { return new SessionManager(); }
8078 getSessionFile() { return ""; }
8079 getSessionDir() { return ""; }
8080 getSessionId() { return ""; }
8081 buildSessionContext() { return buildSessionContext([]); }
8082}
8083
8084export class SettingsManager {
8085 constructor(cwd = "", agentDir = "") {
8086 this.cwd = String(cwd ?? "");
8087 this.agentDir = String(agentDir ?? "");
8088 }
8089 static create(cwd, agentDir) { return new SettingsManager(cwd, agentDir); }
8090}
8091
8092export class DefaultResourceLoader {
8093 constructor(opts = {}) {
8094 this.opts = opts;
8095 }
8096 async reload() { return; }
8097}
8098
8099export function highlightCode(code, _lang, _theme) {
8100 return String(code ?? "");
8101}
8102
8103export function getLanguageFromPath(filePath) {
8104 const ext = String(filePath ?? "").split(".").pop() || "";
8105 const map = { ts: "typescript", js: "javascript", py: "python", rs: "rust", go: "go", md: "markdown", json: "json", html: "html", css: "css", sh: "bash" };
8106 return map[ext] || ext;
8107}
8108
8109export function isBashToolResult(result) {
8110 return result && typeof result === "object" && result.name === "bash";
8111}
8112
8113export async function loadSkills() {
8114 return [];
8115}
8116
8117export function truncateToVisualLines(text, maxLines = DEFAULT_MAX_LINES) {
8118 const raw = String(text ?? "");
8119 const lines = raw.split(/\r?\n/);
8120 if (!Number.isFinite(maxLines) || maxLines <= 0) return "";
8121 return lines.slice(0, Math.floor(maxLines)).join("\n");
8122}
8123
8124export function estimateTokens(input) {
8125 const raw = typeof input === "string" ? input : JSON.stringify(input ?? "");
8126 // Deterministic rough heuristic (chars / 4).
8127 return Math.max(1, Math.ceil(String(raw).length / 4));
8128}
8129
8130export function isToolCallEventType(value) {
8131 const t = String(value?.type ?? value ?? "").toLowerCase();
8132 return t === "tool_call" || t === "tool-call" || t === "toolcall";
8133}
8134
8135export class AuthStorage {
8136 constructor() {}
8137 static load() { return new AuthStorage(); }
8138 static async loadAsync() { return new AuthStorage(); }
8139 resolveApiKey(_provider) { return undefined; }
8140 get(_provider) { return undefined; }
8141}
8142
8143export function createAgentSession(opts = {}) {
8144 const state = {
8145 id: String(opts.id ?? "session"),
8146 messages: Array.isArray(opts.messages) ? opts.messages.slice() : [],
8147 };
8148 return {
8149 id: state.id,
8150 messages: state.messages,
8151 append(entry) { state.messages.push(entry); },
8152 toJSON() { return { id: state.id, messages: state.messages.slice() }; },
8153 };
8154}
8155
8156export default {
8157 VERSION,
8158 DEFAULT_MAX_LINES,
8159 DEFAULT_MAX_BYTES,
8160 formatSize,
8161 truncateHead,
8162 truncateTail,
8163 parseSessionEntries,
8164 convertToLlm,
8165 serializeConversation,
8166 buildSessionContext,
8167 parseFrontmatter,
8168 getMarkdownTheme,
8169 getSettingsListTheme,
8170 getSelectListTheme,
8171 DynamicBorder,
8172 BorderedLoader,
8173 CustomEditor,
8174 createBashTool,
8175 createReadTool,
8176 createLsTool,
8177 createGrepTool,
8178 createWriteTool,
8179 createEditTool,
8180 copyToClipboard,
8181 getAgentDir,
8182 keyHint,
8183 rawKeyHint,
8184 compact,
8185 AssistantMessageComponent,
8186 ToolExecutionComponent,
8187 UserMessageComponent,
8188 ModelSelectorComponent,
8189 SessionManager,
8190 SettingsManager,
8191 DefaultResourceLoader,
8192 highlightCode,
8193 getLanguageFromPath,
8194 isBashToolResult,
8195 loadSkills,
8196 truncateToVisualLines,
8197 estimateTokens,
8198 isToolCallEventType,
8199 AuthStorage,
8200 createAgentSession,
8201};
8202"#
8203 .trim()
8204 .to_string(),
8205 );
8206
8207 modules.insert(
8208 "@anthropic-ai/sdk".to_string(),
8209 r"
8210export default class Anthropic {
8211 constructor(_opts = {}) {}
8212}
8213"
8214 .trim()
8215 .to_string(),
8216 );
8217
8218 modules.insert(
8219 "@anthropic-ai/sandbox-runtime".to_string(),
8220 r"
8221export const SandboxManager = {
8222 initialize: async (_config) => {},
8223 reset: async () => {},
8224};
8225export default { SandboxManager };
8226"
8227 .trim()
8228 .to_string(),
8229 );
8230
8231 modules.insert(
8232 "ms".to_string(),
8233 r#"
8234function parseMs(text) {
8235 const s = String(text ?? "").trim();
8236 if (!s) return undefined;
8237
8238 const match = s.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i);
8239 if (!match) return undefined;
8240 const value = Number(match[1]);
8241 const unit = (match[2] || "ms").toLowerCase();
8242 const mult = unit === "ms" ? 1 :
8243 unit === "s" ? 1000 :
8244 unit === "m" ? 60000 :
8245 unit === "h" ? 3600000 :
8246 unit === "d" ? 86400000 :
8247 unit === "w" ? 604800000 :
8248 unit === "y" ? 31536000000 : 1;
8249 return Math.round(value * mult);
8250}
8251
8252export default function ms(value) {
8253 return parseMs(value);
8254}
8255
8256export const parse = parseMs;
8257"#
8258 .trim()
8259 .to_string(),
8260 );
8261
8262 modules.insert(
8263 "jsonwebtoken".to_string(),
8264 r#"
8265export function sign() {
8266 throw new Error("jsonwebtoken.sign is not available in PiJS");
8267}
8268
8269export function verify() {
8270 throw new Error("jsonwebtoken.verify is not available in PiJS");
8271}
8272
8273export function decode() {
8274 return null;
8275}
8276
8277export default { sign, verify, decode };
8278"#
8279 .trim()
8280 .to_string(),
8281 );
8282
8283 modules.insert(
8285 "shell-quote".to_string(),
8286 r#"
8287export function parse(cmd) {
8288 if (typeof cmd !== 'string') return [];
8289 const args = [];
8290 let current = '';
8291 let inSingle = false;
8292 let inDouble = false;
8293 let escaped = false;
8294 for (let i = 0; i < cmd.length; i++) {
8295 const ch = cmd[i];
8296 if (escaped) { current += ch; escaped = false; continue; }
8297 if (ch === '\\' && !inSingle) { escaped = true; continue; }
8298 if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
8299 if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
8300 if ((ch === ' ' || ch === '\t') && !inSingle && !inDouble) {
8301 if (current) { args.push(current); current = ''; }
8302 continue;
8303 }
8304 current += ch;
8305 }
8306 if (current) args.push(current);
8307 return args;
8308}
8309export function quote(args) {
8310 if (!Array.isArray(args)) return '';
8311 return args.map(a => {
8312 if (/[^a-zA-Z0-9_\-=:./]/.test(a)) return "'" + a.replace(/'/g, "'\\''") + "'";
8313 return a;
8314 }).join(' ');
8315}
8316export default { parse, quote };
8317"#
8318 .trim()
8319 .to_string(),
8320 );
8321
8322 {
8324 let vls = r"
8325export const DiagnosticSeverity = { Error: 1, Warning: 2, Information: 3, Hint: 4 };
8326export const CodeActionKind = { QuickFix: 'quickfix', Refactor: 'refactor', RefactorExtract: 'refactor.extract', RefactorInline: 'refactor.inline', RefactorRewrite: 'refactor.rewrite', Source: 'source', SourceOrganizeImports: 'source.organizeImports', SourceFixAll: 'source.fixAll' };
8327export const DocumentDiagnosticReportKind = { Full: 'full', Unchanged: 'unchanged' };
8328export const SymbolKind = { File: 1, Module: 2, Namespace: 3, Package: 4, Class: 5, Method: 6, Property: 7, Field: 8, Constructor: 9, Enum: 10, Interface: 11, Function: 12, Variable: 13, Constant: 14 };
8329function makeReqType(m) { return { type: { get method() { return m; } }, method: m }; }
8330function makeNotifType(m) { return { type: { get method() { return m; } }, method: m }; }
8331export const InitializeRequest = makeReqType('initialize');
8332export const ShutdownRequest = makeReqType('shutdown');
8333export const DefinitionRequest = makeReqType('textDocument/definition');
8334export const ReferencesRequest = makeReqType('textDocument/references');
8335export const HoverRequest = makeReqType('textDocument/hover');
8336export const SignatureHelpRequest = makeReqType('textDocument/signatureHelp');
8337export const DocumentSymbolRequest = makeReqType('textDocument/documentSymbol');
8338export const RenameRequest = makeReqType('textDocument/rename');
8339export const CodeActionRequest = makeReqType('textDocument/codeAction');
8340export const DocumentDiagnosticRequest = makeReqType('textDocument/diagnostic');
8341export const WorkspaceDiagnosticRequest = makeReqType('workspace/diagnostic');
8342export const InitializedNotification = makeNotifType('initialized');
8343export const DidOpenTextDocumentNotification = makeNotifType('textDocument/didOpen');
8344export const DidChangeTextDocumentNotification = makeNotifType('textDocument/didChange');
8345export const DidCloseTextDocumentNotification = makeNotifType('textDocument/didClose');
8346export const DidSaveTextDocumentNotification = makeNotifType('textDocument/didSave');
8347export const PublishDiagnosticsNotification = makeNotifType('textDocument/publishDiagnostics');
8348export function createMessageConnection(_reader, _writer) {
8349 return {
8350 listen() {},
8351 sendRequest() { return Promise.resolve(null); },
8352 sendNotification() {},
8353 onNotification() {},
8354 onRequest() {},
8355 onClose() {},
8356 dispose() {},
8357 };
8358}
8359export class StreamMessageReader { constructor(_s) {} }
8360export class StreamMessageWriter { constructor(_s) {} }
8361"
8362 .trim()
8363 .to_string();
8364
8365 modules.insert("vscode-languageserver-protocol".to_string(), vls.clone());
8366 modules.insert(
8367 "vscode-languageserver-protocol/node.js".to_string(),
8368 vls.clone(),
8369 );
8370 modules.insert("vscode-languageserver-protocol/node".to_string(), vls);
8371 }
8372
8373 {
8375 let mcp_client = r"
8376export class Client {
8377 constructor(_opts = {}) {}
8378 async connect(_transport) {}
8379 async listTools() { return { tools: [] }; }
8380 async listResources() { return { resources: [] }; }
8381 async callTool(_name, _args) { return { content: [] }; }
8382 async close() {}
8383}
8384"
8385 .trim()
8386 .to_string();
8387
8388 let mcp_transport = r"
8389export class StdioClientTransport {
8390 constructor(_opts = {}) {}
8391 async start() {}
8392 async close() {}
8393}
8394"
8395 .trim()
8396 .to_string();
8397
8398 modules.insert(
8399 "@modelcontextprotocol/sdk/client/index.js".to_string(),
8400 mcp_client.clone(),
8401 );
8402 modules.insert(
8403 "@modelcontextprotocol/sdk/client/index".to_string(),
8404 mcp_client,
8405 );
8406 modules.insert(
8407 "@modelcontextprotocol/sdk/client/stdio.js".to_string(),
8408 mcp_transport,
8409 );
8410 modules.insert(
8411 "@modelcontextprotocol/sdk/client/streamableHttp.js".to_string(),
8412 r"
8413export class StreamableHTTPClientTransport {
8414 constructor(_opts = {}) {}
8415 async start() {}
8416 async close() {}
8417}
8418"
8419 .trim()
8420 .to_string(),
8421 );
8422 modules.insert(
8423 "@modelcontextprotocol/sdk/client/sse.js".to_string(),
8424 r"
8425export class SSEClientTransport {
8426 constructor(_opts = {}) {}
8427 async start() {}
8428 async close() {}
8429}
8430"
8431 .trim()
8432 .to_string(),
8433 );
8434 }
8435
8436 modules.insert(
8438 "glob".to_string(),
8439 r#"
8440import "node:fs";
8441
8442function __pi_glob_vfs() {
8443 return globalThis.__pi_vfs_state || null;
8444}
8445
8446function __pi_is_abs(path) {
8447 const raw = String(path ?? "");
8448 return raw.startsWith("/") || /^[A-Za-z]:[\\/]/.test(raw);
8449}
8450
8451function __pi_normalize(path, cwd) {
8452 const vfs = __pi_glob_vfs();
8453 const raw = String(path ?? "");
8454 if (vfs && typeof vfs.normalizePath === "function") {
8455 if (__pi_is_abs(raw)) return vfs.normalizePath(raw);
8456 const base =
8457 cwd ??
8458 (globalThis.process && typeof globalThis.process.cwd === "function"
8459 ? globalThis.process.cwd()
8460 : "/");
8461 return vfs.normalizePath(`${base}/${raw}`);
8462 }
8463 if (__pi_is_abs(raw)) return raw.replace(/\\/g, "/");
8464 const base = String(cwd ?? "/").replace(/\\/g, "/");
8465 return `${base.replace(/\/$/, "")}/${raw.replace(/^\//, "")}`;
8466}
8467
8468function __pi_glob_regex(pattern) {
8469 let out = "^";
8470 for (let i = 0; i < pattern.length; i++) {
8471 const ch = pattern[i];
8472 if (ch === "*") {
8473 if (pattern[i + 1] === "*") {
8474 while (pattern[i + 1] === "*") i++;
8475 if (pattern[i + 1] === "/") {
8476 out += "(?:.*/)?";
8477 i++;
8478 } else {
8479 out += ".*";
8480 }
8481 } else {
8482 out += "[^/]*";
8483 }
8484 } else if (ch === "?") {
8485 out += "[^/]";
8486 } else if ("\\\\.^$+()[]{}|".includes(ch)) {
8487 out += "\\" + ch;
8488 } else {
8489 out += ch;
8490 }
8491 }
8492 out += "$";
8493 return new RegExp(out);
8494}
8495
8496function __pi_make_relative(base, full) {
8497 if (!base) return full;
8498 if (base === "/") {
8499 return full.startsWith("/") ? full.slice(1) : full;
8500 }
8501 const prefix = base.endsWith("/") ? base : `${base}/`;
8502 if (full.startsWith(prefix)) return full.slice(prefix.length);
8503 return full;
8504}
8505
8506function __pi_collect(patterns, opts) {
8507 const vfs = __pi_glob_vfs();
8508 if (!vfs) return [];
8509 const cwd = opts && typeof opts.cwd === "string" ? opts.cwd : undefined;
8510 const absolute = !!(opts && opts.absolute);
8511 const nodir = !!(opts && opts.nodir);
8512 const base =
8513 vfs && typeof vfs.normalizePath === "function"
8514 ? vfs.normalizePath(
8515 cwd ??
8516 (globalThis.process && typeof globalThis.process.cwd === "function"
8517 ? globalThis.process.cwd()
8518 : "/"),
8519 )
8520 : String(cwd ?? "/").replace(/\\/g, "/");
8521
8522 const results = new Set();
8523 for (const rawPattern of patterns) {
8524 const normalized = __pi_normalize(rawPattern, cwd);
8525 const regex = __pi_glob_regex(normalized);
8526 for (const file of vfs.files.keys()) {
8527 if (regex.test(file)) results.add(file);
8528 }
8529 if (!nodir) {
8530 for (const dir of vfs.dirs.values()) {
8531 if (regex.test(dir)) results.add(dir);
8532 }
8533 if (vfs.symlinks && typeof vfs.symlinks.keys === "function") {
8534 for (const link of vfs.symlinks.keys()) {
8535 if (regex.test(link)) results.add(link);
8536 }
8537 }
8538 }
8539 }
8540
8541 const out = Array.from(results);
8542 out.sort();
8543 if (!absolute) {
8544 const allAbsolute = patterns.every((pat) => __pi_is_abs(pat));
8545 if (!allAbsolute) {
8546 return out.map((path) => __pi_make_relative(base, path));
8547 }
8548 }
8549 return out;
8550}
8551
8552export function globSync(pattern, opts = {}) {
8553 const patterns = Array.isArray(pattern) ? pattern : [pattern];
8554 return __pi_collect(patterns, opts);
8555}
8556
8557export function glob(pattern, optsOrCb, cb) {
8558 const callback = typeof optsOrCb === "function" ? optsOrCb : cb;
8559 const opts = typeof optsOrCb === "object" && optsOrCb ? optsOrCb : {};
8560 try {
8561 const result = globSync(pattern, opts);
8562 if (typeof callback === "function") {
8563 callback(null, result);
8564 return;
8565 }
8566 return Promise.resolve(result);
8567 } catch (err) {
8568 if (typeof callback === "function") {
8569 callback(err, []);
8570 return;
8571 }
8572 return Promise.reject(err);
8573 }
8574}
8575
8576export class Glob {
8577 constructor(pattern, opts = {}) {
8578 this.found = globSync(pattern, opts);
8579 }
8580 on() { return this; }
8581}
8582
8583export default { globSync, glob, Glob };
8584"#
8585 .trim()
8586 .to_string(),
8587 );
8588
8589 modules.insert(
8591 "uuid".to_string(),
8592 r#"
8593function randomHex(n) {
8594 let out = "";
8595 for (let i = 0; i < n; i++) out += Math.floor(Math.random() * 16).toString(16);
8596 return out;
8597}
8598export function v4() {
8599 return [randomHex(8), randomHex(4), "4" + randomHex(3), ((8 + Math.floor(Math.random() * 4)).toString(16)) + randomHex(3), randomHex(12)].join("-");
8600}
8601export function v7() {
8602 const ts = Date.now().toString(16).padStart(12, "0");
8603 return [ts.slice(0, 8), ts.slice(8) + randomHex(1), "7" + randomHex(3), ((8 + Math.floor(Math.random() * 4)).toString(16)) + randomHex(3), randomHex(12)].join("-");
8604}
8605export function v1() { return v4(); }
8606export function v3() { return v4(); }
8607export function v5() { return v4(); }
8608export function validate(uuid) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(uuid ?? "")); }
8609export function version(uuid) { return parseInt(String(uuid ?? "").charAt(14), 16) || 0; }
8610export default { v1, v3, v4, v5, v7, validate, version };
8611"#
8612 .trim()
8613 .to_string(),
8614 );
8615
8616 modules.insert(
8618 "diff".to_string(),
8619 r#"
8620export function createTwoFilesPatch(oldFile, newFile, oldStr, newStr, _oldHeader, _newHeader, _opts) {
8621 const oldLines = String(oldStr ?? "").split("\n");
8622 const newLines = String(newStr ?? "").split("\n");
8623 let patch = [`--- ${oldFile}`, `+++ ${newFile}`, `@@ -1,${oldLines.length} +1,${newLines.length} @@`];
8624 for (const line of oldLines) patch.push(`-${line}`);
8625 for (const line of newLines) patch.push(`+${line}`);
8626 return patch.join('\n') + '\n';
8627}
8628export function createPatch(fileName, oldStr, newStr, oldH, newH, opts) {
8629 return createTwoFilesPatch(fileName, fileName, oldStr, newStr, oldH, newH, opts);
8630}
8631export function diffLines(oldStr, newStr) {
8632 return [{ value: String(oldStr ?? ""), removed: true, added: false }, { value: String(newStr ?? ""), removed: false, added: true }];
8633}
8634export function diffChars(o, n) { return diffLines(o, n); }
8635export function diffWords(o, n) { return diffLines(o, n); }
8636export function applyPatch() { return false; }
8637export default { createTwoFilesPatch, createPatch, diffLines, diffChars, diffWords, applyPatch };
8638"#
8639 .trim()
8640 .to_string(),
8641 );
8642
8643 modules.insert(
8645 "just-bash".to_string(),
8646 r#"
8647export function bash(_cmd, _opts) { return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 }); }
8648export { bash as Bash };
8649export default bash;
8650"#
8651 .trim()
8652 .to_string(),
8653 );
8654
8655 modules.insert(
8657 "bunfig".to_string(),
8658 r"
8659export function define(_schema) { return {}; }
8660export async function loadConfig(opts) {
8661 const defaults = (opts && opts.defaultConfig) ? opts.defaultConfig : {};
8662 return { ...defaults };
8663}
8664export default { define, loadConfig };
8665"
8666 .trim()
8667 .to_string(),
8668 );
8669
8670 modules.insert(
8672 "bun".to_string(),
8673 r"
8674const bun = globalThis.Bun || {};
8675export const argv = bun.argv || [];
8676export const file = (...args) => bun.file(...args);
8677export const write = (...args) => bun.write(...args);
8678export const spawn = (...args) => bun.spawn(...args);
8679export const which = (...args) => bun.which(...args);
8680export default bun;
8681"
8682 .trim()
8683 .to_string(),
8684 );
8685
8686 modules.insert(
8688 "dotenv".to_string(),
8689 r#"
8690export function config(_opts) { return { parsed: {} }; }
8691export function parse(src) {
8692 const result = {};
8693 for (const line of String(src ?? "").split("\n")) {
8694 const idx = line.indexOf("=");
8695 if (idx === -1) continue;
8696 const key = line.slice(0, idx).trim();
8697 const val = line.slice(idx + 1).trim().replace(/^["']|["']$/g, "");
8698 if (key) result[key] = val;
8699 }
8700 return result;
8701}
8702export default { config, parse };
8703"#
8704 .trim()
8705 .to_string(),
8706 );
8707
8708 modules.insert(
8709 "node:path".to_string(),
8710 r#"
8711function __pi_is_abs(s) {
8712 return s.startsWith("/") || (s.length >= 3 && s[1] === ":" && s[2] === "/");
8713}
8714
8715export function join(...parts) {
8716 const cleaned = parts.map((p) => String(p ?? "").replace(/\\/g, "/")).filter((p) => p.length > 0);
8717 if (cleaned.length === 0) return ".";
8718 return normalize(cleaned.join("/"));
8719}
8720
8721export function dirname(p) {
8722 const s = String(p ?? "").replace(/\\/g, "/");
8723 const idx = s.lastIndexOf("/");
8724 if (idx <= 0) return s.startsWith("/") ? "/" : ".";
8725 const dir = s.slice(0, idx);
8726 // Keep trailing slash for drive root: D:/ not D:
8727 if (dir.length === 2 && dir[1] === ":") return dir + "/";
8728 return dir;
8729}
8730
8731export function resolve(...parts) {
8732 const base =
8733 globalThis.pi && globalThis.pi.process && typeof globalThis.pi.process.cwd === "string"
8734 ? globalThis.pi.process.cwd
8735 : "/";
8736 const cleaned = parts
8737 .map((p) => String(p ?? "").replace(/\\/g, "/"))
8738 .filter((p) => p.length > 0);
8739
8740 let out = "";
8741 for (const part of cleaned) {
8742 if (__pi_is_abs(part)) {
8743 out = part;
8744 continue;
8745 }
8746 out = out === "" || out.endsWith("/") ? out + part : out + "/" + part;
8747 }
8748 if (!__pi_is_abs(out)) {
8749 out = base.endsWith("/") ? base + out : base + "/" + out;
8750 }
8751 return normalize(out);
8752}
8753
8754export function basename(p, ext) {
8755 const s = String(p ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
8756 const idx = s.lastIndexOf("/");
8757 const name = idx === -1 ? s : s.slice(idx + 1);
8758 if (ext && name.endsWith(ext)) {
8759 return name.slice(0, -ext.length);
8760 }
8761 return name;
8762}
8763
8764export function relative(from, to) {
8765 const fromParts = String(from ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
8766 const toParts = String(to ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
8767
8768 let common = 0;
8769 while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
8770 common++;
8771 }
8772
8773 const up = fromParts.length - common;
8774 const downs = toParts.slice(common);
8775 const result = [...Array(up).fill(".."), ...downs];
8776 return result.join("/") || ".";
8777}
8778
8779export function isAbsolute(p) {
8780 const s = String(p ?? "").replace(/\\/g, "/");
8781 return __pi_is_abs(s);
8782}
8783
8784export function extname(p) {
8785 const s = String(p ?? "").replace(/\\/g, "/");
8786 const b = s.lastIndexOf("/");
8787 const name = b === -1 ? s : s.slice(b + 1);
8788 const dot = name.lastIndexOf(".");
8789 if (dot <= 0) return "";
8790 return name.slice(dot);
8791}
8792
8793export function normalize(p) {
8794 const s = String(p ?? "").replace(/\\/g, "/");
8795 const isAbs = __pi_is_abs(s);
8796 const parts = s.split("/").filter(Boolean);
8797 const out = [];
8798 for (const part of parts) {
8799 if (part === "..") { if (out.length > 0 && out[out.length - 1] !== "..") out.pop(); else if (!isAbs) out.push(part); }
8800 else if (part !== ".") out.push(part);
8801 }
8802 const result = out.join("/");
8803 if (out.length > 0 && out[0].length === 2 && out[0][1] === ":") return result;
8804 return isAbs ? "/" + result : result || ".";
8805}
8806
8807export function parse(p) {
8808 const s = String(p ?? "").replace(/\\/g, "/");
8809 const isAbs = s.startsWith("/");
8810 const lastSlash = s.lastIndexOf("/");
8811 const dir = lastSlash === -1 ? "" : s.slice(0, lastSlash) || (isAbs ? "/" : "");
8812 const base = lastSlash === -1 ? s : s.slice(lastSlash + 1);
8813 const ext = extname(base);
8814 const name = ext ? base.slice(0, -ext.length) : base;
8815 const root = isAbs ? "/" : "";
8816 return { root, dir, base, ext, name };
8817}
8818
8819export function format(pathObj) {
8820 const dir = pathObj.dir || pathObj.root || "";
8821 const base = pathObj.base || (pathObj.name || "") + (pathObj.ext || "");
8822 if (!dir) return base;
8823 return dir === pathObj.root ? dir + base : dir + "/" + base;
8824}
8825
8826export const sep = "/";
8827export const delimiter = ":";
8828export const posix = { join, dirname, resolve, basename, relative, isAbsolute, extname, normalize, parse, format, sep, delimiter };
8829
8830const win32Stub = new Proxy({}, { get(_, prop) { throw new Error("path.win32." + String(prop) + " is not supported (Pi runs on POSIX only)"); } });
8831export const win32 = win32Stub;
8832
8833export default { join, dirname, resolve, basename, relative, isAbsolute, extname, normalize, parse, format, sep, delimiter, posix, win32 };
8834"#
8835 .trim()
8836 .to_string(),
8837 );
8838
8839 modules.insert("node:os".to_string(), build_node_os_module());
8840
8841 modules.insert(
8842 "node:child_process".to_string(),
8843 r#"
8844const __pi_child_process_state = (() => {
8845 if (globalThis.__pi_child_process_state) {
8846 return globalThis.__pi_child_process_state;
8847 }
8848 const state = {
8849 nextPid: 1000,
8850 children: new Map(),
8851 };
8852 globalThis.__pi_child_process_state = state;
8853 return state;
8854})();
8855
8856function __makeEmitter() {
8857 const listeners = new Map();
8858 const emitter = {
8859 on(event, listener) {
8860 const key = String(event);
8861 if (!listeners.has(key)) listeners.set(key, []);
8862 listeners.get(key).push(listener);
8863 return emitter;
8864 },
8865 once(event, listener) {
8866 const wrapper = (...args) => {
8867 emitter.off(event, wrapper);
8868 listener(...args);
8869 };
8870 return emitter.on(event, wrapper);
8871 },
8872 off(event, listener) {
8873 const key = String(event);
8874 const bucket = listeners.get(key);
8875 if (!bucket) return emitter;
8876 const idx = bucket.indexOf(listener);
8877 if (idx >= 0) bucket.splice(idx, 1);
8878 if (bucket.length === 0) listeners.delete(key);
8879 return emitter;
8880 },
8881 removeListener(event, listener) {
8882 return emitter.off(event, listener);
8883 },
8884 emit(event, ...args) {
8885 const key = String(event);
8886 const bucket = listeners.get(key) || [];
8887 for (const listener of [...bucket]) {
8888 try {
8889 listener(...args);
8890 } catch (_) {}
8891 }
8892 return emitter;
8893 },
8894 };
8895 return emitter;
8896}
8897
8898function __emitCloseOnce(child, code, signal = null) {
8899 if (child.__pi_done) return;
8900 child.__pi_done = true;
8901 child.exitCode = code;
8902 child.signalCode = signal;
8903 __pi_child_process_state.children.delete(child.pid);
8904 child.emit("exit", code, signal);
8905 child.emit("close", code, signal);
8906}
8907
8908function __parseSpawnOptions(raw) {
8909 const options = raw && typeof raw === "object" ? raw : {};
8910 const allowed = new Set(["cwd", "detached", "shell", "stdio", "timeout"]);
8911 for (const key of Object.keys(options)) {
8912 if (!allowed.has(key)) {
8913 throw new Error(`node:child_process.spawn: unsupported option '${key}'`);
8914 }
8915 }
8916
8917 if (options.shell !== undefined && options.shell !== false) {
8918 throw new Error("node:child_process.spawn: only shell=false is supported in PiJS");
8919 }
8920
8921 let stdio = ["pipe", "pipe", "pipe"];
8922 if (options.stdio !== undefined) {
8923 if (!Array.isArray(options.stdio)) {
8924 throw new Error("node:child_process.spawn: options.stdio must be an array");
8925 }
8926 if (options.stdio.length !== 3) {
8927 throw new Error("node:child_process.spawn: options.stdio must have exactly 3 entries");
8928 }
8929 stdio = options.stdio.map((entry, idx) => {
8930 const value = String(entry ?? "");
8931 if (value !== "ignore" && value !== "pipe") {
8932 throw new Error(
8933 `node:child_process.spawn: unsupported stdio[${idx}] value '${value}'`,
8934 );
8935 }
8936 return value;
8937 });
8938 }
8939
8940 const cwd =
8941 typeof options.cwd === "string" && options.cwd.trim().length > 0
8942 ? options.cwd
8943 : undefined;
8944 let timeoutMs = undefined;
8945 if (options.timeout !== undefined) {
8946 if (
8947 typeof options.timeout !== "number" ||
8948 !Number.isFinite(options.timeout) ||
8949 options.timeout < 0
8950 ) {
8951 throw new Error(
8952 "node:child_process.spawn: options.timeout must be a non-negative number",
8953 );
8954 }
8955 timeoutMs = Math.floor(options.timeout);
8956 }
8957
8958 return {
8959 cwd,
8960 detached: Boolean(options.detached),
8961 stdio,
8962 timeoutMs,
8963 };
8964}
8965
8966function __installProcessKillBridge() {
8967 globalThis.__pi_process_kill_impl = (pidValue, signal = "SIGTERM") => {
8968 const pidNumeric = Number(pidValue);
8969 if (!Number.isFinite(pidNumeric) || pidNumeric === 0) {
8970 const err = new Error(`kill EINVAL: invalid pid ${String(pidValue)}`);
8971 err.code = "EINVAL";
8972 throw err;
8973 }
8974 const pid = Math.abs(Math.trunc(pidNumeric));
8975 const child = __pi_child_process_state.children.get(pid);
8976 if (!child) {
8977 const err = new Error(`kill ESRCH: no such process ${pid}`);
8978 err.code = "ESRCH";
8979 throw err;
8980 }
8981 child.kill(signal);
8982 return true;
8983 };
8984}
8985
8986__installProcessKillBridge();
8987
8988export function spawn(command, args = [], options = {}) {
8989 const cmd = String(command ?? "").trim();
8990 if (!cmd) {
8991 throw new Error("node:child_process.spawn: command is required");
8992 }
8993 if (!Array.isArray(args)) {
8994 throw new Error("node:child_process.spawn: args must be an array");
8995 }
8996
8997 const argv = args.map((arg) => String(arg));
8998 const opts = __parseSpawnOptions(options);
8999
9000 const child = __makeEmitter();
9001 child.pid = __pi_child_process_state.nextPid++;
9002 child.killed = false;
9003 child.exitCode = null;
9004 child.signalCode = null;
9005 child.__pi_done = false;
9006 child.__pi_kill_resolver = null;
9007 child.stdout = opts.stdio[1] === "pipe" ? __makeEmitter() : null;
9008 child.stderr = opts.stdio[2] === "pipe" ? __makeEmitter() : null;
9009 child.stdin = opts.stdio[0] === "pipe" ? __makeEmitter() : null;
9010
9011 child.kill = (signal = "SIGTERM") => {
9012 if (child.__pi_done) return false;
9013 child.killed = true;
9014 if (typeof child.__pi_kill_resolver === "function") {
9015 child.__pi_kill_resolver({
9016 kind: "killed",
9017 signal: String(signal || "SIGTERM"),
9018 });
9019 child.__pi_kill_resolver = null;
9020 }
9021 __emitCloseOnce(child, null, String(signal || "SIGTERM"));
9022 return true;
9023 };
9024
9025 __pi_child_process_state.children.set(child.pid, child);
9026
9027 const execOptions = {};
9028 if (opts.cwd !== undefined) execOptions.cwd = opts.cwd;
9029 if (opts.timeoutMs !== undefined) execOptions.timeout = opts.timeoutMs;
9030 const execPromise = pi.exec(cmd, argv, execOptions).then(
9031 (result) => ({ kind: "result", result }),
9032 (error) => ({ kind: "error", error }),
9033 );
9034
9035 const killPromise = new Promise((resolve) => {
9036 child.__pi_kill_resolver = resolve;
9037 });
9038
9039 Promise.race([execPromise, killPromise]).then((outcome) => {
9040 if (!outcome || child.__pi_done) return;
9041
9042 if (outcome.kind === "result") {
9043 const result = outcome.result || {};
9044 if (child.stdout && result.stdout !== undefined && result.stdout !== null && result.stdout !== "") {
9045 child.stdout.emit("data", String(result.stdout));
9046 }
9047 if (child.stderr && result.stderr !== undefined && result.stderr !== null && result.stderr !== "") {
9048 child.stderr.emit("data", String(result.stderr));
9049 }
9050 if (result.killed) {
9051 child.killed = true;
9052 }
9053 const code =
9054 typeof result.code === "number" && Number.isFinite(result.code)
9055 ? result.code
9056 : 0;
9057 const signal =
9058 result.killed || child.killed
9059 ? String(result.signal || "SIGTERM")
9060 : null;
9061 __emitCloseOnce(child, signal ? null : code, signal);
9062 return;
9063 }
9064
9065 if (outcome.kind === "error") {
9066 const source = outcome.error || {};
9067 const error =
9068 source instanceof Error
9069 ? source
9070 : new Error(String(source.message || source || "spawn failed"));
9071 if (!error.code && source && source.code !== undefined) {
9072 error.code = String(source.code);
9073 }
9074 child.emit("error", error);
9075 __emitCloseOnce(child, 1, null);
9076 }
9077 });
9078
9079 return child;
9080}
9081
9082function __parseExecSyncResult(raw, command) {
9083 const result = JSON.parse(raw);
9084 if (result.error) {
9085 const err = new Error(`Command failed: ${command}\n${result.error}`);
9086 err.status = null;
9087 err.stdout = result.stdout || "";
9088 err.stderr = result.stderr || "";
9089 err.pid = result.pid || 0;
9090 err.signal = null;
9091 throw err;
9092 }
9093 if (result.killed) {
9094 const err = new Error(`Command timed out: ${command}`);
9095 err.killed = true;
9096 err.status = result.status;
9097 err.stdout = result.stdout || "";
9098 err.stderr = result.stderr || "";
9099 err.pid = result.pid || 0;
9100 err.signal = "SIGTERM";
9101 throw err;
9102 }
9103 return result;
9104}
9105
9106export function spawnSync(command, argsInput, options) {
9107 const cmd = String(command ?? "").trim();
9108 if (!cmd) {
9109 throw new Error("node:child_process.spawnSync: command is required");
9110 }
9111 const args = Array.isArray(argsInput) ? argsInput.map(String) : [];
9112 const opts = (typeof argsInput === "object" && !Array.isArray(argsInput))
9113 ? argsInput
9114 : (options || {});
9115 const cwd = typeof opts.cwd === "string" ? opts.cwd : "";
9116 const timeout = typeof opts.timeout === "number" ? opts.timeout : 0;
9117 const maxBuffer = typeof opts.maxBuffer === "number" ? opts.maxBuffer : 1024 * 1024;
9118
9119 let result;
9120 try {
9121 const raw = __pi_exec_sync_native(cmd, JSON.stringify(args), cwd, timeout, maxBuffer);
9122 result = JSON.parse(raw);
9123 } catch (e) {
9124 return {
9125 pid: 0,
9126 output: [null, "", e.message || ""],
9127 stdout: "",
9128 stderr: e.message || "",
9129 status: null,
9130 signal: null,
9131 error: e,
9132 };
9133 }
9134
9135 if (result.error) {
9136 const err = new Error(result.error);
9137 return {
9138 pid: result.pid || 0,
9139 output: [null, result.stdout || "", result.stderr || ""],
9140 stdout: result.stdout || "",
9141 stderr: result.stderr || "",
9142 status: null,
9143 signal: result.killed ? "SIGTERM" : null,
9144 error: err,
9145 };
9146 }
9147
9148 return {
9149 pid: result.pid || 0,
9150 output: [null, result.stdout || "", result.stderr || ""],
9151 stdout: result.stdout || "",
9152 stderr: result.stderr || "",
9153 status: result.status ?? 0,
9154 signal: result.killed ? "SIGTERM" : null,
9155 error: undefined,
9156 };
9157}
9158
9159export function execSync(command, options) {
9160 const cmdStr = String(command ?? "").trim();
9161 if (!cmdStr) {
9162 throw new Error("node:child_process.execSync: command is required");
9163 }
9164 const opts = options || {};
9165 const cwd = typeof opts.cwd === "string" ? opts.cwd : "";
9166 const timeout = typeof opts.timeout === "number" ? opts.timeout : 0;
9167 const maxBuffer = typeof opts.maxBuffer === "number" ? opts.maxBuffer : 1024 * 1024;
9168
9169 // execSync runs through a shell, so pass via sh -c
9170 const raw = __pi_exec_sync_native("sh", JSON.stringify(["-c", cmdStr]), cwd, timeout, maxBuffer);
9171 const result = __parseExecSyncResult(raw, cmdStr);
9172
9173 if (result.error) {
9174 result.error.status = result.status;
9175 result.error.stdout = result.stdout || "";
9176 result.error.stderr = result.stderr || "";
9177 result.error.pid = result.pid || 0;
9178 result.error.signal = result.signal;
9179 throw result.error;
9180 }
9181
9182 if (result.status !== 0 && result.status !== null) {
9183 const err = new Error(
9184 `Command failed: ${cmdStr}\n${result.stderr || ""}`,
9185 );
9186 err.status = result.status;
9187 err.stdout = result.stdout || "";
9188 err.stderr = result.stderr || "";
9189 err.pid = result.pid || 0;
9190 err.signal = null;
9191 throw err;
9192 }
9193
9194 const stdout = result.stdout || "";
9195 if (stdout.length > maxBuffer) {
9196 const err = new Error(`stdout maxBuffer length exceeded`);
9197 err.stdout = stdout.slice(0, maxBuffer);
9198 err.stderr = result.stderr || "";
9199 throw err;
9200 }
9201
9202 const encoding = opts.encoding;
9203 if (encoding === "buffer" || encoding === null) {
9204 // Return a "buffer-like" string (QuickJS doesn't have real Buffer)
9205 return stdout;
9206 }
9207 return stdout;
9208}
9209
9210function __normalizeExecOptions(raw) {
9211 const options = raw && typeof raw === "object" ? raw : {};
9212 let timeoutMs = undefined;
9213 if (
9214 typeof options.timeout === "number" &&
9215 Number.isFinite(options.timeout) &&
9216 options.timeout >= 0
9217 ) {
9218 timeoutMs = Math.floor(options.timeout);
9219 }
9220 const maxBuffer =
9221 typeof options.maxBuffer === "number" &&
9222 Number.isFinite(options.maxBuffer) &&
9223 options.maxBuffer > 0
9224 ? Math.floor(options.maxBuffer)
9225 : 1024 * 1024;
9226 return {
9227 cwd: typeof options.cwd === "string" && options.cwd.trim().length > 0 ? options.cwd : undefined,
9228 timeoutMs,
9229 maxBuffer,
9230 encoding: options.encoding,
9231 };
9232}
9233
9234function __wrapExecLike(commandForError, child, opts, callback) {
9235 let stdoutChunks = [];
9236 let stderrChunks = [];
9237 let callbackDone = false;
9238 const finish = (err, outStr, errOutStr) => {
9239 if (callbackDone) return;
9240 callbackDone = true;
9241 const out = outStr !== undefined ? outStr : stdoutChunks.join("");
9242 const errOut = errOutStr !== undefined ? errOutStr : stderrChunks.join("");
9243 if (typeof callback === "function") {
9244 callback(err, out, errOut);
9245 }
9246 };
9247
9248 let stdoutLen = 0;
9249 let stderrLen = 0;
9250 let killedForMaxBuffer = false;
9251
9252 const checkMaxBuffer = (isStdout) => {
9253 if (stdoutLen > opts.maxBuffer || stderrLen > opts.maxBuffer) {
9254 if (!killedForMaxBuffer) {
9255 killedForMaxBuffer = true;
9256 child.kill("SIGTERM");
9257 const out = stdoutChunks.join("");
9258 const errOut = stderrChunks.join("");
9259 const err = new Error(`${isStdout ? "stdout" : "stderr"} maxBuffer length exceeded`);
9260 err.stdout = out.slice(0, opts.maxBuffer);
9261 err.stderr = errOut.slice(0, opts.maxBuffer);
9262 finish(err, err.stdout, err.stderr);
9263 }
9264 }
9265 };
9266
9267 child.stdout?.on("data", (chunk) => {
9268 if (killedForMaxBuffer) return;
9269 const str = String(chunk ?? "");
9270 stdoutChunks.push(str);
9271 stdoutLen += str.length;
9272 checkMaxBuffer(true);
9273 });
9274 child.stderr?.on("data", (chunk) => {
9275 if (killedForMaxBuffer) return;
9276 const str = String(chunk ?? "");
9277 stderrChunks.push(str);
9278 stderrLen += str.length;
9279 checkMaxBuffer(false);
9280 });
9281
9282 child.on("error", (error) => {
9283 if (killedForMaxBuffer) return;
9284 finish(
9285 error instanceof Error ? error : new Error(String(error)),
9286 "",
9287 "",
9288 );
9289 });
9290
9291 child.on("close", (code) => {
9292 if (killedForMaxBuffer) return;
9293 let out = stdoutChunks.join("");
9294 let errOut = stderrChunks.join("");
9295
9296 if (out.length > opts.maxBuffer) {
9297 const err = new Error("stdout maxBuffer length exceeded");
9298 err.stdout = out.slice(0, opts.maxBuffer);
9299 err.stderr = errOut;
9300 finish(err, err.stdout, errOut);
9301 return;
9302 }
9303
9304 if (errOut.length > opts.maxBuffer) {
9305 const err = new Error("stderr maxBuffer length exceeded");
9306 err.stdout = out;
9307 err.stderr = errOut.slice(0, opts.maxBuffer);
9308 finish(err, out, err.stderr);
9309 return;
9310 }
9311
9312 if (opts.encoding !== "buffer" && opts.encoding !== null) {
9313 out = String(out);
9314 errOut = String(errOut);
9315 }
9316
9317 if (code !== 0 && code !== undefined && code !== null) {
9318 const err = new Error(`Command failed: ${commandForError}`);
9319 err.code = code;
9320 err.killed = Boolean(child.killed);
9321 err.stdout = out;
9322 err.stderr = errOut;
9323 finish(err, out, errOut);
9324 return;
9325 }
9326
9327 if (child.killed) {
9328 const err = new Error(`Command timed out: ${commandForError}`);
9329 err.code = null;
9330 err.killed = true;
9331 err.signal = child.signalCode || "SIGTERM";
9332 err.stdout = out;
9333 err.stderr = errOut;
9334 finish(err, out, errOut);
9335 return;
9336 }
9337
9338 finish(null, out, errOut);
9339 });
9340
9341 return child;
9342}
9343
9344export function exec(command, optionsOrCallback, callbackArg) {
9345 const opts = typeof optionsOrCallback === "object" ? optionsOrCallback : {};
9346 const callback = typeof optionsOrCallback === "function"
9347 ? optionsOrCallback
9348 : callbackArg;
9349 const cmdStr = String(command ?? "").trim();
9350 const normalized = __normalizeExecOptions(opts);
9351 const spawnOpts = {
9352 shell: false,
9353 stdio: ["ignore", "pipe", "pipe"],
9354 };
9355 if (normalized.cwd !== undefined) spawnOpts.cwd = normalized.cwd;
9356 if (normalized.timeoutMs !== undefined) spawnOpts.timeout = normalized.timeoutMs;
9357 const child = spawn("sh", ["-c", cmdStr], spawnOpts);
9358 return __wrapExecLike(cmdStr, child, normalized, callback);
9359}
9360
9361export function execFileSync(file, argsInput, options) {
9362 const fileStr = String(file ?? "").trim();
9363 if (!fileStr) {
9364 throw new Error("node:child_process.execFileSync: file is required");
9365 }
9366 const args = Array.isArray(argsInput) ? argsInput.map(String) : [];
9367 const opts = (typeof argsInput === "object" && !Array.isArray(argsInput))
9368 ? argsInput
9369 : (options || {});
9370 const cwd = typeof opts.cwd === "string" ? opts.cwd : "";
9371 const timeout = typeof opts.timeout === "number" ? opts.timeout : 0;
9372 const maxBuffer = typeof opts.maxBuffer === "number" ? opts.maxBuffer : 1024 * 1024;
9373
9374 const raw = __pi_exec_sync_native(fileStr, JSON.stringify(args), cwd, timeout, maxBuffer);
9375 const result = __parseExecSyncResult(raw, fileStr);
9376
9377 if (result.error) {
9378 result.error.status = result.status;
9379 result.error.stdout = result.stdout || "";
9380 result.error.stderr = result.stderr || "";
9381 result.error.pid = result.pid || 0;
9382 result.error.signal = result.signal;
9383 throw result.error;
9384 }
9385
9386 if (result.status !== 0 && result.status !== null) {
9387 const err = new Error(
9388 `Command failed: ${fileStr}\n${result.stderr || ""}`,
9389 );
9390 err.status = result.status;
9391 err.stdout = result.stdout || "";
9392 err.stderr = result.stderr || "";
9393 err.pid = result.pid || 0;
9394 throw err;
9395 }
9396
9397 return result.stdout || "";
9398}
9399
9400export function execFile(file, argsOrOptsOrCb, optsOrCb, callbackArg) {
9401 const fileStr = String(file ?? "").trim();
9402 let args = [];
9403 let opts = {};
9404 let callback;
9405 if (typeof argsOrOptsOrCb === "function") {
9406 callback = argsOrOptsOrCb;
9407 } else if (Array.isArray(argsOrOptsOrCb)) {
9408 args = argsOrOptsOrCb.map(String);
9409 if (typeof optsOrCb === "function") {
9410 callback = optsOrCb;
9411 } else {
9412 opts = optsOrCb || {};
9413 callback = callbackArg;
9414 }
9415 } else if (typeof argsOrOptsOrCb === "object") {
9416 opts = argsOrOptsOrCb || {};
9417 callback = typeof optsOrCb === "function" ? optsOrCb : callbackArg;
9418 }
9419
9420 const normalized = __normalizeExecOptions(opts);
9421 const spawnOpts = {
9422 shell: false,
9423 stdio: ["ignore", "pipe", "pipe"],
9424 };
9425 if (normalized.cwd !== undefined) spawnOpts.cwd = normalized.cwd;
9426 if (normalized.timeoutMs !== undefined) spawnOpts.timeout = normalized.timeoutMs;
9427 const child = spawn(fileStr, args, spawnOpts);
9428 return __wrapExecLike(fileStr, child, normalized, callback);
9429}
9430
9431export function fork(_modulePath, _args, _opts) {
9432 throw new Error("node:child_process.fork is not available in PiJS");
9433}
9434
9435export default { spawn, spawnSync, execSync, execFileSync, exec, execFile, fork };
9436"#
9437 .trim()
9438 .to_string(),
9439 );
9440
9441 modules.insert(
9442 "node:module".to_string(),
9443 r#"
9444import * as fs from "node:fs";
9445import * as fsPromises from "node:fs/promises";
9446import * as path from "node:path";
9447import * as os from "node:os";
9448import * as crypto from "node:crypto";
9449import * as url from "node:url";
9450import * as processMod from "node:process";
9451import * as timersMod from "node:timers";
9452import * as buffer from "node:buffer";
9453import * as childProcess from "node:child_process";
9454import * as http from "node:http";
9455import * as https from "node:https";
9456import * as net from "node:net";
9457import * as events from "node:events";
9458import * as stream from "node:stream";
9459import * as streamPromises from "node:stream/promises";
9460import * as streamWeb from "node:stream/web";
9461import * as stringDecoder from "node:string_decoder";
9462import * as http2 from "node:http2";
9463import * as util from "node:util";
9464import * as readline from "node:readline";
9465import * as readlinePromises from "node:readline/promises";
9466import * as querystring from "node:querystring";
9467import * as assertMod from "node:assert";
9468import * as assertStrict from "node:assert/strict";
9469import * as constantsMod from "node:constants";
9470import * as tls from "node:tls";
9471import * as tty from "node:tty";
9472import * as zlib from "node:zlib";
9473import * as perfHooks from "node:perf_hooks";
9474import * as vm from "node:vm";
9475import * as v8 from "node:v8";
9476import * as workerThreads from "node:worker_threads";
9477import * as testMod from "node:test";
9478
9479function __normalizeBuiltin(id) {
9480 const spec = String(id ?? "");
9481 switch (spec) {
9482 case "fs":
9483 case "node:fs":
9484 return "node:fs";
9485 case "fs/promises":
9486 case "node:fs/promises":
9487 return "node:fs/promises";
9488 case "path":
9489 case "node:path":
9490 return "node:path";
9491 case "os":
9492 case "node:os":
9493 return "node:os";
9494 case "crypto":
9495 case "node:crypto":
9496 return "node:crypto";
9497 case "url":
9498 case "node:url":
9499 return "node:url";
9500 case "process":
9501 case "node:process":
9502 return "node:process";
9503 case "timers":
9504 case "node:timers":
9505 return "node:timers";
9506 case "buffer":
9507 case "node:buffer":
9508 return "node:buffer";
9509 case "child_process":
9510 case "node:child_process":
9511 return "node:child_process";
9512 case "http":
9513 case "node:http":
9514 return "node:http";
9515 case "https":
9516 case "node:https":
9517 return "node:https";
9518 case "net":
9519 case "node:net":
9520 return "node:net";
9521 case "events":
9522 case "node:events":
9523 return "node:events";
9524 case "stream":
9525 case "node:stream":
9526 return "node:stream";
9527 case "stream/web":
9528 case "node:stream/web":
9529 return "node:stream/web";
9530 case "stream/promises":
9531 case "node:stream/promises":
9532 return "node:stream/promises";
9533 case "string_decoder":
9534 case "node:string_decoder":
9535 return "node:string_decoder";
9536 case "http2":
9537 case "node:http2":
9538 return "node:http2";
9539 case "util":
9540 case "node:util":
9541 return "node:util";
9542 case "readline":
9543 case "node:readline":
9544 return "node:readline";
9545 case "readline/promises":
9546 case "node:readline/promises":
9547 return "node:readline/promises";
9548 case "querystring":
9549 case "node:querystring":
9550 return "node:querystring";
9551 case "assert":
9552 case "node:assert":
9553 return "node:assert";
9554 case "assert/strict":
9555 case "node:assert/strict":
9556 return "node:assert/strict";
9557 case "test":
9558 case "node:test":
9559 return "node:test";
9560 case "module":
9561 case "node:module":
9562 return "node:module";
9563 case "constants":
9564 case "node:constants":
9565 return "node:constants";
9566 case "tls":
9567 case "node:tls":
9568 return "node:tls";
9569 case "tty":
9570 case "node:tty":
9571 return "node:tty";
9572 case "zlib":
9573 case "node:zlib":
9574 return "node:zlib";
9575 case "perf_hooks":
9576 case "node:perf_hooks":
9577 return "node:perf_hooks";
9578 case "vm":
9579 case "node:vm":
9580 return "node:vm";
9581 case "v8":
9582 case "node:v8":
9583 return "node:v8";
9584 case "worker_threads":
9585 case "node:worker_threads":
9586 return "node:worker_threads";
9587 default:
9588 return spec;
9589 }
9590}
9591
9592const __builtinModules = {
9593 "node:fs": fs,
9594 "node:fs/promises": fsPromises,
9595 "node:path": path,
9596 "node:os": os,
9597 "node:crypto": crypto,
9598 "node:url": url,
9599 "node:process": processMod,
9600 "node:timers": timersMod,
9601 "node:buffer": buffer,
9602 "node:child_process": childProcess,
9603 "node:http": http,
9604 "node:https": https,
9605 "node:net": net,
9606 "node:events": events,
9607 "node:stream": stream,
9608 "node:stream/web": streamWeb,
9609 "node:stream/promises": streamPromises,
9610 "node:string_decoder": stringDecoder,
9611 "node:http2": http2,
9612 "node:util": util,
9613 "node:readline": readline,
9614 "node:readline/promises": readlinePromises,
9615 "node:querystring": querystring,
9616 "node:assert": assertMod,
9617 "node:assert/strict": assertStrict,
9618 "node:test": testMod,
9619 "node:module": { createRequire },
9620 "node:constants": constantsMod,
9621 "node:tls": tls,
9622 "node:tty": tty,
9623 "node:zlib": zlib,
9624 "node:perf_hooks": perfHooks,
9625 "node:vm": vm,
9626 "node:v8": v8,
9627 "node:worker_threads": workerThreads,
9628};
9629
9630const __missingRequireCache = Object.create(null);
9631
9632function __isBarePackageSpecifier(spec) {
9633 return (
9634 typeof spec === "string" &&
9635 spec.length > 0 &&
9636 !spec.startsWith("./") &&
9637 !spec.startsWith("../") &&
9638 !spec.startsWith("/") &&
9639 !spec.startsWith("file://") &&
9640 !spec.includes(":")
9641 );
9642}
9643
9644function __makeMissingRequireStub(spec) {
9645 if (__missingRequireCache[spec]) {
9646 return __missingRequireCache[spec];
9647 }
9648 const handler = {
9649 get(_target, prop) {
9650 if (typeof prop === "symbol") {
9651 if (prop === Symbol.toPrimitive) return () => "";
9652 return undefined;
9653 }
9654 if (prop === "__esModule") return true;
9655 if (prop === "default") return stub;
9656 if (prop === "toString") return () => "";
9657 if (prop === "valueOf") return () => "";
9658 if (prop === "name") return spec;
9659 if (prop === "then") return undefined;
9660 return stub;
9661 },
9662 apply() { return stub; },
9663 construct() { return stub; },
9664 has() { return false; },
9665 ownKeys() { return []; },
9666 getOwnPropertyDescriptor() {
9667 return { configurable: true, enumerable: false };
9668 },
9669 };
9670 const stub = new Proxy(function __pijs_missing_require_stub() {}, handler);
9671 __missingRequireCache[spec] = stub;
9672 return stub;
9673}
9674
9675export function createRequire(_path) {
9676 function require(id) {
9677 const normalized = __normalizeBuiltin(id);
9678 const builtIn = __builtinModules[normalized];
9679 if (builtIn) {
9680 if (builtIn && Object.prototype.hasOwnProperty.call(builtIn, "default") && builtIn.default !== undefined) {
9681 return builtIn.default;
9682 }
9683 return builtIn;
9684 }
9685 const raw = String(id ?? "");
9686 if (raw.startsWith("node:") || __isBarePackageSpecifier(raw)) {
9687 return __makeMissingRequireStub(raw);
9688 }
9689 throw new Error(`Cannot find module '${raw}' in PiJS require()`);
9690 }
9691 require.resolve = function resolve(id) {
9692 // Return a synthetic path for the requested module. This satisfies
9693 // extensions that call require.resolve() to locate a binary entry
9694 // point (e.g. @sourcegraph/scip-python) without actually needing the
9695 // real node_modules tree.
9696 return `/pijs-virtual/${String(id ?? "unknown")}`;
9697 };
9698 require.resolve.paths = function() { return []; };
9699 return require;
9700}
9701
9702export default { createRequire };
9703"#
9704 .trim()
9705 .to_string(),
9706 );
9707
9708 modules.insert(
9709 "node:fs".to_string(),
9710 r#"
9711import { Readable, Writable } from "node:stream";
9712
9713export const constants = {
9714 R_OK: 4,
9715 W_OK: 2,
9716 X_OK: 1,
9717 F_OK: 0,
9718 O_RDONLY: 0,
9719 O_WRONLY: 1,
9720 O_RDWR: 2,
9721 O_CREAT: 64,
9722 O_EXCL: 128,
9723 O_TRUNC: 512,
9724 O_APPEND: 1024,
9725};
9726const __pi_vfs = (() => {
9727 if (globalThis.__pi_vfs_state) {
9728 return globalThis.__pi_vfs_state;
9729 }
9730
9731 const state = {
9732 files: new Map(),
9733 dirs: new Set(["/"]),
9734 symlinks: new Map(),
9735 fds: new Map(),
9736 nextFd: 100,
9737 };
9738
9739 function checkWriteAccess(resolved) {
9740 if (typeof globalThis.__pi_host_check_write_access === "function") {
9741 globalThis.__pi_host_check_write_access(resolved);
9742 }
9743 }
9744
9745 function normalizePath(input) {
9746 let raw = String(input ?? "").replace(/\\/g, "/");
9747 // Strip Windows UNC verbatim prefix that canonicalize() produces.
9748 // \\?\C:\... becomes /?/C:/... after separator normalization.
9749 if (raw.startsWith("/?/") && raw.length > 5 && /^[A-Za-z]:/.test(raw.substring(3, 5))) {
9750 raw = raw.slice(3);
9751 }
9752 // Detect Windows drive-letter absolute paths (e.g. "C:/Users/...")
9753 const hasDriveLetter = raw.length >= 3 && /^[A-Za-z]:\//.test(raw);
9754 const isAbsolute = raw.startsWith("/") || hasDriveLetter;
9755 const base = isAbsolute
9756 ? raw
9757 : `${(globalThis.process && typeof globalThis.process.cwd === "function" ? globalThis.process.cwd() : "/").replace(/\\/g, "/")}/${raw}`;
9758 const parts = [];
9759 for (const part of base.split("/")) {
9760 if (!part || part === ".") continue;
9761 if (part === "..") {
9762 if (parts.length > 0) parts.pop();
9763 continue;
9764 }
9765 parts.push(part);
9766 }
9767 // Preserve drive letter prefix on Windows (D:/...) instead of /D:/...
9768 if (parts.length > 0 && /^[A-Za-z]:$/.test(parts[0])) {
9769 return `${parts[0]}/${parts.slice(1).join("/")}`;
9770 }
9771 return `/${parts.join("/")}`;
9772 }
9773
9774 function dirname(path) {
9775 const normalized = normalizePath(path);
9776 if (normalized === "/") return "/";
9777 const idx = normalized.lastIndexOf("/");
9778 return idx <= 0 ? "/" : normalized.slice(0, idx);
9779 }
9780
9781 function ensureDir(path) {
9782 const normalized = normalizePath(path);
9783 if (normalized === "/") return "/";
9784 const parts = normalized.slice(1).split("/");
9785 let current = "";
9786 for (const part of parts) {
9787 current = `${current}/${part}`;
9788 state.dirs.add(current);
9789 }
9790 return normalized;
9791 }
9792
9793 function toBytes(data, opts) {
9794 const encoding =
9795 typeof opts === "string"
9796 ? opts
9797 : opts && typeof opts === "object" && typeof opts.encoding === "string"
9798 ? opts.encoding
9799 : undefined;
9800 const normalizedEncoding = encoding ? String(encoding).toLowerCase() : "utf8";
9801
9802 if (typeof data === "string") {
9803 if (normalizedEncoding === "base64") {
9804 return Buffer.from(data, "base64");
9805 }
9806 return new TextEncoder().encode(data);
9807 }
9808 if (data instanceof Uint8Array) {
9809 return new Uint8Array(data);
9810 }
9811 if (data instanceof ArrayBuffer) {
9812 return new Uint8Array(data);
9813 }
9814 if (ArrayBuffer.isView(data)) {
9815 return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
9816 }
9817 if (Array.isArray(data)) {
9818 return new Uint8Array(data);
9819 }
9820 return new TextEncoder().encode(String(data ?? ""));
9821 }
9822
9823 function decodeBytes(bytes, opts) {
9824 const encoding =
9825 typeof opts === "string"
9826 ? opts
9827 : opts && typeof opts === "object" && typeof opts.encoding === "string"
9828 ? opts.encoding
9829 : undefined;
9830 if (!encoding || String(encoding).toLowerCase() === "buffer") {
9831 return Buffer.from(bytes);
9832 }
9833 const normalized = String(encoding).toLowerCase();
9834 if (normalized === "base64") {
9835 let binChunks = [];
9836 let chunk = [];
9837 for (let i = 0; i < bytes.length; i++) {
9838 chunk.push(bytes[i]);
9839 if (chunk.length >= 4096) {
9840 binChunks.push(String.fromCharCode.apply(null, chunk));
9841 chunk.length = 0;
9842 }
9843 }
9844 if (chunk.length > 0) {
9845 binChunks.push(String.fromCharCode.apply(null, chunk));
9846 }
9847 return btoa(binChunks.join(''));
9848 }
9849 return new TextDecoder().decode(bytes);
9850 }
9851
9852 function resolveSymlinkPath(linkPath, target) {
9853 const raw = String(target ?? "");
9854 if (raw.startsWith("/")) {
9855 return normalizePath(raw);
9856 }
9857 return normalizePath(`${dirname(linkPath)}/${raw}`);
9858 }
9859
9860 function resolvePath(path, followSymlinks = true) {
9861 let normalized = normalizePath(path);
9862 if (!followSymlinks) {
9863 return normalized;
9864 }
9865
9866 const seen = new Set();
9867 while (state.symlinks.has(normalized)) {
9868 if (seen.has(normalized)) {
9869 throw new Error(`ELOOP: too many symbolic links encountered, stat '${String(path ?? "")}'`);
9870 }
9871 seen.add(normalized);
9872 normalized = resolveSymlinkPath(normalized, state.symlinks.get(normalized));
9873 }
9874 return normalized;
9875 }
9876
9877 function parseOpenFlags(rawFlags) {
9878 if (typeof rawFlags === "number" && Number.isFinite(rawFlags)) {
9879 const flags = rawFlags | 0;
9880 const accessMode = flags & 3;
9881 const readable = accessMode === constants.O_RDONLY || accessMode === constants.O_RDWR;
9882 const writable = accessMode === constants.O_WRONLY || accessMode === constants.O_RDWR;
9883 return {
9884 readable,
9885 writable,
9886 append: (flags & constants.O_APPEND) !== 0,
9887 create: (flags & constants.O_CREAT) !== 0,
9888 truncate: (flags & constants.O_TRUNC) !== 0,
9889 exclusive: (flags & constants.O_EXCL) !== 0,
9890 };
9891 }
9892
9893 const normalized = String(rawFlags ?? "r");
9894 switch (normalized) {
9895 case "r":
9896 case "rs":
9897 return { readable: true, writable: false, append: false, create: false, truncate: false, exclusive: false };
9898 case "r+":
9899 case "rs+":
9900 return { readable: true, writable: true, append: false, create: false, truncate: false, exclusive: false };
9901 case "w":
9902 return { readable: false, writable: true, append: false, create: true, truncate: true, exclusive: false };
9903 case "w+":
9904 return { readable: true, writable: true, append: false, create: true, truncate: true, exclusive: false };
9905 case "wx":
9906 return { readable: false, writable: true, append: false, create: true, truncate: true, exclusive: true };
9907 case "wx+":
9908 return { readable: true, writable: true, append: false, create: true, truncate: true, exclusive: true };
9909 case "a":
9910 case "as":
9911 return { readable: false, writable: true, append: true, create: true, truncate: false, exclusive: false };
9912 case "a+":
9913 case "as+":
9914 return { readable: true, writable: true, append: true, create: true, truncate: false, exclusive: false };
9915 case "ax":
9916 return { readable: false, writable: true, append: true, create: true, truncate: false, exclusive: true };
9917 case "ax+":
9918 return { readable: true, writable: true, append: true, create: true, truncate: false, exclusive: true };
9919 default:
9920 throw new Error(`EINVAL: invalid open flags '${normalized}'`);
9921 }
9922 }
9923
9924 function getFdEntry(fd) {
9925 const entry = state.fds.get(fd);
9926 if (!entry) {
9927 throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
9928 }
9929 return entry;
9930 }
9931
9932 function toWritableView(buffer) {
9933 if (buffer instanceof Uint8Array) {
9934 return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
9935 }
9936 if (buffer instanceof ArrayBuffer) {
9937 return new Uint8Array(buffer);
9938 }
9939 if (ArrayBuffer.isView(buffer)) {
9940 return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
9941 }
9942 throw new Error("TypeError: buffer must be an ArrayBuffer view");
9943 }
9944
9945 function makeDirent(name, entryKind) {
9946 return {
9947 name,
9948 isDirectory() { return entryKind === "dir"; },
9949 isFile() { return entryKind === "file"; },
9950 isSymbolicLink() { return entryKind === "symlink"; },
9951 };
9952 }
9953
9954 function listChildren(path, withFileTypes) {
9955 const normalized = normalizePath(path);
9956 const prefix = normalized === "/" ? "/" : `${normalized}/`;
9957 const children = new Map();
9958
9959 for (const dir of state.dirs) {
9960 if (!dir.startsWith(prefix) || dir === normalized) continue;
9961 const rest = dir.slice(prefix.length);
9962 if (!rest || rest.includes("/")) continue;
9963 children.set(rest, "dir");
9964 }
9965 for (const file of state.files.keys()) {
9966 if (!file.startsWith(prefix)) continue;
9967 const rest = file.slice(prefix.length);
9968 if (!rest || rest.includes("/")) continue;
9969 if (!children.has(rest)) children.set(rest, "file");
9970 }
9971 for (const link of state.symlinks.keys()) {
9972 if (!link.startsWith(prefix)) continue;
9973 const rest = link.slice(prefix.length);
9974 if (!rest || rest.includes("/")) continue;
9975 if (!children.has(rest)) children.set(rest, "symlink");
9976 }
9977
9978 const names = Array.from(children.keys()).sort();
9979 if (withFileTypes) {
9980 return names.map((name) => makeDirent(name, children.get(name)));
9981 }
9982 return names;
9983 }
9984
9985 function makeStat(path, followSymlinks = true) {
9986 const normalized = normalizePath(path);
9987 const linkTarget = state.symlinks.get(normalized);
9988 if (linkTarget !== undefined) {
9989 if (!followSymlinks) {
9990 const size = new TextEncoder().encode(String(linkTarget)).byteLength;
9991 return {
9992 isFile() { return false; },
9993 isDirectory() { return false; },
9994 isSymbolicLink() { return true; },
9995 isBlockDevice() { return false; },
9996 isCharacterDevice() { return false; },
9997 isFIFO() { return false; },
9998 isSocket() { return false; },
9999 size,
10000 mode: 0o777,
10001 uid: 0,
10002 gid: 0,
10003 atimeMs: 0,
10004 mtimeMs: 0,
10005 ctimeMs: 0,
10006 birthtimeMs: 0,
10007 atime: new Date(0),
10008 mtime: new Date(0),
10009 ctime: new Date(0),
10010 birthtime: new Date(0),
10011 dev: 0,
10012 ino: 0,
10013 nlink: 1,
10014 rdev: 0,
10015 blksize: 4096,
10016 blocks: 0,
10017 };
10018 }
10019 return makeStat(resolvePath(normalized, true), true);
10020 }
10021
10022 const isDir = state.dirs.has(normalized);
10023 let bytes = state.files.get(normalized);
10024 if (!isDir && bytes === undefined && typeof globalThis.__pi_host_read_file_sync === "function") {
10025 try {
10026 const content = globalThis.__pi_host_read_file_sync(normalized);
10027 // Host read payload is base64-encoded to preserve binary file fidelity.
10028 bytes = toBytes(content, "base64");
10029 ensureDir(dirname(normalized));
10030 state.files.set(normalized, bytes);
10031 } catch (e) {
10032 const message = String((e && e.message) ? e.message : e);
10033 if (message.includes("host read denied")) {
10034 throw e;
10035 }
10036 /* not on host FS */
10037 }
10038 }
10039 const isFile = bytes !== undefined;
10040 if (!isDir && !isFile) {
10041 throw new Error(`ENOENT: no such file or directory, stat '${String(path ?? "")}'`);
10042 }
10043 const size = isFile ? bytes.byteLength : 0;
10044 return {
10045 isFile() { return isFile; },
10046 isDirectory() { return isDir; },
10047 isSymbolicLink() { return false; },
10048 isBlockDevice() { return false; },
10049 isCharacterDevice() { return false; },
10050 isFIFO() { return false; },
10051 isSocket() { return false; },
10052 size,
10053 mode: isDir ? 0o755 : 0o644,
10054 uid: 0,
10055 gid: 0,
10056 atimeMs: 0,
10057 mtimeMs: 0,
10058 ctimeMs: 0,
10059 birthtimeMs: 0,
10060 atime: new Date(0),
10061 mtime: new Date(0),
10062 ctime: new Date(0),
10063 birthtime: new Date(0),
10064 dev: 0,
10065 ino: 0,
10066 nlink: 1,
10067 rdev: 0,
10068 blksize: 4096,
10069 blocks: 0,
10070 };
10071 }
10072
10073 state.normalizePath = normalizePath;
10074 state.dirname = dirname;
10075 state.ensureDir = ensureDir;
10076 state.toBytes = toBytes;
10077 state.decodeBytes = decodeBytes;
10078 state.listChildren = listChildren;
10079 state.makeStat = makeStat;
10080 state.resolvePath = resolvePath;
10081 state.checkWriteAccess = checkWriteAccess;
10082 state.parseOpenFlags = parseOpenFlags;
10083 state.getFdEntry = getFdEntry;
10084 state.toWritableView = toWritableView;
10085 globalThis.__pi_vfs_state = state;
10086 return state;
10087})();
10088
10089export function existsSync(path) {
10090 try {
10091 statSync(path);
10092 return true;
10093 } catch (_err) {
10094 return false;
10095 }
10096}
10097
10098export function readFileSync(path, encoding) {
10099 const resolved = __pi_vfs.resolvePath(path, true);
10100 let bytes = __pi_vfs.files.get(resolved);
10101 let hostError;
10102 if (!bytes && typeof globalThis.__pi_host_read_file_sync === "function") {
10103 try {
10104 const content = globalThis.__pi_host_read_file_sync(resolved);
10105 // Host read payload is base64-encoded to preserve binary file fidelity.
10106 bytes = __pi_vfs.toBytes(content, "base64");
10107 __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
10108 __pi_vfs.files.set(resolved, bytes);
10109 } catch (e) {
10110 const message = String((e && e.message) ? e.message : e);
10111 if (message.includes("host read denied")) {
10112 throw e;
10113 }
10114 hostError = message;
10115 /* fall through to ENOENT */
10116 }
10117 }
10118 if (!bytes) {
10119 const detail = hostError ? ` (host: ${hostError})` : "";
10120 throw new Error(`ENOENT: no such file or directory, open '${String(path ?? "")}'${detail}`);
10121 }
10122 return __pi_vfs.decodeBytes(bytes, encoding);
10123}
10124
10125export function appendFileSync(path, data, opts) {
10126 const resolved = __pi_vfs.resolvePath(path, true);
10127 __pi_vfs.checkWriteAccess(resolved);
10128 const current = __pi_vfs.files.get(resolved) || new Uint8Array();
10129 const next = __pi_vfs.toBytes(data, opts);
10130 const merged = new Uint8Array(current.byteLength + next.byteLength);
10131 merged.set(current, 0);
10132 merged.set(next, current.byteLength);
10133 __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
10134 __pi_vfs.files.set(resolved, merged);
10135}
10136
10137export function writeFileSync(path, data, opts) {
10138 const resolved = __pi_vfs.resolvePath(path, true);
10139 __pi_vfs.checkWriteAccess(resolved);
10140 __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
10141 __pi_vfs.files.set(resolved, __pi_vfs.toBytes(data, opts));
10142}
10143
10144export function readdirSync(path, opts) {
10145 const resolved = __pi_vfs.resolvePath(path, true);
10146 if (!__pi_vfs.dirs.has(resolved)) {
10147 throw new Error(`ENOENT: no such file or directory, scandir '${String(path ?? "")}'`);
10148 }
10149 const withFileTypes = !!(opts && typeof opts === "object" && opts.withFileTypes);
10150 return __pi_vfs.listChildren(resolved, withFileTypes);
10151}
10152
10153const __fakeStat = {
10154 isFile() { return false; },
10155 isDirectory() { return false; },
10156 isSymbolicLink() { return false; },
10157 isBlockDevice() { return false; },
10158 isCharacterDevice() { return false; },
10159 isFIFO() { return false; },
10160 isSocket() { return false; },
10161 size: 0, mode: 0o644, uid: 0, gid: 0,
10162 atimeMs: 0, mtimeMs: 0, ctimeMs: 0, birthtimeMs: 0,
10163 atime: new Date(0), mtime: new Date(0), ctime: new Date(0), birthtime: new Date(0),
10164 dev: 0, ino: 0, nlink: 1, rdev: 0, blksize: 4096, blocks: 0,
10165};
10166export function statSync(path) { return __pi_vfs.makeStat(path, true); }
10167export function lstatSync(path) { return __pi_vfs.makeStat(path, false); }
10168export function mkdtempSync(prefix, _opts) {
10169 const p = String(prefix ?? "/tmp/tmp-");
10170 const out = `${p}${Date.now().toString(36)}`;
10171 __pi_vfs.checkWriteAccess(__pi_vfs.normalizePath(out));
10172 __pi_vfs.ensureDir(out);
10173 return out;
10174}
10175export function realpathSync(path, _opts) {
10176 return __pi_vfs.resolvePath(path, true);
10177}
10178export function unlinkSync(path) {
10179 const normalized = __pi_vfs.normalizePath(path);
10180 __pi_vfs.checkWriteAccess(normalized);
10181 if (__pi_vfs.symlinks.delete(normalized)) {
10182 return;
10183 }
10184 if (!__pi_vfs.files.delete(normalized)) {
10185 throw new Error(`ENOENT: no such file or directory, unlink '${String(path ?? "")}'`);
10186 }
10187}
10188export function rmdirSync(path, _opts) {
10189 const normalized = __pi_vfs.normalizePath(path);
10190 __pi_vfs.checkWriteAccess(normalized);
10191 if (normalized === "/") {
10192 throw new Error("EBUSY: resource busy or locked, rmdir '/'");
10193 }
10194 if (__pi_vfs.symlinks.has(normalized)) {
10195 throw new Error(`ENOTDIR: not a directory, rmdir '${String(path ?? "")}'`);
10196 }
10197 for (const filePath of __pi_vfs.files.keys()) {
10198 if (filePath.startsWith(`${normalized}/`)) {
10199 throw new Error(`ENOTEMPTY: directory not empty, rmdir '${String(path ?? "")}'`);
10200 }
10201 }
10202 for (const dirPath of __pi_vfs.dirs) {
10203 if (dirPath.startsWith(`${normalized}/`)) {
10204 throw new Error(`ENOTEMPTY: directory not empty, rmdir '${String(path ?? "")}'`);
10205 }
10206 }
10207 for (const linkPath of __pi_vfs.symlinks.keys()) {
10208 if (linkPath.startsWith(`${normalized}/`)) {
10209 throw new Error(`ENOTEMPTY: directory not empty, rmdir '${String(path ?? "")}'`);
10210 }
10211 }
10212 if (!__pi_vfs.dirs.delete(normalized)) {
10213 throw new Error(`ENOENT: no such file or directory, rmdir '${String(path ?? "")}'`);
10214 }
10215}
10216export function rmSync(path, opts) {
10217 const normalized = __pi_vfs.normalizePath(path);
10218 __pi_vfs.checkWriteAccess(normalized);
10219 if (__pi_vfs.files.has(normalized)) {
10220 __pi_vfs.files.delete(normalized);
10221 return;
10222 }
10223 if (__pi_vfs.symlinks.has(normalized)) {
10224 __pi_vfs.symlinks.delete(normalized);
10225 return;
10226 }
10227 if (__pi_vfs.dirs.has(normalized)) {
10228 const recursive = !!(opts && typeof opts === "object" && opts.recursive);
10229 if (!recursive) {
10230 rmdirSync(normalized);
10231 return;
10232 }
10233 for (const filePath of Array.from(__pi_vfs.files.keys())) {
10234 if (filePath === normalized || filePath.startsWith(`${normalized}/`)) {
10235 __pi_vfs.files.delete(filePath);
10236 }
10237 }
10238 for (const dirPath of Array.from(__pi_vfs.dirs)) {
10239 if (dirPath === normalized || dirPath.startsWith(`${normalized}/`)) {
10240 __pi_vfs.dirs.delete(dirPath);
10241 }
10242 }
10243 for (const linkPath of Array.from(__pi_vfs.symlinks.keys())) {
10244 if (linkPath === normalized || linkPath.startsWith(`${normalized}/`)) {
10245 __pi_vfs.symlinks.delete(linkPath);
10246 }
10247 }
10248 if (!__pi_vfs.dirs.has("/")) {
10249 __pi_vfs.dirs.add("/");
10250 }
10251 return;
10252 }
10253 throw new Error(`ENOENT: no such file or directory, rm '${String(path ?? "")}'`);
10254}
10255export function copyFileSync(src, dest, _mode) {
10256 writeFileSync(dest, readFileSync(src));
10257}
10258export function renameSync(oldPath, newPath) {
10259 const src = __pi_vfs.normalizePath(oldPath);
10260 const dst = __pi_vfs.normalizePath(newPath);
10261 __pi_vfs.checkWriteAccess(src);
10262 __pi_vfs.checkWriteAccess(dst);
10263 const linkTarget = __pi_vfs.symlinks.get(src);
10264 if (linkTarget !== undefined) {
10265 __pi_vfs.ensureDir(__pi_vfs.dirname(dst));
10266 __pi_vfs.symlinks.set(dst, linkTarget);
10267 __pi_vfs.symlinks.delete(src);
10268 return;
10269 }
10270 const bytes = __pi_vfs.files.get(src);
10271 if (bytes !== undefined) {
10272 __pi_vfs.ensureDir(__pi_vfs.dirname(dst));
10273 __pi_vfs.files.set(dst, bytes);
10274 __pi_vfs.files.delete(src);
10275 return;
10276 }
10277 throw new Error(`ENOENT: no such file or directory, rename '${String(oldPath ?? "")}'`);
10278}
10279export function mkdirSync(path, _opts) {
10280 const resolved = __pi_vfs.resolvePath(path, true);
10281 __pi_vfs.checkWriteAccess(resolved);
10282 __pi_vfs.ensureDir(path);
10283 return __pi_vfs.normalizePath(path);
10284}
10285export function accessSync(path, _mode) {
10286 if (!existsSync(path)) {
10287 throw new Error("ENOENT: no such file or directory");
10288 }
10289}
10290export function chmodSync(_path, _mode) { return; }
10291export function chownSync(_path, _uid, _gid) { return; }
10292export function readlinkSync(path, opts) {
10293 const normalized = __pi_vfs.normalizePath(path);
10294 if (!__pi_vfs.symlinks.has(normalized)) {
10295 if (__pi_vfs.files.has(normalized) || __pi_vfs.dirs.has(normalized)) {
10296 throw new Error(`EINVAL: invalid argument, readlink '${String(path ?? "")}'`);
10297 }
10298 throw new Error(`ENOENT: no such file or directory, readlink '${String(path ?? "")}'`);
10299 }
10300 const target = String(__pi_vfs.symlinks.get(normalized));
10301 const encoding =
10302 typeof opts === "string"
10303 ? opts
10304 : opts && typeof opts === "object" && typeof opts.encoding === "string"
10305 ? opts.encoding
10306 : undefined;
10307 if (encoding && String(encoding).toLowerCase() === "buffer") {
10308 return Buffer.from(target, "utf8");
10309 }
10310 return target;
10311}
10312export function symlinkSync(target, path, _type) {
10313 const normalized = __pi_vfs.normalizePath(path);
10314 __pi_vfs.checkWriteAccess(normalized);
10315 const parent = __pi_vfs.dirname(normalized);
10316 if (!__pi_vfs.dirs.has(parent)) {
10317 throw new Error(`ENOENT: no such file or directory, symlink '${String(path ?? "")}'`);
10318 }
10319 if (__pi_vfs.files.has(normalized) || __pi_vfs.dirs.has(normalized) || __pi_vfs.symlinks.has(normalized)) {
10320 throw new Error(`EEXIST: file already exists, symlink '${String(path ?? "")}'`);
10321 }
10322 __pi_vfs.symlinks.set(normalized, String(target ?? ""));
10323}
10324export function openSync(path, flags = "r", _mode) {
10325 const resolved = __pi_vfs.resolvePath(path, true);
10326 const opts = __pi_vfs.parseOpenFlags(flags);
10327
10328 if (opts.writable || opts.create || opts.append || opts.truncate) {
10329 __pi_vfs.checkWriteAccess(resolved);
10330 }
10331
10332 if (__pi_vfs.dirs.has(resolved)) {
10333 throw new Error(`EISDIR: illegal operation on a directory, open '${String(path ?? "")}'`);
10334 }
10335
10336 const exists = __pi_vfs.files.has(resolved);
10337 if (!exists && !opts.create) {
10338 throw new Error(`ENOENT: no such file or directory, open '${String(path ?? "")}'`);
10339 }
10340 if (exists && opts.create && opts.exclusive) {
10341 throw new Error(`EEXIST: file already exists, open '${String(path ?? "")}'`);
10342 }
10343 if (!exists && opts.create) {
10344 __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
10345 __pi_vfs.files.set(resolved, new Uint8Array());
10346 }
10347 if (opts.truncate && opts.writable) {
10348 __pi_vfs.files.set(resolved, new Uint8Array());
10349 }
10350
10351 const fd = __pi_vfs.nextFd++;
10352 const current = __pi_vfs.files.get(resolved) || new Uint8Array();
10353 __pi_vfs.fds.set(fd, {
10354 path: resolved,
10355 readable: opts.readable,
10356 writable: opts.writable,
10357 append: opts.append,
10358 position: opts.append ? current.byteLength : 0,
10359 });
10360 return fd;
10361}
10362export function closeSync(fd) {
10363 if (!__pi_vfs.fds.delete(fd)) {
10364 throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
10365 }
10366}
10367export function readSync(fd, buffer, offset = 0, length, position = null) {
10368 const entry = __pi_vfs.getFdEntry(fd);
10369 if (!entry.readable) {
10370 throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
10371 }
10372 const out = __pi_vfs.toWritableView(buffer);
10373 const start = Number.isInteger(offset) && offset >= 0 ? offset : 0;
10374 const maxLen =
10375 Number.isInteger(length) && length >= 0
10376 ? length
10377 : Math.max(0, out.byteLength - start);
10378 let cursor =
10379 typeof position === "number" && Number.isFinite(position) && position >= 0
10380 ? Math.floor(position)
10381 : entry.position;
10382 const source = __pi_vfs.files.get(entry.path) || new Uint8Array();
10383 if (cursor >= source.byteLength || maxLen <= 0 || start >= out.byteLength) {
10384 return 0;
10385 }
10386 const readLen = Math.min(maxLen, out.byteLength - start, source.byteLength - cursor);
10387 out.set(source.subarray(cursor, cursor + readLen), start);
10388 if (position === null || position === undefined) {
10389 entry.position = cursor + readLen;
10390 }
10391 return readLen;
10392}
10393export function writeSync(fd, buffer, offset, length, position) {
10394 const entry = __pi_vfs.getFdEntry(fd);
10395 if (!entry.writable) {
10396 throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
10397 }
10398
10399 let chunk;
10400 let explicitPosition = false;
10401 let cursor = null;
10402
10403 if (typeof buffer === "string") {
10404 const encoding =
10405 typeof length === "string"
10406 ? length
10407 : typeof offset === "string"
10408 ? offset
10409 : undefined;
10410 chunk = __pi_vfs.toBytes(buffer, encoding);
10411 if (
10412 arguments.length >= 3 &&
10413 typeof offset === "number" &&
10414 Number.isFinite(offset) &&
10415 offset >= 0
10416 ) {
10417 explicitPosition = true;
10418 cursor = Math.floor(offset);
10419 }
10420 } else {
10421 const input = __pi_vfs.toWritableView(buffer);
10422 const start = Number.isInteger(offset) && offset >= 0 ? offset : 0;
10423 const maxLen =
10424 Number.isInteger(length) && length >= 0
10425 ? length
10426 : Math.max(0, input.byteLength - start);
10427 chunk = input.subarray(start, Math.min(input.byteLength, start + maxLen));
10428 if (typeof position === "number" && Number.isFinite(position) && position >= 0) {
10429 explicitPosition = true;
10430 cursor = Math.floor(position);
10431 }
10432 }
10433
10434 if (!explicitPosition) {
10435 cursor = entry.append
10436 ? (__pi_vfs.files.get(entry.path)?.byteLength || 0)
10437 : entry.position;
10438 }
10439
10440 const current = __pi_vfs.files.get(entry.path) || new Uint8Array();
10441 const required = cursor + chunk.byteLength;
10442 const next = new Uint8Array(Math.max(current.byteLength, required));
10443 next.set(current, 0);
10444 next.set(chunk, cursor);
10445 __pi_vfs.files.set(entry.path, next);
10446
10447 if (!explicitPosition) {
10448 entry.position = cursor + chunk.byteLength;
10449 }
10450 return chunk.byteLength;
10451}
10452export function fstatSync(fd) {
10453 const entry = __pi_vfs.getFdEntry(fd);
10454 return __pi_vfs.makeStat(entry.path, true);
10455}
10456export function ftruncateSync(fd, len = 0) {
10457 const entry = __pi_vfs.getFdEntry(fd);
10458 if (!entry.writable) {
10459 throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
10460 }
10461 const targetLen =
10462 Number.isInteger(len) && len >= 0 ? len : 0;
10463 const current = __pi_vfs.files.get(entry.path) || new Uint8Array();
10464 const next = new Uint8Array(targetLen);
10465 next.set(current.subarray(0, Math.min(current.byteLength, targetLen)));
10466 __pi_vfs.files.set(entry.path, next);
10467 if (entry.position > targetLen) {
10468 entry.position = targetLen;
10469 }
10470}
10471export function futimesSync(_fd, _atime, _mtime) { return; }
10472function __fakeWatcher() {
10473 const w = { close() {}, unref() { return w; }, ref() { return w; }, on() { return w; }, once() { return w; }, removeListener() { return w; }, removeAllListeners() { return w; } };
10474 return w;
10475}
10476export function watch(_path, _optsOrListener, _listener) { return __fakeWatcher(); }
10477export function watchFile(_path, _optsOrListener, _listener) { return __fakeWatcher(); }
10478export function unwatchFile(_path, _listener) { return; }
10479function __queueMicrotaskPolyfill(fn) {
10480 if (typeof queueMicrotask === "function") {
10481 queueMicrotask(fn);
10482 return;
10483 }
10484 Promise.resolve().then(fn);
10485}
10486export function createReadStream(path, opts) {
10487 const options = opts && typeof opts === "object" ? opts : {};
10488 const encoding = typeof options.encoding === "string" ? options.encoding : null;
10489 const highWaterMark =
10490 Number.isInteger(options.highWaterMark) && options.highWaterMark > 0
10491 ? options.highWaterMark
10492 : 64 * 1024;
10493
10494 const stream = new Readable({ encoding: encoding || undefined, autoDestroy: false });
10495 stream.path = __pi_vfs.normalizePath(path);
10496
10497 __queueMicrotaskPolyfill(() => {
10498 try {
10499 const bytes = readFileSync(path, "buffer");
10500 const source =
10501 bytes instanceof Uint8Array
10502 ? bytes
10503 : (typeof Buffer !== "undefined" && Buffer.from
10504 ? Buffer.from(bytes)
10505 : __pi_vfs.toBytes(bytes));
10506
10507 if (source.byteLength === 0) {
10508 stream.push(null);
10509 return;
10510 }
10511
10512 let offset = 0;
10513 while (offset < source.byteLength) {
10514 const nextOffset = Math.min(source.byteLength, offset + highWaterMark);
10515 const slice = source.subarray(offset, nextOffset);
10516 if (encoding && typeof Buffer !== "undefined" && Buffer.from) {
10517 stream.push(Buffer.from(slice).toString(encoding));
10518 } else {
10519 stream.push(slice);
10520 }
10521 offset = nextOffset;
10522 }
10523 stream.push(null);
10524 } catch (err) {
10525 stream.emit("error", err instanceof Error ? err : new Error(String(err)));
10526 }
10527 });
10528
10529 return stream;
10530}
10531export function createWriteStream(path, opts) {
10532 const options = opts && typeof opts === "object" ? opts : {};
10533 const encoding = typeof options.encoding === "string" ? options.encoding : "utf8";
10534 const flags = typeof options.flags === "string" ? options.flags : "w";
10535 const appendMode = flags.startsWith("a");
10536 const bufferedChunks = [];
10537
10538 const stream = new Writable({
10539 autoDestroy: false,
10540 write(chunk, chunkEncoding, callback) {
10541 try {
10542 const normalizedEncoding =
10543 typeof chunkEncoding === "string" && chunkEncoding
10544 ? chunkEncoding
10545 : encoding;
10546 const bytes = __pi_vfs.toBytes(chunk, normalizedEncoding);
10547 bufferedChunks.push(bytes);
10548 this.bytesWritten += bytes.byteLength;
10549 callback(null);
10550 } catch (err) {
10551 callback(err instanceof Error ? err : new Error(String(err)));
10552 }
10553 },
10554 final(callback) {
10555 try {
10556 if (appendMode) {
10557 const resolved = __pi_vfs.resolvePath(path, true);
10558 __pi_vfs.checkWriteAccess(resolved);
10559 const current = __pi_vfs.files.get(resolved) || new Uint8Array();
10560 const totalSize = current.byteLength + bufferedChunks.reduce((sum, bytes) => sum + bytes.byteLength, 0);
10561 const merged = new Uint8Array(totalSize);
10562 merged.set(current, 0);
10563 let offset = current.byteLength;
10564 for (const bytes of bufferedChunks) {
10565 merged.set(bytes, offset);
10566 offset += bytes.byteLength;
10567 }
10568 __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
10569 __pi_vfs.files.set(resolved, merged);
10570 } else {
10571 const totalSize = bufferedChunks.reduce((sum, bytes) => sum + bytes.byteLength, 0);
10572 const merged = new Uint8Array(totalSize);
10573 let offset = 0;
10574 for (const bytes of bufferedChunks) {
10575 merged.set(bytes, offset);
10576 offset += bytes.byteLength;
10577 }
10578 writeFileSync(path, merged);
10579 }
10580 callback(null);
10581 } catch (err) {
10582 callback(err instanceof Error ? err : new Error(String(err)));
10583 }
10584 },
10585 });
10586 stream.path = __pi_vfs.normalizePath(path);
10587 stream.bytesWritten = 0;
10588 stream.cork = () => stream;
10589 stream.uncork = () => stream;
10590 return stream;
10591}
10592export function readFile(path, optOrCb, cb) {
10593 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10594 const encoding = typeof optOrCb === 'function' ? undefined : optOrCb;
10595 if (typeof callback === 'function') {
10596 try { callback(null, readFileSync(path, encoding)); }
10597 catch (err) { callback(err); }
10598 }
10599}
10600export function writeFile(path, data, optOrCb, cb) {
10601 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10602 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10603 if (typeof callback === 'function') {
10604 try { writeFileSync(path, data, opts); callback(null); }
10605 catch (err) { callback(err); }
10606 }
10607}
10608export function stat(path, optOrCb, cb) {
10609 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10610 if (typeof callback === 'function') {
10611 try { callback(null, statSync(path)); }
10612 catch (err) { callback(err); }
10613 }
10614}
10615export function readdir(path, optOrCb, cb) {
10616 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10617 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10618 if (typeof callback === 'function') {
10619 try { callback(null, readdirSync(path, opts)); }
10620 catch (err) { callback(err); }
10621 }
10622}
10623export function mkdir(path, optOrCb, cb) {
10624 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10625 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10626 if (typeof callback === 'function') {
10627 try { callback(null, mkdirSync(path, opts)); }
10628 catch (err) { callback(err); }
10629 }
10630}
10631export function unlink(path, cb) {
10632 if (typeof cb === 'function') {
10633 try { unlinkSync(path); cb(null); }
10634 catch (err) { cb(err); }
10635 }
10636}
10637export function readlink(path, optOrCb, cb) {
10638 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10639 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10640 if (typeof callback === 'function') {
10641 try { callback(null, readlinkSync(path, opts)); }
10642 catch (err) { callback(err); }
10643 }
10644}
10645export function symlink(target, path, typeOrCb, cb) {
10646 const callback = typeof typeOrCb === 'function' ? typeOrCb : cb;
10647 const type = typeof typeOrCb === 'function' ? undefined : typeOrCb;
10648 if (typeof callback === 'function') {
10649 try { symlinkSync(target, path, type); callback(null); }
10650 catch (err) { callback(err); }
10651 }
10652}
10653export function lstat(path, optOrCb, cb) {
10654 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10655 if (typeof callback === 'function') {
10656 try { callback(null, lstatSync(path)); }
10657 catch (err) { callback(err); }
10658 }
10659}
10660export function rmdir(path, optOrCb, cb) {
10661 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10662 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10663 if (typeof callback === 'function') {
10664 try { rmdirSync(path, opts); callback(null); }
10665 catch (err) { callback(err); }
10666 }
10667}
10668export function rm(path, optOrCb, cb) {
10669 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10670 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10671 if (typeof callback === 'function') {
10672 try { rmSync(path, opts); callback(null); }
10673 catch (err) { callback(err); }
10674 }
10675}
10676export function rename(oldPath, newPath, cb) {
10677 if (typeof cb === 'function') {
10678 try { renameSync(oldPath, newPath); cb(null); }
10679 catch (err) { cb(err); }
10680 }
10681}
10682export function copyFile(src, dest, flagsOrCb, cb) {
10683 const callback = typeof flagsOrCb === 'function' ? flagsOrCb : cb;
10684 if (typeof callback === 'function') {
10685 try { copyFileSync(src, dest); callback(null); }
10686 catch (err) { callback(err); }
10687 }
10688}
10689export function appendFile(path, data, optOrCb, cb) {
10690 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10691 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10692 if (typeof callback === 'function') {
10693 try { appendFileSync(path, data, opts); callback(null); }
10694 catch (err) { callback(err); }
10695 }
10696}
10697export function chmod(path, mode, cb) {
10698 if (typeof cb === 'function') {
10699 try { chmodSync(path, mode); cb(null); }
10700 catch (err) { cb(err); }
10701 }
10702}
10703export function chown(path, uid, gid, cb) {
10704 if (typeof cb === 'function') {
10705 try { chownSync(path, uid, gid); cb(null); }
10706 catch (err) { cb(err); }
10707 }
10708}
10709export function realpath(path, optOrCb, cb) {
10710 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10711 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10712 if (typeof callback === 'function') {
10713 try { callback(null, realpathSync(path, opts)); }
10714 catch (err) { callback(err); }
10715 }
10716}
10717export function access(_path, modeOrCb, cb) {
10718 const callback = typeof modeOrCb === 'function' ? modeOrCb : cb;
10719 if (typeof callback === 'function') {
10720 try {
10721 accessSync(_path);
10722 callback(null);
10723 } catch (err) {
10724 callback(err);
10725 }
10726 }
10727}
10728export const promises = {
10729 access: async (path, _mode) => accessSync(path),
10730 mkdir: async (path, opts) => mkdirSync(path, opts),
10731 mkdtemp: async (prefix, _opts) => {
10732 return mkdtempSync(prefix, _opts);
10733 },
10734 readFile: async (path, opts) => readFileSync(path, opts),
10735 writeFile: async (path, data, opts) => writeFileSync(path, data, opts),
10736 unlink: async (path) => unlinkSync(path),
10737 readlink: async (path, opts) => readlinkSync(path, opts),
10738 symlink: async (target, path, type) => symlinkSync(target, path, type),
10739 rmdir: async (path, opts) => rmdirSync(path, opts),
10740 stat: async (path) => statSync(path),
10741 lstat: async (path) => lstatSync(path),
10742 realpath: async (path, _opts) => realpathSync(path, _opts),
10743 readdir: async (path, opts) => readdirSync(path, opts),
10744 rm: async (path, opts) => rmSync(path, opts),
10745 rename: async (oldPath, newPath) => renameSync(oldPath, newPath),
10746 copyFile: async (src, dest, mode) => copyFileSync(src, dest, mode),
10747 cp: async (src, dest, opts) => {
10748 if (opts && opts.recursive) {
10749 throw new Error("node:fs.promises.cp recursive copy is not supported in PiJS");
10750 }
10751 return copyFileSync(src, dest);
10752 },
10753 appendFile: async (path, data, opts) => appendFileSync(path, data, opts),
10754 chmod: async (_path, _mode) => {},
10755};
10756export default { constants, existsSync, readFileSync, appendFileSync, writeFileSync, readdirSync, statSync, lstatSync, mkdtempSync, realpathSync, unlinkSync, rmdirSync, rmSync, copyFileSync, renameSync, mkdirSync, accessSync, chmodSync, chownSync, readlinkSync, symlinkSync, openSync, closeSync, readSync, writeSync, fstatSync, ftruncateSync, futimesSync, watch, watchFile, unwatchFile, createReadStream, createWriteStream, readFile, writeFile, stat, lstat, readdir, mkdir, unlink, readlink, symlink, rmdir, rm, rename, copyFile, appendFile, chmod, chown, realpath, access, promises };
10757"#
10758 .trim()
10759 .to_string(),
10760 );
10761
10762 modules.insert(
10763 "node:fs/promises".to_string(),
10764 r"
10765import fs from 'node:fs';
10766
10767export async function access(path, mode) { return fs.promises.access(path, mode); }
10768export async function mkdir(path, opts) { return fs.promises.mkdir(path, opts); }
10769export async function mkdtemp(prefix, opts) { return fs.promises.mkdtemp(prefix, opts); }
10770export async function readFile(path, opts) { return fs.promises.readFile(path, opts); }
10771export async function writeFile(path, data, opts) { return fs.promises.writeFile(path, data, opts); }
10772export async function unlink(path) { return fs.promises.unlink(path); }
10773export async function readlink(path, opts) { return fs.promises.readlink(path, opts); }
10774export async function symlink(target, path, type) { return fs.promises.symlink(target, path, type); }
10775export async function rmdir(path, opts) { return fs.promises.rmdir(path, opts); }
10776export async function stat(path) { return fs.promises.stat(path); }
10777export async function realpath(path, opts) { return fs.promises.realpath(path, opts); }
10778export async function readdir(path, opts) { return fs.promises.readdir(path, opts); }
10779export async function rm(path, opts) { return fs.promises.rm(path, opts); }
10780export async function lstat(path) { return fs.promises.lstat(path); }
10781export async function copyFile(src, dest) { return fs.promises.copyFile(src, dest); }
10782export async function cp(src, dest, opts = {}) {
10783 if (typeof fs.promises.cp === 'function') {
10784 return fs.promises.cp(src, dest, opts);
10785 }
10786 if (opts && opts.recursive) {
10787 throw new Error('node:fs/promises.cp recursive copy is not supported in PiJS');
10788 }
10789 return fs.promises.copyFile(src, dest);
10790}
10791export async function rename(oldPath, newPath) { return fs.promises.rename(oldPath, newPath); }
10792export async function chmod(path, mode) { return; }
10793export async function chown(path, uid, gid) { return; }
10794export async function utimes(path, atime, mtime) { return; }
10795export async function appendFile(path, data, opts) { return fs.promises.appendFile(path, data, opts); }
10796export async function open(path, flags, mode) { return { close: async () => {} }; }
10797export async function truncate(path, len) { return; }
10798export default { access, mkdir, mkdtemp, readFile, writeFile, unlink, readlink, symlink, rmdir, stat, lstat, realpath, readdir, rm, copyFile, cp, rename, chmod, chown, utimes, appendFile, open, truncate };
10799"
10800 .trim()
10801 .to_string(),
10802 );
10803
10804 modules.insert(
10805 "node:http".to_string(),
10806 crate::http_shim::NODE_HTTP_JS.trim().to_string(),
10807 );
10808
10809 modules.insert(
10810 "node:https".to_string(),
10811 crate::http_shim::NODE_HTTPS_JS.trim().to_string(),
10812 );
10813
10814 modules.insert(
10815 "node:http2".to_string(),
10816 r#"
10817import EventEmitter from "node:events";
10818
10819export const constants = {
10820 HTTP2_HEADER_STATUS: ":status",
10821 HTTP2_HEADER_METHOD: ":method",
10822 HTTP2_HEADER_PATH: ":path",
10823 HTTP2_HEADER_AUTHORITY: ":authority",
10824 HTTP2_HEADER_SCHEME: ":scheme",
10825 HTTP2_HEADER_PROTOCOL: ":protocol",
10826 HTTP2_HEADER_CONTENT_TYPE: "content-type",
10827 NGHTTP2_CANCEL: 8,
10828};
10829
10830function __makeStream() {
10831 const stream = new EventEmitter();
10832 stream.end = (_data, _encoding, cb) => {
10833 if (typeof cb === "function") cb();
10834 stream.emit("finish");
10835 };
10836 stream.close = () => stream.emit("close");
10837 stream.destroy = (err) => {
10838 if (err) stream.emit("error", err);
10839 stream.emit("close");
10840 };
10841 stream.respond = () => {};
10842 stream.setEncoding = () => stream;
10843 stream.setTimeout = (_ms, cb) => {
10844 if (typeof cb === "function") cb();
10845 return stream;
10846 };
10847 return stream;
10848}
10849
10850function __makeSession() {
10851 const session = new EventEmitter();
10852 session.closed = false;
10853 session.connecting = false;
10854 session.request = (_headers, _opts) => __makeStream();
10855 session.close = () => {
10856 session.closed = true;
10857 session.emit("close");
10858 };
10859 session.destroy = (err) => {
10860 session.closed = true;
10861 if (err) session.emit("error", err);
10862 session.emit("close");
10863 };
10864 session.ref = () => session;
10865 session.unref = () => session;
10866 return session;
10867}
10868
10869export function connect(_authority, _options, listener) {
10870 const session = __makeSession();
10871 if (typeof listener === "function") {
10872 try {
10873 listener(session);
10874 } catch (_err) {}
10875 }
10876 return session;
10877}
10878
10879export class ClientHttp2Session extends EventEmitter {}
10880export class ClientHttp2Stream extends EventEmitter {}
10881
10882export default { connect, constants, ClientHttp2Session, ClientHttp2Stream };
10883"#
10884 .trim()
10885 .to_string(),
10886 );
10887
10888 modules.insert(
10889 "node:util".to_string(),
10890 r#"
10891export function inspect(value, opts) {
10892 const depth = (opts && typeof opts.depth === 'number') ? opts.depth : 2;
10893 const seen = new Set();
10894 function fmt(v, d) {
10895 if (v === null) return 'null';
10896 if (v === undefined) return 'undefined';
10897 const t = typeof v;
10898 if (t === 'string') return d > 0 ? "'" + v + "'" : v;
10899 if (t === 'number' || t === 'boolean' || t === 'bigint') return String(v);
10900 if (t === 'symbol') return v.toString();
10901 if (t === 'function') return '[Function: ' + (v.name || 'anonymous') + ']';
10902 if (v instanceof Date) return v.toISOString();
10903 if (v instanceof RegExp) return v.toString();
10904 if (v instanceof Error) return v.stack || v.message || String(v);
10905 if (seen.has(v)) return '[Circular]';
10906 seen.add(v);
10907 if (d > depth) { seen.delete(v); return Array.isArray(v) ? '[Array]' : '[Object]'; }
10908 if (Array.isArray(v)) {
10909 const items = v.map(x => fmt(x, d + 1));
10910 seen.delete(v);
10911 return '[ ' + items.join(', ') + ' ]';
10912 }
10913 const keys = Object.keys(v);
10914 if (keys.length === 0) { seen.delete(v); return '{}'; }
10915 const pairs = keys.map(k => k + ': ' + fmt(v[k], d + 1));
10916 seen.delete(v);
10917 return '{ ' + pairs.join(', ') + ' }';
10918 }
10919 return fmt(value, 0);
10920}
10921
10922export function promisify(fn) {
10923 return (...args) => new Promise((resolve, reject) => {
10924 try {
10925 fn(...args, (err, result) => {
10926 if (err) reject(err);
10927 else resolve(result);
10928 });
10929 } catch (e) {
10930 reject(e);
10931 }
10932 });
10933}
10934
10935export function stripVTControlCharacters(str) {
10936 // eslint-disable-next-line no-control-regex
10937 return (str || '').replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '').replace(/\x1B\][^\x07]*\x07/g, '');
10938}
10939
10940export function deprecate(fn, msg) {
10941 let warned = false;
10942 return function(...args) {
10943 if (!warned) { warned = true; if (typeof console !== 'undefined') console.error('DeprecationWarning: ' + (msg || '')); }
10944 return fn.apply(this, args);
10945 };
10946}
10947export function inherits(ctor, superCtor) {
10948 if (!ctor || !superCtor) return ctor;
10949 const ctorProto = ctor && ctor.prototype;
10950 const superProto = superCtor && superCtor.prototype;
10951 if (!ctorProto || !superProto || typeof ctorProto !== 'object' || typeof superProto !== 'object') {
10952 try { ctor.super_ = superCtor; } catch (_) {}
10953 return ctor;
10954 }
10955 try {
10956 Object.setPrototypeOf(ctorProto, superProto);
10957 ctor.super_ = superCtor;
10958 } catch (_) {
10959 try { ctor.super_ = superCtor; } catch (_ignored) {}
10960 }
10961 return ctor;
10962}
10963export function debuglog(section) {
10964 const env = (typeof process !== 'undefined' && process.env && process.env.NODE_DEBUG) || '';
10965 const enabled = env.split(',').some(s => s.trim().toLowerCase() === (section || '').toLowerCase());
10966 if (!enabled) return () => {};
10967 return (...args) => { if (typeof console !== 'undefined') console.error(section.toUpperCase() + ': ' + args.map(String).join(' ')); };
10968}
10969export function format(f, ...args) {
10970 if (typeof f !== 'string') return [f, ...args].map(v => typeof v === 'string' ? v : inspect(v)).join(' ');
10971 let i = 0;
10972 let result = f.replace(/%[sdifjoO%]/g, (m) => {
10973 if (m === '%%') return '%';
10974 if (i >= args.length) return m;
10975 const a = args[i++];
10976 switch (m) {
10977 case '%s': return String(a);
10978 case '%d': case '%f': return Number(a).toString();
10979 case '%i': return parseInt(a, 10).toString();
10980 case '%j': try { return JSON.stringify(a); } catch { return '[Circular]'; }
10981 case '%o': case '%O': return inspect(a);
10982 default: return m;
10983 }
10984 });
10985 while (i < args.length) result += ' ' + (typeof args[i] === 'string' ? args[i] : inspect(args[i])), i++;
10986 return result;
10987}
10988export function callbackify(fn) {
10989 return function(...args) {
10990 const cb = args.pop();
10991 fn(...args).then(r => cb(null, r), e => cb(e));
10992 };
10993}
10994export const types = {
10995 isAsyncFunction: (fn) => typeof fn === 'function' && fn.constructor && fn.constructor.name === 'AsyncFunction',
10996 isPromise: (v) => v instanceof Promise,
10997 isDate: (v) => v instanceof Date,
10998 isRegExp: (v) => v instanceof RegExp,
10999 isNativeError: (v) => v instanceof Error,
11000 isSet: (v) => v instanceof Set,
11001 isMap: (v) => v instanceof Map,
11002 isTypedArray: (v) => ArrayBuffer.isView(v) && !(v instanceof DataView),
11003 isArrayBuffer: (v) => v instanceof ArrayBuffer,
11004 isArrayBufferView: (v) => ArrayBuffer.isView(v),
11005 isDataView: (v) => v instanceof DataView,
11006 isGeneratorFunction: (fn) => typeof fn === 'function' && fn.constructor && fn.constructor.name === 'GeneratorFunction',
11007 isGeneratorObject: (v) => v && typeof v.next === 'function' && typeof v.throw === 'function',
11008 isBooleanObject: (v) => typeof v === 'object' && v instanceof Boolean,
11009 isNumberObject: (v) => typeof v === 'object' && v instanceof Number,
11010 isStringObject: (v) => typeof v === 'object' && v instanceof String,
11011 isSymbolObject: () => false,
11012 isWeakMap: (v) => v instanceof WeakMap,
11013 isWeakSet: (v) => v instanceof WeakSet,
11014};
11015export const TextEncoder = globalThis.TextEncoder;
11016export const TextDecoder = globalThis.TextDecoder;
11017
11018export default { inspect, promisify, stripVTControlCharacters, deprecate, inherits, debuglog, format, callbackify, types, TextEncoder, TextDecoder };
11019"#
11020 .trim()
11021 .to_string(),
11022 );
11023
11024 modules.insert(
11025 "node:crypto".to_string(),
11026 crate::crypto_shim::NODE_CRYPTO_JS.trim().to_string(),
11027 );
11028
11029 modules.insert(
11030 "node:readline".to_string(),
11031 r"
11032// Readline shim backed by pi.ui('input') when UI is available.
11033
11034function __pi_readline_prompt(query) {
11035 const message = String(query === undefined || query === null ? '' : query);
11036 const piRef = globalThis.pi;
11037 if (piRef && typeof piRef.ui === 'function') {
11038 try {
11039 return Promise.resolve(
11040 piRef.ui('input', { title: message })
11041 ).then((value) => (value === undefined || value === null ? '' : String(value)))
11042 .catch(() => '');
11043 } catch (_err) {
11044 return Promise.resolve('');
11045 }
11046 }
11047 return Promise.resolve('');
11048}
11049
11050export function createInterface(_opts) {
11051 return {
11052 question: (query, optionsOrCb, maybeCb) => {
11053 const cb = typeof optionsOrCb === 'function' ? optionsOrCb : maybeCb;
11054 if (typeof cb !== 'function') {
11055 void __pi_readline_prompt(query);
11056 return;
11057 }
11058 if (!globalThis.pi || typeof globalThis.pi.ui !== 'function') {
11059 cb('');
11060 return;
11061 }
11062 void __pi_readline_prompt(query).then((answer) => {
11063 cb(answer);
11064 });
11065 },
11066 close: () => {},
11067 on: () => {},
11068 once: () => {},
11069 };
11070}
11071
11072export const promises = {
11073 createInterface: (_opts) => ({
11074 question: async (query) => __pi_readline_prompt(query),
11075 close: () => {},
11076 [Symbol.asyncIterator]: async function* () {},
11077 }),
11078};
11079
11080export default { createInterface, promises };
11081"
11082 .trim()
11083 .to_string(),
11084 );
11085
11086 modules.insert(
11087 "node:readline/promises".to_string(),
11088 r#"
11089import { promises as readlinePromises } from "node:readline";
11090
11091export const createInterface = readlinePromises.createInterface;
11092export default { createInterface };
11093"#
11094 .trim()
11095 .to_string(),
11096 );
11097
11098 modules.insert(
11099 "node:url".to_string(),
11100 r"
11101export function fileURLToPath(url) {
11102 const u = String(url ?? '');
11103 if (u.startsWith('file://')) {
11104 let p = decodeURIComponent(u.slice(7));
11105 // file:///C:/... → C:/... (strip leading / before Windows drive letter)
11106 if (p.length >= 3 && p[0] === '/' && p[2] === ':') { p = p.slice(1); }
11107 return p;
11108 }
11109 return u;
11110}
11111export function pathToFileURL(path) {
11112 return new URL('file://' + encodeURI(String(path ?? '')));
11113}
11114
11115// Use built-in URL if available (QuickJS may have it), else provide polyfill
11116const _URL = globalThis.URL || (() => {
11117 class URLPolyfill {
11118 constructor(input, base) {
11119 let u = String(input ?? '');
11120 if (base !== undefined) {
11121 const b = String(base);
11122 if (u.startsWith('/')) {
11123 const m = b.match(/^([^:]+:\/\/[^\/]+)/);
11124 u = m ? m[1] + u : b + u;
11125 } else if (!/^[a-z][a-z0-9+.-]*:/i.test(u)) {
11126 u = b.replace(/[^\/]*$/, '') + u;
11127 }
11128 }
11129 this.href = u;
11130 const protoEnd = u.indexOf(':');
11131 this.protocol = protoEnd >= 0 ? u.slice(0, protoEnd + 1) : '';
11132 let rest = protoEnd >= 0 ? u.slice(protoEnd + 1) : u;
11133 this.username = ''; this.password = '';
11134 if (rest.startsWith('//')) {
11135 rest = rest.slice(2);
11136 const pathStart = rest.indexOf('/');
11137 const authority = pathStart >= 0 ? rest.slice(0, pathStart) : rest;
11138 rest = pathStart >= 0 ? rest.slice(pathStart) : '/';
11139 const atIdx = authority.indexOf('@');
11140 let hostPart = authority;
11141 if (atIdx >= 0) {
11142 const userInfo = authority.slice(0, atIdx);
11143 hostPart = authority.slice(atIdx + 1);
11144 const colonIdx = userInfo.indexOf(':');
11145 if (colonIdx >= 0) {
11146 this.username = userInfo.slice(0, colonIdx);
11147 this.password = userInfo.slice(colonIdx + 1);
11148 } else {
11149 this.username = userInfo;
11150 }
11151 }
11152 const portIdx = hostPart.lastIndexOf(':');
11153 if (portIdx >= 0 && /^\d+$/.test(hostPart.slice(portIdx + 1))) {
11154 this.hostname = hostPart.slice(0, portIdx);
11155 this.port = hostPart.slice(portIdx + 1);
11156 } else {
11157 this.hostname = hostPart;
11158 this.port = '';
11159 }
11160 this.host = this.port ? this.hostname + ':' + this.port : this.hostname;
11161 this.origin = this.protocol + '//' + this.host;
11162 } else {
11163 this.hostname = ''; this.host = ''; this.port = '';
11164 this.origin = 'null';
11165 }
11166 const hashIdx = rest.indexOf('#');
11167 if (hashIdx >= 0) {
11168 this.hash = rest.slice(hashIdx);
11169 rest = rest.slice(0, hashIdx);
11170 } else {
11171 this.hash = '';
11172 }
11173 const qIdx = rest.indexOf('?');
11174 if (qIdx >= 0) {
11175 this.search = rest.slice(qIdx);
11176 this.pathname = rest.slice(0, qIdx) || '/';
11177 } else {
11178 this.search = '';
11179 this.pathname = rest || '/';
11180 }
11181 this.searchParams = new _URLSearchParams(this.search.slice(1));
11182 }
11183 toString() { return this.href; }
11184 toJSON() { return this.href; }
11185 }
11186 return URLPolyfill;
11187})();
11188
11189// Always use our polyfill — QuickJS built-in URLSearchParams may not support string init
11190const _URLSearchParams = class URLSearchParamsPolyfill {
11191 constructor(init) {
11192 this._entries = [];
11193 if (typeof init === 'string') {
11194 const s = init.startsWith('?') ? init.slice(1) : init;
11195 if (s) {
11196 for (const pair of s.split('&')) {
11197 const eqIdx = pair.indexOf('=');
11198 if (eqIdx >= 0) {
11199 this._entries.push([decodeURIComponent(pair.slice(0, eqIdx)), decodeURIComponent(pair.slice(eqIdx + 1))]);
11200 } else {
11201 this._entries.push([decodeURIComponent(pair), '']);
11202 }
11203 }
11204 }
11205 }
11206 }
11207 get(key) {
11208 for (const [k, v] of this._entries) { if (k === key) return v; }
11209 return null;
11210 }
11211 set(key, val) {
11212 let found = false;
11213 this._entries = this._entries.filter(([k]) => {
11214 if (k === key && !found) { found = true; return true; }
11215 return k !== key;
11216 });
11217 if (found) {
11218 for (let i = 0; i < this._entries.length; i++) {
11219 if (this._entries[i][0] === key) { this._entries[i][1] = String(val); break; }
11220 }
11221 } else {
11222 this._entries.push([key, String(val)]);
11223 }
11224 }
11225 has(key) { return this._entries.some(([k]) => k === key); }
11226 delete(key) { this._entries = this._entries.filter(([k]) => k !== key); }
11227 append(key, val) { this._entries.push([key, String(val)]); }
11228 getAll(key) { return this._entries.filter(([k]) => k === key).map(([, v]) => v); }
11229 keys() { return this._entries.map(([k]) => k)[Symbol.iterator](); }
11230 values() { return this._entries.map(([, v]) => v)[Symbol.iterator](); }
11231 entries() { return this._entries.slice()[Symbol.iterator](); }
11232 forEach(fn, thisArg) { for (const [k, v] of this._entries) fn.call(thisArg, v, k, this); }
11233 toString() {
11234 return this._entries.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
11235 }
11236 [Symbol.iterator]() { return this.entries(); }
11237 get size() { return this._entries.length; }
11238};
11239
11240export { _URL as URL, _URLSearchParams as URLSearchParams };
11241export function format(urlObj) {
11242 if (typeof urlObj === 'string') return urlObj;
11243 return urlObj && typeof urlObj.href === 'string' ? urlObj.href : String(urlObj);
11244}
11245export function parse(urlStr) {
11246 try { return new _URL(urlStr); } catch (_) { return null; }
11247}
11248export function resolve(from, to) {
11249 try { return new _URL(to, from).href; } catch (_) { return to; }
11250}
11251export default { URL: _URL, URLSearchParams: _URLSearchParams, fileURLToPath, pathToFileURL, format, parse, resolve };
11252"
11253 .trim()
11254 .to_string(),
11255 );
11256
11257 modules.insert(
11258 "node:net".to_string(),
11259 r"
11260import EventEmitter from 'node:events';
11261
11262// Stub net module - socket operations are not available in PiJS (no network I/O)
11263
11264function __pi_net_schedule(fn) {
11265 if (typeof globalThis.setTimeout === 'function') {
11266 globalThis.setTimeout(fn, 0);
11267 return;
11268 }
11269 if (typeof queueMicrotask === 'function') {
11270 queueMicrotask(fn);
11271 return;
11272 }
11273 fn();
11274}
11275
11276function __pi_net_bytes(data) {
11277 if (typeof data === 'string') return data.length;
11278 if (data && typeof data.byteLength === 'number') return data.byteLength;
11279 if (data && typeof data.length === 'number') return data.length;
11280 return 0;
11281}
11282
11283function __pi_net_parse_args(args) {
11284 let options = {};
11285 let connectListener = null;
11286 if (!args || args.length === 0) return { options, connectListener };
11287
11288 const first = args[0];
11289 if (typeof first === 'function') {
11290 connectListener = first;
11291 return { options, connectListener };
11292 }
11293
11294 if (first && typeof first === 'object' && !Array.isArray(first)) {
11295 options = { ...first };
11296 if (typeof args[1] === 'function') connectListener = args[1];
11297 return { options, connectListener };
11298 }
11299
11300 if (typeof first === 'number' || typeof first === 'string') {
11301 options.port = first;
11302 if (typeof args[1] === 'string') {
11303 options.host = args[1];
11304 if (typeof args[2] === 'function') connectListener = args[2];
11305 } else if (typeof args[1] === 'function') {
11306 connectListener = args[1];
11307 }
11308 }
11309
11310 return { options, connectListener };
11311}
11312
11313function __pi_net_apply_options(socket, options) {
11314 const opts = options && typeof options === 'object' ? options : {};
11315 const host = opts.host ?? opts.hostname ?? socket.remoteAddress ?? '127.0.0.1';
11316 const port = opts.port ?? socket.remotePort ?? 0;
11317 socket.remoteAddress = String(host);
11318 socket.remotePort = Number(port) || 0;
11319 socket.localAddress = socket.localAddress || '127.0.0.1';
11320 socket.localPort = socket.localPort || 0;
11321}
11322
11323function __pi_net_finish_connect(socket) {
11324 __pi_net_schedule(() => {
11325 if (socket.destroyed) return;
11326 socket.connecting = false;
11327 socket.readyState = 'open';
11328 socket.emit('connect');
11329 });
11330}
11331
11332export class Socket extends EventEmitter {
11333 constructor(options = {}) {
11334 super();
11335 this.destroyed = false;
11336 this.connecting = false;
11337 this.readyState = 'closed';
11338 this.bytesWritten = 0;
11339 this.bytesRead = 0;
11340 this.localAddress = '127.0.0.1';
11341 this.localPort = 0;
11342 this.remoteAddress = '127.0.0.1';
11343 this.remotePort = 0;
11344 __pi_net_apply_options(this, options);
11345 }
11346
11347 connect(...args) {
11348 const { options, connectListener } = __pi_net_parse_args(args);
11349 __pi_net_apply_options(this, options);
11350 if (typeof connectListener === 'function') this.once('connect', connectListener);
11351 this.connecting = true;
11352 this.readyState = 'opening';
11353 __pi_net_finish_connect(this);
11354 return this;
11355 }
11356
11357 write(data, _encoding, cb) {
11358 this.bytesWritten += __pi_net_bytes(data);
11359 if (typeof cb === 'function') cb(null);
11360 return true;
11361 }
11362
11363 end(data, _encoding, cb) {
11364 if (data !== undefined) this.write(data);
11365 if (typeof cb === 'function') cb(null);
11366 this.destroy();
11367 return this;
11368 }
11369
11370 destroy(err) {
11371 if (this.destroyed) return this;
11372 this.destroyed = true;
11373 this.connecting = false;
11374 this.readyState = 'closed';
11375 if (err) this.emit('error', err);
11376 this.emit('close');
11377 return this;
11378 }
11379
11380 setTimeout(ms, cb) {
11381 if (typeof cb === 'function' && typeof globalThis.setTimeout === 'function') {
11382 globalThis.setTimeout(cb, ms);
11383 }
11384 return this;
11385 }
11386
11387 setNoDelay() { return this; }
11388 setKeepAlive() { return this; }
11389 ref() { return this; }
11390 unref() { return this; }
11391 address() { return { address: this.localAddress, port: this.localPort, family: 'IPv4' }; }
11392}
11393
11394export function createConnection(...args) {
11395 const socket = new Socket();
11396 socket.connect(...args);
11397 return socket;
11398}
11399
11400export function connect(...args) {
11401 return createConnection(...args);
11402}
11403
11404export function createServer(_opts, _callback) {
11405 throw new Error('node:net.createServer is not available in PiJS');
11406}
11407
11408function __pi_net_is_ipv4(input) {
11409 const value = String(input ?? '');
11410 const parts = value.split('.');
11411 if (parts.length !== 4) return false;
11412 for (const part of parts) {
11413 if (!/^\d{1,3}$/.test(part)) return false;
11414 const num = Number(part);
11415 if (!Number.isFinite(num) || num < 0 || num > 255) return false;
11416 }
11417 return true;
11418}
11419
11420function __pi_net_ipv6_segment_count(segments) {
11421 if (segments.length === 1 && segments[0] === '') return 0;
11422 let count = 0;
11423 for (const seg of segments) {
11424 if (seg === '') return null;
11425 if (seg.includes('.')) {
11426 if (!__pi_net_is_ipv4(seg)) return null;
11427 count += 2;
11428 continue;
11429 }
11430 if (!/^[0-9a-fA-F]{1,4}$/.test(seg)) return null;
11431 count += 1;
11432 }
11433 return count;
11434}
11435
11436function __pi_net_is_ipv6(input) {
11437 const value = String(input ?? '').toLowerCase();
11438 if (!value.includes(':')) return false;
11439 if (value.indexOf('::') !== value.lastIndexOf('::')) return false;
11440 const parts = value.split('::');
11441 const head = parts[0] ? parts[0].split(':') : [''];
11442 const tail = parts[1] ? parts[1].split(':') : [''];
11443 const headCount = __pi_net_ipv6_segment_count(head);
11444 const tailCount = __pi_net_ipv6_segment_count(tail);
11445 if (headCount === null || tailCount === null) return false;
11446 if (parts.length === 1) return headCount === 8;
11447 return headCount + tailCount <= 8;
11448}
11449
11450export function isIP(input) {
11451 if (__pi_net_is_ipv4(input)) return 4;
11452 if (__pi_net_is_ipv6(input)) return 6;
11453 return 0;
11454}
11455
11456export function isIPv4(input) { return __pi_net_is_ipv4(input); }
11457export function isIPv6(input) { return __pi_net_is_ipv6(input); }
11458
11459export class Server {
11460 constructor() {
11461 throw new Error('node:net.Server is not available in PiJS');
11462 }
11463}
11464
11465export default { createConnection, createServer, connect, isIP, isIPv4, isIPv6, Socket, Server };
11466"
11467 .trim()
11468 .to_string(),
11469 );
11470
11471 modules.insert(
11473 "node:events".to_string(),
11474 r"
11475class EventEmitter {
11476 constructor() {
11477 this._events = Object.create(null);
11478 this._maxListeners = 10;
11479 }
11480
11481 on(event, listener) {
11482 if (!this._events[event]) this._events[event] = [];
11483 this._events[event].push(listener);
11484 return this;
11485 }
11486
11487 addListener(event, listener) { return this.on(event, listener); }
11488
11489 once(event, listener) {
11490 const wrapper = (...args) => {
11491 this.removeListener(event, wrapper);
11492 listener.apply(this, args);
11493 };
11494 wrapper._original = listener;
11495 return this.on(event, wrapper);
11496 }
11497
11498 off(event, listener) { return this.removeListener(event, listener); }
11499
11500 removeListener(event, listener) {
11501 const list = this._events[event];
11502 if (!list) return this;
11503 this._events[event] = list.filter(
11504 fn => fn !== listener && fn._original !== listener
11505 );
11506 if (this._events[event].length === 0) delete this._events[event];
11507 return this;
11508 }
11509
11510 removeAllListeners(event) {
11511 if (event === undefined) {
11512 this._events = Object.create(null);
11513 } else {
11514 delete this._events[event];
11515 }
11516 return this;
11517 }
11518
11519 emit(event, ...args) {
11520 const list = this._events[event];
11521 if (!list || list.length === 0) return false;
11522 for (const fn of list.slice()) {
11523 try { fn.apply(this, args); } catch (e) {
11524 if (event !== 'error') this.emit('error', e);
11525 }
11526 }
11527 return true;
11528 }
11529
11530 listeners(event) {
11531 const list = this._events[event];
11532 if (!list) return [];
11533 return list.map(fn => fn._original || fn);
11534 }
11535
11536 listenerCount(event) {
11537 const list = this._events[event];
11538 return list ? list.length : 0;
11539 }
11540
11541 eventNames() { return Object.keys(this._events); }
11542
11543 setMaxListeners(n) { this._maxListeners = n; return this; }
11544 getMaxListeners() { return this._maxListeners; }
11545
11546 prependListener(event, listener) {
11547 if (!this._events[event]) this._events[event] = [];
11548 this._events[event].unshift(listener);
11549 return this;
11550 }
11551
11552 prependOnceListener(event, listener) {
11553 const wrapper = (...args) => {
11554 this.removeListener(event, wrapper);
11555 listener.apply(this, args);
11556 };
11557 wrapper._original = listener;
11558 return this.prependListener(event, wrapper);
11559 }
11560
11561 rawListeners(event) {
11562 return this._events[event] ? this._events[event].slice() : [];
11563 }
11564}
11565
11566EventEmitter.EventEmitter = EventEmitter;
11567EventEmitter.defaultMaxListeners = 10;
11568
11569export { EventEmitter };
11570export default EventEmitter;
11571"
11572 .trim()
11573 .to_string(),
11574 );
11575
11576 modules.insert(
11578 "node:buffer".to_string(),
11579 crate::buffer_shim::NODE_BUFFER_JS.trim().to_string(),
11580 );
11581
11582 modules.insert(
11584 "node:assert".to_string(),
11585 r"
11586function assert(value, message) {
11587 if (!value) throw new Error(message || 'Assertion failed');
11588}
11589assert.ok = assert;
11590assert.equal = (a, b, msg) => { if (a != b) throw new Error(msg || `${a} != ${b}`); };
11591assert.strictEqual = (a, b, msg) => { if (a !== b) throw new Error(msg || `${a} !== ${b}`); };
11592assert.notEqual = (a, b, msg) => { if (a == b) throw new Error(msg || `${a} == ${b}`); };
11593assert.notStrictEqual = (a, b, msg) => { if (a === b) throw new Error(msg || `${a} === ${b}`); };
11594assert.deepEqual = assert.deepStrictEqual = (a, b, msg) => {
11595 if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(msg || 'Deep equality failed');
11596};
11597assert.notDeepEqual = assert.notDeepStrictEqual = (a, b, msg) => {
11598 if (JSON.stringify(a) === JSON.stringify(b)) throw new Error(msg || 'Expected values to differ');
11599};
11600assert.throws = (fn, _expected, msg) => {
11601 let threw = false;
11602 try { fn(); } catch (_) { threw = true; }
11603 if (!threw) throw new Error(msg || 'Expected function to throw');
11604};
11605assert.doesNotThrow = (fn, _expected, msg) => {
11606 try { fn(); } catch (e) { throw new Error(msg || `Got unwanted exception: ${e}`); }
11607};
11608assert.fail = (msg) => { throw new Error(msg || 'assert.fail()'); };
11609
11610export default assert;
11611export { assert };
11612"
11613 .trim()
11614 .to_string(),
11615 );
11616
11617 modules.insert(
11619 "node:assert/strict".to_string(),
11620 r#"
11621import assert from "node:assert";
11622
11623export default assert;
11624export const strict = assert;
11625export const ok = assert.ok;
11626export const equal = assert.equal;
11627export const strictEqual = assert.strictEqual;
11628export const deepStrictEqual = assert.deepStrictEqual;
11629export const notEqual = assert.notEqual;
11630export const notStrictEqual = assert.notStrictEqual;
11631export const deepEqual = assert.deepEqual;
11632export const notDeepEqual = assert.notDeepEqual;
11633export const throws = assert.throws;
11634export const doesNotThrow = assert.doesNotThrow;
11635export const fail = assert.fail;
11636export { assert };
11637"#
11638 .trim()
11639 .to_string(),
11640 );
11641
11642 modules.insert(
11644 "node:test".to_string(),
11645 r"
11646function __noop() {}
11647
11648const __state = {
11649 tests: [],
11650 beforeAll: [],
11651 afterAll: [],
11652 beforeEach: [],
11653 afterEach: [],
11654 suiteStack: [],
11655};
11656
11657function __isFn(value) {
11658 return typeof value === 'function';
11659}
11660
11661function __suiteMode() {
11662 let mode = 'run';
11663 for (const entry of __state.suiteStack) {
11664 if (entry.mode === 'skip' || entry.mode === 'todo') return 'skip';
11665 if (entry.mode === 'only') mode = 'only';
11666 }
11667 return mode;
11668}
11669
11670function __registerTest(name, fn, options, modeOverride) {
11671 const suiteMode = __suiteMode();
11672 let mode = modeOverride || 'run';
11673 if (suiteMode === 'skip') {
11674 mode = 'skip';
11675 } else if (suiteMode === 'only' && mode === 'run') {
11676 mode = 'only';
11677 }
11678 const entry = {
11679 name: name === undefined ? '' : String(name),
11680 fn: __isFn(fn) ? fn : null,
11681 options: options || {},
11682 mode,
11683 };
11684 __state.tests.push(entry);
11685 return entry;
11686}
11687
11688function __enterSuite(name, fn, mode) {
11689 const suite = { name: name === undefined ? '' : String(name), mode };
11690 __state.suiteStack.push(suite);
11691 try {
11692 if (mode !== 'skip' && mode !== 'todo' && __isFn(fn)) {
11693 fn();
11694 }
11695 } finally {
11696 __state.suiteStack.pop();
11697 }
11698 return suite;
11699}
11700
11701export function test(name, fn, options) {
11702 return __registerTest(name, fn, options, 'run');
11703}
11704test.skip = (name, fn, options) => __registerTest(name, fn, options, 'skip');
11705test.todo = (name, fn, options) => __registerTest(name, fn, options, 'todo');
11706test.only = (name, fn, options) => __registerTest(name, fn, options, 'only');
11707
11708export function describe(name, fn) {
11709 return __enterSuite(name, fn, 'run');
11710}
11711describe.skip = (name, fn) => __enterSuite(name, fn, 'skip');
11712describe.todo = (name, fn) => __enterSuite(name, fn, 'todo');
11713describe.only = (name, fn) => __enterSuite(name, fn, 'only');
11714
11715export const it = test;
11716it.skip = test.skip;
11717it.todo = test.todo;
11718it.only = test.only;
11719
11720export const before = (fn) => {
11721 if (__isFn(fn)) __state.beforeAll.push(fn);
11722};
11723export const after = (fn) => {
11724 if (__isFn(fn)) __state.afterAll.push(fn);
11725};
11726export const beforeEach = (fn) => {
11727 if (__isFn(fn)) __state.beforeEach.push(fn);
11728};
11729export const afterEach = (fn) => {
11730 if (__isFn(fn)) __state.afterEach.push(fn);
11731};
11732
11733async function __runHookList(list) {
11734 for (const fn of list) {
11735 await fn();
11736 }
11737}
11738
11739async function __runTest(entry) {
11740 if (entry.mode === 'skip' || entry.mode === 'todo') {
11741 return { name: entry.name, status: entry.mode };
11742 }
11743 if (!entry.fn) {
11744 return { name: entry.name, status: 'error', error: 'missing test function' };
11745 }
11746 try {
11747 await __runHookList(__state.beforeEach);
11748 await entry.fn();
11749 await __runHookList(__state.afterEach);
11750 return { name: entry.name, status: 'pass' };
11751 } catch (err) {
11752 try { await __runHookList(__state.afterEach); } catch (_) {}
11753 const message = err && err.message ? err.message : err;
11754 return { name: entry.name, status: 'fail', error: String(message) };
11755 }
11756}
11757
11758export const mock = {
11759 fn: (impl) => (__isFn(impl) ? impl : __noop),
11760 reset: __noop,
11761 restoreAll: __noop,
11762};
11763
11764export async function run() {
11765 const hasOnly = __state.tests.some(entry => entry.mode === 'only');
11766 const selected = hasOnly
11767 ? __state.tests.filter(entry => entry.mode === 'only')
11768 : __state.tests.slice();
11769
11770 let passed = 0;
11771 let failed = 0;
11772 let skipped = 0;
11773 let todo = 0;
11774 const results = [];
11775
11776 await __runHookList(__state.beforeAll);
11777
11778 for (const entry of selected) {
11779 const result = await __runTest(entry);
11780 results.push(result);
11781 if (result.status === 'pass') passed += 1;
11782 else if (result.status === 'fail' || result.status === 'error') failed += 1;
11783 else if (result.status === 'skip') skipped += 1;
11784 else if (result.status === 'todo') todo += 1;
11785 }
11786
11787 await __runHookList(__state.afterAll);
11788
11789 return {
11790 ok: failed === 0,
11791 summary: {
11792 total: selected.length,
11793 passed,
11794 failed,
11795 skipped,
11796 todo,
11797 },
11798 results,
11799 };
11800}
11801
11802export default { test, describe, it, before, after, beforeEach, afterEach, run, mock };
11803"
11804 .trim()
11805 .to_string(),
11806 );
11807
11808 modules.insert(
11810 "node:stream".to_string(),
11811 r#"
11812import EventEmitter from "node:events";
11813
11814function __streamToError(err) {
11815 return err instanceof Error ? err : new Error(String(err ?? "stream error"));
11816}
11817
11818function __streamQueueMicrotask(fn) {
11819 if (typeof queueMicrotask === "function") {
11820 queueMicrotask(fn);
11821 return;
11822 }
11823 Promise.resolve().then(fn);
11824}
11825
11826function __normalizeChunk(chunk, encoding) {
11827 if (chunk === null || chunk === undefined) return chunk;
11828 if (typeof chunk === "string") return chunk;
11829 if (typeof Buffer !== "undefined" && Buffer.isBuffer && Buffer.isBuffer(chunk)) {
11830 return encoding ? chunk.toString(encoding) : chunk;
11831 }
11832 if (chunk instanceof Uint8Array) {
11833 return encoding && typeof Buffer !== "undefined" && Buffer.from
11834 ? Buffer.from(chunk).toString(encoding)
11835 : chunk;
11836 }
11837 if (chunk instanceof ArrayBuffer) {
11838 const view = new Uint8Array(chunk);
11839 return encoding && typeof Buffer !== "undefined" && Buffer.from
11840 ? Buffer.from(view).toString(encoding)
11841 : view;
11842 }
11843 if (ArrayBuffer.isView(chunk)) {
11844 const view = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
11845 return encoding && typeof Buffer !== "undefined" && Buffer.from
11846 ? Buffer.from(view).toString(encoding)
11847 : view;
11848 }
11849 return encoding ? String(chunk) : chunk;
11850}
11851
11852class Stream extends EventEmitter {
11853 constructor() {
11854 super();
11855 this.destroyed = false;
11856 }
11857
11858 destroy(err) {
11859 if (this.destroyed) return this;
11860 this.destroyed = true;
11861 if (err) this.emit("error", __streamToError(err));
11862 this.emit("close");
11863 return this;
11864 }
11865}
11866
11867class Readable extends Stream {
11868 constructor(opts = {}) {
11869 super();
11870 this._readableState = { flowing: null, ended: false, encoding: opts.encoding || null };
11871 this.readable = true;
11872 this._queue = [];
11873 this._pipeCleanup = new Map();
11874 this._autoDestroy = opts.autoDestroy !== false;
11875 }
11876
11877 push(chunk) {
11878 if (chunk === null) {
11879 if (this._readableState.ended) return false;
11880 this._readableState.ended = true;
11881 __streamQueueMicrotask(() => {
11882 this.emit("end");
11883 if (this._autoDestroy) this.emit("close");
11884 });
11885 return false;
11886 }
11887 const normalized = __normalizeChunk(chunk, this._readableState.encoding);
11888 this._queue.push(normalized);
11889 this.emit("data", normalized);
11890 return true;
11891 }
11892
11893 read(_size) {
11894 return this._queue.length > 0 ? this._queue.shift() : null;
11895 }
11896
11897 pipe(dest) {
11898 if (!dest || typeof dest.write !== "function") {
11899 throw new Error("stream.pipe destination must implement write()");
11900 }
11901
11902 const onData = (chunk) => {
11903 const writable = dest.write(chunk);
11904 if (writable === false && typeof this.pause === "function") {
11905 this.pause();
11906 }
11907 };
11908 const onDrain = () => {
11909 if (typeof this.resume === "function") this.resume();
11910 };
11911 const onEnd = () => {
11912 if (typeof dest.end === "function") dest.end();
11913 cleanup();
11914 };
11915 const onError = (err) => {
11916 cleanup();
11917 if (typeof dest.destroy === "function") {
11918 dest.destroy(err);
11919 } else if (typeof dest.emit === "function") {
11920 dest.emit("error", err);
11921 }
11922 };
11923 const cleanup = () => {
11924 this.removeListener("data", onData);
11925 this.removeListener("end", onEnd);
11926 this.removeListener("error", onError);
11927 if (typeof dest.removeListener === "function") {
11928 dest.removeListener("drain", onDrain);
11929 }
11930 this._pipeCleanup.delete(dest);
11931 };
11932
11933 this.on("data", onData);
11934 this.on("end", onEnd);
11935 this.on("error", onError);
11936 if (typeof dest.on === "function") {
11937 dest.on("drain", onDrain);
11938 }
11939 this._pipeCleanup.set(dest, cleanup);
11940 return dest;
11941 }
11942
11943 unpipe(dest) {
11944 if (dest) {
11945 const cleanup = this._pipeCleanup.get(dest);
11946 if (cleanup) cleanup();
11947 return this;
11948 }
11949 for (const cleanup of this._pipeCleanup.values()) {
11950 cleanup();
11951 }
11952 this._pipeCleanup.clear();
11953 return this;
11954 }
11955
11956 resume() {
11957 this._readableState.flowing = true;
11958 return this;
11959 }
11960
11961 pause() {
11962 this._readableState.flowing = false;
11963 return this;
11964 }
11965
11966 [Symbol.asyncIterator]() {
11967 const stream = this;
11968 const queue = [];
11969 const waiters = [];
11970 let done = false;
11971 let failure = null;
11972
11973 const settleDone = () => {
11974 done = true;
11975 while (waiters.length > 0) {
11976 waiters.shift().resolve({ value: undefined, done: true });
11977 }
11978 };
11979 const settleError = (err) => {
11980 failure = __streamToError(err);
11981 while (waiters.length > 0) {
11982 waiters.shift().reject(failure);
11983 }
11984 };
11985 const onData = (value) => {
11986 if (waiters.length > 0) {
11987 waiters.shift().resolve({ value, done: false });
11988 } else {
11989 queue.push(value);
11990 }
11991 };
11992 const onEnd = () => settleDone();
11993 const onError = (err) => settleError(err);
11994 const cleanup = () => {
11995 stream.removeListener("data", onData);
11996 stream.removeListener("end", onEnd);
11997 stream.removeListener("error", onError);
11998 };
11999
12000 stream.on("data", onData);
12001 stream.on("end", onEnd);
12002 stream.on("error", onError);
12003
12004 return {
12005 async next() {
12006 if (queue.length > 0) return { value: queue.shift(), done: false };
12007 if (failure) throw failure;
12008 if (done) return { value: undefined, done: true };
12009 return await new Promise((resolve, reject) => waiters.push({ resolve, reject }));
12010 },
12011 async return() {
12012 cleanup();
12013 settleDone();
12014 return { value: undefined, done: true };
12015 },
12016 [Symbol.asyncIterator]() { return this; },
12017 };
12018 }
12019
12020 static from(iterable, opts = {}) {
12021 const readable = new Readable(opts);
12022 (async () => {
12023 try {
12024 for await (const chunk of iterable) {
12025 readable.push(chunk);
12026 }
12027 readable.push(null);
12028 } catch (err) {
12029 readable.emit("error", __streamToError(err));
12030 }
12031 })();
12032 return readable;
12033 }
12034
12035 static fromWeb(webReadable, opts = {}) {
12036 if (!webReadable || typeof webReadable.getReader !== "function") {
12037 throw new Error("Readable.fromWeb expects a Web ReadableStream");
12038 }
12039 const reader = webReadable.getReader();
12040 const readable = new Readable(opts);
12041 (async () => {
12042 try {
12043 while (true) {
12044 const { done, value } = await reader.read();
12045 if (done) break;
12046 readable.push(value);
12047 }
12048 readable.push(null);
12049 } catch (err) {
12050 readable.emit("error", __streamToError(err));
12051 } finally {
12052 try { reader.releaseLock(); } catch (_) {}
12053 }
12054 })();
12055 return readable;
12056 }
12057
12058 static toWeb(nodeReadable) {
12059 if (typeof ReadableStream !== "function") {
12060 throw new Error("Readable.toWeb requires global ReadableStream");
12061 }
12062 if (!nodeReadable || typeof nodeReadable.on !== "function") {
12063 throw new Error("Readable.toWeb expects a Node Readable stream");
12064 }
12065 return new ReadableStream({
12066 start(controller) {
12067 const onData = (chunk) => controller.enqueue(chunk);
12068 const onEnd = () => {
12069 cleanup();
12070 controller.close();
12071 };
12072 const onError = (err) => {
12073 cleanup();
12074 controller.error(__streamToError(err));
12075 };
12076 const cleanup = () => {
12077 nodeReadable.removeListener?.("data", onData);
12078 nodeReadable.removeListener?.("end", onEnd);
12079 nodeReadable.removeListener?.("error", onError);
12080 };
12081 nodeReadable.on("data", onData);
12082 nodeReadable.on("end", onEnd);
12083 nodeReadable.on("error", onError);
12084 if (typeof nodeReadable.resume === "function") nodeReadable.resume();
12085 },
12086 cancel(reason) {
12087 if (typeof nodeReadable.destroy === "function") {
12088 nodeReadable.destroy(__streamToError(reason ?? "stream cancelled"));
12089 }
12090 },
12091 });
12092 }
12093}
12094
12095class Writable extends Stream {
12096 constructor(opts = {}) {
12097 super();
12098 this._writableState = { ended: false, finished: false };
12099 this.writable = true;
12100 this._autoDestroy = opts.autoDestroy !== false;
12101 this._writeImpl = typeof opts.write === "function" ? opts.write.bind(this) : null;
12102 this._finalImpl = typeof opts.final === "function" ? opts.final.bind(this) : null;
12103 }
12104
12105 _write(chunk, encoding, callback) {
12106 if (this._writeImpl) {
12107 this._writeImpl(chunk, encoding, callback);
12108 return;
12109 }
12110 callback(null);
12111 }
12112
12113 write(chunk, encoding, callback) {
12114 let cb = callback;
12115 let enc = encoding;
12116 if (typeof encoding === "function") {
12117 cb = encoding;
12118 enc = undefined;
12119 }
12120 if (this._writableState.ended) {
12121 const err = new Error("write after end");
12122 if (typeof cb === "function") cb(err);
12123 this.emit("error", err);
12124 return false;
12125 }
12126
12127 try {
12128 this._write(chunk, enc, (err) => {
12129 if (err) {
12130 const normalized = __streamToError(err);
12131 if (typeof cb === "function") cb(normalized);
12132 this.emit("error", normalized);
12133 return;
12134 }
12135 if (typeof cb === "function") cb(null);
12136 this.emit("drain");
12137 });
12138 } catch (err) {
12139 const normalized = __streamToError(err);
12140 if (typeof cb === "function") cb(normalized);
12141 this.emit("error", normalized);
12142 return false;
12143 }
12144 return true;
12145 }
12146
12147 _finish(callback) {
12148 if (this._finalImpl) {
12149 try {
12150 this._finalImpl(callback);
12151 } catch (err) {
12152 callback(__streamToError(err));
12153 }
12154 return;
12155 }
12156 callback(null);
12157 }
12158
12159 end(chunk, encoding, callback) {
12160 let cb = callback;
12161 let enc = encoding;
12162 if (typeof encoding === "function") {
12163 cb = encoding;
12164 enc = undefined;
12165 }
12166
12167 const finalize = () => {
12168 if (this._writableState.ended) {
12169 if (typeof cb === "function") cb(null);
12170 return;
12171 }
12172 this._writableState.ended = true;
12173 this._finish((err) => {
12174 if (err) {
12175 const normalized = __streamToError(err);
12176 if (typeof cb === "function") cb(normalized);
12177 this.emit("error", normalized);
12178 return;
12179 }
12180 this._writableState.finished = true;
12181 this.emit("finish");
12182 if (this._autoDestroy) this.emit("close");
12183 if (typeof cb === "function") cb(null);
12184 });
12185 };
12186
12187 if (chunk !== undefined && chunk !== null) {
12188 this.write(chunk, enc, (err) => {
12189 if (err) {
12190 if (typeof cb === "function") cb(err);
12191 return;
12192 }
12193 finalize();
12194 });
12195 return this;
12196 }
12197
12198 finalize();
12199 return this;
12200 }
12201
12202 static fromWeb(webWritable, opts = {}) {
12203 if (!webWritable || typeof webWritable.getWriter !== "function") {
12204 throw new Error("Writable.fromWeb expects a Web WritableStream");
12205 }
12206 const writer = webWritable.getWriter();
12207 return new Writable({
12208 ...opts,
12209 write(chunk, _encoding, callback) {
12210 Promise.resolve(writer.write(chunk))
12211 .then(() => callback(null))
12212 .catch((err) => callback(__streamToError(err)));
12213 },
12214 final(callback) {
12215 Promise.resolve(writer.close())
12216 .then(() => {
12217 try { writer.releaseLock(); } catch (_) {}
12218 callback(null);
12219 })
12220 .catch((err) => callback(__streamToError(err)));
12221 },
12222 });
12223 }
12224
12225 static toWeb(nodeWritable) {
12226 if (typeof WritableStream !== "function") {
12227 throw new Error("Writable.toWeb requires global WritableStream");
12228 }
12229 if (!nodeWritable || typeof nodeWritable.write !== "function") {
12230 throw new Error("Writable.toWeb expects a Node Writable stream");
12231 }
12232 return new WritableStream({
12233 write(chunk) {
12234 return new Promise((resolve, reject) => {
12235 try {
12236 const ok = nodeWritable.write(chunk, (err) => {
12237 if (err) reject(__streamToError(err));
12238 else resolve();
12239 });
12240 if (ok === true) resolve();
12241 } catch (err) {
12242 reject(__streamToError(err));
12243 }
12244 });
12245 },
12246 close() {
12247 return new Promise((resolve, reject) => {
12248 try {
12249 nodeWritable.end((err) => {
12250 if (err) reject(__streamToError(err));
12251 else resolve();
12252 });
12253 } catch (err) {
12254 reject(__streamToError(err));
12255 }
12256 });
12257 },
12258 abort(reason) {
12259 if (typeof nodeWritable.destroy === "function") {
12260 nodeWritable.destroy(__streamToError(reason ?? "stream aborted"));
12261 }
12262 },
12263 });
12264 }
12265}
12266
12267class Duplex extends Readable {
12268 constructor(opts = {}) {
12269 super(opts);
12270 this._writableState = { ended: false, finished: false };
12271 this.writable = true;
12272 this._autoDestroy = opts.autoDestroy !== false;
12273 this._writeImpl = typeof opts.write === "function" ? opts.write.bind(this) : null;
12274 this._finalImpl = typeof opts.final === "function" ? opts.final.bind(this) : null;
12275 }
12276
12277 _write(chunk, encoding, callback) {
12278 if (this._writeImpl) {
12279 this._writeImpl(chunk, encoding, callback);
12280 return;
12281 }
12282 callback(null);
12283 }
12284
12285 _finish(callback) {
12286 if (this._finalImpl) {
12287 try {
12288 this._finalImpl(callback);
12289 } catch (err) {
12290 callback(__streamToError(err));
12291 }
12292 return;
12293 }
12294 callback(null);
12295 }
12296
12297 write(chunk, encoding, callback) {
12298 return Writable.prototype.write.call(this, chunk, encoding, callback);
12299 }
12300
12301 end(chunk, encoding, callback) {
12302 return Writable.prototype.end.call(this, chunk, encoding, callback);
12303 }
12304}
12305
12306class Transform extends Duplex {
12307 constructor(opts = {}) {
12308 super(opts);
12309 this._transformImpl = typeof opts.transform === "function" ? opts.transform.bind(this) : null;
12310 }
12311
12312 _transform(chunk, encoding, callback) {
12313 if (this._transformImpl) {
12314 this._transformImpl(chunk, encoding, callback);
12315 return;
12316 }
12317 callback(null, chunk);
12318 }
12319
12320 write(chunk, encoding, callback) {
12321 let cb = callback;
12322 let enc = encoding;
12323 if (typeof encoding === "function") {
12324 cb = encoding;
12325 enc = undefined;
12326 }
12327 try {
12328 this._transform(chunk, enc, (err, data) => {
12329 if (err) {
12330 const normalized = __streamToError(err);
12331 if (typeof cb === "function") cb(normalized);
12332 this.emit("error", normalized);
12333 return;
12334 }
12335 if (data !== undefined && data !== null) {
12336 this.push(data);
12337 }
12338 if (typeof cb === "function") cb(null);
12339 });
12340 } catch (err) {
12341 const normalized = __streamToError(err);
12342 if (typeof cb === "function") cb(normalized);
12343 this.emit("error", normalized);
12344 return false;
12345 }
12346 return true;
12347 }
12348
12349 end(chunk, encoding, callback) {
12350 let cb = callback;
12351 let enc = encoding;
12352 if (typeof encoding === "function") {
12353 cb = encoding;
12354 enc = undefined;
12355 }
12356 const finalize = () => {
12357 this.push(null);
12358 this.emit("finish");
12359 this.emit("close");
12360 if (typeof cb === "function") cb(null);
12361 };
12362 if (chunk !== undefined && chunk !== null) {
12363 this.write(chunk, enc, (err) => {
12364 if (err) {
12365 if (typeof cb === "function") cb(err);
12366 return;
12367 }
12368 finalize();
12369 });
12370 return this;
12371 }
12372 finalize();
12373 return this;
12374 }
12375}
12376
12377class PassThrough extends Transform {
12378 _transform(chunk, _encoding, callback) { callback(null, chunk); }
12379}
12380
12381function finished(stream, callback) {
12382 if (!stream || typeof stream.on !== "function") {
12383 const err = new Error("finished expects a stream-like object");
12384 if (typeof callback === "function") callback(err);
12385 return Promise.reject(err);
12386 }
12387 return new Promise((resolve, reject) => {
12388 let settled = false;
12389 const cleanup = () => {
12390 stream.removeListener?.("finish", onDone);
12391 stream.removeListener?.("end", onDone);
12392 stream.removeListener?.("close", onDone);
12393 stream.removeListener?.("error", onError);
12394 };
12395 const settle = (fn, value) => {
12396 if (settled) return;
12397 settled = true;
12398 cleanup();
12399 fn(value);
12400 };
12401 const onDone = () => {
12402 if (typeof callback === "function") callback(null, stream);
12403 settle(resolve, stream);
12404 };
12405 const onError = (err) => {
12406 const normalized = __streamToError(err);
12407 if (typeof callback === "function") callback(normalized);
12408 settle(reject, normalized);
12409 };
12410 stream.on("finish", onDone);
12411 stream.on("end", onDone);
12412 stream.on("close", onDone);
12413 stream.on("error", onError);
12414 });
12415}
12416
12417function pipeline(...args) {
12418 const callback = typeof args[args.length - 1] === "function" ? args.pop() : null;
12419 const streams = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
12420 if (!Array.isArray(streams) || streams.length < 2) {
12421 const err = new Error("pipeline requires at least two streams");
12422 if (callback) callback(err);
12423 throw err;
12424 }
12425
12426 for (let i = 0; i < streams.length - 1; i += 1) {
12427 streams[i].pipe(streams[i + 1]);
12428 }
12429 const last = streams[streams.length - 1];
12430 const done = (err) => {
12431 if (callback) callback(err || null, last);
12432 };
12433 last.on?.("finish", () => done(null));
12434 last.on?.("end", () => done(null));
12435 last.on?.("error", (err) => done(__streamToError(err)));
12436 return last;
12437}
12438
12439const promises = {
12440 pipeline: (...args) =>
12441 new Promise((resolve, reject) => {
12442 try {
12443 pipeline(...args, (err, stream) => {
12444 if (err) reject(err);
12445 else resolve(stream);
12446 });
12447 } catch (err) {
12448 reject(__streamToError(err));
12449 }
12450 }),
12451 finished: (stream) => finished(stream),
12452};
12453
12454export { Stream, Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished, promises };
12455export default { Stream, Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished, promises };
12456"#
12457 .trim()
12458 .to_string(),
12459 );
12460
12461 modules.insert(
12463 "node:stream/promises".to_string(),
12464 r"
12465import { Readable, Writable } from 'node:stream';
12466
12467function __streamToError(err) {
12468 return err instanceof Error ? err : new Error(String(err ?? 'stream error'));
12469}
12470
12471function __isReadableLike(stream) {
12472 return !!stream && typeof stream.pipe === 'function' && typeof stream.on === 'function';
12473}
12474
12475function __isWritableLike(stream) {
12476 return !!stream && typeof stream.write === 'function' && typeof stream.on === 'function';
12477}
12478
12479export async function pipeline(...streams) {
12480 if (streams.length === 1 && Array.isArray(streams[0])) {
12481 streams = streams[0];
12482 }
12483 if (streams.length < 2) {
12484 throw new Error('pipeline requires at least two streams');
12485 }
12486
12487 if (!__isReadableLike(streams[0]) && streams[0] && (typeof streams[0][Symbol.asyncIterator] === 'function' || typeof streams[0][Symbol.iterator] === 'function')) {
12488 streams = [Readable.from(streams[0]), ...streams.slice(1)];
12489 }
12490
12491 return await new Promise((resolve, reject) => {
12492 let settled = false;
12493 const cleanups = [];
12494 const cleanup = () => {
12495 while (cleanups.length > 0) {
12496 try { cleanups.pop()(); } catch (_) {}
12497 }
12498 };
12499 const settleResolve = (value) => {
12500 if (settled) return;
12501 settled = true;
12502 cleanup();
12503 resolve(value);
12504 };
12505 const settleReject = (err) => {
12506 if (settled) return;
12507 settled = true;
12508 cleanup();
12509 reject(__streamToError(err));
12510 };
12511 const addListener = (target, event, handler) => {
12512 if (!target || typeof target.on !== 'function') return;
12513 target.on(event, handler);
12514 cleanups.push(() => {
12515 if (typeof target.removeListener === 'function') {
12516 target.removeListener(event, handler);
12517 }
12518 });
12519 };
12520
12521 for (let i = 0; i < streams.length - 1; i += 1) {
12522 const source = streams[i];
12523 const dest = streams[i + 1];
12524 if (!__isReadableLike(source)) {
12525 settleReject(new Error(`pipeline source at index ${i} is not readable`));
12526 return;
12527 }
12528 if (!__isWritableLike(dest)) {
12529 settleReject(new Error(`pipeline destination at index ${i + 1} is not writable`));
12530 return;
12531 }
12532 try {
12533 source.pipe(dest);
12534 } catch (err) {
12535 settleReject(err);
12536 return;
12537 }
12538 }
12539
12540 const last = streams[streams.length - 1];
12541 for (const stream of streams) {
12542 addListener(stream, 'error', settleReject);
12543 }
12544 addListener(last, 'finish', () => settleResolve(last));
12545 addListener(last, 'end', () => settleResolve(last));
12546 addListener(last, 'close', () => settleResolve(last));
12547
12548 const first = streams[0];
12549 if (first && typeof first.resume === 'function') {
12550 try { first.resume(); } catch (_) {}
12551 }
12552 });
12553}
12554
12555export async function finished(stream) {
12556 if (!stream || typeof stream.on !== 'function') {
12557 throw new Error('finished expects a stream-like object');
12558 }
12559 return await new Promise((resolve, reject) => {
12560 let settled = false;
12561 const cleanup = () => {
12562 if (typeof stream.removeListener !== 'function') return;
12563 stream.removeListener('finish', onDone);
12564 stream.removeListener('end', onDone);
12565 stream.removeListener('close', onDone);
12566 stream.removeListener('error', onError);
12567 };
12568 const onDone = () => {
12569 if (settled) return;
12570 settled = true;
12571 cleanup();
12572 resolve(stream);
12573 };
12574 const onError = (err) => {
12575 if (settled) return;
12576 settled = true;
12577 cleanup();
12578 reject(__streamToError(err));
12579 };
12580 stream.on('finish', onDone);
12581 stream.on('end', onDone);
12582 stream.on('close', onDone);
12583 stream.on('error', onError);
12584 });
12585}
12586export default { pipeline, finished };
12587"
12588 .trim()
12589 .to_string(),
12590 );
12591
12592 modules.insert(
12594 "node:stream/web".to_string(),
12595 r"
12596const _ReadableStream = globalThis.ReadableStream;
12597const _WritableStream = globalThis.WritableStream;
12598const _TransformStream = globalThis.TransformStream;
12599const _TextEncoderStream = globalThis.TextEncoderStream;
12600const _TextDecoderStream = globalThis.TextDecoderStream;
12601const _CompressionStream = globalThis.CompressionStream;
12602const _DecompressionStream = globalThis.DecompressionStream;
12603const _ByteLengthQueuingStrategy = globalThis.ByteLengthQueuingStrategy;
12604const _CountQueuingStrategy = globalThis.CountQueuingStrategy;
12605
12606export const ReadableStream = _ReadableStream;
12607export const WritableStream = _WritableStream;
12608export const TransformStream = _TransformStream;
12609export const TextEncoderStream = _TextEncoderStream;
12610export const TextDecoderStream = _TextDecoderStream;
12611export const CompressionStream = _CompressionStream;
12612export const DecompressionStream = _DecompressionStream;
12613export const ByteLengthQueuingStrategy = _ByteLengthQueuingStrategy;
12614export const CountQueuingStrategy = _CountQueuingStrategy;
12615
12616export default {
12617 ReadableStream,
12618 WritableStream,
12619 TransformStream,
12620 TextEncoderStream,
12621 TextDecoderStream,
12622 CompressionStream,
12623 DecompressionStream,
12624 ByteLengthQueuingStrategy,
12625 CountQueuingStrategy,
12626};
12627"
12628 .trim()
12629 .to_string(),
12630 );
12631
12632 modules.insert(
12634 "node:string_decoder".to_string(),
12635 r"
12636export class StringDecoder {
12637 constructor(encoding) { this.encoding = encoding || 'utf8'; }
12638 write(buf) { return typeof buf === 'string' ? buf : String(buf ?? ''); }
12639 end(buf) { return buf ? this.write(buf) : ''; }
12640}
12641export default { StringDecoder };
12642"
12643 .trim()
12644 .to_string(),
12645 );
12646
12647 modules.insert(
12649 "node:querystring".to_string(),
12650 r"
12651export function parse(qs, sep, eq) {
12652 const s = String(qs ?? '');
12653 const sepStr = sep || '&';
12654 const eqStr = eq || '=';
12655 const result = {};
12656 if (!s) return result;
12657 for (const pair of s.split(sepStr)) {
12658 const idx = pair.indexOf(eqStr);
12659 const key = idx === -1 ? decodeURIComponent(pair) : decodeURIComponent(pair.slice(0, idx));
12660 const val = idx === -1 ? '' : decodeURIComponent(pair.slice(idx + eqStr.length));
12661 if (Object.prototype.hasOwnProperty.call(result, key)) {
12662 if (Array.isArray(result[key])) result[key].push(val);
12663 else result[key] = [result[key], val];
12664 } else {
12665 result[key] = val;
12666 }
12667 }
12668 return result;
12669}
12670export function stringify(obj, sep, eq) {
12671 const sepStr = sep || '&';
12672 const eqStr = eq || '=';
12673 if (!obj || typeof obj !== 'object') return '';
12674 return Object.entries(obj).map(([k, v]) => {
12675 if (Array.isArray(v)) return v.map(i => encodeURIComponent(k) + eqStr + encodeURIComponent(i)).join(sepStr);
12676 return encodeURIComponent(k) + eqStr + encodeURIComponent(v ?? '');
12677 }).join(sepStr);
12678}
12679export const decode = parse;
12680export const encode = stringify;
12681export function escape(str) { return encodeURIComponent(str); }
12682export function unescape(str) { return decodeURIComponent(str); }
12683export default { parse, stringify, decode, encode, escape, unescape };
12684"
12685 .trim()
12686 .to_string(),
12687 );
12688
12689 modules.insert(
12691 "node:constants".to_string(),
12692 r"
12693const _constants = {
12694 EOL: '\n',
12695 F_OK: 0,
12696 R_OK: 4,
12697 W_OK: 2,
12698 X_OK: 1,
12699 UV_UDP_REUSEADDR: 4,
12700 SSL_OP_NO_SSLv2: 0,
12701 SSL_OP_NO_SSLv3: 0,
12702 SSL_OP_NO_TLSv1: 0,
12703 SSL_OP_NO_TLSv1_1: 0,
12704};
12705
12706const constants = new Proxy(_constants, {
12707 get(target, prop) {
12708 if (prop in target) return target[prop];
12709 return 0;
12710 },
12711});
12712
12713export default constants;
12714export { constants };
12715"
12716 .trim()
12717 .to_string(),
12718 );
12719
12720 modules.insert(
12722 "node:tty".to_string(),
12723 r"
12724import EventEmitter from 'node:events';
12725
12726export function isatty(_fd) { return false; }
12727
12728export class ReadStream extends EventEmitter {
12729 constructor(_fd) {
12730 super();
12731 this.isTTY = false;
12732 this.columns = 80;
12733 this.rows = 24;
12734 }
12735 setRawMode(_mode) { return this; }
12736}
12737
12738export class WriteStream extends EventEmitter {
12739 constructor(_fd) {
12740 super();
12741 this.isTTY = false;
12742 this.columns = 80;
12743 this.rows = 24;
12744 }
12745 getColorDepth() { return 1; }
12746 hasColors() { return false; }
12747 getWindowSize() { return [this.columns, this.rows]; }
12748}
12749
12750export default { isatty, ReadStream, WriteStream };
12751"
12752 .trim()
12753 .to_string(),
12754 );
12755
12756 modules.insert(
12758 "node:tls".to_string(),
12759 r"
12760import EventEmitter from 'node:events';
12761
12762export const DEFAULT_MIN_VERSION = 'TLSv1.2';
12763export const DEFAULT_MAX_VERSION = 'TLSv1.3';
12764
12765export class TLSSocket extends EventEmitter {
12766 constructor(_socket, _options) {
12767 super();
12768 this.authorized = false;
12769 this.encrypted = true;
12770 }
12771}
12772
12773export function connect(_portOrOptions, _host, _options, _callback) {
12774 throw new Error('node:tls.connect is not available in PiJS');
12775}
12776
12777export function createServer(_options, _secureConnectionListener) {
12778 throw new Error('node:tls.createServer is not available in PiJS');
12779}
12780
12781export default { connect, createServer, TLSSocket, DEFAULT_MIN_VERSION, DEFAULT_MAX_VERSION };
12782"
12783 .trim()
12784 .to_string(),
12785 );
12786
12787 modules.insert(
12789 "node:zlib".to_string(),
12790 r"
12791const constants = {
12792 Z_NO_COMPRESSION: 0,
12793 Z_BEST_SPEED: 1,
12794 Z_BEST_COMPRESSION: 9,
12795 Z_DEFAULT_COMPRESSION: -1,
12796};
12797
12798function unsupported(name) {
12799 throw new Error(`node:zlib.${name} is not available in PiJS`);
12800}
12801
12802export function gzip(_buffer, callback) {
12803 if (typeof callback === 'function') callback(new Error('node:zlib.gzip is not available in PiJS'));
12804}
12805export function gunzip(_buffer, callback) {
12806 if (typeof callback === 'function') callback(new Error('node:zlib.gunzip is not available in PiJS'));
12807}
12808
12809export function createGzip() { unsupported('createGzip'); }
12810export function createGunzip() { unsupported('createGunzip'); }
12811export function createDeflate() { unsupported('createDeflate'); }
12812export function createInflate() { unsupported('createInflate'); }
12813export function createBrotliCompress() { unsupported('createBrotliCompress'); }
12814export function createBrotliDecompress() { unsupported('createBrotliDecompress'); }
12815
12816export const promises = {
12817 gzip: async () => { unsupported('promises.gzip'); },
12818 gunzip: async () => { unsupported('promises.gunzip'); },
12819};
12820
12821export default {
12822 constants,
12823 gzip,
12824 gunzip,
12825 createGzip,
12826 createGunzip,
12827 createDeflate,
12828 createInflate,
12829 createBrotliCompress,
12830 createBrotliDecompress,
12831 promises,
12832};
12833"
12834 .trim()
12835 .to_string(),
12836 );
12837
12838 modules.insert(
12840 "node:perf_hooks".to_string(),
12841 r"
12842const perf =
12843 globalThis.performance ||
12844 {
12845 now: () => Date.now(),
12846 mark: () => {},
12847 measure: () => {},
12848 clearMarks: () => {},
12849 clearMeasures: () => {},
12850 getEntries: () => [],
12851 getEntriesByType: () => [],
12852 getEntriesByName: () => [],
12853 };
12854
12855export const performance = perf;
12856export const constants = {};
12857export class PerformanceObserver {
12858 constructor(_callback) {}
12859 observe(_opts) {}
12860 disconnect() {}
12861}
12862
12863export default { performance, constants, PerformanceObserver };
12864"
12865 .trim()
12866 .to_string(),
12867 );
12868
12869 modules.insert(
12871 "node:vm".to_string(),
12872 r"
12873function unsupported(name) {
12874 throw new Error(`node:vm.${name} is not available in PiJS`);
12875}
12876
12877export function runInContext() { unsupported('runInContext'); }
12878export function runInNewContext() { unsupported('runInNewContext'); }
12879export function runInThisContext() { unsupported('runInThisContext'); }
12880export function createContext(_sandbox) { return _sandbox || {}; }
12881
12882export class Script {
12883 constructor(_code, _options) { unsupported('Script'); }
12884}
12885
12886export default { runInContext, runInNewContext, runInThisContext, createContext, Script };
12887"
12888 .trim()
12889 .to_string(),
12890 );
12891
12892 modules.insert(
12894 "node:v8".to_string(),
12895 r"
12896function __toBuffer(str) {
12897 if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
12898 return Buffer.from(str, 'utf8');
12899 }
12900 if (typeof TextEncoder !== 'undefined') {
12901 return new TextEncoder().encode(str);
12902 }
12903 return str;
12904}
12905
12906function __fromBuffer(buf) {
12907 if (buf == null) return '';
12908 if (typeof Buffer !== 'undefined' && typeof Buffer.isBuffer === 'function' && Buffer.isBuffer(buf)) {
12909 return buf.toString('utf8');
12910 }
12911 if (buf instanceof Uint8Array && typeof TextDecoder !== 'undefined') {
12912 return new TextDecoder().decode(buf);
12913 }
12914 return String(buf);
12915}
12916
12917export function serialize(value) {
12918 return __toBuffer(JSON.stringify(value));
12919}
12920
12921export function deserialize(value) {
12922 return JSON.parse(__fromBuffer(value));
12923}
12924
12925export default { serialize, deserialize };
12926"
12927 .trim()
12928 .to_string(),
12929 );
12930
12931 modules.insert(
12933 "node:worker_threads".to_string(),
12934 r"
12935export const isMainThread = true;
12936export const threadId = 0;
12937export const workerData = null;
12938export const parentPort = null;
12939
12940export class Worker {
12941 constructor(_filename, _options) {
12942 throw new Error('node:worker_threads.Worker is not available in PiJS');
12943 }
12944}
12945
12946export default { isMainThread, threadId, workerData, parentPort, Worker };
12947"
12948 .trim()
12949 .to_string(),
12950 );
12951
12952 modules.insert(
12954 "node:process".to_string(),
12955 r"
12956const p = globalThis.process || {};
12957export const env = p.env || {};
12958export const argv = p.argv || [];
12959export const cwd = typeof p.cwd === 'function' ? p.cwd : () => '/';
12960export const chdir = typeof p.chdir === 'function' ? p.chdir : () => { throw new Error('ENOSYS'); };
12961export const platform = p.platform || 'linux';
12962export const arch = p.arch || 'x64';
12963export const version = p.version || 'v20.0.0';
12964export const versions = p.versions || {};
12965export const pid = p.pid || 1;
12966export const ppid = p.ppid || 0;
12967export const title = p.title || 'pi';
12968export const execPath = p.execPath || '/usr/bin/pi';
12969export const execArgv = p.execArgv || [];
12970export const stdout = p.stdout || { write() {} };
12971export const stderr = p.stderr || { write() {} };
12972export const stdin = p.stdin || {};
12973export const nextTick = p.nextTick || ((fn, ...a) => Promise.resolve().then(() => fn(...a)));
12974export const hrtime = p.hrtime || Object.assign(() => [0, 0], { bigint: () => BigInt(0) });
12975export const exit = p.exit || (() => {});
12976export const kill = p.kill || (() => {});
12977export const on = p.on || (() => p);
12978export const off = p.off || (() => p);
12979export const once = p.once || (() => p);
12980export const addListener = p.addListener || (() => p);
12981export const removeListener = p.removeListener || (() => p);
12982export const removeAllListeners = p.removeAllListeners || (() => p);
12983export const listeners = p.listeners || (() => []);
12984export const emit = p.emit || (() => false);
12985export const emitWarning = p.emitWarning || (() => {});
12986export const uptime = p.uptime || (() => 0);
12987export const memoryUsage = p.memoryUsage || (() => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0, arrayBuffers: 0 }));
12988export const cpuUsage = p.cpuUsage || (() => ({ user: 0, system: 0 }));
12989export const release = p.release || { name: 'node' };
12990export default p;
12991"
12992 .trim()
12993 .to_string(),
12994 );
12995
12996 modules.insert(
12998 "node:timers".to_string(),
12999 r"
13000export const setTimeout = globalThis.setTimeout;
13001export const clearTimeout = globalThis.clearTimeout;
13002export const setInterval = globalThis.setInterval;
13003export const clearInterval = globalThis.clearInterval;
13004export const setImmediate = globalThis.setImmediate || ((fn, ...args) => globalThis.setTimeout(fn, 0, ...args));
13005export const clearImmediate = globalThis.clearImmediate || ((id) => globalThis.clearTimeout(id));
13006export const queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn));
13007
13008export default {
13009 setTimeout,
13010 clearTimeout,
13011 setInterval,
13012 clearInterval,
13013 setImmediate,
13014 clearImmediate,
13015 queueMicrotask,
13016};
13017"
13018 .trim()
13019 .to_string(),
13020 );
13021
13022 modules.insert(
13029 "@mariozechner/clipboard".to_string(),
13030 r"
13031export async function getText() { return ''; }
13032export async function setText(_text) {}
13033export default { getText, setText };
13034"
13035 .trim()
13036 .to_string(),
13037 );
13038
13039 modules.insert(
13040 "node-pty".to_string(),
13041 r"
13042let _pid = 1000;
13043export function spawn(shell, args, options) {
13044 const pid = _pid++;
13045 const handlers = {};
13046 return {
13047 pid,
13048 onData(cb) { handlers.data = cb; },
13049 onExit(cb) { if (cb) setTimeout(() => cb({ exitCode: 1, signal: undefined }), 0); },
13050 write(d) {},
13051 resize(c, r) {},
13052 kill(s) {},
13053 };
13054}
13055export default { spawn };
13056"
13057 .trim()
13058 .to_string(),
13059 );
13060
13061 modules.insert(
13062 "chokidar".to_string(),
13063 r"
13064function makeWatcher() {
13065 const w = {
13066 on(ev, cb) { return w; },
13067 once(ev, cb) { return w; },
13068 close() { return Promise.resolve(); },
13069 add(p) { return w; },
13070 unwatch(p) { return w; },
13071 getWatched() { return {}; },
13072 };
13073 return w;
13074}
13075export function watch(paths, options) { return makeWatcher(); }
13076export default { watch };
13077"
13078 .trim()
13079 .to_string(),
13080 );
13081
13082 modules.insert(
13083 "jsdom".to_string(),
13084 r"
13085class Element {
13086 constructor(tag, html) { this.tagName = tag; this._html = html || ''; this.childNodes = []; }
13087 get innerHTML() { return this._html; }
13088 set innerHTML(v) { this._html = v; }
13089 get textContent() { return this._html.replace(/<[^>]*>/g, ''); }
13090 get outerHTML() { return `<${this.tagName}>${this._html}</${this.tagName}>`; }
13091 get parentNode() { return null; }
13092 querySelectorAll() { return []; }
13093 querySelector() { return null; }
13094 getElementsByTagName() { return []; }
13095 getElementById() { return null; }
13096 remove() {}
13097 getAttribute() { return null; }
13098 setAttribute() {}
13099 cloneNode() { return new Element(this.tagName, this._html); }
13100}
13101export class JSDOM {
13102 constructor(html, opts) {
13103 const doc = new Element('html', html || '');
13104 doc.body = new Element('body', html || '');
13105 doc.title = '';
13106 doc.querySelectorAll = () => [];
13107 doc.querySelector = () => null;
13108 doc.getElementsByTagName = () => [];
13109 doc.getElementById = () => null;
13110 doc.createElement = (t) => new Element(t, '');
13111 doc.documentElement = doc;
13112 this.window = { document: doc, location: { href: (opts && opts.url) || '' } };
13113 }
13114}
13115"
13116 .trim()
13117 .to_string(),
13118 );
13119
13120 modules.insert(
13121 "@mozilla/readability".to_string(),
13122 r"
13123export class Readability {
13124 constructor(doc, opts) { this._doc = doc; }
13125 parse() {
13126 const text = (this._doc && this._doc.body && this._doc.body.textContent) || '';
13127 return { title: '', content: text, textContent: text, length: text.length, excerpt: '', byline: '', dir: '', siteName: '', lang: '' };
13128 }
13129}
13130"
13131 .trim()
13132 .to_string(),
13133 );
13134
13135 modules.insert(
13136 "beautiful-mermaid".to_string(),
13137 r"
13138export function renderMermaidAscii(source) {
13139 const firstLine = (source || '').split('\n')[0] || 'diagram';
13140 return '[mermaid: ' + firstLine.trim() + ']';
13141}
13142"
13143 .trim()
13144 .to_string(),
13145 );
13146
13147 modules.insert(
13148 "@aliou/pi-utils-settings".to_string(),
13149 r"
13150export class ConfigLoader {
13151 constructor(name, defaultConfig, options) {
13152 this._name = name;
13153 this._default = defaultConfig || {};
13154 this._opts = options || {};
13155 this._data = structuredClone(this._default);
13156 }
13157 async load() { return this._data; }
13158 save(d) { this._data = d; }
13159 get() { return this._data; }
13160 getConfig() { return this._data; }
13161 set(k, v) { this._data[k] = v; }
13162}
13163export class ArrayEditor {
13164 constructor(arr) { this._arr = arr || []; }
13165 add(item) { this._arr.push(item); return this; }
13166 remove(idx) { this._arr.splice(idx, 1); return this; }
13167 toArray() { return this._arr; }
13168}
13169export function registerSettingsCommand(pi, opts) {}
13170export function getNestedValue(obj, path) {
13171 const keys = (path || '').split('.');
13172 let cur = obj;
13173 for (const k of keys) { if (cur == null) return undefined; cur = cur[k]; }
13174 return cur;
13175}
13176export function setNestedValue(obj, path, value) {
13177 const keys = (path || '').split('.');
13178 let cur = obj;
13179 for (let i = 0; i < keys.length - 1; i++) {
13180 if (cur[keys[i]] == null) cur[keys[i]] = {};
13181 cur = cur[keys[i]];
13182 }
13183 cur[keys[keys.length - 1]] = value;
13184}
13185"
13186 .trim()
13187 .to_string(),
13188 );
13189
13190 modules.insert(
13191 "@aliou/sh".to_string(),
13192 r#"
13193export class ParseError extends Error { constructor(msg) { super(msg); this.name = 'ParseError'; } }
13194export function tokenize(cmd) {
13195 const source = String(cmd ?? '');
13196 const tokens = [];
13197 let buf = '';
13198 let inSingle = false;
13199 let inDouble = false;
13200 for (let i = 0; i < source.length; i++) {
13201 const ch = source[i];
13202 if (inSingle) {
13203 if (ch === "'") { inSingle = false; } else { buf += ch; }
13204 continue;
13205 }
13206 if (inDouble) {
13207 if (ch === '"') { inDouble = false; } else { buf += ch; }
13208 continue;
13209 }
13210 if (ch === "'") { inSingle = true; continue; }
13211 if (ch === '"') { inDouble = true; continue; }
13212 if (/\s/.test(ch)) {
13213 if (buf.length) { tokens.push(buf); buf = ''; }
13214 continue;
13215 }
13216 buf += ch;
13217 }
13218 if (inSingle || inDouble) { throw new ParseError('Unclosed quote'); }
13219 if (buf.length) tokens.push(buf);
13220 return tokens;
13221}
13222function isValidName(name) {
13223 return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
13224}
13225function makeLiteralWord(value) {
13226 return { parts: [{ type: 'Literal', value }] };
13227}
13228export function parse(cmd) {
13229 const source = String(cmd ?? '');
13230 if (!source.trim()) {
13231 return { ast: { type: 'Program', body: [] } };
13232 }
13233 if (/[|;&()<>]/.test(source)) {
13234 throw new ParseError('Unsupported shell construct');
13235 }
13236 const tokens = tokenize(source);
13237 let idx = 0;
13238 const assignments = [];
13239 while (idx < tokens.length) {
13240 const token = tokens[idx];
13241 const eq = token.indexOf('=');
13242 if (eq > 0 && isValidName(token.slice(0, eq))) {
13243 assignments.push({ name: token.slice(0, eq) });
13244 idx += 1;
13245 continue;
13246 }
13247 break;
13248 }
13249 const words = tokens.slice(idx).map(makeLiteralWord);
13250 if (words.length === 0) {
13251 return { ast: { type: 'Program', body: [] } };
13252 }
13253 return {
13254 ast: {
13255 type: 'Program',
13256 body: [
13257 {
13258 type: 'Statement',
13259 command: { type: 'SimpleCommand', words, assignments },
13260 },
13261 ],
13262 },
13263 };
13264}
13265export function quote(s) { return "'" + (s || '').replace(/'/g, "'\\''") + "'"; }
13266"#
13267 .trim()
13268 .to_string(),
13269 );
13270
13271 modules.insert(
13272 "@marckrenn/pi-sub-shared".to_string(),
13273 r#"
13274export const PROVIDERS = ["anthropic", "openai", "google", "aws", "azure"];
13275export const MODEL_MULTIPLIERS = {};
13276const _meta = (name) => ({
13277 name, displayName: name.charAt(0).toUpperCase() + name.slice(1),
13278 detection: { envVars: [], configPaths: [] },
13279 status: { operational: true },
13280});
13281export const PROVIDER_METADATA = Object.fromEntries(PROVIDERS.map(p => [p, _meta(p)]));
13282export const PROVIDER_DISPLAY_NAMES = Object.fromEntries(
13283 PROVIDERS.map(p => [p, p.charAt(0).toUpperCase() + p.slice(1)])
13284);
13285export function getDefaultCoreSettings() {
13286 return { providers: {}, behavior: { autoSwitch: false } };
13287}
13288"#
13289 .trim()
13290 .to_string(),
13291 );
13292
13293 modules.insert(
13294 "turndown".to_string(),
13295 r"
13296class TurndownService {
13297 constructor(opts) { this._opts = opts || {}; }
13298 turndown(html) { return (html || '').replace(/<[^>]*>/g, ''); }
13299 addRule(name, rule) { return this; }
13300 use(plugin) { return this; }
13301 remove(filter) { return this; }
13302}
13303export default TurndownService;
13304"
13305 .trim()
13306 .to_string(),
13307 );
13308
13309 modules.insert(
13310 "@xterm/headless".to_string(),
13311 r"
13312export class Terminal {
13313 constructor(opts) { this._opts = opts || {}; this.cols = opts?.cols || 80; this.rows = opts?.rows || 24; this.buffer = { active: { cursorX: 0, cursorY: 0, length: 0, getLine: () => null } }; }
13314 write(data) {}
13315 writeln(data) {}
13316 resize(cols, rows) { this.cols = cols; this.rows = rows; }
13317 dispose() {}
13318 onData(cb) { return { dispose() {} }; }
13319 onLineFeed(cb) { return { dispose() {} }; }
13320}
13321export default { Terminal };
13322"
13323 .trim()
13324 .to_string(),
13325 );
13326
13327 modules.insert(
13328 "@opentelemetry/api".to_string(),
13329 r"
13330export const SpanStatusCode = { UNSET: 0, OK: 1, ERROR: 2 };
13331const noopSpan = {
13332 setAttribute() { return this; },
13333 setAttributes() { return this; },
13334 addEvent() { return this; },
13335 setStatus() { return this; },
13336 end() {},
13337 isRecording() { return false; },
13338 recordException() {},
13339 spanContext() { return { traceId: '', spanId: '', traceFlags: 0 }; },
13340};
13341const noopTracer = {
13342 startSpan() { return noopSpan; },
13343 startActiveSpan(name, optsOrFn, fn) {
13344 const cb = typeof optsOrFn === 'function' ? optsOrFn : fn;
13345 return cb ? cb(noopSpan) : noopSpan;
13346 },
13347};
13348export const trace = {
13349 getTracer() { return noopTracer; },
13350 getActiveSpan() { return noopSpan; },
13351 setSpan(ctx) { return ctx; },
13352};
13353export const context = {
13354 active() { return {}; },
13355 with(ctx, fn) { return fn(); },
13356};
13357"
13358 .trim()
13359 .to_string(),
13360 );
13361
13362 modules.insert(
13363 "@juanibiapina/pi-extension-settings".to_string(),
13364 r"
13365export function getSetting(pi, key, defaultValue) { return defaultValue; }
13366export function setSetting(pi, key, value) {}
13367export function getSettings(pi) { return {}; }
13368"
13369 .trim()
13370 .to_string(),
13371 );
13372
13373 modules.insert(
13374 "@xterm/addon-serialize".to_string(),
13375 r"
13376export class SerializeAddon {
13377 activate(terminal) {}
13378 serialize(opts) { return ''; }
13379 dispose() {}
13380}
13381"
13382 .trim()
13383 .to_string(),
13384 );
13385
13386 modules.insert(
13387 "turndown-plugin-gfm".to_string(),
13388 r"
13389export function gfm(service) {}
13390export function tables(service) {}
13391export function strikethrough(service) {}
13392export function taskListItems(service) {}
13393"
13394 .trim()
13395 .to_string(),
13396 );
13397
13398 modules.insert(
13399 "@opentelemetry/exporter-trace-otlp-http".to_string(),
13400 r"
13401export class OTLPTraceExporter {
13402 constructor(opts) { this._opts = opts || {}; }
13403 export(spans, cb) { if (cb) cb({ code: 0 }); }
13404 shutdown() { return Promise.resolve(); }
13405}
13406"
13407 .trim()
13408 .to_string(),
13409 );
13410
13411 modules.insert(
13412 "@opentelemetry/resources".to_string(),
13413 r"
13414export class Resource {
13415 constructor(attrs) { this.attributes = attrs || {}; }
13416 merge(other) { return new Resource({ ...this.attributes, ...(other?.attributes || {}) }); }
13417}
13418export function resourceFromAttributes(attrs) { return new Resource(attrs); }
13419"
13420 .trim()
13421 .to_string(),
13422 );
13423
13424 modules.insert(
13425 "@opentelemetry/sdk-trace-base".to_string(),
13426 r"
13427const noopSpan = { setAttribute() { return this; }, end() {}, isRecording() { return false; }, spanContext() { return {}; } };
13428export class BasicTracerProvider {
13429 constructor(opts) { this._opts = opts || {}; }
13430 addSpanProcessor(p) {}
13431 register() {}
13432 getTracer() { return { startSpan() { return noopSpan; }, startActiveSpan(n, fn) { return fn(noopSpan); } }; }
13433 shutdown() { return Promise.resolve(); }
13434}
13435export class SimpleSpanProcessor {
13436 constructor(exporter) {}
13437 onStart() {}
13438 onEnd() {}
13439 shutdown() { return Promise.resolve(); }
13440 forceFlush() { return Promise.resolve(); }
13441}
13442export class BatchSpanProcessor extends SimpleSpanProcessor {}
13443"
13444 .trim()
13445 .to_string(),
13446 );
13447
13448 modules.insert(
13449 "@opentelemetry/semantic-conventions".to_string(),
13450 r"
13451export const SemanticResourceAttributes = {
13452 SERVICE_NAME: 'service.name',
13453 SERVICE_VERSION: 'service.version',
13454 DEPLOYMENT_ENVIRONMENT: 'deployment.environment',
13455};
13456export const SEMRESATTRS_SERVICE_NAME = 'service.name';
13457export const SEMRESATTRS_SERVICE_VERSION = 'service.version';
13458"
13459 .trim()
13460 .to_string(),
13461 );
13462
13463 {
13466 let openclaw_plugin_sdk = r#"
13467export function definePlugin(spec = {}) { return spec; }
13468export function createPlugin(spec = {}) { return spec; }
13469export function tool(spec = {}) { return { ...spec, type: "tool" }; }
13470export function command(spec = {}) { return { ...spec, type: "command" }; }
13471export function provider(spec = {}) { return { ...spec, type: "provider" }; }
13472export const DEFAULT_ACCOUNT_ID = "default";
13473const __schema = {
13474 parse(value) { return value; },
13475 safeParse(value) { return { success: true, data: value }; },
13476 optional() { return this; },
13477 nullable() { return this; },
13478 default() { return this; },
13479 array() { return this; },
13480 transform() { return this; },
13481 refine() { return this; },
13482};
13483export const emptyPluginConfigSchema = __schema;
13484export function createReplyPrefixContext() { return {}; }
13485export function stringEnum(values = []) { return values[0] ?? ""; }
13486export function getChatChannelMeta() { return {}; }
13487export function addWildcardAllowFrom() { return []; }
13488export function listFeishuAccountIds() { return []; }
13489export function normalizeAccountId(value) { return String(value ?? ""); }
13490export function jsonResult(value) {
13491 return {
13492 content: [{ type: "text", text: JSON.stringify(value ?? null) }],
13493 details: { value },
13494 };
13495}
13496export function stripAnsi(value) {
13497 return String(value ?? "").replace(/\u001b\[[0-9;]*m/g, "");
13498}
13499export function recordInboundSession() { return undefined; }
13500export class OpenClawPlugin {
13501 constructor(spec = {}) { this.spec = spec; }
13502 async activate(pi) {
13503 const plugin = this.spec || {};
13504 if (Array.isArray(plugin.tools)) {
13505 for (const t of plugin.tools) {
13506 if (!t || !t.name) continue;
13507 const execute = typeof t.execute === "function" ? t.execute : async () => ({ content: [] });
13508 pi.registerTool?.({ ...t, execute });
13509 }
13510 }
13511 if (Array.isArray(plugin.commands)) {
13512 for (const c of plugin.commands) {
13513 if (!c || !c.name) continue;
13514 const handler = typeof c.handler === "function" ? c.handler : async () => ({});
13515 pi.registerCommand?.(c.name, { ...c, handler });
13516 }
13517 }
13518 if (typeof plugin.activate === "function") {
13519 await plugin.activate(pi);
13520 }
13521 }
13522}
13523export async function registerOpenClaw(pi, plugin) {
13524 if (typeof plugin === "function") {
13525 return await plugin(pi);
13526 }
13527 if (plugin && typeof plugin.default === "function") {
13528 return await plugin.default(pi);
13529 }
13530 if (plugin && typeof plugin.activate === "function") {
13531 return await plugin.activate(pi);
13532 }
13533 return undefined;
13534}
13535export default {
13536 definePlugin,
13537 createPlugin,
13538 tool,
13539 command,
13540 provider,
13541 DEFAULT_ACCOUNT_ID,
13542 emptyPluginConfigSchema,
13543 createReplyPrefixContext,
13544 stringEnum,
13545 getChatChannelMeta,
13546 addWildcardAllowFrom,
13547 listFeishuAccountIds,
13548 normalizeAccountId,
13549 jsonResult,
13550 stripAnsi,
13551 recordInboundSession,
13552 registerOpenClaw,
13553 OpenClawPlugin,
13554};
13555"#
13556 .trim()
13557 .to_string();
13558
13559 modules.insert(
13560 "openclaw/plugin-sdk".to_string(),
13561 openclaw_plugin_sdk.clone(),
13562 );
13563 modules.insert(
13564 "openclaw/plugin-sdk/index.js".to_string(),
13565 openclaw_plugin_sdk.clone(),
13566 );
13567 modules.insert(
13568 "clawdbot/plugin-sdk".to_string(),
13569 openclaw_plugin_sdk.clone(),
13570 );
13571 modules.insert(
13572 "clawdbot/plugin-sdk/index.js".to_string(),
13573 openclaw_plugin_sdk,
13574 );
13575 }
13576
13577 modules.insert(
13578 "zod".to_string(),
13579 r"
13580const __schema = {
13581 parse(value) { return value; },
13582 safeParse(value) { return { success: true, data: value }; },
13583 optional() { return this; },
13584 nullable() { return this; },
13585 nullish() { return this; },
13586 default() { return this; },
13587 array() { return this; },
13588 transform() { return this; },
13589 refine() { return this; },
13590 describe() { return this; },
13591 min() { return this; },
13592 max() { return this; },
13593 length() { return this; },
13594 regex() { return this; },
13595 url() { return this; },
13596 email() { return this; },
13597 uuid() { return this; },
13598 int() { return this; },
13599 positive() { return this; },
13600 nonnegative() { return this; },
13601 nonempty() { return this; },
13602};
13603function makeSchema() { return Object.create(__schema); }
13604export const z = {
13605 string() { return makeSchema(); },
13606 number() { return makeSchema(); },
13607 boolean() { return makeSchema(); },
13608 object() { return makeSchema(); },
13609 array() { return makeSchema(); },
13610 enum() { return makeSchema(); },
13611 literal() { return makeSchema(); },
13612 union() { return makeSchema(); },
13613 intersection() { return makeSchema(); },
13614 record() { return makeSchema(); },
13615 any() { return makeSchema(); },
13616 unknown() { return makeSchema(); },
13617 null() { return makeSchema(); },
13618 undefined() { return makeSchema(); },
13619 optional(inner) { return inner ?? makeSchema(); },
13620 nullable(inner) { return inner ?? makeSchema(); },
13621};
13622export default z;
13623"
13624 .trim()
13625 .to_string(),
13626 );
13627
13628 modules.insert(
13629 "yaml".to_string(),
13630 r##"
13631export function parse(input) {
13632 const text = String(input ?? "").trim();
13633 if (!text) return {};
13634 const out = {};
13635 for (const rawLine of text.split(/\r?\n/)) {
13636 const line = rawLine.trim();
13637 if (!line || line.startsWith("#")) continue;
13638 const idx = line.indexOf(":");
13639 if (idx === -1) continue;
13640 const key = line.slice(0, idx).trim();
13641 const value = line.slice(idx + 1).trim();
13642 if (key) out[key] = value;
13643 }
13644 return out;
13645}
13646export function stringify(value) {
13647 if (!value || typeof value !== "object") return "";
13648 const lines = Object.entries(value).map(([k, v]) => `${k}: ${v ?? ""}`);
13649 return lines.length ? `${lines.join("\n")}\n` : "";
13650}
13651export default { parse, stringify };
13652"##
13653 .trim()
13654 .to_string(),
13655 );
13656
13657 modules.insert(
13658 "better-sqlite3".to_string(),
13659 r#"
13660class Statement {
13661 all() { return []; }
13662 get() { return undefined; }
13663 run() { return { changes: 0, lastInsertRowid: 0 }; }
13664}
13665
13666function BetterSqlite3(filename, options = {}) {
13667 if (!(this instanceof BetterSqlite3)) return new BetterSqlite3(filename, options);
13668 this.filename = String(filename ?? "");
13669 this.options = options;
13670}
13671
13672BetterSqlite3.prototype.prepare = function(_sql) { return new Statement(); };
13673BetterSqlite3.prototype.exec = function(_sql) { return this; };
13674BetterSqlite3.prototype.pragma = function(_sql) { return []; };
13675BetterSqlite3.prototype.transaction = function(fn) {
13676 const wrapped = (...args) => (typeof fn === "function" ? fn(...args) : undefined);
13677 wrapped.immediate = wrapped;
13678 wrapped.deferred = wrapped;
13679 wrapped.exclusive = wrapped;
13680 return wrapped;
13681};
13682BetterSqlite3.prototype.close = function() {};
13683
13684BetterSqlite3.Statement = Statement;
13685BetterSqlite3.Database = BetterSqlite3;
13686
13687export { Statement };
13688export default BetterSqlite3;
13689"#
13690 .trim()
13691 .to_string(),
13692 );
13693
13694 modules.insert(
13695 "ws".to_string(),
13696 r#"
13697function __makeEmitter(target) {
13698 target._listeners = {};
13699 target.on = function(event, handler) {
13700 if (!this._listeners[event]) this._listeners[event] = [];
13701 this._listeners[event].push(handler);
13702 return this;
13703 };
13704 target.once = function(event, handler) {
13705 const wrapper = (...args) => {
13706 this.off(event, wrapper);
13707 handler(...args);
13708 };
13709 return this.on(event, wrapper);
13710 };
13711 target.off = function(event, handler) {
13712 const list = this._listeners[event];
13713 if (!list) return this;
13714 const idx = list.indexOf(handler);
13715 if (idx >= 0) list.splice(idx, 1);
13716 return this;
13717 };
13718 target.emit = function(event, ...args) {
13719 const list = this._listeners[event];
13720 if (!list) return false;
13721 list.slice().forEach(fn => fn(...args));
13722 return true;
13723 };
13724 target.addEventListener = target.on;
13725 target.removeEventListener = target.off;
13726 return target;
13727}
13728
13729export class WebSocket {
13730 constructor(url, protocols) {
13731 this.url = String(url ?? "");
13732 this.protocol = Array.isArray(protocols) ? (protocols[0] ?? "") : (protocols || "");
13733 this.extensions = "";
13734 this.readyState = WebSocket.CONNECTING;
13735 this.binaryType = "nodebuffer";
13736 this.bufferedAmount = 0;
13737 __makeEmitter(this);
13738 const schedule = (fn) => {
13739 if (typeof globalThis.queueMicrotask === "function") {
13740 globalThis.queueMicrotask(fn);
13741 return;
13742 }
13743 if (typeof globalThis.setTimeout === "function") {
13744 globalThis.setTimeout(fn, 0);
13745 return;
13746 }
13747 try {
13748 Promise.resolve().then(fn);
13749 } catch (_err) {
13750 fn();
13751 }
13752 };
13753 schedule(() => {
13754 this.readyState = WebSocket.OPEN;
13755 const evt = { type: "open" };
13756 if (typeof this.onopen === "function") this.onopen(evt);
13757 this.emit("open", evt);
13758 });
13759 }
13760 send(_data, cb) { if (typeof cb === "function") cb(); }
13761 close(code, reason) {
13762 if (this.readyState === WebSocket.CLOSED) return;
13763 this.readyState = WebSocket.CLOSING;
13764 this.readyState = WebSocket.CLOSED;
13765 const evt = { code: code ?? 1000, reason: reason ?? "", wasClean: true };
13766 if (typeof this.onclose === "function") this.onclose(evt);
13767 this.emit("close", evt);
13768 }
13769 terminate() { this.close(); }
13770}
13771WebSocket.CONNECTING = 0;
13772WebSocket.OPEN = 1;
13773WebSocket.CLOSING = 2;
13774WebSocket.CLOSED = 3;
13775
13776export class WebSocketServer {
13777 constructor(options = {}) {
13778 this.options = options;
13779 this.clients = new Set();
13780 __makeEmitter(this);
13781 }
13782 handleUpgrade(_req, _socket, _head, cb) {
13783 const ws = new WebSocket("ws://stub");
13784 this.clients.add(ws);
13785 if (typeof cb === "function") cb(ws);
13786 this.emit("connection", ws);
13787 }
13788 close(cb) { if (typeof cb === "function") cb(); }
13789}
13790
13791WebSocket.Server = WebSocketServer;
13792WebSocket.WebSocketServer = WebSocketServer;
13793export const Server = WebSocketServer;
13794export default WebSocket;
13795"#
13796 .trim()
13797 .to_string(),
13798 );
13799
13800 modules.insert(
13801 "axios".to_string(),
13802 r#"
13803function __makeResponse(config, data) {
13804 return {
13805 data: data ?? null,
13806 status: 200,
13807 statusText: "OK",
13808 headers: {},
13809 config: config ?? {},
13810 request: {},
13811 };
13812}
13813
13814async function axios(config = {}) {
13815 return __makeResponse(config, config.data);
13816}
13817
13818axios.request = (config) => axios(config);
13819axios.defaults = {};
13820axios.get = (url, config = {}) => axios({ ...config, url, method: "get" });
13821axios.delete = (url, config = {}) => axios({ ...config, url, method: "delete" });
13822axios.head = (url, config = {}) => axios({ ...config, url, method: "head" });
13823axios.options = (url, config = {}) => axios({ ...config, url, method: "options" });
13824axios.post = (url, data, config = {}) => axios({ ...config, url, data, method: "post" });
13825axios.put = (url, data, config = {}) => axios({ ...config, url, data, method: "put" });
13826axios.patch = (url, data, config = {}) => axios({ ...config, url, data, method: "patch" });
13827axios.create = (defaults = {}) => {
13828 const instance = (config = {}) => axios({ ...defaults, ...config });
13829 instance.request = (config) => instance(config);
13830 instance.get = (url, config = {}) => instance({ ...config, url, method: "get" });
13831 instance.delete = (url, config = {}) => instance({ ...config, url, method: "delete" });
13832 instance.head = (url, config = {}) => instance({ ...config, url, method: "head" });
13833 instance.options = (url, config = {}) => instance({ ...config, url, method: "options" });
13834 instance.post = (url, data, config = {}) => instance({ ...config, url, data, method: "post" });
13835 instance.put = (url, data, config = {}) => instance({ ...config, url, data, method: "put" });
13836 instance.patch = (url, data, config = {}) => instance({ ...config, url, data, method: "patch" });
13837 instance.defaults = { ...defaults };
13838 return instance;
13839};
13840axios.isAxiosError = (err) => !!(err && err.isAxiosError);
13841axios.AxiosError = class AxiosError extends Error {
13842 constructor(message) {
13843 super(message || "AxiosError");
13844 this.isAxiosError = true;
13845 }
13846};
13847axios.Cancel = class Cancel extends Error {
13848 constructor(message) {
13849 super(message || "Cancel");
13850 this.__CANCEL__ = true;
13851 }
13852};
13853axios.CancelToken = {
13854 source() { return { token: {}, cancel: () => {} }; },
13855};
13856axios.all = (promises) => Promise.all(promises);
13857axios.spread = (cb) => (arr) => cb(...arr);
13858
13859export default axios;
13860"#
13861 .trim()
13862 .to_string(),
13863 );
13864
13865 modules.insert(
13866 "open".to_string(),
13867 r"
13868async function open(_target, _options = {}) {
13869 return {
13870 pid: 0,
13871 stdout: null,
13872 stderr: null,
13873 stdin: null,
13874 unref() {},
13875 kill() {},
13876 on() {},
13877 once() {},
13878 };
13879}
13880
13881open.apps = {};
13882open.openApp = open;
13883
13884export const apps = open.apps;
13885export const openApp = open.openApp;
13886export default open;
13887"
13888 .trim()
13889 .to_string(),
13890 );
13891
13892 modules.insert(
13893 "commander".to_string(),
13894 r#"
13895export class Option {
13896 constructor(flags, description) {
13897 this.flags = flags;
13898 this.description = description;
13899 this.defaultValue = undefined;
13900 }
13901 default(value) { this.defaultValue = value; return this; }
13902}
13903
13904export class Argument {
13905 constructor(name, description) {
13906 this.name = name;
13907 this.description = description;
13908 this.defaultValue = undefined;
13909 }
13910 default(value) { this.defaultValue = value; return this; }
13911}
13912
13913export class Command {
13914 constructor(name) {
13915 this._name = name || "";
13916 this._options = [];
13917 this._args = [];
13918 this._commands = [];
13919 this._action = null;
13920 this.args = [];
13921 }
13922 name(value) { if (value !== undefined) this._name = value; return this; }
13923 description(_value) { return this; }
13924 version(_value, _flags, _desc) { return this; }
13925 option(_flags, _desc, _defaultValue) { return this; }
13926 argument(_name, _desc, _defaultValue) { return this; }
13927 addOption(_opt) { return this; }
13928 addCommand(cmd) { this._commands.push(cmd); return this; }
13929 command(name) { const cmd = new Command(name); this._commands.push(cmd); return cmd; }
13930 action(fn) { this._action = fn; return this; }
13931 parse(_argv, _opts) { if (typeof this._action === "function") this._action(); return this; }
13932 parseAsync(argv, opts) { this.parse(argv, opts); return Promise.resolve(this); }
13933 opts() { return {}; }
13934 optsWithGlobals() { return {}; }
13935 requiredOption() { return this; }
13936 allowUnknownOption() { return this; }
13937 allowExcessArguments() { return this; }
13938 help() { return this; }
13939 outputHelp() { return this; }
13940}
13941
13942export function createCommand(name) { return new Command(name); }
13943export const program = new Command();
13944export default { Command, Option, Argument, program, createCommand };
13945"#
13946 .trim()
13947 .to_string(),
13948 );
13949
13950 modules.insert(
13951 "chalk".to_string(),
13952 r#"
13953function chalk(...args) {
13954 return args.join("");
13955}
13956
13957const styles = [
13958 "reset", "bold", "dim", "italic", "underline", "inverse", "hidden", "strikethrough",
13959 "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray",
13960 "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta", "bgCyan", "bgWhite",
13961];
13962
13963for (const style of styles) {
13964 chalk[style] = chalk;
13965}
13966
13967chalk.hex = () => chalk;
13968chalk.rgb = () => chalk;
13969chalk.ansi256 = () => chalk;
13970chalk.level = 0;
13971chalk.supportsColor = false;
13972chalk.enabled = true;
13973
13974export class Chalk {
13975 constructor() { return chalk; }
13976}
13977
13978export default chalk;
13979export { chalk };
13980"#
13981 .trim()
13982 .to_string(),
13983 );
13984
13985 modules.insert(
13986 "@mariozechner/pi-agent-core".to_string(),
13987 r#"
13988export const ThinkingLevel = {
13989 low: "low",
13990 medium: "medium",
13991 high: "high",
13992};
13993export class AgentTool {}
13994export default { ThinkingLevel, AgentTool };
13995"#
13996 .trim()
13997 .to_string(),
13998 );
13999
14000 modules.insert(
14001 "@mariozechner/pi-agent-core/index.js".to_string(),
14002 r#"
14003export const ThinkingLevel = {
14004 low: "low",
14005 medium: "medium",
14006 high: "high",
14007};
14008export class AgentTool {}
14009export default { ThinkingLevel, AgentTool };
14010"#
14011 .trim()
14012 .to_string(),
14013 );
14014
14015 modules.insert(
14016 "openai".to_string(),
14017 r#"
14018class OpenAI {
14019 constructor(config = {}) { this.config = config; }
14020 get chat() {
14021 return { completions: { create: async () => ({ choices: [{ message: { content: "" } }] }) } };
14022 }
14023}
14024export default OpenAI;
14025export { OpenAI };
14026"#
14027 .trim()
14028 .to_string(),
14029 );
14030
14031 modules.insert(
14032 "adm-zip".to_string(),
14033 r#"
14034class AdmZip {
14035 constructor(path) { this.path = path; this.entries = []; }
14036 getEntries() { return this.entries; }
14037 readAsText() { return ""; }
14038 extractAllTo() {}
14039 addFile() {}
14040 writeZip() {}
14041}
14042export default AdmZip;
14043"#
14044 .trim()
14045 .to_string(),
14046 );
14047
14048 modules.insert(
14049 "linkedom".to_string(),
14050 r#"
14051export function parseHTML(html) {
14052 const doc = {
14053 documentElement: { outerHTML: html || "" },
14054 querySelector: () => null,
14055 querySelectorAll: () => [],
14056 createElement: (tag) => ({ tagName: tag, textContent: "", innerHTML: "", children: [], appendChild() {} }),
14057 body: { textContent: "", innerHTML: "", children: [] },
14058 title: "",
14059 };
14060 return { document: doc, window: { document: doc } };
14061}
14062"#
14063 .trim()
14064 .to_string(),
14065 );
14066
14067 modules.insert(
14068 "@sourcegraph/scip-typescript".to_string(),
14069 r"
14070export const scip = { Index: class {} };
14071export default { scip };
14072"
14073 .trim()
14074 .to_string(),
14075 );
14076
14077 modules.insert(
14078 "@sourcegraph/scip-typescript/dist/src/scip.js".to_string(),
14079 r"
14080export const scip = { Index: class {} };
14081export default { scip };
14082"
14083 .trim()
14084 .to_string(),
14085 );
14086
14087 modules.insert(
14088 "p-limit".to_string(),
14089 r"
14090export default function pLimit(concurrency) {
14091 const queue = [];
14092 let active = 0;
14093 const next = () => {
14094 active--;
14095 if (queue.length > 0) queue.shift()();
14096 };
14097 const run = async (fn, resolve, ...args) => {
14098 active++;
14099 const result = (async () => fn(...args))();
14100 resolve(result);
14101 try { await result; } catch {}
14102 next();
14103 };
14104 const enqueue = (fn, resolve, ...args) => {
14105 queue.push(run.bind(null, fn, resolve, ...args));
14106 (async () => { if (active < concurrency && queue.length > 0) queue.shift()(); })();
14107 };
14108 const generator = (fn, ...args) => new Promise(resolve => enqueue(fn, resolve, ...args));
14109 Object.defineProperties(generator, {
14110 activeCount: { get: () => active },
14111 pendingCount: { get: () => queue.length },
14112 clearQueue: { value: () => { queue.length = 0; } },
14113 });
14114 return generator;
14115}
14116"
14117 .trim()
14118 .to_string(),
14119 );
14120
14121 modules.insert(
14123 "@sourcegraph/scip-typescript/dist/src/main.js".to_string(),
14124 r"
14125export function main() { return 0; }
14126export function run() { return 0; }
14127export default main;
14128"
14129 .trim()
14130 .to_string(),
14131 );
14132
14133 modules.insert(
14134 "unpdf".to_string(),
14135 r#"
14136export async function getDocumentProxy(data) {
14137 return { numPages: 0, getPage: async () => ({ getTextContent: async () => ({ items: [] }) }) };
14138}
14139export async function extractText(data) { return { totalPages: 0, text: "" }; }
14140export async function renderPageAsImage() { return new Uint8Array(); }
14141"#
14142 .trim()
14143 .to_string(),
14144 );
14145
14146 modules.insert(
14147 "@sourcegraph/scip-python".to_string(),
14148 r"
14149export class PythonIndexer { async index() { return []; } }
14150export default { PythonIndexer };
14151"
14152 .trim()
14153 .to_string(),
14154 );
14155
14156 modules.insert(
14157 "@sourcegraph/scip-python/index.js".to_string(),
14158 r"
14159export class PythonIndexer { async index() { return []; } }
14160export default { PythonIndexer };
14161"
14162 .trim()
14163 .to_string(),
14164 );
14165
14166 modules
14167}
14168
14169fn default_virtual_modules_shared() -> Arc<HashMap<String, String>> {
14170 static DEFAULT_VIRTUAL_MODULES: std::sync::OnceLock<Arc<HashMap<String, String>>> =
14171 std::sync::OnceLock::new();
14172 Arc::clone(DEFAULT_VIRTUAL_MODULES.get_or_init(|| Arc::new(default_virtual_modules())))
14173}
14174
14175#[must_use]
14180pub fn available_virtual_module_names() -> std::collections::BTreeSet<String> {
14181 default_virtual_modules_shared().keys().cloned().collect()
14182}
14183
14184const UNBOUNDED_MEMORY_USAGE_SAMPLE_EVERY_TICKS: u64 = 32;
14190
14191pub struct PiJsRuntime<C: SchedulerClock = WallClock> {
14226 runtime: AsyncRuntime,
14227 context: AsyncContext,
14228 scheduler: Rc<RefCell<Scheduler<C>>>,
14229 hostcall_queue: HostcallQueue,
14230 trace_seq: Arc<AtomicU64>,
14231 hostcall_tracker: Rc<RefCell<HostcallTracker>>,
14232 hostcalls_total: Arc<AtomicU64>,
14233 hostcalls_timed_out: Arc<AtomicU64>,
14234 last_memory_used_bytes: Arc<AtomicU64>,
14235 peak_memory_used_bytes: Arc<AtomicU64>,
14236 tick_counter: Arc<AtomicU64>,
14237 interrupt_budget: Rc<InterruptBudget>,
14238 config: PiJsRuntimeConfig,
14239 allowed_read_roots: Arc<std::sync::Mutex<Vec<PathBuf>>>,
14242 repair_events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>>,
14245 module_state: Rc<RefCell<PiJsModuleState>>,
14249 policy: Option<ExtensionPolicy>,
14251}
14252
14253#[derive(Debug, Clone, Default, serde::Deserialize)]
14254#[serde(rename_all = "camelCase")]
14255struct JsRuntimeRegistrySnapshot {
14256 extensions: u64,
14257 tools: u64,
14258 commands: u64,
14259 hooks: u64,
14260 event_bus_hooks: u64,
14261 providers: u64,
14262 shortcuts: u64,
14263 message_renderers: u64,
14264 pending_tasks: u64,
14265 pending_hostcalls: u64,
14266 pending_timers: u64,
14267 pending_event_listener_lists: u64,
14268 provider_streams: u64,
14269}
14270
14271#[derive(Debug, Clone, serde::Deserialize)]
14272struct JsRuntimeResetPayload {
14273 before: JsRuntimeRegistrySnapshot,
14274 after: JsRuntimeRegistrySnapshot,
14275 clean: bool,
14276}
14277
14278#[derive(Debug, Clone, Default)]
14279pub struct PiJsWarmResetReport {
14280 pub reused: bool,
14281 pub reason_code: Option<String>,
14282 pub rust_pending_hostcalls: u64,
14283 pub rust_pending_hostcall_queue: u64,
14284 pub rust_scheduler_pending: bool,
14285 pub pending_tasks_before: u64,
14286 pub pending_hostcalls_before: u64,
14287 pub pending_timers_before: u64,
14288 pub residual_entries_after: u64,
14289 pub dynamic_module_invalidations: u64,
14290 pub module_cache_hits: u64,
14291 pub module_cache_misses: u64,
14292 pub module_cache_invalidations: u64,
14293 pub module_cache_entries: u64,
14294}
14295
14296#[allow(clippy::future_not_send)]
14297impl PiJsRuntime<WallClock> {
14298 #[allow(clippy::future_not_send)]
14300 pub async fn new() -> Result<Self> {
14301 Self::with_clock(WallClock).await
14302 }
14303}
14304
14305#[allow(clippy::future_not_send)]
14306impl<C: SchedulerClock + 'static> PiJsRuntime<C> {
14307 #[allow(clippy::future_not_send)]
14309 pub async fn with_clock(clock: C) -> Result<Self> {
14310 Self::with_clock_and_config(clock, PiJsRuntimeConfig::default()).await
14311 }
14312
14313 #[allow(clippy::future_not_send)]
14315 pub async fn with_clock_and_config(clock: C, config: PiJsRuntimeConfig) -> Result<Self> {
14316 Self::with_clock_and_config_with_policy(clock, config, None).await
14317 }
14318
14319 #[allow(clippy::future_not_send, clippy::too_many_lines)]
14321 pub async fn with_clock_and_config_with_policy(
14322 clock: C,
14323 mut config: PiJsRuntimeConfig,
14324 policy: Option<ExtensionPolicy>,
14325 ) -> Result<Self> {
14326 #[cfg(target_arch = "x86_64")]
14328 config
14329 .env
14330 .entry("PI_TARGET_ARCH".to_string())
14331 .or_insert_with(|| "x64".to_string());
14332 #[cfg(target_arch = "aarch64")]
14333 config
14334 .env
14335 .entry("PI_TARGET_ARCH".to_string())
14336 .or_insert_with(|| "arm64".to_string());
14337 #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
14338 config
14339 .env
14340 .entry("PI_TARGET_ARCH".to_string())
14341 .or_insert_with(|| "x64".to_string());
14342
14343 {
14346 let platform = match std::env::consts::OS {
14347 "macos" => "darwin",
14348 "windows" => "win32",
14349 other => other,
14350 };
14351 config
14352 .env
14353 .entry("PI_PLATFORM".to_string())
14354 .or_insert_with(|| platform.to_string());
14355 }
14356
14357 let runtime = AsyncRuntime::new().map_err(|err| map_js_error(&err))?;
14358 if let Some(limit) = config.limits.memory_limit_bytes {
14359 runtime.set_memory_limit(limit).await;
14360 }
14361 if let Some(limit) = config.limits.max_stack_bytes {
14362 runtime.set_max_stack_size(limit).await;
14363 }
14364
14365 let interrupt_budget = Rc::new(InterruptBudget::new(config.limits.interrupt_budget));
14366 if config.limits.interrupt_budget.is_some() {
14367 let budget = Rc::clone(&interrupt_budget);
14368 runtime
14369 .set_interrupt_handler(Some(Box::new(move || budget.on_interrupt())))
14370 .await;
14371 }
14372
14373 let repair_events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>> =
14374 Arc::new(std::sync::Mutex::new(Vec::new()));
14375 let module_state = Rc::new(RefCell::new(
14376 PiJsModuleState::new()
14377 .with_repair_mode(config.repair_mode)
14378 .with_repair_events(Arc::clone(&repair_events))
14379 .with_disk_cache_dir(config.disk_cache_dir.clone()),
14380 ));
14381 runtime
14382 .set_loader(
14383 PiJsResolver {
14384 state: Rc::clone(&module_state),
14385 },
14386 PiJsLoader {
14387 state: Rc::clone(&module_state),
14388 },
14389 )
14390 .await;
14391
14392 let context = AsyncContext::full(&runtime)
14393 .await
14394 .map_err(|err| map_js_error(&err))?;
14395
14396 let scheduler = Rc::new(RefCell::new(Scheduler::with_clock(clock)));
14397 let fast_queue_capacity = if config.limits.hostcall_fast_queue_capacity == 0 {
14398 HOSTCALL_FAST_RING_CAPACITY
14399 } else {
14400 config.limits.hostcall_fast_queue_capacity
14401 };
14402 let overflow_queue_capacity = if config.limits.hostcall_overflow_queue_capacity == 0 {
14403 HOSTCALL_OVERFLOW_CAPACITY
14404 } else {
14405 config.limits.hostcall_overflow_queue_capacity
14406 };
14407 let hostcall_queue: HostcallQueue = Rc::new(RefCell::new(
14408 HostcallRequestQueue::with_capacities(fast_queue_capacity, overflow_queue_capacity),
14409 ));
14410 let hostcall_tracker = Rc::new(RefCell::new(HostcallTracker::default()));
14411 let hostcalls_total = Arc::new(AtomicU64::new(0));
14412 let hostcalls_timed_out = Arc::new(AtomicU64::new(0));
14413 let last_memory_used_bytes = Arc::new(AtomicU64::new(0));
14414 let peak_memory_used_bytes = Arc::new(AtomicU64::new(0));
14415 let tick_counter = Arc::new(AtomicU64::new(0));
14416 let trace_seq = Arc::new(AtomicU64::new(1));
14417
14418 let instance = Self {
14419 runtime,
14420 context,
14421 scheduler,
14422 hostcall_queue,
14423 trace_seq,
14424 hostcall_tracker,
14425 hostcalls_total,
14426 hostcalls_timed_out,
14427 last_memory_used_bytes,
14428 peak_memory_used_bytes,
14429 tick_counter,
14430 interrupt_budget,
14431 config,
14432 allowed_read_roots: Arc::new(std::sync::Mutex::new(Vec::new())),
14433 repair_events,
14434 module_state,
14435 policy,
14436 };
14437
14438 instance.install_pi_bridge().await?;
14439 Ok(instance)
14440 }
14441
14442 async fn map_quickjs_error(&self, err: &rquickjs::Error) -> Error {
14443 if self.interrupt_budget.did_trip() {
14444 self.interrupt_budget.clear_trip();
14445 return Error::extension("PiJS execution budget exceeded".to_string());
14446 }
14447 if matches!(err, rquickjs::Error::Exception) {
14448 let detail = self
14449 .context
14450 .with(|ctx| {
14451 let caught = ctx.catch();
14452 Ok::<String, rquickjs::Error>(format_quickjs_exception(&ctx, caught))
14453 })
14454 .await
14455 .ok();
14456 if let Some(detail) = detail {
14457 let detail = detail.trim();
14458 if !detail.is_empty() && detail != "undefined" {
14459 return Error::extension(format!("QuickJS exception: {detail}"));
14460 }
14461 }
14462 }
14463 map_js_error(err)
14464 }
14465
14466 fn map_quickjs_job_error<E: std::fmt::Display>(&self, err: E) -> Error {
14467 if self.interrupt_budget.did_trip() {
14468 self.interrupt_budget.clear_trip();
14469 return Error::extension("PiJS execution budget exceeded".to_string());
14470 }
14471 Error::extension(format!("QuickJS job: {err}"))
14472 }
14473
14474 fn should_sample_memory_usage(&self) -> bool {
14475 if self.config.limits.memory_limit_bytes.is_some() {
14476 return true;
14477 }
14478
14479 let tick = self.tick_counter.fetch_add(1, AtomicOrdering::SeqCst) + 1;
14480 tick == 1 || (tick % UNBOUNDED_MEMORY_USAGE_SAMPLE_EVERY_TICKS == 0)
14481 }
14482
14483 fn module_cache_snapshot(&self) -> (u64, u64, u64, u64, u64) {
14484 let state = self.module_state.borrow();
14485 let entries = u64::try_from(state.compiled_sources.len()).unwrap_or(u64::MAX);
14486 (
14487 state.module_cache_counters.hits,
14488 state.module_cache_counters.misses,
14489 state.module_cache_counters.invalidations,
14490 entries,
14491 state.module_cache_counters.disk_hits,
14492 )
14493 }
14494
14495 #[allow(clippy::future_not_send, clippy::too_many_lines)]
14496 pub async fn reset_for_warm_reload(&self) -> Result<PiJsWarmResetReport> {
14497 let rust_pending_hostcalls =
14498 u64::try_from(self.hostcall_tracker.borrow().pending_count()).unwrap_or(u64::MAX);
14499 let rust_pending_hostcall_queue =
14500 u64::try_from(self.hostcall_queue.borrow().len()).unwrap_or(u64::MAX);
14501 let rust_scheduler_pending = self.scheduler.borrow().has_pending();
14502
14503 let mut report = PiJsWarmResetReport {
14504 rust_pending_hostcalls,
14505 rust_pending_hostcall_queue,
14506 rust_scheduler_pending,
14507 ..PiJsWarmResetReport::default()
14508 };
14509
14510 if rust_pending_hostcalls > 0 || rust_pending_hostcall_queue > 0 || rust_scheduler_pending {
14511 report.reason_code = Some("pending_rust_work".to_string());
14512 return Ok(report);
14513 }
14514
14515 let reset_payload_value = match self
14516 .context
14517 .with(|ctx| {
14518 let global = ctx.globals();
14519 let reset_fn: Function<'_> = global.get("__pi_reset_extension_runtime_state")?;
14520 let value: Value<'_> = reset_fn.call(())?;
14521 js_to_json(&value)
14522 })
14523 .await
14524 {
14525 Ok(value) => value,
14526 Err(err) => return Err(self.map_quickjs_error(&err).await),
14527 };
14528
14529 let reset_payload: JsRuntimeResetPayload = serde_json::from_value(reset_payload_value)
14530 .map_err(|err| {
14531 Error::extension(format!("PiJS warm reset payload decode failed: {err}"))
14532 })?;
14533
14534 report.pending_tasks_before = reset_payload.before.pending_tasks;
14535 report.pending_hostcalls_before = reset_payload.before.pending_hostcalls;
14536 report.pending_timers_before = reset_payload.before.pending_timers;
14537
14538 let residual_after = reset_payload.after.extensions
14539 + reset_payload.after.tools
14540 + reset_payload.after.commands
14541 + reset_payload.after.hooks
14542 + reset_payload.after.event_bus_hooks
14543 + reset_payload.after.providers
14544 + reset_payload.after.shortcuts
14545 + reset_payload.after.message_renderers
14546 + reset_payload.after.pending_tasks
14547 + reset_payload.after.pending_hostcalls
14548 + reset_payload.after.pending_timers
14549 + reset_payload.after.pending_event_listener_lists
14550 + reset_payload.after.provider_streams;
14551 report.residual_entries_after = residual_after;
14552
14553 self.hostcall_queue.borrow_mut().clear();
14554 *self.hostcall_tracker.borrow_mut() = HostcallTracker::default();
14555
14556 if let Ok(mut roots) = self.allowed_read_roots.lock() {
14557 roots.clear();
14558 }
14559
14560 let mut dynamic_invalidations = 0_u64;
14561 {
14562 let mut state = self.module_state.borrow_mut();
14563 let dynamic_specs: Vec<String> =
14564 state.dynamic_virtual_modules.keys().cloned().collect();
14565 state.dynamic_virtual_modules.clear();
14566 state.dynamic_virtual_named_exports.clear();
14567 state.extension_roots.clear();
14568 state.canonical_extension_roots.clear();
14569 state.extension_root_tiers.clear();
14570 state.extension_root_scopes.clear();
14571 state.extension_roots_by_id.clear();
14572 state.extension_roots_without_id.clear();
14573
14574 for spec in dynamic_specs {
14575 if state.compiled_sources.remove(&spec).is_some() {
14576 dynamic_invalidations = dynamic_invalidations.saturating_add(1);
14577 }
14578 }
14579 if dynamic_invalidations > 0 {
14580 state.module_cache_counters.invalidations = state
14581 .module_cache_counters
14582 .invalidations
14583 .saturating_add(dynamic_invalidations);
14584 }
14585 }
14586 report.dynamic_module_invalidations = dynamic_invalidations;
14587
14588 let (cache_hits, cache_misses, cache_invalidations, cache_entries, _disk_hits) =
14589 self.module_cache_snapshot();
14590 report.module_cache_hits = cache_hits;
14591 report.module_cache_misses = cache_misses;
14592 report.module_cache_invalidations = cache_invalidations;
14593 report.module_cache_entries = cache_entries;
14594
14595 if report.pending_tasks_before > 0
14596 || report.pending_hostcalls_before > 0
14597 || report.pending_timers_before > 0
14598 {
14599 report.reason_code = Some("pending_js_work".to_string());
14600 return Ok(report);
14601 }
14602
14603 if !reset_payload.clean || residual_after > 0 {
14604 report.reason_code = Some("reset_residual_state".to_string());
14605 return Ok(report);
14606 }
14607
14608 report.reused = true;
14609 Ok(report)
14610 }
14611
14612 pub async fn eval(&self, source: &str) -> Result<()> {
14614 self.interrupt_budget.reset();
14615 match self.context.with(|ctx| ctx.eval::<(), _>(source)).await {
14616 Ok(()) => {}
14617 Err(err) => return Err(self.map_quickjs_error(&err).await),
14618 }
14619 self.drain_jobs().await?;
14621 Ok(())
14622 }
14623
14624 pub async fn call_global_void(&self, name: &str) -> Result<()> {
14629 self.interrupt_budget.reset();
14630 match self
14631 .context
14632 .with(|ctx| {
14633 let global = ctx.globals();
14634 let function: Function<'_> = global.get(name)?;
14635 function.call::<(), ()>(())?;
14636 Ok::<(), rquickjs::Error>(())
14637 })
14638 .await
14639 {
14640 Ok(()) => {}
14641 Err(err) => return Err(self.map_quickjs_error(&err).await),
14642 }
14643 self.drain_jobs().await?;
14644 Ok(())
14645 }
14646
14647 pub const fn repair_mode(&self) -> RepairMode {
14651 self.config.repair_mode
14652 }
14653
14654 pub const fn auto_repair_enabled(&self) -> bool {
14656 self.config.repair_mode.should_apply()
14657 }
14658
14659 pub fn record_repair(&self, event: ExtensionRepairEvent) {
14663 tracing::info!(
14664 event = "pijs.repair",
14665 extension_id = %event.extension_id,
14666 pattern = %event.pattern,
14667 success = event.success,
14668 repair_action = %event.repair_action,
14669 "auto-repair applied"
14670 );
14671 if let Ok(mut events) = self.repair_events.lock() {
14672 events.push(event);
14673 }
14674 }
14675
14676 pub fn drain_repair_events(&self) -> Vec<ExtensionRepairEvent> {
14680 self.repair_events
14681 .lock()
14682 .map(|mut v| std::mem::take(&mut *v))
14683 .unwrap_or_default()
14684 }
14685
14686 pub fn repair_count(&self) -> u64 {
14688 self.repair_events.lock().map_or(0, |v| v.len() as u64)
14689 }
14690
14691 pub fn reset_transient_state(&self) {
14699 let mut state = self.module_state.borrow_mut();
14700 state.extension_roots.clear();
14701 state.canonical_extension_roots.clear();
14702 state.extension_root_tiers.clear();
14703 state.extension_root_scopes.clear();
14704 state.extension_roots_by_id.clear();
14705 state.extension_roots_without_id.clear();
14706 state.dynamic_virtual_modules.clear();
14707 state.dynamic_virtual_named_exports.clear();
14708 state.module_cache_counters = ModuleCacheCounters::default();
14709 drop(state);
14713
14714 self.hostcall_queue.borrow_mut().clear();
14716 *self.hostcall_tracker.borrow_mut() = HostcallTracker::default();
14717 if let Ok(mut events) = self.repair_events.lock() {
14719 events.clear();
14720 }
14721 self.hostcalls_total
14723 .store(0, std::sync::atomic::Ordering::SeqCst);
14724 self.hostcalls_timed_out
14725 .store(0, std::sync::atomic::Ordering::SeqCst);
14726 self.tick_counter
14727 .store(0, std::sync::atomic::Ordering::SeqCst);
14728 }
14729
14730 pub async fn eval_file(&self, path: &std::path::Path) -> Result<()> {
14732 self.interrupt_budget.reset();
14733 match self.context.with(|ctx| ctx.eval_file::<(), _>(path)).await {
14734 Ok(()) => {}
14735 Err(err) => return Err(self.map_quickjs_error(&err).await),
14736 }
14737 self.drain_jobs().await?;
14738 Ok(())
14739 }
14740
14741 pub(crate) async fn with_ctx<F, R>(&self, f: F) -> Result<R>
14746 where
14747 F: for<'js> FnOnce(Ctx<'js>) -> rquickjs::Result<R> + rquickjs::markers::ParallelSend,
14748 R: rquickjs::markers::ParallelSend,
14749 {
14750 self.interrupt_budget.reset();
14751 match self.context.with(f).await {
14752 Ok(value) => Ok(value),
14753 Err(err) => Err(self.map_quickjs_error(&err).await),
14754 }
14755 }
14756
14757 pub async fn read_global_json(&self, name: &str) -> Result<serde_json::Value> {
14762 self.interrupt_budget.reset();
14763 let value = match self
14764 .context
14765 .with(|ctx| {
14766 let global = ctx.globals();
14767 let value: Value<'_> = global.get(name)?;
14768 js_to_json(&value)
14769 })
14770 .await
14771 {
14772 Ok(value) => value,
14773 Err(err) => return Err(self.map_quickjs_error(&err).await),
14774 };
14775 Ok(value)
14776 }
14777
14778 pub fn drain_hostcall_requests(&self) -> VecDeque<HostcallRequest> {
14783 self.hostcall_queue.borrow_mut().drain_all()
14784 }
14785
14786 pub async fn drain_microtasks(&self) -> Result<usize> {
14788 self.drain_jobs().await
14789 }
14790
14791 pub fn next_timer_deadline_ms(&self) -> Option<u64> {
14793 self.scheduler.borrow().next_timer_deadline()
14794 }
14795
14796 pub fn pending_hostcall_count(&self) -> usize {
14798 self.hostcall_tracker.borrow().pending_count()
14799 }
14800
14801 pub fn hostcall_queue_telemetry(&self) -> HostcallQueueTelemetry {
14803 self.hostcall_queue.borrow().snapshot()
14804 }
14805
14806 pub fn hostcall_queue_wait_ms(&self, call_id: &str) -> Option<u64> {
14808 let now_ms = self.scheduler.borrow().now_ms();
14809 self.hostcall_tracker
14810 .borrow()
14811 .queue_wait_ms(call_id, now_ms)
14812 }
14813
14814 pub fn is_hostcall_pending(&self, call_id: &str) -> bool {
14819 self.hostcall_tracker.borrow().is_pending(call_id)
14820 }
14821
14822 pub fn is_hostcall_active(&self, call_id: &str) -> bool {
14824 self.hostcall_tracker.borrow().is_active(call_id)
14825 }
14826
14827 pub async fn get_registered_tools(&self) -> Result<Vec<ExtensionToolDef>> {
14829 self.interrupt_budget.reset();
14830 let value = match self
14831 .context
14832 .with(|ctx| {
14833 let global = ctx.globals();
14834 let getter: Function<'_> = global.get("__pi_get_registered_tools")?;
14835 let tools: Value<'_> = getter.call(())?;
14836 js_to_json(&tools)
14837 })
14838 .await
14839 {
14840 Ok(value) => value,
14841 Err(err) => return Err(self.map_quickjs_error(&err).await),
14842 };
14843
14844 serde_json::from_value(value).map_err(|err| Error::Json(Box::new(err)))
14845 }
14846
14847 pub async fn get_global_json(&self, name: &str) -> Result<serde_json::Value> {
14852 self.interrupt_budget.reset();
14853 match self
14854 .context
14855 .with(|ctx| {
14856 let global = ctx.globals();
14857 let value: Value<'_> = global.get(name)?;
14858 js_to_json(&value)
14859 })
14860 .await
14861 {
14862 Ok(value) => Ok(value),
14863 Err(err) => Err(self.map_quickjs_error(&err).await),
14864 }
14865 }
14866
14867 pub fn complete_hostcall(&self, call_id: impl Into<String>, outcome: HostcallOutcome) {
14869 let call_id = call_id.into();
14870 if let HostcallOutcome::StreamChunk { sequence, .. } = &outcome {
14871 self.hostcall_tracker
14872 .borrow_mut()
14873 .record_stream_seq(&call_id, *sequence);
14874 }
14875 self.scheduler
14876 .borrow_mut()
14877 .enqueue_hostcall_complete(call_id, outcome);
14878 }
14879
14880 pub fn complete_hostcalls_batch<I>(&self, completions: I)
14882 where
14883 I: IntoIterator<Item = (String, HostcallOutcome)>,
14884 {
14885 let mut scheduler = self.scheduler.borrow_mut();
14886 let mut tracker = self.hostcall_tracker.borrow_mut();
14887 for (call_id, outcome) in completions {
14888 if let HostcallOutcome::StreamChunk { sequence, .. } = &outcome {
14889 tracker.record_stream_seq(&call_id, *sequence);
14890 }
14891 scheduler.enqueue_hostcall_complete(call_id, outcome);
14892 }
14893 }
14894
14895 pub fn cancel_hostcall(&self, call_id: &str) -> bool {
14897 let (timer_id, next_seq) = {
14898 let mut tracker = self.hostcall_tracker.borrow_mut();
14899 let Some(timer_id) = tracker.cancel(call_id) else {
14900 return false;
14901 };
14902 let next_seq = tracker.stream_next_seq(call_id);
14903 (Some(timer_id), next_seq)
14904 };
14905
14906 if let Some(timer_id) = timer_id {
14907 let _ = self.scheduler.borrow_mut().clear_timeout(timer_id);
14908 }
14909
14910 if let Some(sequence) = next_seq {
14911 self.scheduler.borrow_mut().enqueue_stream_chunk(
14912 call_id.to_string(),
14913 sequence,
14914 serde_json::Value::Null,
14915 true,
14916 );
14917 }
14918 true
14919 }
14920
14921 pub fn enqueue_event(&self, event_id: impl Into<String>, payload: serde_json::Value) {
14923 self.scheduler
14924 .borrow_mut()
14925 .enqueue_event(event_id.into(), payload);
14926 }
14927
14928 pub fn set_timeout(&self, delay_ms: u64) -> u64 {
14932 self.scheduler.borrow_mut().set_timeout(delay_ms)
14933 }
14934
14935 pub fn clear_timeout(&self, timer_id: u64) -> bool {
14937 self.scheduler.borrow_mut().clear_timeout(timer_id)
14938 }
14939
14940 pub fn now_ms(&self) -> u64 {
14942 self.scheduler.borrow().now_ms()
14943 }
14944
14945 pub fn has_pending(&self) -> bool {
14947 self.scheduler.borrow().has_pending() || self.pending_hostcall_count() > 0
14948 }
14949
14950 pub async fn tick(&self) -> Result<PiJsTickStats> {
14959 let macrotask = self.scheduler.borrow_mut().tick();
14961
14962 let mut stats = PiJsTickStats::default();
14963
14964 if let Some(task) = macrotask {
14965 stats.ran_macrotask = true;
14966 self.interrupt_budget.reset();
14967
14968 let result = self
14970 .context
14971 .with(|ctx| {
14972 self.handle_macrotask(&ctx, &task)?;
14973 Ok::<_, rquickjs::Error>(())
14974 })
14975 .await;
14976 if let Err(err) = result {
14977 return Err(self.map_quickjs_error(&err).await);
14978 }
14979
14980 stats.jobs_drained = self.drain_jobs().await?;
14982 }
14983
14984 stats.pending_hostcalls = self.hostcall_tracker.borrow().pending_count();
14985 stats.hostcalls_total = self
14986 .hostcalls_total
14987 .load(std::sync::atomic::Ordering::SeqCst);
14988 stats.hostcalls_timed_out = self
14989 .hostcalls_timed_out
14990 .load(std::sync::atomic::Ordering::SeqCst);
14991
14992 if self.should_sample_memory_usage() {
14993 let usage = self.runtime.memory_usage().await;
14994 stats.memory_used_bytes = u64::try_from(usage.memory_used_size).unwrap_or(0);
14995 self.last_memory_used_bytes
14996 .store(stats.memory_used_bytes, std::sync::atomic::Ordering::SeqCst);
14997
14998 let mut peak = self
14999 .peak_memory_used_bytes
15000 .load(std::sync::atomic::Ordering::SeqCst);
15001 if stats.memory_used_bytes > peak {
15002 peak = stats.memory_used_bytes;
15003 self.peak_memory_used_bytes
15004 .store(peak, std::sync::atomic::Ordering::SeqCst);
15005 }
15006 stats.peak_memory_used_bytes = peak;
15007 } else {
15008 stats.memory_used_bytes = self
15009 .last_memory_used_bytes
15010 .load(std::sync::atomic::Ordering::SeqCst);
15011 stats.peak_memory_used_bytes = self
15012 .peak_memory_used_bytes
15013 .load(std::sync::atomic::Ordering::SeqCst);
15014 }
15015 stats.repairs_total = self.repair_count();
15016 let (cache_hits, cache_misses, cache_invalidations, cache_entries, disk_hits) =
15017 self.module_cache_snapshot();
15018 stats.module_cache_hits = cache_hits;
15019 stats.module_cache_misses = cache_misses;
15020 stats.module_cache_invalidations = cache_invalidations;
15021 stats.module_cache_entries = cache_entries;
15022 stats.module_disk_cache_hits = disk_hits;
15023
15024 if let Some(limit) = self.config.limits.memory_limit_bytes {
15025 let limit = u64::try_from(limit).unwrap_or(u64::MAX);
15026 if stats.memory_used_bytes > limit {
15027 return Err(Error::extension(format!(
15028 "PiJS memory budget exceeded (used {} bytes, limit {} bytes)",
15029 stats.memory_used_bytes, limit
15030 )));
15031 }
15032 }
15033
15034 Ok(stats)
15035 }
15036
15037 async fn drain_jobs(&self) -> Result<usize> {
15039 let mut count = 0;
15040 loop {
15041 if count >= MAX_JOBS_PER_TICK {
15042 return Err(Error::extension(format!(
15043 "PiJS microtask limit exceeded ({MAX_JOBS_PER_TICK})"
15044 )));
15045 }
15046 let ran = match self.runtime.execute_pending_job().await {
15047 Ok(ran) => ran,
15048 Err(err) => return Err(self.map_quickjs_job_error(err)),
15049 };
15050 if !ran {
15051 break;
15052 }
15053 count += 1;
15054 }
15055 Ok(count)
15056 }
15057
15058 fn handle_macrotask(
15060 &self,
15061 ctx: &Ctx<'_>,
15062 task: &crate::scheduler::Macrotask,
15063 ) -> rquickjs::Result<()> {
15064 use crate::scheduler::MacrotaskKind as SMK;
15065
15066 match &task.kind {
15067 SMK::HostcallComplete { call_id, outcome } => {
15068 let is_nonfinal_stream = matches!(
15069 outcome,
15070 HostcallOutcome::StreamChunk {
15071 is_final: false,
15072 ..
15073 }
15074 );
15075
15076 if is_nonfinal_stream {
15077 if !self.hostcall_tracker.borrow().is_active(call_id) {
15079 tracing::debug!(
15080 event = "pijs.macrotask.stream_chunk.ignored",
15081 call_id = %call_id,
15082 "Ignoring stream chunk (not pending)"
15083 );
15084 return Ok(());
15085 }
15086 } else {
15087 let completion = self.hostcall_tracker.borrow_mut().on_complete(call_id);
15089 let timer_id = match completion {
15090 HostcallCompletion::Delivered { timer_id } => timer_id,
15091 HostcallCompletion::Unknown => {
15092 tracing::debug!(
15093 event = "pijs.macrotask.hostcall_complete.ignored",
15094 call_id = %call_id,
15095 "Ignoring hostcall completion (not pending)"
15096 );
15097 return Ok(());
15098 }
15099 };
15100
15101 if let Some(timer_id) = timer_id {
15102 let _ = self.scheduler.borrow_mut().clear_timeout(timer_id);
15103 }
15104 }
15105
15106 tracing::debug!(
15107 event = "pijs.macrotask.hostcall_complete",
15108 call_id = %call_id,
15109 seq = task.seq.value(),
15110 "Delivering hostcall completion"
15111 );
15112 Self::deliver_hostcall_completion(ctx, call_id, outcome)?;
15113 }
15114 SMK::TimerFired { timer_id } => {
15115 if let Some(call_id) = self
15116 .hostcall_tracker
15117 .borrow_mut()
15118 .take_timed_out_call(*timer_id)
15119 {
15120 self.hostcalls_timed_out
15121 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15122 tracing::warn!(
15123 event = "pijs.hostcall.timeout",
15124 call_id = %call_id,
15125 timer_id = timer_id,
15126 "Hostcall timed out"
15127 );
15128
15129 let outcome = HostcallOutcome::Error {
15130 code: "timeout".to_string(),
15131 message: "Hostcall timed out".to_string(),
15132 };
15133 Self::deliver_hostcall_completion(ctx, &call_id, &outcome)?;
15134 return Ok(());
15135 }
15136
15137 tracing::debug!(
15138 event = "pijs.macrotask.timer_fired",
15139 timer_id = timer_id,
15140 seq = task.seq.value(),
15141 "Timer fired"
15142 );
15143 Self::deliver_timer_fire(ctx, *timer_id)?;
15145 }
15146 SMK::InboundEvent { event_id, payload } => {
15147 tracing::debug!(
15148 event = "pijs.macrotask.inbound_event",
15149 event_id = %event_id,
15150 seq = task.seq.value(),
15151 "Delivering inbound event"
15152 );
15153 Self::deliver_inbound_event(ctx, event_id, payload)?;
15154 }
15155 }
15156 Ok(())
15157 }
15158
15159 fn deliver_hostcall_completion(
15161 ctx: &Ctx<'_>,
15162 call_id: &str,
15163 outcome: &HostcallOutcome,
15164 ) -> rquickjs::Result<()> {
15165 let global = ctx.globals();
15166 let complete_fn: Function<'_> = global.get("__pi_complete_hostcall")?;
15167 let js_outcome = match outcome {
15168 HostcallOutcome::Success(value) => {
15169 let obj = Object::new(ctx.clone())?;
15170 obj.set("ok", true)?;
15171 obj.set("value", json_to_js(ctx, value)?)?;
15172 obj
15173 }
15174 HostcallOutcome::Error { code, message } => {
15175 let obj = Object::new(ctx.clone())?;
15176 obj.set("ok", false)?;
15177 obj.set("code", code.clone())?;
15178 obj.set("message", message.clone())?;
15179 obj
15180 }
15181 HostcallOutcome::StreamChunk {
15182 chunk,
15183 sequence,
15184 is_final,
15185 } => {
15186 let obj = Object::new(ctx.clone())?;
15187 obj.set("ok", true)?;
15188 obj.set("stream", true)?;
15189 obj.set("sequence", *sequence)?;
15190 obj.set("isFinal", *is_final)?;
15191 obj.set("chunk", json_to_js(ctx, chunk)?)?;
15192 obj
15193 }
15194 };
15195 complete_fn.call::<_, ()>((call_id, js_outcome))?;
15196 Ok(())
15197 }
15198
15199 fn deliver_timer_fire(ctx: &Ctx<'_>, timer_id: u64) -> rquickjs::Result<()> {
15201 let global = ctx.globals();
15202 let fire_fn: Function<'_> = global.get("__pi_fire_timer")?;
15203 fire_fn.call::<_, ()>((timer_id,))?;
15204 Ok(())
15205 }
15206
15207 fn deliver_inbound_event(
15209 ctx: &Ctx<'_>,
15210 event_id: &str,
15211 payload: &serde_json::Value,
15212 ) -> rquickjs::Result<()> {
15213 let global = ctx.globals();
15214 let dispatch_fn: Function<'_> = global.get("__pi_dispatch_event")?;
15215 let js_payload = json_to_js(ctx, payload)?;
15216 dispatch_fn.call::<_, ()>((event_id, js_payload))?;
15217 Ok(())
15218 }
15219
15220 fn next_trace_id(&self) -> u64 {
15222 self.trace_seq.fetch_add(1, AtomicOrdering::SeqCst)
15223 }
15224
15225 pub fn add_allowed_read_root(&self, root: &std::path::Path) {
15236 let canonical_root = crate::extensions::safe_canonicalize(root);
15237 if let Ok(mut roots) = self.allowed_read_roots.lock() {
15238 if !roots.contains(&canonical_root) {
15239 roots.push(canonical_root);
15240 }
15241 }
15242 }
15243
15244 pub fn add_extension_root(&self, root: PathBuf) {
15248 self.add_extension_root_with_id(root, None);
15249 }
15250
15251 pub fn add_extension_root_with_id(&self, root: PathBuf, extension_id: Option<&str>) {
15257 let canonical_root = crate::extensions::safe_canonicalize(&root);
15258 self.add_allowed_read_root(&canonical_root);
15259 let mut state = self.module_state.borrow_mut();
15260 if !state.extension_roots.contains(&root) {
15261 state.canonical_extension_roots.push(canonical_root.clone());
15262 state.extension_roots.push(root.clone());
15263 }
15264
15265 if let Some(extension_id) = extension_id {
15266 let roots = state
15267 .extension_roots_by_id
15268 .entry(extension_id.to_string())
15269 .or_default();
15270 if !roots.contains(&canonical_root) {
15271 roots.push(canonical_root);
15272 }
15273 } else if !state.extension_roots_without_id.contains(&canonical_root) {
15274 state.extension_roots_without_id.push(canonical_root);
15275 }
15276
15277 let tier = extension_id.map_or_else(
15278 || root_path_hint_tier(&root),
15279 |id| classify_proxy_stub_source_tier(id, &root),
15280 );
15281 state.extension_root_tiers.insert(root.clone(), tier);
15282
15283 if let Some(scope) = read_extension_package_scope(&root) {
15284 state.extension_root_scopes.insert(root, scope);
15285 }
15286 }
15287
15288 #[allow(clippy::too_many_lines)]
15289 async fn install_pi_bridge(&self) -> Result<()> {
15290 let hostcall_queue = self.hostcall_queue.clone();
15291 let scheduler = Rc::clone(&self.scheduler);
15292 let hostcall_tracker = Rc::clone(&self.hostcall_tracker);
15293 let hostcalls_total = Arc::clone(&self.hostcalls_total);
15294 let trace_seq = Arc::clone(&self.trace_seq);
15295 let default_hostcall_timeout_ms = self.config.limits.hostcall_timeout_ms;
15296 let process_cwd = self.config.cwd.clone();
15297 let process_args = self.config.args.clone();
15298 let env = self.config.env.clone();
15299 let deny_env = self.config.deny_env;
15300 let repair_mode = self.config.repair_mode;
15301 let repair_events = Arc::clone(&self.repair_events);
15302 let allow_unsafe_sync_exec = self.config.allow_unsafe_sync_exec;
15303 let allowed_read_roots = Arc::clone(&self.allowed_read_roots);
15304 let module_state = Rc::clone(&self.module_state);
15305 let policy = self.policy.clone();
15306
15307 self.context
15308 .with(|ctx| {
15309 let global = ctx.globals();
15310
15311 global.set(
15316 "__pi_tool_native",
15317 Func::from({
15318 let queue = hostcall_queue.clone();
15319 let tracker = hostcall_tracker.clone();
15320 let scheduler = Rc::clone(&scheduler);
15321 let hostcalls_total = Arc::clone(&hostcalls_total);
15322 let trace_seq = Arc::clone(&trace_seq);
15323 move |ctx: Ctx<'_>,
15324 name: String,
15325 input: Value<'_>|
15326 -> rquickjs::Result<String> {
15327 let payload = js_to_json(&input)?;
15328 let call_id = format!("call-{}", generate_call_id());
15329 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15330 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15331 let enqueued_at_ms = scheduler.borrow().now_ms();
15332 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15333 let timer_id =
15334 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15335 tracker
15336 .borrow_mut()
15337 .register(call_id.clone(), timer_id, enqueued_at_ms);
15338 let extension_id: Option<String> = ctx
15339 .globals()
15340 .get::<_, Option<String>>("__pi_current_extension_id")
15341 .ok()
15342 .flatten()
15343 .map(|value| value.trim().to_string())
15344 .filter(|value| !value.is_empty());
15345 let request = HostcallRequest {
15346 call_id: call_id.clone(),
15347 kind: HostcallKind::Tool { name },
15348 payload,
15349 trace_id,
15350 extension_id,
15351 };
15352 enqueue_hostcall_request_with_backpressure(
15353 &queue, &tracker, &scheduler, request,
15354 );
15355 Ok(call_id)
15356 }
15357 }),
15358 )?;
15359
15360 global.set(
15362 "__pi_exec_native",
15363 Func::from({
15364 let queue = hostcall_queue.clone();
15365 let tracker = hostcall_tracker.clone();
15366 let scheduler = Rc::clone(&scheduler);
15367 let hostcalls_total = Arc::clone(&hostcalls_total);
15368 let trace_seq = Arc::clone(&trace_seq);
15369 move |ctx: Ctx<'_>,
15370 cmd: String,
15371 args: Value<'_>,
15372 options: Opt<Value<'_>>|
15373 -> rquickjs::Result<String> {
15374 let mut options_json = match options.0.as_ref() {
15375 None => serde_json::json!({}),
15376 Some(value) if value.is_null() => serde_json::json!({}),
15377 Some(value) => js_to_json(value)?,
15378 };
15379 if let Some(default_timeout_ms) =
15380 default_hostcall_timeout_ms.filter(|ms| *ms > 0)
15381 {
15382 match &mut options_json {
15383 serde_json::Value::Object(map) => {
15384 let has_timeout = map.contains_key("timeout")
15385 || map.contains_key("timeoutMs")
15386 || map.contains_key("timeout_ms");
15387 if !has_timeout {
15388 map.insert(
15389 "timeoutMs".to_string(),
15390 serde_json::Value::from(default_timeout_ms),
15391 );
15392 }
15393 }
15394 _ => {
15395 options_json =
15396 serde_json::json!({ "timeoutMs": default_timeout_ms });
15397 }
15398 }
15399 }
15400 let payload = serde_json::json!({
15401 "args": js_to_json(&args)?,
15402 "options": options_json,
15403 });
15404 let call_id = format!("call-{}", generate_call_id());
15405 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15406 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15407 let enqueued_at_ms = scheduler.borrow().now_ms();
15408 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15409 let timer_id =
15410 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15411 tracker
15412 .borrow_mut()
15413 .register(call_id.clone(), timer_id, enqueued_at_ms);
15414 let extension_id: Option<String> = ctx
15415 .globals()
15416 .get::<_, Option<String>>("__pi_current_extension_id")
15417 .ok()
15418 .flatten()
15419 .map(|value| value.trim().to_string())
15420 .filter(|value| !value.is_empty());
15421 let request = HostcallRequest {
15422 call_id: call_id.clone(),
15423 kind: HostcallKind::Exec { cmd },
15424 payload,
15425 trace_id,
15426 extension_id,
15427 };
15428 enqueue_hostcall_request_with_backpressure(
15429 &queue, &tracker, &scheduler, request,
15430 );
15431 Ok(call_id)
15432 }
15433 }),
15434 )?;
15435
15436 global.set(
15438 "__pi_http_native",
15439 Func::from({
15440 let queue = hostcall_queue.clone();
15441 let tracker = hostcall_tracker.clone();
15442 let scheduler = Rc::clone(&scheduler);
15443 let hostcalls_total = Arc::clone(&hostcalls_total);
15444 let trace_seq = Arc::clone(&trace_seq);
15445 move |ctx: Ctx<'_>, req: Value<'_>| -> rquickjs::Result<String> {
15446 let payload = js_to_json(&req)?;
15447 let call_id = format!("call-{}", generate_call_id());
15448 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15449 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15450 let enqueued_at_ms = scheduler.borrow().now_ms();
15451 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15452 let timer_id =
15453 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15454 tracker
15455 .borrow_mut()
15456 .register(call_id.clone(), timer_id, enqueued_at_ms);
15457 let extension_id: Option<String> = ctx
15458 .globals()
15459 .get::<_, Option<String>>("__pi_current_extension_id")
15460 .ok()
15461 .flatten()
15462 .map(|value| value.trim().to_string())
15463 .filter(|value| !value.is_empty());
15464 let request = HostcallRequest {
15465 call_id: call_id.clone(),
15466 kind: HostcallKind::Http,
15467 payload,
15468 trace_id,
15469 extension_id,
15470 };
15471 enqueue_hostcall_request_with_backpressure(
15472 &queue, &tracker, &scheduler, request,
15473 );
15474 Ok(call_id)
15475 }
15476 }),
15477 )?;
15478
15479 global.set(
15481 "__pi_session_native",
15482 Func::from({
15483 let queue = hostcall_queue.clone();
15484 let tracker = hostcall_tracker.clone();
15485 let scheduler = Rc::clone(&scheduler);
15486 let hostcalls_total = Arc::clone(&hostcalls_total);
15487 let trace_seq = Arc::clone(&trace_seq);
15488 move |ctx: Ctx<'_>,
15489 op: String,
15490 args: Value<'_>|
15491 -> rquickjs::Result<String> {
15492 let payload = js_to_json(&args)?;
15493 let call_id = format!("call-{}", generate_call_id());
15494 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15495 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15496 let enqueued_at_ms = scheduler.borrow().now_ms();
15497 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15498 let timer_id =
15499 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15500 tracker
15501 .borrow_mut()
15502 .register(call_id.clone(), timer_id, enqueued_at_ms);
15503 let extension_id: Option<String> = ctx
15504 .globals()
15505 .get::<_, Option<String>>("__pi_current_extension_id")
15506 .ok()
15507 .flatten()
15508 .map(|value| value.trim().to_string())
15509 .filter(|value| !value.is_empty());
15510 let request = HostcallRequest {
15511 call_id: call_id.clone(),
15512 kind: HostcallKind::Session { op },
15513 payload,
15514 trace_id,
15515 extension_id,
15516 };
15517 enqueue_hostcall_request_with_backpressure(
15518 &queue, &tracker, &scheduler, request,
15519 );
15520 Ok(call_id)
15521 }
15522 }),
15523 )?;
15524
15525 global.set(
15527 "__pi_ui_native",
15528 Func::from({
15529 let queue = hostcall_queue.clone();
15530 let tracker = hostcall_tracker.clone();
15531 let scheduler = Rc::clone(&scheduler);
15532 let hostcalls_total = Arc::clone(&hostcalls_total);
15533 let trace_seq = Arc::clone(&trace_seq);
15534 move |ctx: Ctx<'_>,
15535 op: String,
15536 args: Value<'_>|
15537 -> rquickjs::Result<String> {
15538 let payload = js_to_json(&args)?;
15539 let call_id = format!("call-{}", generate_call_id());
15540 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15541 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15542 let enqueued_at_ms = scheduler.borrow().now_ms();
15543 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15544 let timer_id =
15545 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15546 tracker
15547 .borrow_mut()
15548 .register(call_id.clone(), timer_id, enqueued_at_ms);
15549 let extension_id: Option<String> = ctx
15550 .globals()
15551 .get::<_, Option<String>>("__pi_current_extension_id")
15552 .ok()
15553 .flatten()
15554 .map(|value| value.trim().to_string())
15555 .filter(|value| !value.is_empty());
15556 let request = HostcallRequest {
15557 call_id: call_id.clone(),
15558 kind: HostcallKind::Ui { op },
15559 payload,
15560 trace_id,
15561 extension_id,
15562 };
15563 enqueue_hostcall_request_with_backpressure(
15564 &queue, &tracker, &scheduler, request,
15565 );
15566 Ok(call_id)
15567 }
15568 }),
15569 )?;
15570
15571 global.set(
15573 "__pi_events_native",
15574 Func::from({
15575 let queue = hostcall_queue.clone();
15576 let tracker = hostcall_tracker.clone();
15577 let scheduler = Rc::clone(&scheduler);
15578 let hostcalls_total = Arc::clone(&hostcalls_total);
15579 let trace_seq = Arc::clone(&trace_seq);
15580 move |ctx: Ctx<'_>,
15581 op: String,
15582 args: Value<'_>|
15583 -> rquickjs::Result<String> {
15584 let payload = js_to_json(&args)?;
15585 let call_id = format!("call-{}", generate_call_id());
15586 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15587 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15588 let enqueued_at_ms = scheduler.borrow().now_ms();
15589 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15590 let timer_id =
15591 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15592 tracker
15593 .borrow_mut()
15594 .register(call_id.clone(), timer_id, enqueued_at_ms);
15595 let extension_id: Option<String> = ctx
15596 .globals()
15597 .get::<_, Option<String>>("__pi_current_extension_id")
15598 .ok()
15599 .flatten()
15600 .map(|value| value.trim().to_string())
15601 .filter(|value| !value.is_empty());
15602 let request = HostcallRequest {
15603 call_id: call_id.clone(),
15604 kind: HostcallKind::Events { op },
15605 payload,
15606 trace_id,
15607 extension_id,
15608 };
15609 enqueue_hostcall_request_with_backpressure(
15610 &queue, &tracker, &scheduler, request,
15611 );
15612 Ok(call_id)
15613 }
15614 }),
15615 )?;
15616
15617 global.set(
15619 "__pi_log_native",
15620 Func::from({
15621 let queue = hostcall_queue.clone();
15622 let tracker = hostcall_tracker.clone();
15623 let scheduler = Rc::clone(&scheduler);
15624 let hostcalls_total = Arc::clone(&hostcalls_total);
15625 let trace_seq = Arc::clone(&trace_seq);
15626 move |ctx: Ctx<'_>, entry: Value<'_>| -> rquickjs::Result<String> {
15627 let payload = js_to_json(&entry)?;
15628 let call_id = format!("call-{}", generate_call_id());
15629 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15630 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15631 let enqueued_at_ms = scheduler.borrow().now_ms();
15632 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15633 let timer_id =
15634 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15635 tracker
15636 .borrow_mut()
15637 .register(call_id.clone(), timer_id, enqueued_at_ms);
15638 let extension_id: Option<String> = ctx
15639 .globals()
15640 .get::<_, Option<String>>("__pi_current_extension_id")
15641 .ok()
15642 .flatten()
15643 .map(|value| value.trim().to_string())
15644 .filter(|value| !value.is_empty());
15645 let request = HostcallRequest {
15646 call_id: call_id.clone(),
15647 kind: HostcallKind::Log,
15648 payload,
15649 trace_id,
15650 extension_id,
15651 };
15652 enqueue_hostcall_request_with_backpressure(
15653 &queue, &tracker, &scheduler, request,
15654 );
15655 Ok(call_id)
15656 }
15657 }),
15658 )?;
15659
15660 global.set(
15662 "__pi_set_timeout_native",
15663 Func::from({
15664 let scheduler = Rc::clone(&scheduler);
15665 move |_ctx: Ctx<'_>, delay_ms: u64| -> rquickjs::Result<u64> {
15666 Ok(scheduler.borrow_mut().set_timeout(delay_ms))
15667 }
15668 }),
15669 )?;
15670
15671 global.set(
15673 "__pi_clear_timeout_native",
15674 Func::from({
15675 let scheduler = Rc::clone(&scheduler);
15676 move |_ctx: Ctx<'_>, timer_id: u64| -> rquickjs::Result<bool> {
15677 Ok(scheduler.borrow_mut().clear_timeout(timer_id))
15678 }
15679 }),
15680 )?;
15681
15682 global.set(
15684 "__pi_cancel_hostcall_native",
15685 Func::from({
15686 let tracker = hostcall_tracker.clone();
15687 let scheduler = Rc::clone(&scheduler);
15688 move |_ctx: Ctx<'_>, call_id: String| -> rquickjs::Result<bool> {
15689 let (timer_id, next_seq) = {
15690 let mut tracker = tracker.borrow_mut();
15691 let Some(timer_id) = tracker.cancel(&call_id) else {
15692 return Ok(false);
15693 };
15694 let next_seq = tracker.stream_next_seq(&call_id);
15695 (Some(timer_id), next_seq)
15696 };
15697
15698 if let Some(timer_id) = timer_id {
15699 let _ = scheduler.borrow_mut().clear_timeout(timer_id);
15700 }
15701
15702 if let Some(sequence) = next_seq {
15703 scheduler
15704 .borrow_mut()
15705 .enqueue_stream_chunk(call_id, sequence, serde_json::Value::Null, true);
15706 }
15707
15708 Ok(true)
15709 }
15710 }),
15711 )?;
15712
15713 global.set(
15715 "__pi_now_ms_native",
15716 Func::from({
15717 let scheduler = Rc::clone(&scheduler);
15718 move |_ctx: Ctx<'_>| -> rquickjs::Result<u64> {
15719 Ok(scheduler.borrow().now_ms())
15720 }
15721 }),
15722 )?;
15723
15724 global.set(
15726 "__pi_process_cwd_native",
15727 Func::from({
15728 let process_cwd = process_cwd.clone();
15729 move |_ctx: Ctx<'_>| -> rquickjs::Result<String> { Ok(process_cwd.clone()) }
15730 }),
15731 )?;
15732
15733 global.set(
15735 "__pi_process_args_native",
15736 Func::from({
15737 let process_args = process_args.clone();
15738 move |_ctx: Ctx<'_>| -> rquickjs::Result<Vec<String>> {
15739 Ok(process_args.clone())
15740 }
15741 }),
15742 )?;
15743
15744 global.set(
15746 "__pi_process_exit_native",
15747 Func::from({
15748 let queue = hostcall_queue.clone();
15749 let tracker = hostcall_tracker.clone();
15750 let scheduler = Rc::clone(&scheduler);
15751 move |_ctx: Ctx<'_>, code: i32| -> rquickjs::Result<()> {
15752 tracing::info!(
15753 event = "pijs.process.exit",
15754 code,
15755 "process.exit requested"
15756 );
15757 let call_id = format!("call-{}", generate_call_id());
15758 let enqueued_at_ms = scheduler.borrow().now_ms();
15759 tracker
15760 .borrow_mut()
15761 .register(call_id.clone(), None, enqueued_at_ms);
15762 let request = HostcallRequest {
15763 call_id,
15764 kind: HostcallKind::Events {
15765 op: "exit".to_string(),
15766 },
15767 payload: serde_json::json!({ "code": code }),
15768 trace_id: 0,
15769 extension_id: None,
15770 };
15771 enqueue_hostcall_request_with_backpressure(
15772 &queue, &tracker, &scheduler, request,
15773 );
15774 Ok(())
15775 }
15776 }),
15777 )?;
15778
15779 global.set(
15781 "__pi_process_execpath_native",
15782 Func::from(move |_ctx: Ctx<'_>| -> rquickjs::Result<String> {
15783 Ok(std::env::current_exe().map_or_else(
15784 |_| "/usr/bin/pi".to_string(),
15785 |p| p.to_string_lossy().into_owned(),
15786 ))
15787 }),
15788 )?;
15789
15790 global.set(
15792 "__pi_env_get_native",
15793 Func::from({
15794 let env = env.clone();
15795 let policy_for_env = policy.clone();
15796 move |_ctx: Ctx<'_>, key: String| -> rquickjs::Result<Option<String>> {
15797 if let Some(value) = compat_env_fallback_value(&key, &env) {
15802 tracing::debug!(
15803 event = "pijs.env.get.compat",
15804 key = %key,
15805 "env compat fallback"
15806 );
15807 return Ok(Some(value));
15808 }
15809 if deny_env {
15810 tracing::debug!(event = "pijs.env.get.denied", key = %key, "env capability denied");
15811 return Ok(None);
15812 }
15813 let allowed = policy_for_env.as_ref().map_or_else(
15817 || is_env_var_allowed(&key),
15818 |policy| !policy.secret_broker.is_secret(&key),
15819 );
15820 tracing::debug!(
15821 event = "pijs.env.get",
15822 key = %key,
15823 allowed,
15824 "env get"
15825 );
15826 if !allowed {
15827 return Ok(None);
15828 }
15829 Ok(env.get(&key).cloned())
15830 }
15831 }),
15832 )?;
15833
15834 global.set(
15838 "__pi_crypto_random_bytes_native",
15839 Func::from(
15840 move |_ctx: Ctx<'_>, len: usize| -> rquickjs::Result<Vec<u8>> {
15841 if len > 10 * 1024 * 1024 {
15842 return Err(rquickjs::Error::new_from_js(
15843 "number",
15844 "randomBytes size limit exceeded (max 10MB)",
15845 ));
15846 }
15847 tracing::debug!(
15848 event = "pijs.crypto.random_bytes",
15849 len,
15850 "crypto random bytes"
15851 );
15852 random_bytes(len)
15853 .map_err(|err| map_crypto_entropy_error("randomBytes", err))
15854 },
15855 ),
15856 )?;
15857
15858 global.set(
15860 "__pi_base64_encode_native",
15861 Func::from(
15862 move |_ctx: Ctx<'_>, input: String| -> rquickjs::Result<String> {
15863 let mut bytes = Vec::with_capacity(input.len());
15864 for ch in input.chars() {
15865 let code = ch as u32;
15866 let byte = u8::try_from(code).map_err(|_| {
15867 rquickjs::Error::new_into_js_message(
15868 "base64",
15869 "encode",
15870 "Input contains non-latin1 characters",
15871 )
15872 })?;
15873 bytes.push(byte);
15874 }
15875 Ok(BASE64_STANDARD.encode(bytes))
15876 },
15877 ),
15878 )?;
15879
15880 global.set(
15882 "__pi_base64_encode_bytes_native",
15883 Func::from(
15884 move |_ctx: Ctx<'_>, input: rquickjs::TypedArray<'_, u8>| -> rquickjs::Result<String> {
15885 let bytes = input.as_bytes().ok_or_else(|| {
15886 rquickjs::Error::new_into_js_message(
15887 "base64",
15888 "encode",
15889 "Detached TypedArray",
15890 )
15891 })?;
15892 Ok(BASE64_STANDARD.encode(bytes))
15893 },
15894 ),
15895 )?;
15896
15897 global.set(
15899 "__pi_base64_decode_native",
15900 Func::from(
15901 move |_ctx: Ctx<'_>, input: String| -> rquickjs::Result<String> {
15902 let bytes = BASE64_STANDARD.decode(input).map_err(|err| {
15903 rquickjs::Error::new_into_js_message(
15904 "base64",
15905 "decode",
15906 format!("Invalid base64: {err}"),
15907 )
15908 })?;
15909
15910 let mut out = String::with_capacity(bytes.len());
15911 for byte in bytes {
15912 out.push(byte as char);
15913 }
15914 Ok(out)
15915 },
15916 ),
15917 )?;
15918
15919 global.set(
15923 "__pi_console_output_native",
15924 Func::from(
15925 move |_ctx: Ctx<'_>,
15926 level: String,
15927 message: String|
15928 -> rquickjs::Result<()> {
15929 match level.as_str() {
15930 "error" => tracing::error!(
15931 target: "pijs.console",
15932 "{message}"
15933 ),
15934 "warn" => tracing::warn!(
15935 target: "pijs.console",
15936 "{message}"
15937 ),
15938 "debug" => tracing::debug!(
15939 target: "pijs.console",
15940 "{message}"
15941 ),
15942 "trace" => tracing::trace!(
15943 target: "pijs.console",
15944 "{message}"
15945 ),
15946 _ => tracing::info!(
15948 target: "pijs.console",
15949 "{message}"
15950 ),
15951 }
15952 Ok(())
15953 },
15954 ),
15955 )?;
15956
15957 global.set(
15961 "__pi_host_check_write_access",
15962 Func::from({
15963 let process_cwd = process_cwd.clone();
15964 let allowed_read_roots = Arc::clone(&allowed_read_roots);
15965 let module_state = Rc::clone(&module_state);
15966 move |ctx: Ctx<'_>, path: String| -> rquickjs::Result<()> {
15967 let extension_id = current_extension_id(&ctx);
15968
15969 if extension_id.is_none() {
15971 return Ok(());
15972 }
15973
15974 let workspace_root =
15975 crate::extensions::safe_canonicalize(Path::new(&process_cwd));
15976 let requested = PathBuf::from(&path);
15977 let requested_abs = if requested.is_absolute() {
15978 requested
15979 } else {
15980 workspace_root.join(requested)
15981 };
15982 let checked_path = crate::extensions::safe_canonicalize(&requested_abs);
15983
15984 let in_ext_root = path_is_in_allowed_extension_root(
15985 &checked_path,
15986 extension_id.as_deref(),
15987 &module_state,
15988 &allowed_read_roots,
15989 );
15990
15991 let allowed = checked_path.starts_with(&workspace_root) || in_ext_root;
15992
15993 if allowed {
15994 Ok(())
15995 } else {
15996 Err(rquickjs::Error::new_loading_message(
15997 &path,
15998 "host write denied: path outside extension root".to_string(),
15999 ))
16000 }
16001 }
16002 }),
16003 )?;
16004
16005 global.set(
16011 "__pi_host_read_file_sync",
16012 Func::from({
16013 let process_cwd = process_cwd.clone();
16014 let allowed_read_roots = Arc::clone(&allowed_read_roots);
16015 let module_state = Rc::clone(&module_state);
16016 let configured_repair_mode = repair_mode;
16017 let repair_events = Arc::clone(&repair_events);
16018 move |ctx: Ctx<'_>, path: String| -> rquickjs::Result<String> {
16019 const MAX_SYNC_READ_SIZE: u64 = 64 * 1024 * 1024; let extension_id = current_extension_id(&ctx);
16021
16022 let workspace_root =
16023 crate::extensions::safe_canonicalize(Path::new(&process_cwd));
16024
16025 let requested = PathBuf::from(&path);
16026 let requested_abs = if requested.is_absolute() {
16027 requested
16028 } else {
16029 workspace_root.join(requested)
16030 };
16031
16032 let apply_missing_asset_fallback = |checked_path: &Path, error_msg: &str| -> rquickjs::Result<String> {
16033 let in_ext_root = path_is_in_allowed_extension_root(
16034 checked_path,
16035 extension_id.as_deref(),
16036 &module_state,
16037 &allowed_read_roots,
16038 );
16039
16040 if in_ext_root {
16041 let ext = checked_path
16042 .extension()
16043 .and_then(|e| e.to_str())
16044 .unwrap_or("");
16045 let fallback = match ext {
16046 "html" | "htm" => "<!DOCTYPE html><html><body></body></html>",
16047 "css" => "/* auto-repair: empty stylesheet */",
16048 "js" | "mjs" => "// auto-repair: empty script",
16049 "md" | "txt" | "toml" | "yaml" | "yml" => "",
16050 _ => {
16051 return Err(rquickjs::Error::new_loading_message(
16052 &path,
16053 format!("host read open: {error_msg}"),
16054 ));
16055 }
16056 };
16057
16058 tracing::info!(
16059 event = "pijs.repair.missing_asset",
16060 path = %path,
16061 ext = %ext,
16062 "returning empty fallback for missing asset"
16063 );
16064
16065 if let Ok(mut events) = repair_events.lock() {
16066 events.push(ExtensionRepairEvent {
16067 extension_id: extension_id.clone().unwrap_or_default(),
16068 pattern: RepairPattern::MissingAsset,
16069 original_error: format!("ENOENT: {}", checked_path.display()),
16070 repair_action: format!("returned empty {ext} fallback"),
16071 success: true,
16072 timestamp_ms: 0,
16073 });
16074 }
16075
16076 return Ok(BASE64_STANDARD.encode(fallback.as_bytes()));
16077 }
16078
16079 Err(rquickjs::Error::new_loading_message(
16080 &path,
16081 format!("host read open: {error_msg}"),
16082 ))
16083 };
16084
16085 #[cfg(target_os = "linux")]
16086 {
16087 use std::io::Read;
16088 use std::os::fd::AsRawFd;
16089
16090 let file = match std::fs::File::open(&requested_abs) {
16094 Ok(file) => file,
16095 Err(err)
16096 if err.kind() == std::io::ErrorKind::NotFound
16097 && configured_repair_mode.should_apply() =>
16098 {
16099 let checked_path = crate::extensions::safe_canonicalize(&requested_abs);
16101
16102 let in_ext_root = path_is_in_allowed_extension_root(
16103 &checked_path,
16104 extension_id.as_deref(),
16105 &module_state,
16106 &allowed_read_roots,
16107 );
16108 let allowed = checked_path.starts_with(&workspace_root) || in_ext_root;
16109
16110 if !allowed {
16111 return Err(rquickjs::Error::new_loading_message(
16112 &path,
16113 format!("host read open: {err}"),
16114 ));
16115 }
16116
16117 return apply_missing_asset_fallback(&checked_path, &err.to_string());
16118 }
16119 Err(err) => {
16120 return Err(rquickjs::Error::new_loading_message(
16121 &path,
16122 format!("host read open: {err}"),
16123 ));
16124 }
16125 };
16126
16127 let secure_path_buf = std::fs::read_link(format!(
16128 "/proc/self/fd/{}",
16129 file.as_raw_fd()
16130 ))
16131 .map_err(|err| {
16132 rquickjs::Error::new_loading_message(
16133 &path,
16134 format!("host read verify: {err}"),
16135 )
16136 })?;
16137 let secure_path =
16138 crate::extensions::strip_unc_prefix(secure_path_buf);
16139
16140 let in_ext_root = path_is_in_allowed_extension_root(
16141 &secure_path,
16142 extension_id.as_deref(),
16143 &module_state,
16144 &allowed_read_roots,
16145 );
16146 let allowed =
16147 secure_path.starts_with(&workspace_root) || in_ext_root;
16148
16149 if !allowed {
16150 return Err(rquickjs::Error::new_loading_message(
16151 &path,
16152 "host read denied: path outside extension root".to_string(),
16153 ));
16154 }
16155
16156 let mut reader = file.take(MAX_SYNC_READ_SIZE + 1);
16157 let mut buffer = Vec::new();
16158 reader.read_to_end(&mut buffer).map_err(|err| {
16159 rquickjs::Error::new_loading_message(
16160 &path,
16161 format!("host read content: {err}"),
16162 )
16163 })?;
16164
16165 if buffer.len() as u64 > MAX_SYNC_READ_SIZE {
16166 return Err(rquickjs::Error::new_loading_message(
16167 &path,
16168 format!(
16169 "host read failed: file exceeds {MAX_SYNC_READ_SIZE} bytes"
16170 ),
16171 ));
16172 }
16173
16174 Ok(BASE64_STANDARD.encode(buffer))
16175 }
16176
16177 #[cfg(not(target_os = "linux"))]
16178 {
16179 let checked_path = crate::extensions::safe_canonicalize(&requested_abs);
16180
16181 let in_ext_root = path_is_in_allowed_extension_root(
16184 &checked_path,
16185 extension_id.as_deref(),
16186 &module_state,
16187 &allowed_read_roots,
16188 );
16189 let allowed =
16190 checked_path.starts_with(&workspace_root) || in_ext_root;
16191
16192 if !allowed {
16193 return Err(rquickjs::Error::new_loading_message(
16194 &path,
16195 "host read denied: path outside extension root".to_string(),
16196 ));
16197 }
16198
16199 use std::io::Read;
16200 let file = match std::fs::File::open(&checked_path) {
16201 Ok(file) => file,
16202 Err(err) => {
16203 if err.kind() == std::io::ErrorKind::NotFound && in_ext_root && configured_repair_mode.should_apply() {
16204 return apply_missing_asset_fallback(&checked_path, &err.to_string());
16205 }
16206 return Err(rquickjs::Error::new_loading_message(
16207 &path,
16208 format!("host read: {err}"),
16209 ));
16210 }
16211 };
16212
16213 let mut reader = file.take(MAX_SYNC_READ_SIZE + 1);
16214 let mut buffer = Vec::new();
16215 reader.read_to_end(&mut buffer).map_err(|err| {
16216 rquickjs::Error::new_loading_message(
16217 &path,
16218 format!("host read content: {err}"),
16219 )
16220 })?;
16221
16222 if buffer.len() as u64 > MAX_SYNC_READ_SIZE {
16223 return Err(rquickjs::Error::new_loading_message(
16224 &path,
16225 format!("host read failed: file exceeds {} bytes", MAX_SYNC_READ_SIZE),
16226 ));
16227 }
16228
16229 Ok(BASE64_STANDARD.encode(buffer))
16230 }
16231 }
16232 }),
16233 )?;
16234
16235 global.set(
16239 "__pi_exec_sync_native",
16240 Func::from({
16241 let process_cwd = process_cwd.clone();
16242 let policy = self.policy.clone();
16243 move |ctx: Ctx<'_>,
16244 cmd: String,
16245 args_json: String,
16246 cwd: Opt<String>,
16247 timeout_ms: Opt<f64>,
16248 max_buffer: Opt<f64>|
16249 -> rquickjs::Result<String> {
16250 use std::process::{Command, Stdio};
16251 use std::time::{Duration, Instant};
16252
16253 tracing::debug!(
16254 event = "pijs.exec_sync",
16255 cmd = %cmd,
16256 "exec_sync"
16257 );
16258
16259 let args: Vec<String> = serde_json::from_str(&args_json)
16260 .map_err(|err| rquickjs::Error::new_into_js_message(
16261 "String",
16262 "Array",
16263 format!("invalid JSON args: {err}"),
16264 ))?;
16265
16266 let mut denied_reason = if allow_unsafe_sync_exec {
16267 None
16268 } else {
16269 Some("sync child_process APIs are disabled by default".to_string())
16270 };
16271
16272 if denied_reason.is_none() {
16274 if let Some(policy) = &policy {
16275 let extension_id: Option<String> = ctx
16276 .globals()
16277 .get::<_, Option<String>>("__pi_current_extension_id")
16278 .ok()
16279 .flatten()
16280 .map(|value| value.trim().to_string())
16281 .filter(|value| !value.is_empty());
16282
16283 if check_exec_capability(policy, extension_id.as_deref()) {
16284 match evaluate_exec_mediation(&policy.exec_mediation, &cmd, &args) {
16285 ExecMediationResult::Deny { reason, .. } => {
16286 denied_reason = Some(format!(
16287 "command blocked by exec mediation: {reason}"
16288 ));
16289 }
16290 ExecMediationResult::AllowWithAudit {
16291 class,
16292 reason,
16293 } => {
16294 tracing::info!(
16295 event = "pijs.exec_sync.mediation_audit",
16296 cmd = %cmd,
16297 class = class.label(),
16298 reason = %reason,
16299 "sync child_process command allowed with exec mediation audit"
16300 );
16301 }
16302 ExecMediationResult::Allow => {}
16303 }
16304 } else {
16305 denied_reason = Some("extension lacks 'exec' capability".to_string());
16306 }
16307 }
16308 }
16309
16310 if let Some(reason) = denied_reason {
16311 tracing::warn!(
16312 event = "pijs.exec_sync.denied",
16313 cmd = %cmd,
16314 reason = %reason,
16315 "sync child_process execution denied by security policy"
16316 );
16317 let denied = serde_json::json!({
16318 "stdout": "",
16319 "stderr": "",
16320 "status": null,
16321 "error": format!("Execution denied by policy ({reason})"),
16322 "killed": false,
16323 "pid": 0,
16324 "code": "denied",
16325 });
16326 return Ok(denied.to_string());
16327 }
16328
16329 let working_dir = cwd
16330 .0
16331 .filter(|s| !s.is_empty())
16332 .unwrap_or_else(|| process_cwd.clone());
16333
16334 let timeout = timeout_ms
16335 .0
16336 .filter(|ms| ms.is_finite() && *ms > 0.0)
16337 .map(|ms| Duration::from_secs_f64(ms / 1000.0));
16338
16339 let limit_bytes = max_buffer
16341 .0
16342 .filter(|b| b.is_finite() && *b > 0.0)
16343 .and_then(|b| b.trunc().to_string().parse::<usize>().ok())
16344 .unwrap_or(10 * 1024 * 1024);
16345
16346 let result: std::result::Result<serde_json::Value, String> = (|| {
16347 #[derive(Clone, Copy)]
16348 enum StreamKind {
16349 Stdout,
16350 Stderr,
16351 }
16352 struct StreamChunk {
16353 kind: StreamKind,
16354 bytes: Vec<u8>,
16355 }
16356 fn pump_stream(
16357 mut reader: impl std::io::Read,
16358 tx: &std::sync::mpsc::SyncSender<StreamChunk>,
16359 kind: StreamKind,
16360 ) {
16361 let mut buf = [0u8; 8192];
16362 loop {
16363 let read = match reader.read(&mut buf) {
16364 Ok(0) => break,
16365 Ok(read) => read,
16366 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
16367 Err(_) => break,
16368 };
16369 let chunk = StreamChunk {
16370 kind,
16371 bytes: buf[..read].to_vec(),
16372 };
16373 if tx.send(chunk).is_err() {
16374 break;
16375 }
16376 }
16377 }
16378
16379 let mut command = Command::new(&cmd);
16380 command
16381 .args(&args)
16382 .current_dir(&working_dir)
16383 .stdin(Stdio::null())
16384 .stdout(Stdio::piped())
16385 .stderr(Stdio::piped());
16386 crate::tools::isolate_command_process_group(&mut command);
16387
16388 let mut child = command.spawn().map_err(|e| e.to_string())?;
16389 let pid = child.id();
16390
16391 let stdout_pipe =
16392 child.stdout.take().ok_or("Missing stdout pipe")?;
16393 let stderr_pipe =
16394 child.stderr.take().ok_or("Missing stderr pipe")?;
16395
16396 let (tx, rx) = std::sync::mpsc::sync_channel::<StreamChunk>(128);
16397 let tx_stdout = tx.clone();
16398 let _stdout_handle =
16399 std::thread::spawn(move || pump_stream(stdout_pipe, &tx_stdout, StreamKind::Stdout));
16400 let _stderr_handle =
16401 std::thread::spawn(move || pump_stream(stderr_pipe, &tx, StreamKind::Stderr));
16402
16403 let start = Instant::now();
16404 let mut killed = false;
16405 let mut limit_exceeded = false;
16406 let mut limit_error: Option<String> = None;
16407
16408 let mut stdout_bytes = Vec::new();
16409 let mut stderr_bytes = Vec::new();
16410
16411 macro_rules! ingest_chunk {
16412 ($kind:expr, $bytes:expr) => {
16413 if !limit_exceeded {
16414 match $kind {
16415 StreamKind::Stdout => {
16416 if stdout_bytes.len() + $bytes.len() > limit_bytes {
16417 stdout_bytes.extend_from_slice(&$bytes[..limit_bytes.saturating_sub(stdout_bytes.len())]);
16418 limit_exceeded = true;
16419 limit_error = Some("stdout maxBuffer length exceeded".to_string());
16420 } else {
16421 stdout_bytes.extend($bytes);
16422 }
16423 }
16424 StreamKind::Stderr => {
16425 if stderr_bytes.len() + $bytes.len() > limit_bytes {
16426 stderr_bytes.extend_from_slice(&$bytes[..limit_bytes.saturating_sub(stderr_bytes.len())]);
16427 limit_exceeded = true;
16428 limit_error = Some("stderr maxBuffer length exceeded".to_string());
16429 } else {
16430 stderr_bytes.extend($bytes);
16431 }
16432 }
16433 }
16434 }
16435 };
16436 }
16437
16438 let status = loop {
16439 while let Ok(chunk) = rx.try_recv() {
16440 ingest_chunk!(chunk.kind, chunk.bytes);
16441 }
16442
16443 if let Some(st) = child.try_wait().map_err(|e| e.to_string())? {
16444 break st;
16445 }
16446 if !killed && limit_exceeded {
16447 killed = true;
16448 crate::tools::kill_process_group_tree(Some(pid));
16449 let _ = child.kill();
16450 break child.wait().map_err(|e| e.to_string())?;
16451 }
16452 if let Some(t) = timeout {
16453 if !killed && start.elapsed() >= t {
16454 killed = true;
16455 crate::tools::kill_process_group_tree(Some(pid));
16456 let _ = child.kill();
16457 break child.wait().map_err(|e| e.to_string())?;
16458 }
16459 }
16460 if let Ok(chunk) = rx.recv_timeout(Duration::from_millis(5)) {
16461 ingest_chunk!(chunk.kind, chunk.bytes);
16462 }
16463 };
16464
16465 let drain_deadline = Instant::now() + Duration::from_secs(2);
16466 loop {
16467 match rx.try_recv() {
16468 Ok(chunk) => ingest_chunk!(chunk.kind, chunk.bytes),
16469 Err(std::sync::mpsc::TryRecvError::Empty) => {
16470 if Instant::now() >= drain_deadline {
16471 break;
16472 }
16473 std::thread::sleep(Duration::from_millis(10));
16474 }
16475 Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
16476 }
16477 }
16478
16479 drop(rx);
16480 let _ = child.wait();
16481
16482 let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
16483 let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
16484 let code = status.code();
16485
16486 Ok(serde_json::json!({
16487 "stdout": stdout,
16488 "stderr": stderr,
16489 "status": code,
16490 "killed": killed,
16491 "pid": pid,
16492 "error": limit_error
16493 }))
16494 })(
16495 );
16496
16497 let json = match result {
16498 Ok(v) => v,
16499 Err(e) => serde_json::json!({
16500 "stdout": "",
16501 "stderr": "",
16502 "status": null,
16503 "error": e,
16504 "killed": false,
16505 "pid": 0,
16506 }),
16507 };
16508 Ok(json.to_string())
16509 }
16510 }),
16511 )?;
16512
16513 crate::crypto_shim::register_crypto_hostcalls(&global)?;
16515
16516 #[cfg(feature = "wasm-host")]
16518 {
16519 let wasm_state = std::rc::Rc::new(std::cell::RefCell::new(
16520 crate::pi_wasm::WasmBridgeState::new(),
16521 ));
16522 crate::pi_wasm::inject_wasm_globals(&ctx, &wasm_state)?;
16523 }
16524
16525 match ctx.eval::<(), _>(PI_BRIDGE_JS) {
16527 Ok(()) => {}
16528 Err(rquickjs::Error::Exception) => {
16529 let detail = format_quickjs_exception(&ctx, ctx.catch());
16530 return Err(rquickjs::Error::new_into_js_message(
16531 "PI_BRIDGE_JS",
16532 "eval",
16533 detail,
16534 ));
16535 }
16536 Err(err) => return Err(err),
16537 }
16538
16539 Ok(())
16540 })
16541 .await
16542 .map_err(|err| map_js_error(&err))?;
16543
16544 Ok(())
16545 }
16546}
16547
16548fn generate_call_id() -> u64 {
16550 use std::sync::atomic::{AtomicU64, Ordering};
16551 static COUNTER: AtomicU64 = AtomicU64::new(1);
16552 COUNTER.fetch_add(1, Ordering::Relaxed)
16553}
16554
16555fn hex_lower(bytes: &[u8]) -> String {
16556 const HEX: [char; 16] = [
16557 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
16558 ];
16559
16560 let mut output = String::with_capacity(bytes.len() * 2);
16561 for &byte in bytes {
16562 output.push(HEX[usize::from(byte >> 4)]);
16563 output.push(HEX[usize::from(byte & 0x0f)]);
16564 }
16565 output
16566}
16567
16568fn map_crypto_entropy_error(api: &'static str, err: getrandom::Error) -> rquickjs::Error {
16569 tracing::error!(
16570 event = "pijs.crypto.entropy_failure",
16571 api,
16572 error = %err,
16573 "OS randomness unavailable"
16574 );
16575 rquickjs::Error::new_into_js_message("crypto", api, format!("OS randomness unavailable: {err}"))
16576}
16577
16578fn fill_random_bytes_with<F, E>(len: usize, mut fill: F) -> std::result::Result<Vec<u8>, E>
16579where
16580 F: FnMut(&mut [u8]) -> std::result::Result<(), E>,
16581{
16582 let mut out = vec![0u8; len];
16583 if len > 0 {
16584 fill(&mut out)?;
16585 }
16586 Ok(out)
16587}
16588
16589fn random_bytes(len: usize) -> std::result::Result<Vec<u8>, getrandom::Error> {
16590 fill_random_bytes_with(len, getrandom::fill)
16591}
16592
16593const PI_BRIDGE_JS: &str = r"
16598// ============================================================================
16599// Console global — must come first so all other bridge code can use it.
16600// ============================================================================
16601if (typeof globalThis.console === 'undefined') {
16602 const __fmt = (...args) => args.map(a => {
16603 if (a === null) return 'null';
16604 if (a === undefined) return 'undefined';
16605 if (typeof a === 'object') {
16606 try { return JSON.stringify(a); } catch (_) { return String(a); }
16607 }
16608 return String(a);
16609 }).join(' ');
16610
16611 globalThis.console = {
16612 log: (...args) => { __pi_console_output_native('log', __fmt(...args)); },
16613 info: (...args) => { __pi_console_output_native('info', __fmt(...args)); },
16614 warn: (...args) => { __pi_console_output_native('warn', __fmt(...args)); },
16615 error: (...args) => { __pi_console_output_native('error', __fmt(...args)); },
16616 debug: (...args) => { __pi_console_output_native('debug', __fmt(...args)); },
16617 trace: (...args) => { __pi_console_output_native('trace', __fmt(...args)); },
16618 dir: (...args) => { __pi_console_output_native('log', __fmt(...args)); },
16619 time: () => {},
16620 timeEnd: () => {},
16621 timeLog: () => {},
16622 assert: (cond, ...args) => {
16623 if (!cond) __pi_console_output_native('error', 'Assertion failed: ' + __fmt(...args));
16624 },
16625 count: () => {},
16626 countReset: () => {},
16627 group: () => {},
16628 groupEnd: () => {},
16629 table: (...args) => { __pi_console_output_native('log', __fmt(...args)); },
16630 clear: () => {},
16631 };
16632}
16633
16634// ============================================================================
16635// Intl polyfill — minimal stubs for extensions that use Intl APIs.
16636// QuickJS does not ship with Intl support; these cover the most common uses.
16637// ============================================================================
16638if (typeof globalThis.Intl === 'undefined') {
16639 const __intlPad = (n, w) => String(n).padStart(w || 2, '0');
16640
16641 class NumberFormat {
16642 constructor(locale, opts) {
16643 this._locale = locale || 'en-US';
16644 this._opts = opts || {};
16645 }
16646 format(n) {
16647 const o = this._opts;
16648 if (o.style === 'currency') {
16649 const c = o.currency || 'USD';
16650 const v = Number(n).toFixed(o.maximumFractionDigits ?? 2);
16651 return c + ' ' + v;
16652 }
16653 if (o.notation === 'compact') {
16654 const abs = Math.abs(n);
16655 if (abs >= 1e9) return (n / 1e9).toFixed(1) + 'B';
16656 if (abs >= 1e6) return (n / 1e6).toFixed(1) + 'M';
16657 if (abs >= 1e3) return (n / 1e3).toFixed(1) + 'K';
16658 return String(n);
16659 }
16660 if (o.style === 'percent') return (Number(n) * 100).toFixed(0) + '%';
16661 return String(n);
16662 }
16663 resolvedOptions() { return { ...this._opts, locale: this._locale }; }
16664 }
16665
16666 const __months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
16667 class DateTimeFormat {
16668 constructor(locale, opts) {
16669 this._locale = locale || 'en-US';
16670 this._opts = opts || {};
16671 }
16672 format(d) {
16673 const dt = d instanceof Date ? d : new Date(d ?? Date.now());
16674 const o = this._opts;
16675 const parts = [];
16676 if (o.month === 'short') parts.push(__months[dt.getMonth()]);
16677 else if (o.month === 'numeric' || o.month === '2-digit') parts.push(__intlPad(dt.getMonth() + 1));
16678 if (o.day === 'numeric' || o.day === '2-digit') parts.push(String(dt.getDate()));
16679 if (o.year === 'numeric') parts.push(String(dt.getFullYear()));
16680 if (parts.length === 0) {
16681 return __intlPad(dt.getMonth()+1) + '/' + __intlPad(dt.getDate()) + '/' + dt.getFullYear();
16682 }
16683 if (o.hour !== undefined) {
16684 parts.push(__intlPad(dt.getHours()) + ':' + __intlPad(dt.getMinutes()));
16685 }
16686 return parts.join(' ');
16687 }
16688 resolvedOptions() { return { ...this._opts, locale: this._locale, timeZone: 'UTC' }; }
16689 }
16690
16691 class Collator {
16692 constructor(locale, opts) {
16693 this._locale = locale || 'en';
16694 this._opts = opts || {};
16695 }
16696 compare(a, b) {
16697 const sa = String(a ?? '');
16698 const sb = String(b ?? '');
16699 if (this._opts.sensitivity === 'base') {
16700 return sa.toLowerCase().localeCompare(sb.toLowerCase());
16701 }
16702 return sa.localeCompare(sb);
16703 }
16704 resolvedOptions() { return { ...this._opts, locale: this._locale }; }
16705 }
16706
16707 class Segmenter {
16708 constructor(locale, opts) {
16709 this._locale = locale || 'en';
16710 this._opts = opts || {};
16711 }
16712 segment(str) {
16713 const s = String(str ?? '');
16714 const segments = [];
16715 // Approximate grapheme segmentation: split by codepoints
16716 for (const ch of s) {
16717 segments.push({ segment: ch, index: segments.length, input: s });
16718 }
16719 segments[Symbol.iterator] = function*() { for (const seg of segments) yield seg; };
16720 return segments;
16721 }
16722 }
16723
16724 class RelativeTimeFormat {
16725 constructor(locale, opts) {
16726 this._locale = locale || 'en';
16727 this._opts = opts || {};
16728 }
16729 format(value, unit) {
16730 const v = Number(value);
16731 const u = String(unit);
16732 const abs = Math.abs(v);
16733 const plural = abs !== 1 ? 's' : '';
16734 if (this._opts.numeric === 'auto') {
16735 if (v === -1 && u === 'day') return 'yesterday';
16736 if (v === 1 && u === 'day') return 'tomorrow';
16737 }
16738 if (v < 0) return abs + ' ' + u + plural + ' ago';
16739 return 'in ' + abs + ' ' + u + plural;
16740 }
16741 }
16742
16743 globalThis.Intl = {
16744 NumberFormat,
16745 DateTimeFormat,
16746 Collator,
16747 Segmenter,
16748 RelativeTimeFormat,
16749 };
16750}
16751
16752// Pending hostcalls: call_id -> { resolve, reject }
16753const __pi_pending_hostcalls = new Map();
16754
16755// Timer callbacks: timer_id -> callback
16756const __pi_timer_callbacks = new Map();
16757
16758// Event listeners: event_id -> [callback, ...]
16759const __pi_event_listeners = new Map();
16760
16761// ============================================================================
16762// Extension Registry (registration + hooks)
16763// ============================================================================
16764
16765var __pi_current_extension_id = null;
16766
16767// extension_id -> { id, name, version, apiVersion, tools: Map, commands: Map, hooks: Map, mcpServers: Map }
16768const __pi_extensions = new Map();
16769
16770// Fast indexes
16771const __pi_tool_index = new Map(); // tool_name -> { extensionId, spec, execute }
16772const __pi_command_index = new Map(); // command_name -> { extensionId, name, description, handler }
16773const __pi_hook_index = new Map(); // event_name -> [{ extensionId, handler }, ...]
16774const __pi_event_bus_index = new Map(); // event_name -> [{ extensionId, handler }, ...] (pi.events.on)
16775const __pi_provider_index = new Map(); // provider_id -> { extensionId, spec }
16776const __pi_shortcut_index = new Map(); // key_id -> { extensionId, key, description, handler }
16777const __pi_message_renderer_index = new Map(); // customType -> { extensionId, customType, renderer }
16778const __pi_mcp_server_index = new Map(); // server_name -> { extensionId, spec }
16779
16780// Async task tracking for Rust-driven calls (tool exec, command exec, event dispatch).
16781// task_id -> { status: 'pending'|'resolved'|'rejected', value?, error? }
16782const __pi_tasks = new Map();
16783
16784function __pi_serialize_error(err) {
16785 if (!err) {
16786 return { message: 'Unknown error' };
16787 }
16788 if (typeof err === 'string') {
16789 return { message: err };
16790 }
16791 const out = { message: String(err.message || err) };
16792 if (err.code) out.code = String(err.code);
16793 if (err.stack) out.stack = String(err.stack);
16794 return out;
16795}
16796
16797function __pi_task_start(task_id, promise) {
16798 const id = String(task_id || '').trim();
16799 if (!id) {
16800 throw new Error('task_id is required');
16801 }
16802 __pi_tasks.set(id, { status: 'pending' });
16803 Promise.resolve(promise).then(
16804 (value) => {
16805 __pi_tasks.set(id, { status: 'resolved', value: value });
16806 },
16807 (err) => {
16808 __pi_tasks.set(id, { status: 'rejected', error: __pi_serialize_error(err) });
16809 }
16810 );
16811 return id;
16812}
16813
16814function __pi_task_poll(task_id) {
16815 const id = String(task_id || '').trim();
16816 return __pi_tasks.get(id) || null;
16817}
16818
16819function __pi_task_take(task_id) {
16820 const id = String(task_id || '').trim();
16821 const state = __pi_tasks.get(id) || null;
16822 if (state && state.status !== 'pending') {
16823 __pi_tasks.delete(id);
16824 }
16825 return state;
16826}
16827
16828function __pi_runtime_registry_snapshot() {
16829 return {
16830 extensions: __pi_extensions.size,
16831 tools: __pi_tool_index.size,
16832 commands: __pi_command_index.size,
16833 hooks: __pi_hook_index.size,
16834 eventBusHooks: __pi_event_bus_index.size,
16835 providers: __pi_provider_index.size,
16836 shortcuts: __pi_shortcut_index.size,
16837 messageRenderers: __pi_message_renderer_index.size,
16838 mcpServers: __pi_mcp_server_index.size,
16839 pendingTasks: __pi_tasks.size,
16840 pendingHostcalls: __pi_pending_hostcalls.size,
16841 pendingTimers: __pi_timer_callbacks.size,
16842 pendingEventListenerLists: __pi_event_listeners.size,
16843 providerStreams:
16844 typeof __pi_provider_streams !== 'undefined' &&
16845 __pi_provider_streams &&
16846 typeof __pi_provider_streams.size === 'number'
16847 ? __pi_provider_streams.size
16848 : 0,
16849 };
16850}
16851
16852function __pi_reset_extension_runtime_state() {
16853 const before = __pi_runtime_registry_snapshot();
16854
16855 if (
16856 typeof __pi_provider_streams !== 'undefined' &&
16857 __pi_provider_streams &&
16858 typeof __pi_provider_streams.values === 'function'
16859 ) {
16860 for (const stream of __pi_provider_streams.values()) {
16861 try {
16862 if (stream && stream.controller && typeof stream.controller.abort === 'function') {
16863 stream.controller.abort();
16864 }
16865 } catch (_) {}
16866 try {
16867 if (
16868 stream &&
16869 stream.iterator &&
16870 typeof stream.iterator.return === 'function'
16871 ) {
16872 stream.iterator.return();
16873 }
16874 } catch (_) {}
16875 }
16876 if (typeof __pi_provider_streams.clear === 'function') {
16877 __pi_provider_streams.clear();
16878 }
16879 }
16880 if (typeof __pi_provider_stream_seq === 'number') {
16881 __pi_provider_stream_seq = 0;
16882 }
16883
16884 __pi_current_extension_id = null;
16885 __pi_extensions.clear();
16886 __pi_tool_index.clear();
16887 __pi_command_index.clear();
16888 __pi_hook_index.clear();
16889 __pi_event_bus_index.clear();
16890 __pi_provider_index.clear();
16891 __pi_shortcut_index.clear();
16892 __pi_message_renderer_index.clear();
16893 __pi_mcp_server_index.clear();
16894 __pi_tasks.clear();
16895 __pi_pending_hostcalls.clear();
16896 __pi_timer_callbacks.clear();
16897 __pi_event_listeners.clear();
16898
16899 const after = __pi_runtime_registry_snapshot();
16900 const clean =
16901 after.extensions === 0 &&
16902 after.tools === 0 &&
16903 after.commands === 0 &&
16904 after.hooks === 0 &&
16905 after.eventBusHooks === 0 &&
16906 after.providers === 0 &&
16907 after.shortcuts === 0 &&
16908 after.messageRenderers === 0 &&
16909 after.mcpServers === 0 &&
16910 after.pendingTasks === 0 &&
16911 after.pendingHostcalls === 0 &&
16912 after.pendingTimers === 0 &&
16913 after.pendingEventListenerLists === 0 &&
16914 after.providerStreams === 0;
16915
16916 return { before, after, clean };
16917}
16918
16919function __pi_get_or_create_extension(extension_id, meta) {
16920 const id = String(extension_id || '').trim();
16921 if (!id) {
16922 throw new Error('extension_id is required');
16923 }
16924
16925 if (!__pi_extensions.has(id)) {
16926 __pi_extensions.set(id, {
16927 id: id,
16928 name: (meta && meta.name) ? String(meta.name) : id,
16929 version: (meta && meta.version) ? String(meta.version) : '0.0.0',
16930 apiVersion: (meta && meta.apiVersion) ? String(meta.apiVersion) : '1.0',
16931 tools: new Map(),
16932 commands: new Map(),
16933 hooks: new Map(),
16934 eventBusHooks: new Map(),
16935 providers: new Map(),
16936 mcpServers: new Map(),
16937 shortcuts: new Map(),
16938 flags: new Map(),
16939 flagValues: new Map(),
16940 messageRenderers: new Map(),
16941 activeTools: null,
16942 });
16943 }
16944
16945 return __pi_extensions.get(id);
16946}
16947
16948function __pi_begin_extension(extension_id, meta) {
16949 const ext = __pi_get_or_create_extension(extension_id, meta);
16950 __pi_current_extension_id = ext.id;
16951}
16952
16953function __pi_end_extension() {
16954 __pi_current_extension_id = null;
16955}
16956
16957function __pi_current_extension_or_throw() {
16958 if (!__pi_current_extension_id) {
16959 throw new Error('No active extension. Did you forget to call __pi_begin_extension?');
16960 }
16961 const ext = __pi_extensions.get(__pi_current_extension_id);
16962 if (!ext) {
16963 throw new Error('Internal error: active extension not found');
16964 }
16965 return ext;
16966}
16967
16968async function __pi_with_extension_async(extension_id, fn) {
16969 const prev = __pi_current_extension_id;
16970 __pi_current_extension_id = String(extension_id || '').trim();
16971 try {
16972 return await fn();
16973 } finally {
16974 __pi_current_extension_id = prev;
16975 }
16976}
16977
16978// Pattern 5 (bd-k5q5.8.6): log export shape normalization repairs.
16979// This is a lightweight JS-side event emitter; the Rust repair_events
16980// collector is not called from here to keep the bridge minimal.
16981function __pi_emit_repair_event(pattern, ext_id, entry, error, action) {
16982 if (typeof globalThis.__pi_host_log_event === 'function') {
16983 try {
16984 globalThis.__pi_host_log_event('pijs.repair.' + pattern, JSON.stringify({
16985 extension_id: ext_id, entry, error, action
16986 }));
16987 } catch (_) { /* best-effort */ }
16988 }
16989}
16990
16991async function __pi_load_extension(extension_id, entry_specifier, meta) {
16992 const id = String(extension_id || '').trim();
16993 const entry = String(entry_specifier || '').trim();
16994 if (!id) {
16995 throw new Error('load_extension: extension_id is required');
16996 }
16997 if (!entry) {
16998 throw new Error('load_extension: entry_specifier is required');
16999 }
17000
17001 const prev = __pi_current_extension_id;
17002 __pi_begin_extension(id, meta);
17003 try {
17004 const mod = await import(entry);
17005 let init = mod && mod.default;
17006
17007 // Pattern 5 (bd-k5q5.8.6): export shape normalization.
17008 // Try alternative activation function shapes before failing.
17009 if (typeof init !== 'function') {
17010 // 5a: double-wrapped default (CJS→ESM artifact)
17011 if (init && typeof init === 'object' && typeof init.default === 'function') {
17012 init = init.default;
17013 __pi_emit_repair_event('export_shape', id, entry,
17014 'double-wrapped default export', 'unwrapped mod.default.default');
17015 }
17016 // 5b: named 'activate' export
17017 else if (typeof mod.activate === 'function') {
17018 init = mod.activate;
17019 __pi_emit_repair_event('export_shape', id, entry,
17020 'no default export function', 'used named export mod.activate');
17021 }
17022 // 5c: nested CJS default with activate method
17023 else if (init && typeof init === 'object' && typeof init.activate === 'function') {
17024 init = init.activate;
17025 __pi_emit_repair_event('export_shape', id, entry,
17026 'default is object with activate method', 'used mod.default.activate');
17027 }
17028 }
17029
17030 if (typeof init !== 'function') {
17031 const namedFallbacks = ['init', 'initialize', 'setup', 'register', 'plugin', 'main'];
17032 for (const key of namedFallbacks) {
17033 if (typeof mod?.[key] === 'function') {
17034 init = mod[key];
17035 __pi_emit_repair_event('export_shape', id, entry,
17036 'no default export function', `used named export mod.${key}`);
17037 break;
17038 }
17039 }
17040 }
17041
17042 if (typeof init !== 'function' && init && typeof init === 'object') {
17043 const nestedFallbacks = ['init', 'initialize', 'setup', 'register', 'plugin', 'main'];
17044 for (const key of nestedFallbacks) {
17045 if (typeof init?.[key] === 'function') {
17046 init = init[key];
17047 __pi_emit_repair_event('export_shape', id, entry,
17048 'default is object with init-like export', `used mod.default.${key}`);
17049 break;
17050 }
17051 }
17052 }
17053
17054 if (typeof init !== 'function') {
17055 for (const [key, value] of Object.entries(mod || {})) {
17056 if (typeof value === 'function') {
17057 init = value;
17058 __pi_emit_repair_event('export_shape', id, entry,
17059 'no default export function', `used first function export mod.${key}`);
17060 break;
17061 }
17062 }
17063 }
17064
17065 if (typeof init !== 'function') {
17066 throw new Error('load_extension: entry module must default-export a function');
17067 }
17068 await init(pi);
17069 return true;
17070 } finally {
17071 __pi_current_extension_id = prev;
17072 }
17073}
17074
17075function __pi_register_tool(spec) {
17076 const ext = __pi_current_extension_or_throw();
17077 if (!spec || typeof spec !== 'object') {
17078 throw new Error('registerTool: spec must be an object');
17079 }
17080 const name = String(spec.name || '').trim();
17081 if (!name) {
17082 throw new Error('registerTool: spec.name is required');
17083 }
17084 if (typeof spec.execute !== 'function') {
17085 throw new Error('registerTool: spec.execute must be a function');
17086 }
17087
17088 const toolSpec = {
17089 name: name,
17090 description: spec.description ? String(spec.description) : '',
17091 parameters: spec.parameters || { type: 'object', properties: {} },
17092 };
17093 if (typeof spec.label === 'string') {
17094 toolSpec.label = spec.label;
17095 }
17096
17097 if (__pi_tool_index.has(name)) {
17098 const existing = __pi_tool_index.get(name);
17099 if (existing && existing.extensionId !== ext.id) {
17100 throw new Error(`registerTool: tool name collision: ${name}`);
17101 }
17102 }
17103
17104 const record = { extensionId: ext.id, spec: toolSpec, execute: spec.execute };
17105 ext.tools.set(name, record);
17106 __pi_tool_index.set(name, record);
17107}
17108
17109function __pi_get_registered_tools() {
17110 const names = Array.from(__pi_tool_index.keys()).map((v) => String(v));
17111 names.sort();
17112 const out = [];
17113 for (const name of names) {
17114 const record = __pi_tool_index.get(name);
17115 if (!record || !record.spec) continue;
17116 out.push(record.spec);
17117 }
17118 return out;
17119}
17120
17121function __pi_register_command(name, spec) {
17122 const ext = __pi_current_extension_or_throw();
17123 const cmd = String(name || '').trim().replace(/^\//, '');
17124 if (!cmd) {
17125 throw new Error('registerCommand: name is required');
17126 }
17127 if (!spec || typeof spec !== 'object') {
17128 throw new Error('registerCommand: spec must be an object');
17129 }
17130 // Accept both spec.handler and spec.fn (PiCommand compat)
17131 const handler = typeof spec.handler === 'function' ? spec.handler
17132 : typeof spec.fn === 'function' ? spec.fn
17133 : undefined;
17134 if (!handler) {
17135 throw new Error('registerCommand: spec.handler must be a function');
17136 }
17137
17138 const cmdSpec = {
17139 name: cmd,
17140 description: spec.description ? String(spec.description) : '',
17141 };
17142
17143 if (__pi_command_index.has(cmd)) {
17144 const existing = __pi_command_index.get(cmd);
17145 if (existing && existing.extensionId !== ext.id) {
17146 throw new Error(`registerCommand: command name collision: ${cmd}`);
17147 }
17148 }
17149
17150 const record = {
17151 extensionId: ext.id,
17152 name: cmd,
17153 description: cmdSpec.description,
17154 handler: handler,
17155 spec: cmdSpec,
17156 };
17157 ext.commands.set(cmd, record);
17158 __pi_command_index.set(cmd, record);
17159}
17160
17161function __pi_register_provider(provider_id, spec) {
17162 const ext = __pi_current_extension_or_throw();
17163 const id = String(provider_id || '').trim();
17164 if (!id) {
17165 throw new Error('registerProvider: id is required');
17166 }
17167 if (!spec || typeof spec !== 'object') {
17168 throw new Error('registerProvider: spec must be an object');
17169 }
17170
17171 const models = Array.isArray(spec.models) ? spec.models.map((m) => {
17172 const out = {
17173 id: m && m.id ? String(m.id) : '',
17174 name: m && m.name ? String(m.name) : '',
17175 };
17176 if (m && m.api) out.api = String(m.api);
17177 if (m && m.reasoning !== undefined) out.reasoning = !!m.reasoning;
17178 if (m && Array.isArray(m.input)) out.input = m.input.slice();
17179 if (m && m.cost) out.cost = m.cost;
17180 if (m && m.contextWindow !== undefined) out.contextWindow = m.contextWindow;
17181 if (m && m.maxTokens !== undefined) out.maxTokens = m.maxTokens;
17182 return out;
17183 }) : [];
17184
17185 const hasStreamSimple = typeof spec.streamSimple === 'function';
17186 if (spec.streamSimple !== undefined && spec.streamSimple !== null && !hasStreamSimple) {
17187 throw new Error('registerProvider: spec.streamSimple must be a function');
17188 }
17189
17190 const providerSpec = {
17191 id: id,
17192 baseUrl: spec.baseUrl ? String(spec.baseUrl) : '',
17193 apiKey: spec.apiKey ? String(spec.apiKey) : '',
17194 api: spec.api ? String(spec.api) : '',
17195 models: models,
17196 hasStreamSimple: hasStreamSimple,
17197 };
17198
17199 if (hasStreamSimple && !providerSpec.api) {
17200 throw new Error('registerProvider: api is required when registering streamSimple');
17201 }
17202
17203 if (__pi_provider_index.has(id)) {
17204 const existing = __pi_provider_index.get(id);
17205 if (existing && existing.extensionId !== ext.id) {
17206 throw new Error(`registerProvider: provider id collision: ${id}`);
17207 }
17208 }
17209
17210 const record = {
17211 extensionId: ext.id,
17212 spec: providerSpec,
17213 streamSimple: hasStreamSimple ? spec.streamSimple : null,
17214 };
17215 ext.providers.set(id, record);
17216 __pi_provider_index.set(id, record);
17217}
17218
17219function __pi_register_mcp_server(name, spec) {
17220 const ext = __pi_current_extension_or_throw();
17221 const serverName = String(name || '').trim();
17222 if (!serverName) {
17223 throw new Error('registerMcpServer: name is required');
17224 }
17225 if (!spec || typeof spec !== 'object') {
17226 throw new Error('registerMcpServer: spec must be an object');
17227 }
17228
17229 const command = spec.command !== undefined && spec.command !== null
17230 ? String(spec.command).trim()
17231 : '';
17232 const url = spec.url !== undefined && spec.url !== null
17233 ? String(spec.url).trim()
17234 : '';
17235 if (!command && !url) {
17236 throw new Error('registerMcpServer: spec.command or spec.url is required');
17237 }
17238
17239 let args = undefined;
17240 if (spec.args !== undefined && spec.args !== null) {
17241 if (!Array.isArray(spec.args)) {
17242 throw new Error('registerMcpServer: spec.args must be an array');
17243 }
17244 args = spec.args.map((value) => String(value));
17245 }
17246
17247 let env = undefined;
17248 if (spec.env !== undefined && spec.env !== null) {
17249 if (typeof spec.env !== 'object' || Array.isArray(spec.env)) {
17250 throw new Error('registerMcpServer: spec.env must be an object');
17251 }
17252 env = Object.create(null);
17253 for (const [key, value] of Object.entries(spec.env)) {
17254 env[String(key)] = String(value);
17255 }
17256 }
17257
17258 const mcpSpec = {
17259 name: serverName,
17260 transport: spec.transport ? String(spec.transport) : undefined,
17261 command: command || undefined,
17262 url: url || undefined,
17263 args: args,
17264 env: env,
17265 };
17266 if (spec.description !== undefined) {
17267 mcpSpec.description = String(spec.description);
17268 }
17269 if (spec.cwd !== undefined) {
17270 mcpSpec.cwd = String(spec.cwd);
17271 }
17272
17273 if (__pi_mcp_server_index.has(serverName)) {
17274 const existing = __pi_mcp_server_index.get(serverName);
17275 if (existing && existing.extensionId !== ext.id) {
17276 throw new Error(`registerMcpServer: server name collision: ${serverName}`);
17277 }
17278 }
17279
17280 const record = { extensionId: ext.id, spec: mcpSpec };
17281 ext.mcpServers.set(serverName, record);
17282 __pi_mcp_server_index.set(serverName, record);
17283 return mcpSpec;
17284}
17285
17286function __pi_register_mcp_server_for_extension(extension_id, name, spec) {
17287 const extId = String(extension_id || '').trim();
17288 if (!extId) {
17289 throw new Error('registerMcpServer: extension_id is required');
17290 }
17291 const prev = __pi_current_extension_id;
17292 __pi_current_extension_id = extId;
17293 try {
17294 return __pi_register_mcp_server(name, spec);
17295 } finally {
17296 __pi_current_extension_id = prev;
17297 }
17298}
17299
17300// ============================================================================
17301// Provider Streaming (streamSimple bridge)
17302// ============================================================================
17303
17304let __pi_provider_stream_seq = 0;
17305const __pi_provider_streams = new Map(); // stream_id -> { iterator, controller }
17306
17307function __pi_make_abort_controller() {
17308 const listeners = new Set();
17309 const signal = {
17310 aborted: false,
17311 addEventListener: (type, cb) => {
17312 if (type !== 'abort') return;
17313 if (typeof cb === 'function') listeners.add(cb);
17314 },
17315 removeEventListener: (type, cb) => {
17316 if (type !== 'abort') return;
17317 listeners.delete(cb);
17318 },
17319 };
17320 return {
17321 signal,
17322 abort: () => {
17323 if (signal.aborted) return;
17324 signal.aborted = true;
17325 for (const cb of listeners) {
17326 try {
17327 cb();
17328 } catch (_) {}
17329 }
17330 },
17331 };
17332}
17333
17334async function __pi_provider_stream_simple_start(provider_id, model, context, options) {
17335 const id = String(provider_id || '').trim();
17336 if (!id) {
17337 throw new Error('providerStreamSimple.start: provider_id is required');
17338 }
17339 const record = __pi_provider_index.get(id);
17340 if (!record) {
17341 throw new Error('providerStreamSimple.start: unknown provider: ' + id);
17342 }
17343 if (!record.streamSimple || typeof record.streamSimple !== 'function') {
17344 throw new Error('providerStreamSimple.start: provider has no streamSimple handler: ' + id);
17345 }
17346
17347 const controller = __pi_make_abort_controller();
17348 const mergedOptions = Object.assign({}, options || {}, { signal: controller.signal });
17349
17350 const stream = record.streamSimple(model, context, mergedOptions);
17351 const iterator = stream && stream[Symbol.asyncIterator] ? stream[Symbol.asyncIterator]() : stream;
17352 if (!iterator || typeof iterator.next !== 'function') {
17353 throw new Error('providerStreamSimple.start: streamSimple must return an async iterator');
17354 }
17355
17356 const stream_id = 'provider-stream-' + String(++__pi_provider_stream_seq);
17357 __pi_provider_streams.set(stream_id, { iterator, controller });
17358 return stream_id;
17359}
17360
17361async function __pi_provider_stream_simple_next(stream_id) {
17362 const id = String(stream_id || '').trim();
17363 const record = __pi_provider_streams.get(id);
17364 if (!record) {
17365 return { done: true, value: null };
17366 }
17367
17368 const result = await record.iterator.next();
17369 if (!result || result.done) {
17370 __pi_provider_streams.delete(id);
17371 return { done: true, value: null };
17372 }
17373
17374 return { done: false, value: result.value };
17375}
17376
17377async function __pi_provider_stream_simple_cancel(stream_id) {
17378 const id = String(stream_id || '').trim();
17379 const record = __pi_provider_streams.get(id);
17380 if (!record) {
17381 return false;
17382 }
17383
17384 try {
17385 record.controller.abort();
17386 } catch (_) {}
17387
17388 try {
17389 if (record.iterator && typeof record.iterator.return === 'function') {
17390 await record.iterator.return();
17391 }
17392 } catch (_) {}
17393
17394 __pi_provider_streams.delete(id);
17395 return true;
17396}
17397
17398const __pi_reserved_keys = new Set(['ctrl+c', 'ctrl+d', 'ctrl+l', 'ctrl+z']);
17399
17400function __pi_key_to_string(key) {
17401 // Convert Key object from @mariozechner/pi-tui to string format
17402 if (typeof key === 'string') {
17403 return key.toLowerCase();
17404 }
17405 if (key && typeof key === 'object') {
17406 const kind = key.kind;
17407 const k = key.key || '';
17408 if (kind === 'ctrlAlt') {
17409 return 'ctrl+alt+' + k.toLowerCase();
17410 }
17411 if (kind === 'ctrlShift') {
17412 return 'ctrl+shift+' + k.toLowerCase();
17413 }
17414 if (kind === 'ctrl') {
17415 return 'ctrl+' + k.toLowerCase();
17416 }
17417 if (kind === 'alt') {
17418 return 'alt+' + k.toLowerCase();
17419 }
17420 if (kind === 'shift') {
17421 return 'shift+' + k.toLowerCase();
17422 }
17423 // Fallback for unknown object format
17424 if (k) {
17425 return k.toLowerCase();
17426 }
17427 }
17428 return '<unknown>';
17429}
17430
17431function __pi_register_shortcut(key, spec) {
17432 const ext = __pi_current_extension_or_throw();
17433 if (!spec || typeof spec !== 'object') {
17434 throw new Error('registerShortcut: spec must be an object');
17435 }
17436 if (typeof spec.handler !== 'function') {
17437 throw new Error('registerShortcut: spec.handler must be a function');
17438 }
17439
17440 const keyId = __pi_key_to_string(key);
17441 if (__pi_reserved_keys.has(keyId)) {
17442 throw new Error('registerShortcut: key ' + keyId + ' is reserved and cannot be overridden');
17443 }
17444
17445 const record = {
17446 key: key,
17447 keyId: keyId,
17448 description: spec.description ? String(spec.description) : '',
17449 handler: spec.handler,
17450 extensionId: ext.id,
17451 spec: { shortcut: keyId, key: key, key_id: keyId, description: spec.description ? String(spec.description) : '' },
17452 };
17453 ext.shortcuts.set(keyId, record);
17454 __pi_shortcut_index.set(keyId, record);
17455}
17456
17457function __pi_register_message_renderer(customType, renderer) {
17458 const ext = __pi_current_extension_or_throw();
17459 const typeId = String(customType || '').trim();
17460 if (!typeId) {
17461 throw new Error('registerMessageRenderer: customType is required');
17462 }
17463 if (typeof renderer !== 'function') {
17464 throw new Error('registerMessageRenderer: renderer must be a function');
17465 }
17466
17467 const record = {
17468 customType: typeId,
17469 renderer: renderer,
17470 extensionId: ext.id,
17471 };
17472 ext.messageRenderers.set(typeId, record);
17473 __pi_message_renderer_index.set(typeId, record);
17474}
17475
17476 function __pi_register_hook(event_name, handler) {
17477 const ext = __pi_current_extension_or_throw();
17478 const eventName = String(event_name || '').trim();
17479 if (!eventName) {
17480 throw new Error('on: event name is required');
17481 }
17482 if (typeof handler !== 'function') {
17483 throw new Error('on: handler must be a function');
17484 }
17485
17486 if (!ext.hooks.has(eventName)) {
17487 ext.hooks.set(eventName, []);
17488 }
17489 ext.hooks.get(eventName).push(handler);
17490
17491 if (!__pi_hook_index.has(eventName)) {
17492 __pi_hook_index.set(eventName, []);
17493 }
17494 const indexed = { extensionId: ext.id, handler: handler };
17495 __pi_hook_index.get(eventName).push(indexed);
17496
17497 let removed = false;
17498 return function unsubscribe() {
17499 if (removed) return;
17500 removed = true;
17501
17502 const local = ext.hooks.get(eventName);
17503 if (Array.isArray(local)) {
17504 const idx = local.indexOf(handler);
17505 if (idx !== -1) local.splice(idx, 1);
17506 if (local.length === 0) ext.hooks.delete(eventName);
17507 }
17508
17509 const global = __pi_hook_index.get(eventName);
17510 if (Array.isArray(global)) {
17511 const idx = global.indexOf(indexed);
17512 if (idx !== -1) global.splice(idx, 1);
17513 if (global.length === 0) __pi_hook_index.delete(eventName);
17514 }
17515 };
17516 }
17517
17518 function __pi_register_event_bus_hook(event_name, handler) {
17519 const ext = __pi_current_extension_or_throw();
17520 const eventName = String(event_name || '').trim();
17521 if (!eventName) {
17522 throw new Error('events.on: event name is required');
17523 }
17524 if (typeof handler !== 'function') {
17525 throw new Error('events.on: handler must be a function');
17526 }
17527
17528 if (!ext.eventBusHooks.has(eventName)) {
17529 ext.eventBusHooks.set(eventName, []);
17530 }
17531 ext.eventBusHooks.get(eventName).push(handler);
17532
17533 if (!__pi_event_bus_index.has(eventName)) {
17534 __pi_event_bus_index.set(eventName, []);
17535 }
17536 const indexed = { extensionId: ext.id, handler: handler };
17537 __pi_event_bus_index.get(eventName).push(indexed);
17538
17539 let removed = false;
17540 return function unsubscribe() {
17541 if (removed) return;
17542 removed = true;
17543
17544 const local = ext.eventBusHooks.get(eventName);
17545 if (Array.isArray(local)) {
17546 const idx = local.indexOf(handler);
17547 if (idx !== -1) local.splice(idx, 1);
17548 if (local.length === 0) ext.eventBusHooks.delete(eventName);
17549 }
17550
17551 const global = __pi_event_bus_index.get(eventName);
17552 if (Array.isArray(global)) {
17553 const idx = global.indexOf(indexed);
17554 if (idx !== -1) global.splice(idx, 1);
17555 if (global.length === 0) __pi_event_bus_index.delete(eventName);
17556 }
17557 };
17558 }
17559
17560function __pi_register_flag(flag_name, spec) {
17561 const ext = __pi_current_extension_or_throw();
17562 const name = String(flag_name || '').trim().replace(/^\//, '');
17563 if (!name) {
17564 throw new Error('registerFlag: name is required');
17565 }
17566 if (!spec || typeof spec !== 'object') {
17567 throw new Error('registerFlag: spec must be an object');
17568 }
17569 ext.flags.set(name, spec);
17570}
17571
17572function __pi_set_flag_value(extension_id, flag_name, value) {
17573 const extId = String(extension_id || '').trim();
17574 const name = String(flag_name || '').trim().replace(/^\//, '');
17575 if (!extId || !name) return false;
17576 const ext = __pi_extensions.get(extId);
17577 if (!ext) return false;
17578 ext.flagValues.set(name, value);
17579 return true;
17580}
17581
17582function __pi_get_flag(flag_name) {
17583 const ext = __pi_current_extension_or_throw();
17584 const name = String(flag_name || '').trim().replace(/^\//, '');
17585 if (!name) return undefined;
17586 if (ext.flagValues.has(name)) {
17587 return ext.flagValues.get(name);
17588 }
17589 const spec = ext.flags.get(name);
17590 return spec ? spec.default : undefined;
17591}
17592
17593function __pi_set_active_tools(tools) {
17594 const ext = __pi_current_extension_or_throw();
17595 if (!Array.isArray(tools)) {
17596 throw new Error('setActiveTools: tools must be an array');
17597 }
17598 ext.activeTools = tools.map((t) => String(t));
17599 // Best-effort notify host; ignore completion.
17600 try {
17601 pi.events('setActiveTools', { extensionId: ext.id, tools: ext.activeTools }).catch(() => {});
17602 } catch (_) {}
17603}
17604
17605function __pi_get_active_tools() {
17606 const ext = __pi_current_extension_or_throw();
17607 if (!Array.isArray(ext.activeTools)) return undefined;
17608 return ext.activeTools.slice();
17609}
17610
17611function __pi_get_model() {
17612 return pi.events('getModel', {});
17613}
17614
17615function __pi_set_model(provider, modelId) {
17616 const p = provider != null ? String(provider) : null;
17617 const m = modelId != null ? String(modelId) : null;
17618 return pi.events('setModel', { provider: p, modelId: m });
17619}
17620
17621function __pi_get_thinking_level() {
17622 return pi.events('getThinkingLevel', {});
17623}
17624
17625function __pi_set_thinking_level(level) {
17626 const l = level != null ? String(level).trim() : null;
17627 return pi.events('setThinkingLevel', { thinkingLevel: l });
17628}
17629
17630function __pi_get_session_name() {
17631 return pi.session('get_name', {});
17632}
17633
17634function __pi_set_session_name(name) {
17635 const n = name != null ? String(name) : '';
17636 return pi.session('set_name', { name: n });
17637}
17638
17639function __pi_set_label(entryId, label) {
17640 const eid = String(entryId || '').trim();
17641 if (!eid) {
17642 throw new Error('setLabel: entryId is required');
17643 }
17644 const l = label != null ? String(label).trim() : null;
17645 return pi.session('set_label', { targetId: eid, label: l || undefined });
17646}
17647
17648function __pi_append_entry(custom_type, data) {
17649 const ext = __pi_current_extension_or_throw();
17650 const customType = String(custom_type || '').trim();
17651 if (!customType) {
17652 throw new Error('appendEntry: customType is required');
17653 }
17654 try {
17655 pi.events('appendEntry', {
17656 extensionId: ext.id,
17657 customType: customType,
17658 data: data === undefined ? null : data,
17659 }).catch(() => {});
17660 } catch (_) {}
17661}
17662
17663function __pi_send_message(message, options) {
17664 const ext = __pi_current_extension_or_throw();
17665 if (!message || typeof message !== 'object') {
17666 throw new Error('sendMessage: message must be an object');
17667 }
17668 const opts = options && typeof options === 'object' ? options : {};
17669 try {
17670 pi.events('sendMessage', { extensionId: ext.id, message: message, options: opts }).catch(() => {});
17671 } catch (_) {}
17672}
17673
17674function __pi_send_user_message(text, options) {
17675 const ext = __pi_current_extension_or_throw();
17676 const msg = String(text === undefined || text === null ? '' : text).trim();
17677 if (!msg) return;
17678 const opts = options && typeof options === 'object' ? options : {};
17679 try {
17680 pi.events('sendUserMessage', { extensionId: ext.id, text: msg, options: opts }).catch(() => {});
17681 } catch (_) {}
17682}
17683
17684function __pi_snapshot_extensions() {
17685 const out = [];
17686 for (const [id, ext] of __pi_extensions.entries()) {
17687 const tools = [];
17688 for (const tool of ext.tools.values()) {
17689 tools.push(tool.spec);
17690 }
17691
17692 const commands = [];
17693 for (const cmd of ext.commands.values()) {
17694 commands.push(cmd.spec);
17695 }
17696
17697 const providers = [];
17698 for (const provider of ext.providers.values()) {
17699 providers.push(provider.spec);
17700 }
17701
17702 const mcp_servers = [];
17703 if (ext.mcpServers && typeof ext.mcpServers.values === 'function') {
17704 for (const server of ext.mcpServers.values()) {
17705 if (server && server.spec) {
17706 mcp_servers.push(server.spec);
17707 }
17708 }
17709 }
17710
17711 const event_hooks = [];
17712 for (const key of ext.hooks.keys()) {
17713 event_hooks.push(String(key));
17714 }
17715
17716 const shortcuts = [];
17717 for (const shortcut of ext.shortcuts.values()) {
17718 shortcuts.push(shortcut.spec);
17719 }
17720
17721 const message_renderers = [];
17722 for (const renderer of ext.messageRenderers.values()) {
17723 message_renderers.push(renderer.customType);
17724 }
17725
17726 const flags = [];
17727 for (const [flagName, flagSpec] of ext.flags.entries()) {
17728 flags.push({
17729 name: flagName,
17730 description: flagSpec.description ? String(flagSpec.description) : '',
17731 type: flagSpec.type ? String(flagSpec.type) : 'string',
17732 default: flagSpec.default !== undefined ? flagSpec.default : null,
17733 });
17734 }
17735
17736 out.push({
17737 id: id,
17738 name: ext.name,
17739 version: ext.version,
17740 api_version: ext.apiVersion,
17741 tools: tools,
17742 slash_commands: commands,
17743 providers: providers,
17744 mcp_servers: mcp_servers,
17745 shortcuts: shortcuts,
17746 message_renderers: message_renderers,
17747 flags: flags,
17748 event_hooks: event_hooks,
17749 active_tools: Array.isArray(ext.activeTools) ? ext.activeTools.slice() : null,
17750 });
17751 }
17752 return out;
17753}
17754
17755function __pi_make_extension_theme() {
17756 return Object.create(__pi_extension_theme_template);
17757}
17758
17759const __pi_extension_theme_template = {
17760 // Minimal theme shim. Legacy emits ANSI; conformance harness should normalize ANSI away.
17761 fg: (_style, text) => String(text === undefined || text === null ? '' : text),
17762 bold: (text) => String(text === undefined || text === null ? '' : text),
17763 strikethrough: (text) => String(text === undefined || text === null ? '' : text),
17764};
17765
17766function __pi_build_extension_ui_template(hasUI) {
17767 const toUiText = (value) => {
17768 if (value && typeof value === 'object') {
17769 if (value.text !== undefined && value.text !== null) return String(value.text);
17770 if (value.message !== undefined && value.message !== null) return String(value.message);
17771 if (value.title !== undefined && value.title !== null) return String(value.title);
17772 }
17773 return String(value === undefined || value === null ? '' : value);
17774 };
17775 return {
17776 select: (title, options) => {
17777 if (!hasUI) return Promise.resolve(undefined);
17778 const list = Array.isArray(options) ? options : [];
17779 const mapped = list.map((v) => String(v));
17780 return pi.ui('select', { title: String(title === undefined || title === null ? '' : title), options: mapped });
17781 },
17782 confirm: (title, message) => {
17783 if (!hasUI) return Promise.resolve(false);
17784 return pi.ui('confirm', {
17785 title: String(title === undefined || title === null ? '' : title),
17786 message: String(message === undefined || message === null ? '' : message),
17787 });
17788 },
17789 input: (title, placeholder, def) => {
17790 if (!hasUI) return Promise.resolve(undefined);
17791 // Legacy extensions typically call input(title, placeholder?, default?)
17792 let payloadDefault = def;
17793 let payloadPlaceholder = placeholder;
17794 if (def === undefined && typeof placeholder === 'string') {
17795 payloadDefault = placeholder;
17796 payloadPlaceholder = undefined;
17797 }
17798 return pi.ui('input', {
17799 title: String(title === undefined || title === null ? '' : title),
17800 placeholder: payloadPlaceholder,
17801 default: payloadDefault,
17802 });
17803 },
17804 editor: (title, def, language) => {
17805 if (!hasUI) return Promise.resolve(undefined);
17806 // Legacy extensions typically call editor(title, defaultText)
17807 return pi.ui('editor', {
17808 title: String(title === undefined || title === null ? '' : title),
17809 language: language,
17810 default: def,
17811 });
17812 },
17813 notify: (message, level) => {
17814 const notifyType = level ? String(level) : undefined;
17815 const payload = {
17816 message: String(message === undefined || message === null ? '' : message),
17817 };
17818 if (notifyType) {
17819 payload.level = notifyType;
17820 payload.notifyType = notifyType; // legacy field
17821 }
17822 void pi.ui('notify', payload).catch(() => {});
17823 },
17824 setStatus: (statusKey, statusText) => {
17825 const key = String(statusKey === undefined || statusKey === null ? '' : statusKey);
17826 const text = String(statusText === undefined || statusText === null ? '' : statusText);
17827 void pi.ui('setStatus', {
17828 statusKey: key,
17829 statusText: text,
17830 text: text, // compat: some UI surfaces only consume `text`
17831 }).catch(() => {});
17832 },
17833 setFooter: (text) => {
17834 const value = toUiText(text);
17835 void pi.ui('setStatus', {
17836 statusKey: 'footer',
17837 statusText: value,
17838 text: value,
17839 }).catch(() => {});
17840 },
17841 setHeader: (text) => {
17842 const value = toUiText(text);
17843 void pi.ui('setTitle', {
17844 title: value,
17845 text: value,
17846 }).catch(() => {});
17847 },
17848 setWorkingMessage: (text) => {
17849 const value = toUiText(text);
17850 void pi.ui('setStatus', {
17851 statusKey: 'working',
17852 statusText: value,
17853 text: value,
17854 }).catch(() => {});
17855 },
17856 setWidget: (widgetKey, lines) => {
17857 if (!hasUI) return;
17858 const payload = { widgetKey: String(widgetKey === undefined || widgetKey === null ? '' : widgetKey) };
17859 if (Array.isArray(lines)) {
17860 payload.lines = lines.map((v) => String(v));
17861 payload.widgetLines = payload.lines; // compat with pi-mono RPC naming
17862 payload.content = payload.lines.join('\n'); // compat: some UI surfaces expect a single string
17863 }
17864 void pi.ui('setWidget', payload).catch(() => {});
17865 },
17866 setTitle: (title) => {
17867 void pi.ui('setTitle', {
17868 title: String(title === undefined || title === null ? '' : title),
17869 }).catch(() => {});
17870 },
17871 setEditorText: (text) => {
17872 void pi.ui('set_editor_text', {
17873 text: String(text === undefined || text === null ? '' : text),
17874 }).catch(() => {});
17875 },
17876 getEditorText: () => {
17877 if (!hasUI) return Promise.resolve('');
17878 return pi.ui('getEditorText', {});
17879 },
17880 custom: async (componentFactory, options) => {
17881 if (!hasUI) return undefined;
17882 const opts = options && typeof options === 'object' ? options : {};
17883 if (typeof componentFactory !== 'function') {
17884 return pi.ui('custom', opts);
17885 }
17886
17887 const widgetKey = '__pi_custom_overlay';
17888 const parseWidth = (value, fallback) => {
17889 if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
17890 return Math.max(20, Math.floor(value));
17891 }
17892 if (typeof value === 'string') {
17893 const text = value.trim();
17894 if (!text) return fallback;
17895 if (text.endsWith('%')) {
17896 const pct = Number.parseFloat(text.slice(0, -1));
17897 if (Number.isFinite(pct) && pct > 0) {
17898 return Math.max(20, Math.floor((fallback * pct) / 100));
17899 }
17900 return fallback;
17901 }
17902 const parsed = Number.parseInt(text, 10);
17903 if (Number.isFinite(parsed) && parsed > 0) {
17904 return Math.max(20, parsed);
17905 }
17906 }
17907 return fallback;
17908 };
17909 const fallbackWidth = parseWidth(
17910 opts.width ?? (opts.overlayOptions && opts.overlayOptions.width),
17911 80
17912 );
17913
17914 let done = false;
17915 let doneValue = undefined;
17916 let renderWidth = fallbackWidth;
17917 let needsRender = true;
17918 let renderInFlight = false;
17919 let pollInFlight = false;
17920 let component = null;
17921 let renderTimer = null;
17922 let pollTimer = null;
17923
17924 const theme = (this && this.theme) || __pi_make_extension_theme();
17925 const keybindings = {};
17926 const onDone = (value) => {
17927 done = true;
17928 doneValue = value;
17929 };
17930 const tui = {
17931 requestRender: () => {
17932 needsRender = true;
17933 },
17934 };
17935
17936 const toKittyRelease = (keyData) => {
17937 if (typeof keyData !== 'string' || keyData.length === 0) return null;
17938 if (keyData.length !== 1) return null;
17939 const ch = keyData;
17940 if (ch >= 'A' && ch <= 'Z') {
17941 const code = ch.toLowerCase().charCodeAt(0);
17942 return `\u001b[${code};2:3u`;
17943 }
17944 return `\u001b[${ch.charCodeAt(0)};1:3u`;
17945 };
17946
17947 const disposeComponent = () => {
17948 if (component && typeof component.dispose === 'function') {
17949 try {
17950 component.dispose();
17951 } catch (_) {}
17952 }
17953 };
17954
17955 const pushFrame = async () => {
17956 if (!component || typeof component.render !== 'function') return;
17957 let lines = [];
17958 try {
17959 const rendered = component.render(renderWidth);
17960 if (Array.isArray(rendered)) {
17961 lines = rendered.map((line) =>
17962 String(line === undefined || line === null ? '' : line)
17963 );
17964 } else if (rendered !== undefined && rendered !== null) {
17965 lines = String(rendered).split('\n');
17966 }
17967 } catch (_) {
17968 done = true;
17969 return;
17970 }
17971 await pi
17972 .ui('setWidget', {
17973 widgetKey,
17974 lines,
17975 title:
17976 typeof opts.title === 'string'
17977 ? opts.title
17978 : (opts.overlay ? 'Extension Overlay' : undefined),
17979 })
17980 .catch(() => {});
17981 };
17982
17983 const handlePollResponse = (response) => {
17984 if (!response || typeof response !== 'object') return;
17985 if (typeof response.width === 'number' && Number.isFinite(response.width)) {
17986 const nextWidth = Math.max(20, Math.floor(response.width));
17987 if (nextWidth !== renderWidth) {
17988 renderWidth = nextWidth;
17989 needsRender = true;
17990 }
17991 }
17992 if (response.closed || response.cancelled) {
17993 done = true;
17994 return;
17995 }
17996 const keyData = typeof response.key === 'string' ? response.key : null;
17997 if (keyData && component && typeof component.handleInput === 'function') {
17998 try {
17999 component.handleInput(keyData);
18000 const release = toKittyRelease(keyData);
18001 if (release) {
18002 component.handleInput(release);
18003 }
18004 } catch (_) {
18005 done = true;
18006 return;
18007 }
18008 needsRender = true;
18009 }
18010 };
18011
18012 const pollInput = () => {
18013 if (done || pollInFlight) return;
18014 pollInFlight = true;
18015 void pi
18016 .ui('custom', {
18017 ...opts,
18018 mode: 'poll',
18019 widgetKey,
18020 })
18021 .then(handlePollResponse)
18022 .catch(() => {})
18023 .finally(() => {
18024 pollInFlight = false;
18025 });
18026 };
18027
18028 try {
18029 component = componentFactory(tui, theme, keybindings, onDone);
18030 } catch (err) {
18031 disposeComponent();
18032 throw err;
18033 }
18034
18035 renderTimer = setInterval(() => {
18036 if (done || renderInFlight || !needsRender) return;
18037 needsRender = false;
18038 renderInFlight = true;
18039 void pushFrame().finally(() => {
18040 renderInFlight = false;
18041 });
18042 }, 1000 / 30);
18043
18044 pollTimer = setInterval(() => {
18045 pollInput();
18046 }, 16);
18047
18048 pollInput();
18049 needsRender = false;
18050 await pushFrame();
18051
18052 while (!done) {
18053 await __pi_sleep(16);
18054 }
18055
18056 if (renderTimer) clearInterval(renderTimer);
18057 if (pollTimer) clearInterval(pollTimer);
18058 disposeComponent();
18059
18060 await pi.ui('setWidget', { widgetKey, clear: true, lines: [] }).catch(() => {});
18061 await pi
18062 .ui('custom', {
18063 ...opts,
18064 mode: 'close',
18065 close: true,
18066 widgetKey,
18067 })
18068 .catch(() => {});
18069
18070 return doneValue;
18071 },
18072 getAllThemes: () => {
18073 if (!hasUI) return Promise.resolve([]);
18074 return pi.ui('getAllThemes', {});
18075 },
18076 getTheme: (name) => {
18077 if (!hasUI) return Promise.resolve(undefined);
18078 return pi.ui('getTheme', {
18079 name: String(name === undefined || name === null ? '' : name),
18080 });
18081 },
18082 setTheme: (themeOrName) => {
18083 if (!hasUI) return Promise.resolve({ success: false, error: 'UI not available' });
18084 if (themeOrName && typeof themeOrName === 'object') {
18085 const name = themeOrName.name;
18086 return pi.ui('setTheme', {
18087 name: String(name === undefined || name === null ? '' : name),
18088 });
18089 }
18090 return pi.ui('setTheme', {
18091 name: String(themeOrName === undefined || themeOrName === null ? '' : themeOrName),
18092 });
18093 },
18094 };
18095}
18096
18097const __pi_extension_ui_templates = {
18098 with_ui: __pi_build_extension_ui_template(true),
18099 without_ui: __pi_build_extension_ui_template(false),
18100};
18101
18102function __pi_make_extension_ui(hasUI) {
18103 const template = hasUI ? __pi_extension_ui_templates.with_ui : __pi_extension_ui_templates.without_ui;
18104 const ui = Object.create(template);
18105 ui.theme = __pi_make_extension_theme();
18106 return ui;
18107}
18108
18109function __pi_make_extension_ctx(ctx_payload) {
18110 const hasUI = !!(ctx_payload && (ctx_payload.hasUI || ctx_payload.has_ui));
18111 const cwd = ctx_payload && (ctx_payload.cwd || ctx_payload.CWD) ? String(ctx_payload.cwd || ctx_payload.CWD) : '';
18112
18113 const entriesRaw =
18114 (ctx_payload && (ctx_payload.sessionEntries || ctx_payload.session_entries || ctx_payload.entries)) || [];
18115 const branchRaw =
18116 (ctx_payload && (ctx_payload.sessionBranch || ctx_payload.session_branch || ctx_payload.branch)) || entriesRaw;
18117
18118 const entries = Array.isArray(entriesRaw) ? entriesRaw : [];
18119 const branch = Array.isArray(branchRaw) ? branchRaw : entries;
18120
18121 const leafEntry =
18122 (ctx_payload &&
18123 (ctx_payload.sessionLeafEntry ||
18124 ctx_payload.session_leaf_entry ||
18125 ctx_payload.leafEntry ||
18126 ctx_payload.leaf_entry)) ||
18127 null;
18128
18129 const modelRegistryValues =
18130 (ctx_payload && (ctx_payload.modelRegistry || ctx_payload.model_registry || ctx_payload.model_registry_values)) ||
18131 {};
18132
18133 const sessionManager = {
18134 getEntries: () => entries,
18135 getBranch: () => branch,
18136 getLeafEntry: () => leafEntry,
18137 };
18138
18139 return {
18140 hasUI: hasUI,
18141 cwd: cwd,
18142 ui: __pi_make_extension_ui(hasUI),
18143 sessionManager: sessionManager,
18144 modelRegistry: {
18145 getApiKeyForProvider: async (provider) => {
18146 const key = String(provider || '').trim();
18147 if (!key) return undefined;
18148 const value = modelRegistryValues[key];
18149 if (value === undefined || value === null) return undefined;
18150 return String(value);
18151 },
18152 },
18153 };
18154}
18155
18156 async function __pi_dispatch_event_inner(eventName, event_payload, ctx) {
18157 const handlers = [
18158 ...(__pi_hook_index.get(eventName) || []),
18159 ...(__pi_event_bus_index.get(eventName) || []),
18160 ];
18161 if (handlers.length === 0) {
18162 return undefined;
18163 }
18164
18165 const needsSignal = eventName === 'session_before_compact' || eventName === 'session_before_tree';
18166 if (needsSignal) {
18167 const base = event_payload && typeof event_payload === 'object' ? event_payload : {};
18168 if (base !== event_payload) {
18169 event_payload = base;
18170 }
18171 if (!('signal' in base)) {
18172 base.signal = new AbortController().signal;
18173 }
18174 }
18175
18176 if (eventName === 'input') {
18177 const base = event_payload && typeof event_payload === 'object' ? event_payload : {};
18178 const originalText = typeof base.text === 'string'
18179 ? base.text
18180 : (typeof base.content === 'string' ? base.content : String(base.text ?? base.content ?? ''));
18181 const originalImages = Array.isArray(base.images)
18182 ? base.images
18183 : (Array.isArray(base.attachments) ? base.attachments : undefined);
18184 const source = base.source !== undefined ? base.source : 'extension';
18185
18186 let currentText = originalText;
18187 let currentImages = originalImages;
18188
18189 for (const entry of handlers) {
18190 const handler = entry && entry.handler;
18191 if (typeof handler !== 'function') continue;
18192 const event = { type: 'input', text: currentText, images: currentImages, source: source };
18193 let result = undefined;
18194 try {
18195 result = await __pi_with_extension_async(entry.extensionId, () => handler(event, ctx));
18196 } catch (e) {
18197 try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
18198 continue;
18199 }
18200 if (result && typeof result === 'object') {
18201 if (result.action === 'handled') return result;
18202 if (result.action === 'transform' && typeof result.text === 'string') {
18203 currentText = result.text;
18204 if (result.images !== undefined) currentImages = result.images;
18205 }
18206 }
18207 }
18208
18209 if (currentText !== originalText || currentImages !== originalImages) {
18210 return { action: 'transform', text: currentText, images: currentImages };
18211 }
18212 return { action: 'continue' };
18213 }
18214
18215 if (eventName === 'before_agent_start') {
18216 const base = event_payload && typeof event_payload === 'object' ? event_payload : {};
18217 const prompt = typeof base.prompt === 'string' ? base.prompt : '';
18218 const images = Array.isArray(base.images) ? base.images : undefined;
18219 let currentSystemPrompt = typeof base.systemPrompt === 'string' ? base.systemPrompt : '';
18220 let modified = false;
18221 const messages = [];
18222
18223 for (const entry of handlers) {
18224 const handler = entry && entry.handler;
18225 if (typeof handler !== 'function') continue;
18226 const event = { type: 'before_agent_start', prompt, images, systemPrompt: currentSystemPrompt };
18227 let result = undefined;
18228 try {
18229 result = await __pi_with_extension_async(entry.extensionId, () => handler(event, ctx));
18230 } catch (e) {
18231 try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
18232 continue;
18233 }
18234 if (result && typeof result === 'object') {
18235 if (result.message !== undefined) messages.push(result.message);
18236 if (result.systemPrompt !== undefined) {
18237 currentSystemPrompt = String(result.systemPrompt);
18238 modified = true;
18239 }
18240 }
18241 }
18242
18243 if (messages.length > 0 || modified) {
18244 return { messages: messages.length > 0 ? messages : undefined, systemPrompt: modified ? currentSystemPrompt : undefined };
18245 }
18246 return undefined;
18247 }
18248
18249 if (eventName === 'resources_discover') {
18250 const skillPaths = [];
18251 const promptPaths = [];
18252 const themePaths = [];
18253
18254 const pushPaths = (target, value) => {
18255 if (!value) return;
18256 if (Array.isArray(value)) {
18257 for (const entry of value) {
18258 if (typeof entry === 'string' && entry.trim()) {
18259 target.push(entry.trim());
18260 }
18261 }
18262 return;
18263 }
18264 if (typeof value === 'string' && value.trim()) {
18265 target.push(value.trim());
18266 }
18267 };
18268
18269 for (const entry of handlers) {
18270 const handler = entry && entry.handler;
18271 if (typeof handler !== 'function') continue;
18272 let result = undefined;
18273 try {
18274 result = await __pi_with_extension_async(entry.extensionId, () => handler(event_payload, ctx));
18275 } catch (e) {
18276 try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
18277 continue;
18278 }
18279 if (!result || typeof result !== 'object') continue;
18280 pushPaths(skillPaths, result.skillPaths || result.skill_paths);
18281 pushPaths(promptPaths, result.promptPaths || result.prompt_paths);
18282 pushPaths(themePaths, result.themePaths || result.theme_paths);
18283 }
18284
18285 const response = {};
18286 if (skillPaths.length > 0) response.skillPaths = skillPaths;
18287 if (promptPaths.length > 0) response.promptPaths = promptPaths;
18288 if (themePaths.length > 0) response.themePaths = themePaths;
18289 if (Object.keys(response).length > 0) {
18290 return response;
18291 }
18292 return undefined;
18293 }
18294
18295 let last = undefined;
18296 for (const entry of handlers) {
18297 const handler = entry && entry.handler;
18298 if (typeof handler !== 'function') continue;
18299 let value = undefined;
18300 try {
18301 value = await __pi_with_extension_async(entry.extensionId, () => handler(event_payload, ctx));
18302 } catch (e) {
18303 try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
18304 if (eventName === 'tool_call' || eventName.startsWith('session_before_')) {
18305 throw e;
18306 }
18307 continue;
18308 }
18309 if (value === undefined) continue;
18310
18311 // First-result semantics (legacy parity)
18312 if (eventName === 'user_bash') {
18313 return value;
18314 }
18315
18316 last = value;
18317
18318 // Early-stop semantics (legacy parity)
18319 if (eventName === 'tool_call' && value && typeof value === 'object' && value.block) {
18320 return value;
18321 }
18322 if (eventName.startsWith('session_before_') && value && typeof value === 'object' && value.cancel) {
18323 return value;
18324 }
18325 }
18326 return last;
18327}
18328
18329 async function __pi_dispatch_extension_event(event_name, event_payload, ctx_payload) {
18330 const eventName = String(event_name || '').trim();
18331 if (!eventName) {
18332 throw new Error('dispatch_event: event name is required');
18333 }
18334 const ctx = __pi_make_extension_ctx(ctx_payload);
18335 return __pi_dispatch_event_inner(eventName, event_payload, ctx);
18336 }
18337
18338 async function __pi_dispatch_extension_events_batch(events_json, ctx_payload) {
18339 const ctx = __pi_make_extension_ctx(ctx_payload);
18340 const results = [];
18341 for (const entry of events_json) {
18342 const eventName = String(entry.event_name || '').trim();
18343 if (!eventName) continue;
18344 try {
18345 const value = await __pi_dispatch_event_inner(eventName, entry.event_payload, ctx);
18346 results.push({ event: eventName, ok: true, value: value });
18347 } catch (e) {
18348 results.push({ event: eventName, ok: false, error: String(e) });
18349 }
18350 }
18351 return results;
18352 }
18353
18354function __pi_validate_tool_input(schema, input) {
18355 if (!schema || typeof schema !== 'object') return;
18356 const schemaType = schema.type;
18357 const typeList = Array.isArray(schemaType)
18358 ? schemaType.filter((value) => typeof value === 'string')
18359 : (typeof schemaType === 'string' ? [schemaType] : []);
18360 const typeIsObject = typeList.includes('object');
18361 const allowsNull = typeList.includes('null');
18362 const hasExplicitTypes = schemaType !== undefined && typeList.length > 0;
18363 const hasProperties = schema.properties && typeof schema.properties === 'object';
18364 const schemaIsObject = schemaType !== undefined ? typeIsObject : hasProperties;
18365 if (!schemaIsObject) return;
18366 const required = Array.isArray(schema.required)
18367 ? schema.required.filter((value) => typeof value === 'string')
18368 : [];
18369 if (input === undefined) {
18370 if (required.length === 0) return;
18371 throw new Error(`Tool input missing required fields: ${required.join(', ')}`);
18372 }
18373 if (input === null) {
18374 if (allowsNull) return;
18375 throw new Error('Tool input must be an object');
18376 }
18377 if (typeof input !== 'object' || Array.isArray(input)) {
18378 if (hasExplicitTypes) {
18379 const inputType = Array.isArray(input) ? 'array' : typeof input;
18380 if (typeList.includes(inputType)) return;
18381 if (inputType === 'number' && typeList.includes('integer') && Number.isInteger(input)) {
18382 return;
18383 }
18384 }
18385 throw new Error('Tool input must be an object');
18386 }
18387 if (required.length === 0) return;
18388 const missing = [];
18389 for (const key of required) {
18390 if (!Object.prototype.hasOwnProperty.call(input, key) || input[key] === undefined) {
18391 missing.push(key);
18392 }
18393 }
18394 if (missing.length > 0) {
18395 throw new Error(`Tool input missing required fields: ${missing.join(', ')}`);
18396 }
18397}
18398
18399async function __pi_execute_tool(tool_name, tool_call_id, input, ctx_payload) {
18400 const name = String(tool_name || '').trim();
18401 const record = __pi_tool_index.get(name);
18402 if (!record) {
18403 throw new Error(`Unknown tool: ${name}`);
18404 }
18405
18406 __pi_validate_tool_input(record.spec && record.spec.parameters, input);
18407
18408 const ctx = __pi_make_extension_ctx(ctx_payload);
18409 return __pi_with_extension_async(record.extensionId, () =>
18410 record.execute(tool_call_id, input, undefined, undefined, ctx)
18411 );
18412}
18413
18414async function __pi_execute_command(command_name, args, ctx_payload) {
18415 const name = String(command_name || '').trim().replace(/^\//, '');
18416 const record = __pi_command_index.get(name);
18417 if (!record) {
18418 throw new Error(`Unknown command: ${name}`);
18419 }
18420
18421 const ctx = __pi_make_extension_ctx(ctx_payload);
18422 return __pi_with_extension_async(record.extensionId, () => record.handler(args, ctx));
18423}
18424
18425async function __pi_execute_shortcut(key_id, ctx_payload) {
18426 const id = String(key_id || '').trim().toLowerCase();
18427 const record = __pi_shortcut_index.get(id);
18428 if (!record) {
18429 throw new Error('Unknown shortcut: ' + id);
18430 }
18431
18432 const ctx = __pi_make_extension_ctx(ctx_payload);
18433 return __pi_with_extension_async(record.extensionId, () => record.handler(ctx));
18434}
18435
18436// Hostcall stream class (async iterator for streaming hostcall results)
18437class __pi_HostcallStream {
18438 constructor(callId, options = undefined) {
18439 this.callId = callId;
18440 this.buffer = [];
18441 this.waitResolve = null;
18442 this.done = false;
18443 this.bufferLimit = 16;
18444 this.stallTimeoutMs = 30000;
18445 this.stallTimer = null;
18446
18447 if (options && typeof options === 'object') {
18448 const bufferSize = options.buffer_size ?? options.bufferSize;
18449 if (Number.isFinite(bufferSize) && bufferSize > 0) {
18450 this.bufferLimit = Math.max(1, Math.floor(bufferSize));
18451 }
18452 const stallTimeout = options.stall_timeout_ms ?? options.stallTimeoutMs;
18453 if (Number.isFinite(stallTimeout) && stallTimeout >= 0) {
18454 this.stallTimeoutMs = Math.floor(stallTimeout);
18455 }
18456 }
18457 }
18458 _clearStallTimer() {
18459 if (this.stallTimer !== null) {
18460 clearTimeout(this.stallTimer);
18461 this.stallTimer = null;
18462 }
18463 }
18464 _armStallTimer() {
18465 if (this.stallTimeoutMs <= 0) return;
18466 if (this.stallTimer !== null) {
18467 clearTimeout(this.stallTimer);
18468 }
18469 const timeoutMs = this.stallTimeoutMs;
18470 this.stallTimer = setTimeout(() => {
18471 this.stallTimer = null;
18472 if (this.done) return;
18473 if (this.waitResolve) return;
18474 if (this.buffer.length < this.bufferLimit) return;
18475 const seconds = Math.max(1, Math.round(timeoutMs / 1000));
18476 console.warn(`Stream stalled: JS consumer did not pull for ${seconds}s`);
18477 if (typeof __pi_cancel_hostcall_native === 'function') {
18478 try {
18479 __pi_cancel_hostcall_native(this.callId);
18480 } catch (e) {
18481 console.error('Hostcall cancel error:', e);
18482 }
18483 }
18484 }, timeoutMs);
18485 }
18486 pushChunk(chunk, isFinal) {
18487 if (isFinal) {
18488 this.done = true;
18489 this._clearStallTimer();
18490 }
18491 if (this.waitResolve) {
18492 const resolve = this.waitResolve;
18493 this.waitResolve = null;
18494 this._clearStallTimer();
18495 if (isFinal && chunk === null) {
18496 resolve({ value: undefined, done: true });
18497 } else {
18498 resolve({ value: chunk, done: false });
18499 }
18500 } else {
18501 this.buffer.push({ chunk, isFinal });
18502 if (!this.done && this.buffer.length >= this.bufferLimit) {
18503 this._armStallTimer();
18504 }
18505 }
18506 }
18507 pushError(error) {
18508 this.done = true;
18509 this._clearStallTimer();
18510 if (this.waitResolve) {
18511 const rej = this.waitResolve;
18512 this.waitResolve = null;
18513 rej({ __error: error });
18514 } else {
18515 this.buffer.push({ __error: error });
18516 }
18517 }
18518 next() {
18519 if (this.buffer.length > 0) {
18520 const entry = this.buffer.shift();
18521 if (!this.done) {
18522 if (this.buffer.length < this.bufferLimit) {
18523 this._clearStallTimer();
18524 } else {
18525 this._armStallTimer();
18526 }
18527 }
18528 if (entry.__error) return Promise.reject(entry.__error);
18529 if (entry.isFinal && entry.chunk === null) return Promise.resolve({ value: undefined, done: true });
18530 return Promise.resolve({ value: entry.chunk, done: false });
18531 }
18532 if (this.done) {
18533 this._clearStallTimer();
18534 return Promise.resolve({ value: undefined, done: true });
18535 }
18536 this._clearStallTimer();
18537 return new Promise((resolve, reject) => {
18538 this.waitResolve = (result) => {
18539 if (result && result.__error) reject(result.__error);
18540 else resolve(result);
18541 };
18542 });
18543 }
18544 return() {
18545 this.done = true;
18546 this.buffer = [];
18547 this.waitResolve = null;
18548 this._clearStallTimer();
18549 return Promise.resolve({ value: undefined, done: true });
18550 }
18551 [Symbol.asyncIterator]() { return this; }
18552}
18553
18554// Complete a hostcall (called from Rust)
18555function __pi_complete_hostcall_impl(call_id, outcome) {
18556 const pending = __pi_pending_hostcalls.get(call_id);
18557 if (!pending) return;
18558
18559 if (outcome.stream) {
18560 const seq = Number(outcome.sequence);
18561 if (!Number.isFinite(seq)) {
18562 const error = new Error('Invalid stream sequence');
18563 error.code = 'STREAM_SEQUENCE';
18564 if (pending.stream) pending.stream.pushError(error);
18565 else if (pending.reject) pending.reject(error);
18566 __pi_pending_hostcalls.delete(call_id);
18567 return;
18568 }
18569 if (pending.lastSeq === undefined) {
18570 if (seq !== 0) {
18571 const error = new Error('Stream sequence must start at 0');
18572 error.code = 'STREAM_SEQUENCE';
18573 if (pending.stream) pending.stream.pushError(error);
18574 else if (pending.reject) pending.reject(error);
18575 __pi_pending_hostcalls.delete(call_id);
18576 return;
18577 }
18578 } else if (seq <= pending.lastSeq) {
18579 const error = new Error('Stream sequence out of order');
18580 error.code = 'STREAM_SEQUENCE';
18581 if (pending.stream) pending.stream.pushError(error);
18582 else if (pending.reject) pending.reject(error);
18583 __pi_pending_hostcalls.delete(call_id);
18584 return;
18585 }
18586 pending.lastSeq = seq;
18587
18588 if (pending.stream) {
18589 pending.stream.pushChunk(outcome.chunk, outcome.isFinal);
18590 } else if (pending.onChunk) {
18591 const chunk = outcome.chunk;
18592 const isFinal = outcome.isFinal;
18593 Promise.resolve().then(() => {
18594 try {
18595 pending.onChunk(chunk, isFinal);
18596 } catch (e) {
18597 console.error('Hostcall onChunk error:', e);
18598 }
18599 });
18600 }
18601 if (outcome.isFinal) {
18602 __pi_pending_hostcalls.delete(call_id);
18603 if (pending.resolve) pending.resolve(outcome.chunk);
18604 }
18605 return;
18606 }
18607
18608 if (!outcome.ok && pending.stream) {
18609 const error = new Error(outcome.message);
18610 error.code = outcome.code;
18611 pending.stream.pushError(error);
18612 __pi_pending_hostcalls.delete(call_id);
18613 return;
18614 }
18615
18616 __pi_pending_hostcalls.delete(call_id);
18617 if (outcome.ok) {
18618 pending.resolve(outcome.value);
18619 } else {
18620 const error = new Error(outcome.message);
18621 error.code = outcome.code;
18622 pending.reject(error);
18623 }
18624}
18625
18626function __pi_complete_hostcall(call_id, outcome) {
18627 const pending = __pi_pending_hostcalls.get(call_id);
18628 if (pending && pending.extensionId) {
18629 const prev = __pi_current_extension_id;
18630 __pi_current_extension_id = pending.extensionId;
18631 try {
18632 return __pi_complete_hostcall_impl(call_id, outcome);
18633 } finally {
18634 Promise.resolve().then(() => { __pi_current_extension_id = prev; });
18635 }
18636 }
18637 return __pi_complete_hostcall_impl(call_id, outcome);
18638}
18639
18640// Fire a timer callback (called from Rust)
18641function __pi_fire_timer(timer_id) {
18642 const callback = __pi_timer_callbacks.get(timer_id);
18643 if (callback) {
18644 __pi_timer_callbacks.delete(timer_id);
18645 try {
18646 callback();
18647 } catch (e) {
18648 console.error('Timer callback error:', e);
18649 }
18650 }
18651}
18652
18653// Dispatch an inbound event (called from Rust)
18654function __pi_dispatch_event(event_id, payload) {
18655 const listeners = __pi_event_listeners.get(event_id);
18656 if (listeners) {
18657 for (const listener of listeners) {
18658 try {
18659 listener(payload);
18660 } catch (e) {
18661 console.error('Event listener error:', e);
18662 }
18663 }
18664 }
18665}
18666
18667// Register a timer callback (used by setTimeout)
18668function __pi_register_timer(timer_id, callback) {
18669 __pi_timer_callbacks.set(timer_id, callback);
18670}
18671
18672// Unregister a timer callback (used by clearTimeout)
18673function __pi_unregister_timer(timer_id) {
18674 __pi_timer_callbacks.delete(timer_id);
18675}
18676
18677// Add an event listener
18678function __pi_add_event_listener(event_id, callback) {
18679 if (!__pi_event_listeners.has(event_id)) {
18680 __pi_event_listeners.set(event_id, []);
18681 }
18682 __pi_event_listeners.get(event_id).push(callback);
18683}
18684
18685// Remove an event listener
18686function __pi_remove_event_listener(event_id, callback) {
18687 const listeners = __pi_event_listeners.get(event_id);
18688 if (listeners) {
18689 const index = listeners.indexOf(callback);
18690 if (index !== -1) {
18691 listeners.splice(index, 1);
18692 }
18693 }
18694}
18695
18696// Helper to create a Promise-returning hostcall wrapper
18697function __pi_make_hostcall(nativeFn) {
18698 return function(...args) {
18699 return new Promise((resolve, reject) => {
18700 const call_id = nativeFn(...args);
18701 __pi_pending_hostcalls.set(call_id, {
18702 resolve,
18703 reject,
18704 extensionId: __pi_current_extension_id
18705 });
18706 });
18707 };
18708}
18709
18710function __pi_make_streaming_hostcall(nativeFn, ...args) {
18711 const call_id = nativeFn(...args);
18712 let options = undefined;
18713 if (args.length > 0) {
18714 const last = args[args.length - 1];
18715 if (last && typeof last === 'object' && !Array.isArray(last)) {
18716 options = last;
18717 }
18718 }
18719 const stream = new __pi_HostcallStream(call_id, options);
18720 __pi_pending_hostcalls.set(call_id, {
18721 stream,
18722 resolve: () => {},
18723 reject: () => {},
18724 extensionId: __pi_current_extension_id
18725 });
18726 return stream;
18727}
18728
18729function __pi_env_get(key) {
18730 const value = __pi_env_get_native(key);
18731 if (value === null || value === undefined) {
18732 return undefined;
18733 }
18734 return value;
18735}
18736
18737function __pi_path_join(...parts) {
18738 let out = '';
18739 for (const part of parts) {
18740 if (!part) continue;
18741 if (out === '' || out.endsWith('/')) {
18742 out += part;
18743 } else {
18744 out += '/' + part;
18745 }
18746 }
18747 return __pi_path_normalize(out);
18748}
18749
18750function __pi_path_basename(path) {
18751 if (!path) return '';
18752 let p = path;
18753 while (p.length > 1 && p.endsWith('/')) {
18754 p = p.slice(0, -1);
18755 }
18756 const idx = p.lastIndexOf('/');
18757 return idx === -1 ? p : p.slice(idx + 1);
18758}
18759
18760function __pi_path_normalize(path) {
18761 if (!path) return '';
18762 const isAbs = path.startsWith('/');
18763 const parts = path.split('/').filter(p => p.length > 0);
18764 const stack = [];
18765 for (const part of parts) {
18766 if (part === '.') continue;
18767 if (part === '..') {
18768 if (stack.length > 0 && stack[stack.length - 1] !== '..') {
18769 stack.pop();
18770 } else if (!isAbs) {
18771 stack.push('..');
18772 }
18773 continue;
18774 }
18775 stack.push(part);
18776 }
18777 const joined = stack.join('/');
18778 return isAbs ? '/' + joined : joined || (isAbs ? '/' : '');
18779}
18780
18781function __pi_sleep(ms) {
18782 return new Promise((resolve) => setTimeout(resolve, ms));
18783}
18784
18785// Create the pi global object with Promise-returning methods
18786const __pi_exec_hostcall = __pi_make_hostcall(__pi_exec_native);
18787 const pi = {
18788 // pi.tool(name, input) - invoke a tool
18789 tool: __pi_make_hostcall(__pi_tool_native),
18790
18791 // pi.exec(cmd, args, options) - execute a shell command
18792 exec: (cmd, args, options = {}) => {
18793 if (options && options.stream) {
18794 const onChunk =
18795 options && typeof options === 'object'
18796 ? (options.onChunk || options.on_chunk)
18797 : undefined;
18798 if (typeof onChunk === 'function') {
18799 const opts = Object.assign({}, options);
18800 delete opts.onChunk;
18801 delete opts.on_chunk;
18802 const call_id = __pi_exec_native(cmd, args, opts);
18803 return new Promise((resolve, reject) => {
18804 __pi_pending_hostcalls.set(call_id, { onChunk, resolve, reject, extensionId: __pi_current_extension_id });
18805 });
18806 }
18807 return __pi_make_streaming_hostcall(__pi_exec_native, cmd, args, options);
18808 }
18809 return __pi_exec_hostcall(cmd, args, options);
18810 },
18811
18812 // pi.http(request) - make an HTTP request
18813 http: (request) => {
18814 if (request && request.stream) {
18815 const onChunk =
18816 request && typeof request === 'object'
18817 ? (request.onChunk || request.on_chunk)
18818 : undefined;
18819 if (typeof onChunk === 'function') {
18820 const req = Object.assign({}, request);
18821 delete req.onChunk;
18822 delete req.on_chunk;
18823 const call_id = __pi_http_native(req);
18824 return new Promise((resolve, reject) => {
18825 __pi_pending_hostcalls.set(call_id, { onChunk, resolve, reject, extensionId: __pi_current_extension_id });
18826 });
18827 }
18828 return __pi_make_streaming_hostcall(__pi_http_native, request);
18829 }
18830 return __pi_make_hostcall(__pi_http_native)(request);
18831 },
18832
18833 // pi.session(op, args) - session operations
18834 session: __pi_make_hostcall(__pi_session_native),
18835
18836 // pi.ui(op, args) - UI operations
18837 ui: __pi_make_hostcall(__pi_ui_native),
18838
18839 // pi.events(op, args) - event operations
18840 events: __pi_make_hostcall(__pi_events_native),
18841
18842 // pi.log(entry) - structured log emission
18843 log: __pi_make_hostcall(__pi_log_native),
18844
18845 // Extension API (legacy-compatible subset)
18846 registerTool: __pi_register_tool,
18847 registerCommand: __pi_register_command,
18848 registerProvider: __pi_register_provider,
18849 registerMcpServer: __pi_register_mcp_server,
18850 registerShortcut: __pi_register_shortcut,
18851 registerMessageRenderer: __pi_register_message_renderer,
18852 on: __pi_register_hook,
18853 registerFlag: __pi_register_flag,
18854 getFlag: __pi_get_flag,
18855 setActiveTools: __pi_set_active_tools,
18856 getActiveTools: __pi_get_active_tools,
18857 getModel: __pi_get_model,
18858 setModel: __pi_set_model,
18859 getThinkingLevel: __pi_get_thinking_level,
18860 setThinkingLevel: __pi_set_thinking_level,
18861 appendEntry: __pi_append_entry,
18862 sendMessage: __pi_send_message,
18863 sendUserMessage: __pi_send_user_message,
18864 getSessionName: __pi_get_session_name,
18865 setSessionName: __pi_set_session_name,
18866 setLabel: __pi_set_label,
18867 };
18868
18869 // Convenience API: pi.events.emit/on (inter-extension bus).
18870 // Keep pi.events callable for legacy hostcall operations.
18871 pi.events.emit = (event, data, options = undefined) => {
18872 const name = String(event || '').trim();
18873 if (!name) {
18874 throw new Error('events.emit: event name is required');
18875 }
18876 const payload = { event: name, data: (data === undefined ? null : data) };
18877 if (options && typeof options === 'object') {
18878 if (options.ctx !== undefined) payload.ctx = options.ctx;
18879 if (options.timeout_ms !== undefined) payload.timeout_ms = options.timeout_ms;
18880 if (options.timeoutMs !== undefined) payload.timeoutMs = options.timeoutMs;
18881 if (options.timeout !== undefined) payload.timeout = options.timeout;
18882 }
18883 return pi.events('emit', payload);
18884 };
18885 pi.events.on = (event, handler) => __pi_register_event_bus_hook(event, handler);
18886
18887 pi.env = {
18888 get: __pi_env_get,
18889 };
18890
18891pi.process = {
18892 cwd: __pi_process_cwd_native(),
18893 args: __pi_process_args_native(),
18894};
18895
18896const __pi_det_cwd = __pi_env_get('PI_DETERMINISTIC_CWD');
18897if (__pi_det_cwd) {
18898 try { pi.process.cwd = __pi_det_cwd; } catch (_) {}
18899}
18900
18901try { Object.freeze(pi.process.args); } catch (_) {}
18902try { Object.freeze(pi.process); } catch (_) {}
18903
18904pi.path = {
18905 join: __pi_path_join,
18906 basename: __pi_path_basename,
18907 normalize: __pi_path_normalize,
18908};
18909
18910function __pi_crypto_bytes_to_array(raw) {
18911 if (raw == null) return [];
18912 if (Array.isArray(raw)) {
18913 return raw.map((value) => Number(value) & 0xff);
18914 }
18915 if (raw instanceof Uint8Array) {
18916 return Array.from(raw, (value) => Number(value) & 0xff);
18917 }
18918 if (raw instanceof ArrayBuffer) {
18919 return Array.from(new Uint8Array(raw), (value) => Number(value) & 0xff);
18920 }
18921 if (typeof raw === 'string') {
18922 // Depending on bridge coercion, bytes may arrive as:
18923 // 1) hex text (2 chars per byte), or 2) latin1-style binary string.
18924 const isHex = raw.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(raw);
18925 if (isHex) {
18926 const out = [];
18927 for (let i = 0; i + 1 < raw.length; i += 2) {
18928 const byte = Number.parseInt(raw.slice(i, i + 2), 16);
18929 out.push(Number.isFinite(byte) ? (byte & 0xff) : 0);
18930 }
18931 return out;
18932 }
18933 const out = new Array(raw.length);
18934 for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i) & 0xff;
18935 return out;
18936 }
18937 if (typeof raw.length === 'number') {
18938 const len = Number(raw.length) || 0;
18939 const out = new Array(len);
18940 for (let i = 0; i < len; i++) out[i] = Number(raw[i] || 0) & 0xff;
18941 return out;
18942 }
18943 return [];
18944}
18945
18946pi.crypto = {
18947 randomBytes: function(n) {
18948 return __pi_crypto_bytes_to_array(__pi_crypto_random_bytes_native(n));
18949 },
18950};
18951
18952pi.time = {
18953 nowMs: __pi_now_ms_native,
18954 sleep: __pi_sleep,
18955};
18956
18957// Make pi available globally
18958globalThis.pi = pi;
18959
18960const __pi_det_time_raw = __pi_env_get('PI_DETERMINISTIC_TIME_MS');
18961const __pi_det_time_step_raw = __pi_env_get('PI_DETERMINISTIC_TIME_STEP_MS');
18962const __pi_det_random_raw = __pi_env_get('PI_DETERMINISTIC_RANDOM');
18963const __pi_det_random_seed_raw = __pi_env_get('PI_DETERMINISTIC_RANDOM_SEED');
18964
18965if (__pi_det_time_raw !== undefined) {
18966 const __pi_det_base = Number(__pi_det_time_raw);
18967 if (Number.isFinite(__pi_det_base)) {
18968 const __pi_det_step = (() => {
18969 if (__pi_det_time_step_raw === undefined) return 1;
18970 const value = Number(__pi_det_time_step_raw);
18971 return Number.isFinite(value) ? value : 1;
18972 })();
18973 let __pi_det_tick = 0;
18974 const __pi_det_now = () => {
18975 const value = __pi_det_base + (__pi_det_step * __pi_det_tick);
18976 __pi_det_tick += 1;
18977 return value;
18978 };
18979
18980 if (pi && pi.time) {
18981 pi.time.nowMs = () => __pi_det_now();
18982 }
18983
18984 const __pi_OriginalDate = Date;
18985 class PiDeterministicDate extends __pi_OriginalDate {
18986 constructor(...args) {
18987 if (args.length === 0) {
18988 super(__pi_det_now());
18989 } else {
18990 super(...args);
18991 }
18992 }
18993 static now() {
18994 return __pi_det_now();
18995 }
18996 }
18997 PiDeterministicDate.UTC = __pi_OriginalDate.UTC;
18998 PiDeterministicDate.parse = __pi_OriginalDate.parse;
18999 globalThis.Date = PiDeterministicDate;
19000 }
19001}
19002
19003if (__pi_det_random_raw !== undefined) {
19004 const __pi_det_random_val = Number(__pi_det_random_raw);
19005 if (Number.isFinite(__pi_det_random_val)) {
19006 Math.random = () => __pi_det_random_val;
19007 }
19008} else if (__pi_det_random_seed_raw !== undefined) {
19009 let __pi_det_state = Number(__pi_det_random_seed_raw);
19010 if (Number.isFinite(__pi_det_state)) {
19011 __pi_det_state = __pi_det_state >>> 0;
19012 Math.random = () => {
19013 __pi_det_state = (__pi_det_state * 1664525 + 1013904223) >>> 0;
19014 return __pi_det_state / 4294967296;
19015 };
19016 }
19017}
19018
19019// ============================================================================
19020// Minimal Web/Node polyfills for legacy extensions (best-effort)
19021// ============================================================================
19022
19023if (typeof globalThis.btoa !== 'function') {
19024 globalThis.btoa = (s) => {
19025 const bin = String(s === undefined || s === null ? '' : s);
19026 return __pi_base64_encode_native(bin);
19027 };
19028}
19029
19030if (typeof globalThis.atob !== 'function') {
19031 globalThis.atob = (s) => {
19032 const b64 = String(s === undefined || s === null ? '' : s);
19033 return __pi_base64_decode_native(b64);
19034 };
19035}
19036
19037if (typeof globalThis.TextEncoder === 'undefined') {
19038 class TextEncoder {
19039 encode(input) {
19040 const s = String(input === undefined || input === null ? '' : input);
19041 const bytes = [];
19042 for (let i = 0; i < s.length; i++) {
19043 let code = s.charCodeAt(i);
19044 if (code < 0x80) {
19045 bytes.push(code);
19046 continue;
19047 }
19048 if (code < 0x800) {
19049 bytes.push(0xc0 | (code >> 6));
19050 bytes.push(0x80 | (code & 0x3f));
19051 continue;
19052 }
19053 if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
19054 const next = s.charCodeAt(i + 1);
19055 if (next >= 0xdc00 && next <= 0xdfff) {
19056 const cp = ((code - 0xd800) << 10) + (next - 0xdc00) + 0x10000;
19057 bytes.push(0xf0 | (cp >> 18));
19058 bytes.push(0x80 | ((cp >> 12) & 0x3f));
19059 bytes.push(0x80 | ((cp >> 6) & 0x3f));
19060 bytes.push(0x80 | (cp & 0x3f));
19061 i++;
19062 continue;
19063 }
19064 }
19065 bytes.push(0xe0 | (code >> 12));
19066 bytes.push(0x80 | ((code >> 6) & 0x3f));
19067 bytes.push(0x80 | (code & 0x3f));
19068 }
19069 return new Uint8Array(bytes);
19070 }
19071 }
19072 globalThis.TextEncoder = TextEncoder;
19073}
19074
19075if (typeof globalThis.TextDecoder === 'undefined') {
19076 class TextDecoder {
19077 constructor(encoding = 'utf-8') {
19078 this.encoding = encoding;
19079 }
19080
19081 decode(input, _opts) {
19082 if (input === undefined || input === null) return '';
19083 if (typeof input === 'string') return input;
19084
19085 let bytes;
19086 if (input instanceof ArrayBuffer) {
19087 bytes = new Uint8Array(input);
19088 } else if (ArrayBuffer.isView && ArrayBuffer.isView(input)) {
19089 bytes = new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
19090 } else if (Array.isArray(input)) {
19091 bytes = new Uint8Array(input);
19092 } else if (typeof input.length === 'number') {
19093 bytes = new Uint8Array(input);
19094 } else {
19095 return '';
19096 }
19097
19098 let outChunks = [];
19099 let chunk = [];
19100 for (let i = 0; i < bytes.length; ) {
19101 if (chunk.length >= 4096) {
19102 outChunks.push(String.fromCharCode.apply(null, chunk));
19103 chunk.length = 0;
19104 }
19105 const b0 = bytes[i++];
19106 if (b0 < 0x80) {
19107 chunk.push(b0);
19108 continue;
19109 }
19110 if ((b0 & 0xe0) === 0xc0) {
19111 const b1 = bytes[i++] & 0x3f;
19112 chunk.push(((b0 & 0x1f) << 6) | b1);
19113 continue;
19114 }
19115 if ((b0 & 0xf0) === 0xe0) {
19116 const b1 = bytes[i++] & 0x3f;
19117 const b2 = bytes[i++] & 0x3f;
19118 chunk.push(((b0 & 0x0f) << 12) | (b1 << 6) | b2);
19119 continue;
19120 }
19121 if ((b0 & 0xf8) === 0xf0) {
19122 const b1 = bytes[i++] & 0x3f;
19123 const b2 = bytes[i++] & 0x3f;
19124 const b3 = bytes[i++] & 0x3f;
19125 let cp = ((b0 & 0x07) << 18) | (b1 << 12) | (b2 << 6) | b3;
19126 cp -= 0x10000;
19127 chunk.push(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
19128 continue;
19129 }
19130 }
19131 if (chunk.length > 0) {
19132 outChunks.push(String.fromCharCode.apply(null, chunk));
19133 }
19134 return outChunks.join('');
19135 }
19136 }
19137
19138 globalThis.TextDecoder = TextDecoder;
19139}
19140
19141// structuredClone — deep clone using JSON round-trip
19142if (typeof globalThis.structuredClone === 'undefined') {
19143 globalThis.structuredClone = (value) => JSON.parse(JSON.stringify(value));
19144}
19145
19146// queueMicrotask — schedule a microtask
19147if (typeof globalThis.queueMicrotask === 'undefined') {
19148 globalThis.queueMicrotask = (fn) => Promise.resolve().then(fn);
19149}
19150
19151// performance.now() — high-resolution timer
19152if (typeof globalThis.performance === 'undefined') {
19153 const start = Date.now();
19154 globalThis.performance = { now: () => Date.now() - start, timeOrigin: start };
19155}
19156
19157if (typeof globalThis.URLSearchParams === 'undefined') {
19158 class URLSearchParams {
19159 constructor(init) {
19160 this._pairs = [];
19161 if (typeof init === 'string') {
19162 const s = init.replace(/^\?/, '');
19163 if (s.length > 0) {
19164 for (const part of s.split('&')) {
19165 const idx = part.indexOf('=');
19166 if (idx === -1) {
19167 this.append(decodeURIComponent(part), '');
19168 } else {
19169 const k = part.slice(0, idx);
19170 const v = part.slice(idx + 1);
19171 this.append(decodeURIComponent(k), decodeURIComponent(v));
19172 }
19173 }
19174 }
19175 } else if (Array.isArray(init)) {
19176 for (const entry of init) {
19177 if (!entry) continue;
19178 this.append(entry[0], entry[1]);
19179 }
19180 } else if (init && typeof init === 'object') {
19181 for (const k of Object.keys(init)) {
19182 this.append(k, init[k]);
19183 }
19184 }
19185 }
19186
19187 append(key, value) {
19188 this._pairs.push([String(key), String(value)]);
19189 }
19190
19191 toString() {
19192 const out = [];
19193 for (const [k, v] of this._pairs) {
19194 out.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
19195 }
19196 return out.join('&');
19197 }
19198 }
19199
19200 globalThis.URLSearchParams = URLSearchParams;
19201}
19202
19203if (typeof globalThis.URL === 'undefined') {
19204 class URL {
19205 constructor(input, base) {
19206 const s = base ? new URL(base).href.replace(/\/[^/]*$/, '/') + String(input ?? '') : String(input ?? '');
19207 const m = s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]*)([^?#]*)(\?[^#]*)?(#.*)?$/);
19208 if (m) {
19209 this.protocol = m[1] + ':';
19210 const auth = m[2];
19211 const atIdx = auth.lastIndexOf('@');
19212 if (atIdx !== -1) {
19213 const userinfo = auth.slice(0, atIdx);
19214 const ci = userinfo.indexOf(':');
19215 this.username = ci === -1 ? userinfo : userinfo.slice(0, ci);
19216 this._pw = ci === -1 ? String() : userinfo.slice(ci + 1);
19217 this.host = auth.slice(atIdx + 1);
19218 } else {
19219 this.username = '';
19220 this._pw = String();
19221 this.host = auth;
19222 }
19223 const hi = this.host.indexOf(':');
19224 this.hostname = hi === -1 ? this.host : this.host.slice(0, hi);
19225 this.port = hi === -1 ? '' : this.host.slice(hi + 1);
19226 this.pathname = m[3] || '/';
19227 this.search = m[4] || '';
19228 this.hash = m[5] || '';
19229 } else {
19230 this.protocol = '';
19231 this.username = '';
19232 this._pw = String();
19233 this.host = '';
19234 this.hostname = '';
19235 this.port = '';
19236 this.pathname = s;
19237 this.search = '';
19238 this.hash = '';
19239 }
19240 this.searchParams = new globalThis.URLSearchParams(this.search.replace(/^\?/, ''));
19241 this.origin = this.protocol ? `${this.protocol}//${this.host}` : '';
19242 this.href = this.toString();
19243 }
19244 get password() {
19245 return this._pw;
19246 }
19247 set password(value) {
19248 this._pw = value == null ? String() : String(value);
19249 }
19250 toString() {
19251 const auth = this.username ? `${this.username}${this.password ? ':' + this.password : ''}@` : '';
19252 return this.protocol ? `${this.protocol}//${auth}${this.host}${this.pathname}${this.search}${this.hash}` : this.pathname;
19253 }
19254 toJSON() { return this.toString(); }
19255 }
19256 globalThis.URL = URL;
19257}
19258
19259if (typeof globalThis.Buffer === 'undefined') {
19260 class Buffer extends Uint8Array {
19261 static _normalizeSearchOffset(length, byteOffset) {
19262 if (byteOffset == null) return 0;
19263 const number = Number(byteOffset);
19264 if (Number.isNaN(number)) return 0;
19265 if (number === Infinity) return length;
19266 if (number === -Infinity) return 0;
19267 const offset = Math.trunc(number);
19268 if (offset < 0) return Math.max(length + offset, 0);
19269 if (offset > length) return length;
19270 return offset;
19271 }
19272 static from(input, encoding) {
19273 if (typeof input === 'string') {
19274 const enc = String(encoding || '').toLowerCase();
19275 if (enc === 'base64') {
19276 const bin = __pi_base64_decode_native(input);
19277 const out = new Buffer(bin.length);
19278 for (let i = 0; i < bin.length; i++) {
19279 out[i] = bin.charCodeAt(i) & 0xff;
19280 }
19281 return out;
19282 }
19283 if (enc === 'hex') {
19284 const hex = input.replace(/[^0-9a-fA-F]/g, '');
19285 const out = new Buffer(hex.length >> 1);
19286 for (let i = 0; i < out.length; i++) {
19287 out[i] = parseInt(hex.substr(i * 2, 2), 16);
19288 }
19289 return out;
19290 }
19291 const encoded = new TextEncoder().encode(input);
19292 const out = new Buffer(encoded.length);
19293 out.set(encoded);
19294 return out;
19295 }
19296 if (input instanceof ArrayBuffer) {
19297 const out = new Buffer(input.byteLength);
19298 out.set(new Uint8Array(input));
19299 return out;
19300 }
19301 if (ArrayBuffer.isView && ArrayBuffer.isView(input)) {
19302 const out = new Buffer(input.byteLength);
19303 out.set(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
19304 return out;
19305 }
19306 if (Array.isArray(input)) {
19307 const out = new Buffer(input.length);
19308 for (let i = 0; i < input.length; i++) out[i] = input[i] & 0xff;
19309 return out;
19310 }
19311 throw new Error('Buffer.from: unsupported input');
19312 }
19313 static alloc(size, fill) {
19314 const buf = new Buffer(size);
19315 if (fill !== undefined) buf.fill(typeof fill === 'number' ? fill : 0);
19316 return buf;
19317 }
19318 static allocUnsafe(size) { return new Buffer(size); }
19319 static isBuffer(obj) { return obj instanceof Buffer; }
19320 static isEncoding(enc) {
19321 return ['utf8','utf-8','ascii','latin1','binary','base64','hex','ucs2','ucs-2','utf16le','utf-16le'].includes(String(enc).toLowerCase());
19322 }
19323 static byteLength(str, encoding) {
19324 if (typeof str !== 'string') return str.length || 0;
19325 const enc = String(encoding || 'utf8').toLowerCase();
19326 if (enc === 'base64') return Math.ceil(str.length * 3 / 4);
19327 if (enc === 'hex') return str.length >> 1;
19328 return new TextEncoder().encode(str).length;
19329 }
19330 static concat(list, totalLength) {
19331 if (!Array.isArray(list) || list.length === 0) return Buffer.alloc(0);
19332 const total = totalLength !== undefined ? totalLength : list.reduce((s, b) => s + b.length, 0);
19333 const out = Buffer.alloc(total);
19334 let offset = 0;
19335 for (const buf of list) {
19336 if (offset >= total) break;
19337 const src = buf instanceof Uint8Array ? buf : Buffer.from(buf);
19338 const copyLen = Math.min(src.length, total - offset);
19339 out.set(src.subarray(0, copyLen), offset);
19340 offset += copyLen;
19341 }
19342 return out;
19343 }
19344 static compare(a, b) {
19345 const len = Math.min(a.length, b.length);
19346 for (let i = 0; i < len; i++) {
19347 if (a[i] < b[i]) return -1;
19348 if (a[i] > b[i]) return 1;
19349 }
19350 if (a.length < b.length) return -1;
19351 if (a.length > b.length) return 1;
19352 return 0;
19353 }
19354 toString(encoding, start, end) {
19355 const s = start || 0;
19356 const e = end !== undefined ? end : this.length;
19357 const view = this.subarray(s, e);
19358 const enc = String(encoding || 'utf8').toLowerCase();
19359 if (enc === 'base64') {
19360 if (typeof globalThis.__pi_base64_encode_bytes_native === 'function') {
19361 return __pi_base64_encode_bytes_native(view);
19362 }
19363 let binaryChunks = [];
19364 let chunk = [];
19365 for (let i = 0; i < view.length; i++) {
19366 chunk.push(view[i]);
19367 if (chunk.length >= 4096) {
19368 binaryChunks.push(String.fromCharCode.apply(null, chunk));
19369 chunk.length = 0;
19370 }
19371 }
19372 if (chunk.length > 0) {
19373 binaryChunks.push(String.fromCharCode.apply(null, chunk));
19374 }
19375 return __pi_base64_encode_native(binaryChunks.join(''));
19376 }
19377 if (enc === 'hex') {
19378 const hexArr = new Array(view.length);
19379 for (let i = 0; i < view.length; i++) {
19380 hexArr[i] = (view[i] < 16 ? '0' : '') + view[i].toString(16);
19381 }
19382 return hexArr.join('');
19383 }
19384 return new TextDecoder().decode(view);
19385 }
19386 toJSON() {
19387 return { type: 'Buffer', data: Array.from(this) };
19388 }
19389 equals(other) {
19390 if (this.length !== other.length) return false;
19391 for (let i = 0; i < this.length; i++) {
19392 if (this[i] !== other[i]) return false;
19393 }
19394 return true;
19395 }
19396 compare(other) { return Buffer.compare(this, other); }
19397 copy(target, targetStart, sourceStart, sourceEnd) {
19398 const ts = targetStart || 0;
19399 const ss = sourceStart || 0;
19400 const se = sourceEnd !== undefined ? sourceEnd : this.length;
19401 const src = this.subarray(ss, se);
19402 const copyLen = Math.min(src.length, target.length - ts);
19403 target.set(src.subarray(0, copyLen), ts);
19404 return copyLen;
19405 }
19406 slice(start, end) {
19407 const sliced = super.slice(start, end);
19408 const buf = new Buffer(sliced.length);
19409 buf.set(sliced);
19410 return buf;
19411 }
19412 indexOf(value, byteOffset, encoding) {
19413 let offset = Buffer._normalizeSearchOffset(this.length, byteOffset);
19414 let searchEncoding = encoding;
19415 if (typeof byteOffset === 'string') {
19416 offset = 0;
19417 searchEncoding = byteOffset;
19418 }
19419 if (typeof value === 'number') {
19420 for (let i = offset; i < this.length; i++) {
19421 if (this[i] === (value & 0xff)) return i;
19422 }
19423 return -1;
19424 }
19425 const needle = typeof value === 'string' ? Buffer.from(value, searchEncoding) : value;
19426 if (needle.length === 0) return offset;
19427 outer: for (let i = offset; i <= this.length - needle.length; i++) {
19428 for (let j = 0; j < needle.length; j++) {
19429 if (this[i + j] !== needle[j]) continue outer;
19430 }
19431 return i;
19432 }
19433 return -1;
19434 }
19435 includes(value, byteOffset, encoding) {
19436 return this.indexOf(value, byteOffset, encoding) !== -1;
19437 }
19438 write(string, offset, length, encoding) {
19439 const o = offset || 0;
19440 const enc = encoding || 'utf8';
19441 const bytes = Buffer.from(string, enc);
19442 const len = length !== undefined ? Math.min(length, bytes.length) : bytes.length;
19443 const copyLen = Math.min(len, this.length - o);
19444 this.set(bytes.subarray(0, copyLen), o);
19445 return copyLen;
19446 }
19447 fill(value, offset, end, encoding) {
19448 const s = offset || 0;
19449 const e = end !== undefined ? end : this.length;
19450 const v = typeof value === 'number' ? (value & 0xff) : 0;
19451 for (let i = s; i < e; i++) this[i] = v;
19452 return this;
19453 }
19454 readUInt8(offset) { return this[offset || 0]; }
19455 readUInt16BE(offset) { const o = offset || 0; return (this[o] << 8) | this[o + 1]; }
19456 readUInt16LE(offset) { const o = offset || 0; return this[o] | (this[o + 1] << 8); }
19457 readUInt32BE(offset) { const o = offset || 0; return ((this[o] << 24) | (this[o+1] << 16) | (this[o+2] << 8) | this[o+3]) >>> 0; }
19458 readUInt32LE(offset) { const o = offset || 0; return (this[o] | (this[o+1] << 8) | (this[o+2] << 16) | (this[o+3] << 24)) >>> 0; }
19459 readInt8(offset) { const v = this[offset || 0]; return v > 127 ? v - 256 : v; }
19460 writeUInt8(value, offset) { this[offset || 0] = value & 0xff; return (offset || 0) + 1; }
19461 writeUInt16BE(value, offset) { const o = offset || 0; this[o] = (value >> 8) & 0xff; this[o+1] = value & 0xff; return o + 2; }
19462 writeUInt16LE(value, offset) { const o = offset || 0; this[o] = value & 0xff; this[o+1] = (value >> 8) & 0xff; return o + 2; }
19463 writeUInt32BE(value, offset) { const o = offset || 0; this[o]=(value>>>24)&0xff; this[o+1]=(value>>>16)&0xff; this[o+2]=(value>>>8)&0xff; this[o+3]=value&0xff; return o+4; }
19464 writeUInt32LE(value, offset) { const o = offset || 0; this[o]=value&0xff; this[o+1]=(value>>>8)&0xff; this[o+2]=(value>>>16)&0xff; this[o+3]=(value>>>24)&0xff; return o+4; }
19465 }
19466 globalThis.Buffer = Buffer;
19467}
19468
19469if (typeof globalThis.crypto === 'undefined') {
19470 globalThis.crypto = {};
19471}
19472
19473if (typeof globalThis.crypto.getRandomValues !== 'function') {
19474 globalThis.crypto.getRandomValues = (arr) => {
19475 const len = Number(arr && arr.length ? arr.length : 0);
19476 const bytes = __pi_crypto_bytes_to_array(__pi_crypto_random_bytes_native(len));
19477 for (let i = 0; i < len; i++) {
19478 arr[i] = bytes[i] || 0;
19479 }
19480 return arr;
19481 };
19482}
19483
19484if (!globalThis.crypto.subtle) {
19485 globalThis.crypto.subtle = {};
19486}
19487
19488if (typeof globalThis.crypto.subtle.digest !== 'function') {
19489 globalThis.crypto.subtle.digest = async (algorithm, data) => {
19490 const name = typeof algorithm === 'string' ? algorithm : (algorithm && algorithm.name ? algorithm.name : '');
19491 const upper = String(name).toUpperCase();
19492 if (upper !== 'SHA-256') {
19493 throw new Error('crypto.subtle.digest: only SHA-256 is supported');
19494 }
19495 const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
19496 const hex = __pi_crypto_hash_native('sha256', bytes, 'hex');
19497 const out = new Uint8Array(hex.length / 2);
19498 for (let i = 0; i < out.length; i++) {
19499 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
19500 }
19501 return out.buffer;
19502 };
19503}
19504
19505if (typeof globalThis.crypto.randomUUID !== 'function') {
19506 globalThis.crypto.randomUUID = () => {
19507 const bytes = __pi_crypto_bytes_to_array(__pi_crypto_random_bytes_native(16));
19508 while (bytes.length < 16) bytes.push(0);
19509 bytes[6] = (bytes[6] & 0x0f) | 0x40;
19510 bytes[8] = (bytes[8] & 0x3f) | 0x80;
19511 const hex = Array.from(bytes, (b) => (b & 0xff).toString(16).padStart(2, '0')).join('');
19512 return (
19513 hex.slice(0, 8) +
19514 '-' +
19515 hex.slice(8, 12) +
19516 '-' +
19517 hex.slice(12, 16) +
19518 '-' +
19519 hex.slice(16, 20) +
19520 '-' +
19521 hex.slice(20)
19522 );
19523 };
19524}
19525
19526if (typeof globalThis.process === 'undefined') {
19527 const rawPlatform =
19528 __pi_env_get_native('PI_PLATFORM') ||
19529 __pi_env_get_native('OSTYPE') ||
19530 __pi_env_get_native('OS') ||
19531 'linux';
19532 // Normalize to Node.js conventions: strip version suffix from OSTYPE
19533 // (e.g. darwin24.0 -> darwin, linux-gnu -> linux, msys -> win32)
19534 const platform = (() => {
19535 const s = String(rawPlatform).replace(/[0-9].*$/, '').split('-')[0].toLowerCase();
19536 if (s === 'darwin') return 'darwin';
19537 if (s === 'msys' || s === 'cygwin' || s === 'windows_nt') return 'win32';
19538 return s || 'linux';
19539 })();
19540 const detHome = __pi_env_get_native('PI_DETERMINISTIC_HOME');
19541 const detCwd = __pi_env_get_native('PI_DETERMINISTIC_CWD');
19542
19543 const envProxy = new Proxy(
19544 {},
19545 {
19546 get(_target, prop) {
19547 if (typeof prop !== 'string') return undefined;
19548 if (prop === 'HOME' && detHome) return detHome;
19549 const value = __pi_env_get_native(prop);
19550 return value === null || value === undefined ? undefined : value;
19551 },
19552 set(_target, prop, _value) {
19553 // Read-only in PiJS — silently ignore writes
19554 return typeof prop === 'string';
19555 },
19556 deleteProperty(_target, prop) {
19557 // Read-only — silently ignore deletes
19558 return typeof prop === 'string';
19559 },
19560 has(_target, prop) {
19561 if (typeof prop !== 'string') return false;
19562 if (prop === 'HOME' && detHome) return true;
19563 const value = __pi_env_get_native(prop);
19564 return value !== null && value !== undefined;
19565 },
19566 ownKeys() {
19567 // Cannot enumerate real env — return empty
19568 return [];
19569 },
19570 getOwnPropertyDescriptor(_target, prop) {
19571 if (typeof prop !== 'string') return undefined;
19572 const value = __pi_env_get_native(prop);
19573 if (value === null || value === undefined) return undefined;
19574 return { value, writable: false, enumerable: true, configurable: true };
19575 },
19576 },
19577 );
19578
19579 // stdout/stderr that route through console output
19580 function makeWritable(level) {
19581 return {
19582 write(chunk) {
19583 if (typeof __pi_console_output_native === 'function') {
19584 __pi_console_output_native(level, String(chunk));
19585 }
19586 return true;
19587 },
19588 end() { return this; },
19589 on() { return this; },
19590 once() { return this; },
19591 pipe() { return this; },
19592 isTTY: false,
19593 };
19594 }
19595
19596 // Event listener registry
19597 const __evtMap = Object.create(null);
19598 function __on(event, fn) {
19599 if (!__evtMap[event]) __evtMap[event] = [];
19600 __evtMap[event].push(fn);
19601 return globalThis.process;
19602 }
19603 function __off(event, fn) {
19604 const arr = __evtMap[event];
19605 if (!arr) return globalThis.process;
19606 const idx = arr.indexOf(fn);
19607 if (idx >= 0) arr.splice(idx, 1);
19608 return globalThis.process;
19609 }
19610
19611 const startMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
19612
19613 globalThis.process = {
19614 env: envProxy,
19615 argv: __pi_process_args_native(),
19616 cwd: () => detCwd || __pi_process_cwd_native(),
19617 platform: String(platform).split('-')[0],
19618 arch: __pi_env_get_native('PI_TARGET_ARCH') || 'x64',
19619 version: 'v20.0.0',
19620 versions: { node: '20.0.0', v8: '0.0.0', modules: '0' },
19621 pid: 1,
19622 ppid: 0,
19623 title: 'pi',
19624 execPath: (typeof __pi_process_execpath_native === 'function')
19625 ? __pi_process_execpath_native()
19626 : '/usr/bin/pi',
19627 execArgv: [],
19628 stdout: makeWritable('log'),
19629 stderr: makeWritable('error'),
19630 stdin: { on() { return this; }, once() { return this; }, read() {}, resume() { return this; }, pause() { return this; } },
19631 nextTick: (fn, ...args) => { Promise.resolve().then(() => fn(...args)); },
19632 hrtime: Object.assign((prev) => {
19633 const nowMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
19634 const secs = Math.floor(nowMs / 1000);
19635 const nanos = Math.floor((nowMs % 1000) * 1e6);
19636 if (Array.isArray(prev) && prev.length >= 2) {
19637 let ds = secs - prev[0];
19638 let dn = nanos - prev[1];
19639 if (dn < 0) { ds -= 1; dn += 1e9; }
19640 return [ds, dn];
19641 }
19642 return [secs, nanos];
19643 }, {
19644 bigint: () => {
19645 const nowMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
19646 return BigInt(Math.floor(nowMs * 1e6));
19647 },
19648 }),
19649 kill: (pid, sig) => {
19650 const impl = globalThis.__pi_process_kill_impl;
19651 if (typeof impl === 'function') {
19652 return impl(pid, sig);
19653 }
19654 const err = new Error('process.kill is not available in PiJS');
19655 err.code = 'ENOSYS';
19656 throw err;
19657 },
19658 exit: (code) => {
19659 const exitCode = code === undefined ? 0 : Number(code);
19660 // Fire exit listeners
19661 const listeners = __evtMap['exit'];
19662 if (listeners) {
19663 for (const fn of listeners.slice()) {
19664 try { fn(exitCode); } catch (_) {}
19665 }
19666 }
19667 // Signal native side
19668 if (typeof __pi_process_exit_native === 'function') {
19669 __pi_process_exit_native(exitCode);
19670 }
19671 const err = new Error('process.exit(' + exitCode + ')');
19672 err.code = 'ERR_PROCESS_EXIT';
19673 err.exitCode = exitCode;
19674 throw err;
19675 },
19676 chdir: (_dir) => {
19677 const err = new Error('process.chdir is not supported in PiJS');
19678 err.code = 'ENOSYS';
19679 throw err;
19680 },
19681 uptime: () => {
19682 const nowMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
19683 return Math.floor((nowMs - startMs) / 1000);
19684 },
19685 memoryUsage: () => ({
19686 rss: 0, heapTotal: 0, heapUsed: 0, external: 0, arrayBuffers: 0,
19687 }),
19688 cpuUsage: (_prev) => ({ user: 0, system: 0 }),
19689 emitWarning: (msg) => {
19690 if (typeof __pi_console_output_native === 'function') {
19691 __pi_console_output_native('warn', 'Warning: ' + msg);
19692 }
19693 },
19694 release: { name: 'node', lts: 'PiJS' },
19695 config: { variables: {} },
19696 features: {},
19697 on: __on,
19698 addListener: __on,
19699 off: __off,
19700 removeListener: __off,
19701 once(event, fn) {
19702 const wrapped = (...args) => {
19703 __off(event, wrapped);
19704 fn(...args);
19705 };
19706 wrapped._original = fn;
19707 __on(event, wrapped);
19708 return globalThis.process;
19709 },
19710 removeAllListeners(event) {
19711 if (event) { delete __evtMap[event]; }
19712 else { for (const k in __evtMap) delete __evtMap[k]; }
19713 return globalThis.process;
19714 },
19715 listeners(event) {
19716 return (__evtMap[event] || []).slice();
19717 },
19718 emit(event, ...args) {
19719 const listeners = __evtMap[event];
19720 if (!listeners || listeners.length === 0) return false;
19721 for (const fn of listeners.slice()) {
19722 try { fn(...args); } catch (_) {}
19723 }
19724 return true;
19725 },
19726 };
19727
19728 try { Object.freeze(envProxy); } catch (_) {}
19729 try { Object.freeze(globalThis.process.argv); } catch (_) {}
19730 // Do NOT freeze globalThis.process — extensions may need to monkey-patch it
19731}
19732
19733// Node.js global alias compatibility.
19734if (typeof globalThis.global === 'undefined') {
19735 globalThis.global = globalThis;
19736}
19737
19738if (typeof globalThis.Bun === 'undefined') {
19739 const __pi_bun_require = (specifier) => {
19740 try {
19741 if (typeof require === 'function') {
19742 return require(specifier);
19743 }
19744 } catch (_) {}
19745 return null;
19746 };
19747
19748 const __pi_bun_fs = () => __pi_bun_require('node:fs');
19749 const __pi_bun_import_fs = () => import('node:fs');
19750 const __pi_bun_child_process = () => __pi_bun_require('node:child_process');
19751
19752 const __pi_bun_to_uint8 = (value) => {
19753 if (value instanceof Uint8Array) {
19754 return value;
19755 }
19756 if (value instanceof ArrayBuffer) {
19757 return new Uint8Array(value);
19758 }
19759 if (ArrayBuffer.isView && ArrayBuffer.isView(value)) {
19760 return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
19761 }
19762 if (typeof value === 'string') {
19763 return new TextEncoder().encode(value);
19764 }
19765 if (value === undefined || value === null) {
19766 return new Uint8Array();
19767 }
19768 return new TextEncoder().encode(String(value));
19769 };
19770
19771 const __pi_bun_make_text_stream = (fetchText) => ({
19772 async text() {
19773 return fetchText();
19774 },
19775 async arrayBuffer() {
19776 const text = await fetchText();
19777 const bytes = new TextEncoder().encode(String(text ?? ''));
19778 return bytes.buffer;
19779 },
19780 });
19781
19782 const __pi_bun_schedule = (fn) => {
19783 if (typeof globalThis.queueMicrotask === 'function') {
19784 globalThis.queueMicrotask(fn);
19785 return;
19786 }
19787 if (typeof globalThis.setTimeout === 'function') {
19788 globalThis.setTimeout(fn, 0);
19789 return;
19790 }
19791 try {
19792 Promise.resolve().then(fn);
19793 } catch (_) {
19794 fn();
19795 }
19796 };
19797
19798 const __pi_bun_make_emitter = (target) => {
19799 const listeners = {};
19800 target.on = function(event, handler) {
19801 if (typeof handler !== 'function') return this;
19802 if (!listeners[event]) listeners[event] = [];
19803 listeners[event].push(handler);
19804 return this;
19805 };
19806 target.once = function(event, handler) {
19807 if (typeof handler !== 'function') return this;
19808 const wrapper = (...args) => {
19809 this.off(event, wrapper);
19810 handler(...args);
19811 };
19812 return this.on(event, wrapper);
19813 };
19814 target.off = function(event, handler) {
19815 const list = listeners[event];
19816 if (!list) return this;
19817 if (!handler) {
19818 delete listeners[event];
19819 return this;
19820 }
19821 const idx = list.indexOf(handler);
19822 if (idx >= 0) list.splice(idx, 1);
19823 return this;
19824 };
19825 target.emit = function(event, ...args) {
19826 const list = listeners[event];
19827 if (!list || list.length === 0) return false;
19828 list.slice().forEach((fn) => {
19829 try { fn(...args); } catch (_) {}
19830 });
19831 return true;
19832 };
19833 target.addEventListener = target.on;
19834 target.removeEventListener = target.off;
19835 return target;
19836 };
19837
19838 const __pi_bun_make_socket = (options = {}, handler = null) => {
19839 const socket = __pi_bun_make_emitter({});
19840 socket.remoteAddress = String(options.hostname || options.host || '127.0.0.1');
19841 socket.remotePort = Number(options.port || 0);
19842 socket.localAddress = '127.0.0.1';
19843 socket.localPort = 0;
19844 socket.readyState = 'opening';
19845 socket.connecting = true;
19846 socket.data = Object.prototype.hasOwnProperty.call(options, 'data') ? options.data : null;
19847 socket.binaryType = options.binaryType || 'buffer';
19848 socket.closed = false;
19849 socket.write = (data) => {
19850 if (typeof data === 'string') return data.length;
19851 if (data && typeof data.byteLength === 'number') return data.byteLength;
19852 if (data && typeof data.length === 'number') return data.length;
19853 return 0;
19854 };
19855 socket.end = (data) => {
19856 if (data !== undefined) socket.write(data);
19857 socket.close();
19858 };
19859 socket.close = () => {
19860 if (socket.closed) return;
19861 socket.closed = true;
19862 socket.readyState = 'closed';
19863 socket.connecting = false;
19864 if (handler && typeof handler.close === 'function') {
19865 try { handler.close(socket); } catch (_) {}
19866 }
19867 const evt = { type: 'close' };
19868 if (typeof socket.onclose === 'function') socket.onclose(evt);
19869 socket.emit('close', evt);
19870 };
19871 socket.ref = () => socket;
19872 socket.unref = () => socket;
19873 return socket;
19874 };
19875
19876 const Bun = {};
19877
19878 Bun.argv = Array.isArray(globalThis.process && globalThis.process.argv)
19879 ? globalThis.process.argv.slice()
19880 : [];
19881
19882 Bun.file = (path) => {
19883 const targetPath = String(path ?? '');
19884 return {
19885 path: targetPath,
19886 name: targetPath,
19887 async exists() {
19888 const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
19889 return Boolean(fs && typeof fs.existsSync === 'function' && fs.existsSync(targetPath));
19890 },
19891 async text() {
19892 const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
19893 if (!fs || typeof fs.readFileSync !== 'function') {
19894 throw new Error('Bun.file.text: node:fs is unavailable');
19895 }
19896 return String(fs.readFileSync(targetPath, 'utf8'));
19897 },
19898 async arrayBuffer() {
19899 const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
19900 if (!fs || typeof fs.readFileSync !== 'function') {
19901 throw new Error('Bun.file.arrayBuffer: node:fs is unavailable');
19902 }
19903 const bytes = __pi_bun_to_uint8(fs.readFileSync(targetPath));
19904 return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
19905 },
19906 async json() {
19907 return JSON.parse(await this.text());
19908 },
19909 };
19910 };
19911
19912 Bun.write = async (destination, data) => {
19913 const targetPath =
19914 destination && typeof destination === 'object' && typeof destination.path === 'string'
19915 ? destination.path
19916 : String(destination ?? '');
19917 if (!targetPath) {
19918 throw new Error('Bun.write: destination path is required');
19919 }
19920 const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
19921 if (!fs || typeof fs.writeFileSync !== 'function') {
19922 throw new Error('Bun.write: node:fs is unavailable');
19923 }
19924
19925 let payload = data;
19926 if (payload && typeof payload === 'object' && typeof payload.text === 'function') {
19927 payload = payload.text();
19928 }
19929 if (payload && typeof payload === 'object' && typeof payload.arrayBuffer === 'function') {
19930 payload = payload.arrayBuffer();
19931 }
19932 if (payload && typeof payload.then === 'function') {
19933 payload = await payload;
19934 }
19935
19936 const bytes = __pi_bun_to_uint8(payload);
19937 fs.writeFileSync(targetPath, bytes);
19938 return bytes.byteLength;
19939 };
19940
19941 Bun.connect = (rawOptions = {}) => {
19942 const options = rawOptions && typeof rawOptions === 'object' ? rawOptions : {};
19943 const handler = options.socket && typeof options.socket === 'object' ? options.socket : null;
19944 const socket = __pi_bun_make_socket(options, handler);
19945 __pi_bun_schedule(() => {
19946 if (socket.closed) return;
19947 socket.connecting = false;
19948 socket.readyState = 'open';
19949 const evt = { type: 'open' };
19950 if (typeof socket.onopen === 'function') socket.onopen(evt);
19951 socket.emit('open', evt);
19952 if (handler && typeof handler.open === 'function') {
19953 try { handler.open(socket); } catch (_) {}
19954 }
19955 });
19956 return socket;
19957 };
19958
19959 Bun.listen = (rawOptions = {}) => {
19960 const options = rawOptions && typeof rawOptions === 'object' ? rawOptions : {};
19961 const handler = options.socket && typeof options.socket === 'object' ? options.socket : null;
19962 const server = __pi_bun_make_emitter({});
19963 server.closed = false;
19964 server.listening = false;
19965 server.port = Number(options.port || 0);
19966 server.hostname = String(options.hostname || '0.0.0.0');
19967 if (options.unix !== undefined) server.unix = options.unix;
19968 server.stop = () => {
19969 if (server.closed) return;
19970 server.closed = true;
19971 server.listening = false;
19972 const evt = { type: 'close' };
19973 if (typeof server.onclose === 'function') server.onclose(evt);
19974 server.emit('close', evt);
19975 };
19976 server.reload = () => server;
19977 server.ref = () => server;
19978 server.unref = () => server;
19979 __pi_bun_schedule(() => {
19980 if (server.closed) return;
19981 server.listening = true;
19982 const evt = { type: 'listening' };
19983 if (typeof server.onlistening === 'function') server.onlistening(evt);
19984 server.emit('listening', evt);
19985 if (handler && typeof handler.open === 'function') {
19986 const socket = __pi_bun_make_socket(options, handler);
19987 try { handler.open(socket); } catch (_) {}
19988 }
19989 });
19990 return server;
19991 };
19992
19993 Bun.which = (command) => {
19994 const name = String(command ?? '').trim();
19995 if (!name) return null;
19996 const cwd =
19997 globalThis.process && typeof globalThis.process.cwd === 'function'
19998 ? globalThis.process.cwd()
19999 : '/';
20000 const raw = __pi_exec_sync_native('which', JSON.stringify([name]), cwd, 2000, undefined);
20001 try {
20002 const parsed = JSON.parse(raw || '{}');
20003 if (Number(parsed && parsed.code) !== 0) return null;
20004 const out = String((parsed && parsed.stdout) || '').trim();
20005 return out ? out.split('\n')[0] : null;
20006 } catch (_) {
20007 return null;
20008 }
20009 };
20010
20011 Bun.spawn = (commandOrArgv, rawOptions = {}) => {
20012 const options = rawOptions && typeof rawOptions === 'object' ? rawOptions : {};
20013
20014 let command = '';
20015 let args = [];
20016 if (Array.isArray(commandOrArgv)) {
20017 if (commandOrArgv.length === 0) {
20018 throw new Error('Bun.spawn: command is required');
20019 }
20020 command = String(commandOrArgv[0] ?? '');
20021 args = commandOrArgv.slice(1).map((arg) => String(arg ?? ''));
20022 } else {
20023 command = String(commandOrArgv ?? '');
20024 if (Array.isArray(options.args)) {
20025 args = options.args.map((arg) => String(arg ?? ''));
20026 }
20027 }
20028
20029 if (!command.trim()) {
20030 throw new Error('Bun.spawn: command is required');
20031 }
20032
20033 const spawnOptions = {
20034 shell: false,
20035 stdio: [
20036 options.stdin === 'pipe' ? 'pipe' : 'ignore',
20037 options.stdout === 'ignore' ? 'ignore' : 'pipe',
20038 options.stderr === 'ignore' ? 'ignore' : 'pipe',
20039 ],
20040 };
20041 if (typeof options.cwd === 'string' && options.cwd.trim().length > 0) {
20042 spawnOptions.cwd = options.cwd;
20043 }
20044 if (
20045 typeof options.timeout === 'number' &&
20046 Number.isFinite(options.timeout) &&
20047 options.timeout >= 0
20048 ) {
20049 spawnOptions.timeout = Math.floor(options.timeout);
20050 }
20051
20052 const childProcess = __pi_bun_child_process();
20053 if (childProcess && typeof childProcess.spawn === 'function') {
20054 const child = childProcess.spawn(command, args, spawnOptions);
20055 let stdoutChunks = [];
20056 let stderrChunks = [];
20057
20058 if (child && child.stdout && typeof child.stdout.on === 'function') {
20059 child.stdout.on('data', (chunk) => {
20060 stdoutChunks.push(String(chunk ?? ''));
20061 });
20062 }
20063 if (child && child.stderr && typeof child.stderr.on === 'function') {
20064 child.stderr.on('data', (chunk) => {
20065 stderrChunks.push(String(chunk ?? ''));
20066 });
20067 }
20068
20069 const exited = new Promise((resolve, reject) => {
20070 let settled = false;
20071 child.on('error', (err) => {
20072 if (settled) return;
20073 settled = true;
20074 reject(err instanceof Error ? err : new Error(String(err)));
20075 });
20076 child.on('close', (code) => {
20077 if (settled) return;
20078 settled = true;
20079 resolve(typeof code === 'number' ? code : null);
20080 });
20081 });
20082
20083 return {
20084 pid: typeof child.pid === 'number' ? child.pid : 0,
20085 stdin: child.stdin || null,
20086 stdout: __pi_bun_make_text_stream(async () => {
20087 await exited.catch(() => null);
20088 return stdoutChunks.join('');
20089 }),
20090 stderr: __pi_bun_make_text_stream(async () => {
20091 await exited.catch(() => null);
20092 return stderrChunks.join('');
20093 }),
20094 exited,
20095 kill(signal) {
20096 try {
20097 return child.kill(signal);
20098 } catch (_) {
20099 return false;
20100 }
20101 },
20102 ref() { return this; },
20103 unref() { return this; },
20104 };
20105 }
20106
20107 // Fallback path if node:child_process is unavailable in context.
20108 const execOptions = {};
20109 if (spawnOptions.cwd !== undefined) execOptions.cwd = spawnOptions.cwd;
20110 if (spawnOptions.timeout !== undefined) execOptions.timeout = spawnOptions.timeout;
20111 const execPromise = pi.exec(command, args, execOptions);
20112 let killed = false;
20113
20114 const exited = execPromise.then(
20115 (result) => (killed ? null : (Number(result && result.code) || 0)),
20116 () => (killed ? null : 1),
20117 );
20118
20119 return {
20120 pid: 0,
20121 stdin: null,
20122 stdout: __pi_bun_make_text_stream(async () => {
20123 try {
20124 const result = await execPromise;
20125 return String((result && result.stdout) || '');
20126 } catch (_) {
20127 return '';
20128 }
20129 }),
20130 stderr: __pi_bun_make_text_stream(async () => {
20131 try {
20132 const result = await execPromise;
20133 return String((result && result.stderr) || '');
20134 } catch (_) {
20135 return '';
20136 }
20137 }),
20138 exited,
20139 kill() {
20140 killed = true;
20141 return true;
20142 },
20143 ref() { return this; },
20144 unref() { return this; },
20145 };
20146 };
20147
20148 globalThis.Bun = Bun;
20149}
20150
20151if (typeof globalThis.setTimeout !== 'function') {
20152 globalThis.setTimeout = (callback, delay, ...args) => {
20153 const ms = Number(delay || 0);
20154 const timer_id = __pi_set_timeout_native(ms <= 0 ? 0 : Math.floor(ms));
20155 const captured_id = __pi_current_extension_id;
20156 __pi_register_timer(timer_id, () => {
20157 const prev = __pi_current_extension_id;
20158 __pi_current_extension_id = captured_id;
20159 try {
20160 callback(...args);
20161 } catch (e) {
20162 console.error('setTimeout callback error:', e);
20163 } finally {
20164 __pi_current_extension_id = prev;
20165 }
20166 });
20167 return timer_id;
20168 };
20169}
20170
20171if (typeof globalThis.clearTimeout !== 'function') {
20172 globalThis.clearTimeout = (timer_id) => {
20173 __pi_unregister_timer(timer_id);
20174 try {
20175 __pi_clear_timeout_native(timer_id);
20176 } catch (_) {}
20177 };
20178}
20179
20180// setInterval polyfill using setTimeout
20181const __pi_intervals = new Map();
20182let __pi_interval_id = 0;
20183
20184if (typeof globalThis.setInterval !== 'function') {
20185 globalThis.setInterval = (callback, delay, ...args) => {
20186 const ms = Math.max(0, Number(delay || 0));
20187 const id = ++__pi_interval_id;
20188 const captured_id = __pi_current_extension_id;
20189 const run = () => {
20190 if (!__pi_intervals.has(id)) return;
20191 const prev = __pi_current_extension_id;
20192 __pi_current_extension_id = captured_id;
20193 try {
20194 callback(...args);
20195 } catch (e) {
20196 console.error('setInterval callback error:', e);
20197 } finally {
20198 __pi_current_extension_id = prev;
20199 }
20200 if (__pi_intervals.has(id)) {
20201 __pi_intervals.set(id, globalThis.setTimeout(run, ms));
20202 }
20203 };
20204 __pi_intervals.set(id, globalThis.setTimeout(run, ms));
20205 return id;
20206 };
20207}
20208
20209if (typeof globalThis.clearInterval !== 'function') {
20210 globalThis.clearInterval = (id) => {
20211 const timerId = __pi_intervals.get(id);
20212 if (timerId !== undefined) {
20213 globalThis.clearTimeout(timerId);
20214 __pi_intervals.delete(id);
20215 }
20216 };
20217}
20218
20219if (typeof globalThis.fetch !== 'function') {
20220 const __pi_fetch_body_bytes_to_base64 = (value) => {
20221 let bytes = null;
20222 if (value instanceof Uint8Array) {
20223 bytes = value;
20224 } else if (value instanceof ArrayBuffer) {
20225 bytes = new Uint8Array(value);
20226 } else if (ArrayBuffer.isView && ArrayBuffer.isView(value)) {
20227 bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
20228 }
20229 if (!bytes) return null;
20230 if (typeof globalThis.__pi_base64_encode_bytes_native === 'function') {
20231 return __pi_base64_encode_bytes_native(bytes);
20232 }
20233 let binaryChunks = [];
20234 let chunk = [];
20235 for (let i = 0; i < bytes.length; i++) {
20236 chunk.push(bytes[i]);
20237 if (chunk.length >= 4096) {
20238 binaryChunks.push(String.fromCharCode.apply(null, chunk));
20239 chunk.length = 0;
20240 }
20241 }
20242 if (chunk.length > 0) {
20243 binaryChunks.push(String.fromCharCode.apply(null, chunk));
20244 }
20245 return __pi_base64_encode_native(binaryChunks.join(''));
20246 };
20247
20248 class Headers {
20249 constructor(init) {
20250 this._map = {};
20251 if (init && typeof init === 'object') {
20252 if (Array.isArray(init)) {
20253 for (const pair of init) {
20254 if (pair && pair.length >= 2) this.set(pair[0], pair[1]);
20255 }
20256 } else if (typeof init.forEach === 'function') {
20257 init.forEach((v, k) => this.set(k, v));
20258 } else {
20259 for (const k of Object.keys(init)) {
20260 this.set(k, init[k]);
20261 }
20262 }
20263 }
20264 }
20265
20266 get(name) {
20267 const key = String(name || '').toLowerCase();
20268 return this._map[key] === undefined ? null : this._map[key];
20269 }
20270
20271 set(name, value) {
20272 const key = String(name || '').toLowerCase();
20273 this._map[key] = String(value === undefined || value === null ? '' : value);
20274 }
20275
20276 entries() {
20277 return Object.entries(this._map);
20278 }
20279 }
20280
20281 class Response {
20282 constructor(bodyBytes, init) {
20283 const options = init && typeof init === 'object' ? init : {};
20284 this.status = Number(options.status || 0);
20285 this.ok = this.status >= 200 && this.status < 300;
20286 this.headers = new Headers(options.headers || {});
20287 this._bytes = bodyBytes || new Uint8Array();
20288 this.body = {
20289 getReader: () => {
20290 let done = false;
20291 return {
20292 read: async () => {
20293 if (done) return { done: true, value: undefined };
20294 done = true;
20295 return { done: false, value: this._bytes };
20296 },
20297 cancel: async () => {
20298 done = true;
20299 },
20300 releaseLock: () => {},
20301 };
20302 },
20303 };
20304 }
20305
20306 async text() {
20307 return new TextDecoder().decode(this._bytes);
20308 }
20309
20310 async json() {
20311 return JSON.parse(await this.text());
20312 }
20313
20314 async arrayBuffer() {
20315 const copy = new Uint8Array(this._bytes.length);
20316 copy.set(this._bytes);
20317 return copy.buffer;
20318 }
20319 }
20320
20321 globalThis.Headers = Headers;
20322 globalThis.Response = Response;
20323
20324 if (typeof globalThis.Event === 'undefined') {
20325 class Event {
20326 constructor(type, options) {
20327 const opts = options && typeof options === 'object' ? options : {};
20328 this.type = String(type || '');
20329 this.bubbles = !!opts.bubbles;
20330 this.cancelable = !!opts.cancelable;
20331 this.composed = !!opts.composed;
20332 this.defaultPrevented = false;
20333 this.target = null;
20334 this.currentTarget = null;
20335 this.timeStamp = Date.now();
20336 }
20337 preventDefault() {
20338 if (this.cancelable) this.defaultPrevented = true;
20339 }
20340 stopPropagation() {}
20341 stopImmediatePropagation() {}
20342 }
20343 globalThis.Event = Event;
20344 }
20345
20346 if (typeof globalThis.CustomEvent === 'undefined' && typeof globalThis.Event === 'function') {
20347 class CustomEvent extends globalThis.Event {
20348 constructor(type, options) {
20349 const opts = options && typeof options === 'object' ? options : {};
20350 super(type, opts);
20351 this.detail = opts.detail;
20352 }
20353 }
20354 globalThis.CustomEvent = CustomEvent;
20355 }
20356
20357 if (typeof globalThis.EventTarget === 'undefined') {
20358 class EventTarget {
20359 constructor() {
20360 this.__listeners = Object.create(null);
20361 }
20362 addEventListener(type, listener) {
20363 const key = String(type || '');
20364 if (!key || !listener) return;
20365 if (!this.__listeners[key]) this.__listeners[key] = [];
20366 if (!this.__listeners[key].includes(listener)) this.__listeners[key].push(listener);
20367 }
20368 removeEventListener(type, listener) {
20369 const key = String(type || '');
20370 const list = this.__listeners[key];
20371 if (!list || !listener) return;
20372 this.__listeners[key] = list.filter((fn) => fn !== listener);
20373 }
20374 dispatchEvent(event) {
20375 if (!event || typeof event.type !== 'string') return true;
20376 const key = event.type;
20377 const list = (this.__listeners[key] || []).slice();
20378 try {
20379 event.target = this;
20380 event.currentTarget = this;
20381 } catch (_) {}
20382 for (const listener of list) {
20383 try {
20384 if (typeof listener === 'function') listener.call(this, event);
20385 else if (listener && typeof listener.handleEvent === 'function') listener.handleEvent(event);
20386 } catch (_) {}
20387 }
20388 return !(event && event.defaultPrevented);
20389 }
20390 }
20391 globalThis.EventTarget = EventTarget;
20392 }
20393
20394 if (typeof globalThis.TransformStream === 'undefined') {
20395 class TransformStream {
20396 constructor(_transformer) {
20397 const queue = [];
20398 let closed = false;
20399 this.readable = {
20400 getReader() {
20401 return {
20402 async read() {
20403 if (queue.length > 0) {
20404 return { done: false, value: queue.shift() };
20405 }
20406 return { done: closed, value: undefined };
20407 },
20408 async cancel() {
20409 closed = true;
20410 },
20411 releaseLock() {},
20412 };
20413 },
20414 };
20415 this.writable = {
20416 getWriter() {
20417 return {
20418 async write(chunk) {
20419 queue.push(chunk);
20420 },
20421 async close() {
20422 closed = true;
20423 },
20424 async abort() {
20425 closed = true;
20426 },
20427 releaseLock() {},
20428 };
20429 },
20430 };
20431 }
20432 }
20433 globalThis.TransformStream = TransformStream;
20434 }
20435
20436 // AbortController / AbortSignal polyfill — many npm packages check for these
20437 if (typeof globalThis.AbortController === 'undefined') {
20438 class AbortSignal {
20439 constructor() { this.aborted = false; this._listeners = []; }
20440 get reason() { return this.aborted ? (this._reason !== undefined ? this._reason : new Error('This operation was aborted')) : undefined; }
20441 addEventListener(type, fn) { if (type === 'abort') this._listeners.push(fn); }
20442 removeEventListener(type, fn) { if (type === 'abort') this._listeners = this._listeners.filter(f => f !== fn); }
20443 throwIfAborted() { if (this.aborted) throw this.reason; }
20444 static abort(reason) { const s = new AbortSignal(); s.aborted = true; s._reason = reason !== undefined ? reason : new Error('This operation was aborted'); return s; }
20445 static timeout(ms) { const s = new AbortSignal(); setTimeout(() => { s.aborted = true; s._reason = new Error('The operation was aborted due to timeout'); s._listeners.forEach(fn => fn()); }, ms); return s; }
20446 }
20447 class AbortController {
20448 constructor() { this.signal = new AbortSignal(); }
20449 abort(reason) { this.signal.aborted = true; this.signal._reason = reason; this.signal._listeners.forEach(fn => fn()); }
20450 }
20451 globalThis.AbortController = AbortController;
20452 globalThis.AbortSignal = AbortSignal;
20453 }
20454
20455 globalThis.fetch = async (input, init) => {
20456 const url = typeof input === 'string' ? input : String(input && input.url ? input.url : input);
20457 const options = init && typeof init === 'object' ? init : {};
20458 const method = options.method ? String(options.method) : 'GET';
20459
20460 const headers = {};
20461 if (options.headers && typeof options.headers === 'object') {
20462 if (options.headers instanceof Headers) {
20463 for (const [k, v] of options.headers.entries()) headers[k] = v;
20464 } else if (Array.isArray(options.headers)) {
20465 for (const pair of options.headers) {
20466 if (pair && pair.length >= 2) headers[String(pair[0])] = String(pair[1]);
20467 }
20468 } else {
20469 for (const k of Object.keys(options.headers)) {
20470 headers[k] = String(options.headers[k]);
20471 }
20472 }
20473 }
20474
20475 let body = undefined;
20476 let body_bytes = undefined;
20477 if (options.body !== undefined && options.body !== null) {
20478 const encoded = __pi_fetch_body_bytes_to_base64(options.body);
20479 if (encoded !== null) {
20480 body_bytes = encoded;
20481 } else {
20482 body = typeof options.body === 'string' ? options.body : String(options.body);
20483 }
20484 }
20485
20486 const request = { url, method, headers };
20487 if (body !== undefined) request.body = body;
20488 if (body_bytes !== undefined) request.body_bytes = body_bytes;
20489
20490 const resp = await pi.http(request);
20491 const status = resp && resp.status !== undefined ? Number(resp.status) : 0;
20492 const respHeaders = resp && resp.headers && typeof resp.headers === 'object' ? resp.headers : {};
20493
20494 let bytes = new Uint8Array();
20495 if (resp && resp.body_bytes) {
20496 const bin = __pi_base64_decode_native(String(resp.body_bytes));
20497 const out = new Uint8Array(bin.length);
20498 for (let i = 0; i < bin.length; i++) {
20499 out[i] = bin.charCodeAt(i) & 0xff;
20500 }
20501 bytes = out;
20502 } else if (resp && resp.body !== undefined && resp.body !== null) {
20503 bytes = new TextEncoder().encode(String(resp.body));
20504 }
20505
20506 return new Response(bytes, { status, headers: respHeaders });
20507 };
20508}
20509";
20510
20511#[cfg(test)]
20512#[allow(clippy::future_not_send)]
20513mod tests {
20514 use super::*;
20515 use crate::scheduler::DeterministicClock;
20516
20517 #[allow(clippy::future_not_send)]
20518 async fn get_global_json<C: SchedulerClock + 'static>(
20519 runtime: &PiJsRuntime<C>,
20520 name: &str,
20521 ) -> serde_json::Value {
20522 runtime
20523 .context
20524 .with(|ctx| {
20525 let global = ctx.globals();
20526 let value: Value<'_> = global.get(name)?;
20527 js_to_json(&value)
20528 })
20529 .await
20530 .expect("js context")
20531 }
20532
20533 #[allow(clippy::future_not_send)]
20534 async fn call_global_fn_json<C: SchedulerClock + 'static>(
20535 runtime: &PiJsRuntime<C>,
20536 name: &str,
20537 ) -> serde_json::Value {
20538 runtime
20539 .context
20540 .with(|ctx| {
20541 let global = ctx.globals();
20542 let function: Function<'_> = global.get(name)?;
20543 let value: Value<'_> = function.call(())?;
20544 js_to_json(&value)
20545 })
20546 .await
20547 .expect("js context")
20548 }
20549
20550 #[allow(clippy::future_not_send)]
20551 async fn runtime_with_sync_exec_enabled(
20552 clock: Arc<DeterministicClock>,
20553 ) -> PiJsRuntime<Arc<DeterministicClock>> {
20554 let config = PiJsRuntimeConfig {
20555 allow_unsafe_sync_exec: true,
20556 ..PiJsRuntimeConfig::default()
20557 };
20558 PiJsRuntime::with_clock_and_config_with_policy(clock, config, None)
20559 .await
20560 .expect("create runtime")
20561 }
20562
20563 #[allow(clippy::future_not_send)]
20564 async fn drain_until_idle(
20565 runtime: &PiJsRuntime<Arc<DeterministicClock>>,
20566 clock: &Arc<DeterministicClock>,
20567 ) {
20568 for _ in 0..10_000 {
20569 if !runtime.has_pending() {
20570 break;
20571 }
20572
20573 let stats = runtime.tick().await.expect("tick");
20574 if stats.ran_macrotask {
20575 continue;
20576 }
20577
20578 let next_deadline = runtime.scheduler.borrow().next_timer_deadline();
20579 let Some(next_deadline) = next_deadline else {
20580 break;
20581 };
20582
20583 let now = runtime.now_ms();
20584 assert!(
20585 next_deadline > now,
20586 "expected future timer deadline (deadline={next_deadline}, now={now})"
20587 );
20588 clock.set(next_deadline);
20589 }
20590 }
20591
20592 #[test]
20593 fn extract_static_require_specifiers_skips_literals_and_comments() {
20594 let source = r#"
20595const fs = require("fs");
20596const text = "require('left-pad')";
20597const tpl = `require("ajv/dist/runtime/validation_error").default`;
20598// require("zlib")
20599/* require("tty") */
20600const path = require('path');
20601"#;
20602
20603 let specifiers = extract_static_require_specifiers(source);
20604 assert_eq!(specifiers, vec!["fs".to_string(), "path".to_string()]);
20605 }
20606
20607 #[test]
20608 fn maybe_cjs_to_esm_ignores_codegen_string_requires() {
20609 let source = r#"
20610const fs = require("fs");
20611const generated = `require("ajv/dist/runtime/validation_error").default`;
20612module.exports = { fs, generated };
20613"#;
20614
20615 let rewritten = maybe_cjs_to_esm(source);
20616 assert!(rewritten.contains(r#"from "fs";"#));
20617 assert!(!rewritten.contains(r#"from "ajv/dist/runtime/validation_error";"#));
20618 }
20619
20620 #[test]
20621 fn maybe_cjs_to_esm_leaves_doom_style_dirname_module_alone() {
20622 let source = r#"
20623import { dirname, join } from "node:path";
20624import { fileURLToPath } from "node:url";
20625
20626const __dirname = dirname(fileURLToPath(import.meta.url));
20627export const bundled = join(__dirname, "doom1.wad");
20628"#;
20629
20630 let rewritten = maybe_cjs_to_esm(source);
20631 assert!(
20632 !rewritten.contains("const __filename ="),
20633 "declared __dirname should not trigger __filename shim:\n{rewritten}"
20634 );
20635 assert!(
20636 !rewritten.contains("const __dirname = (() =>"),
20637 "declared __dirname should not be replaced:\n{rewritten}"
20638 );
20639 }
20640
20641 #[test]
20642 fn source_declares_binding_detects_inline_const_binding() {
20643 let source = r#"import { dirname } from "node:path"; const __dirname = dirname("/tmp/demo"); export const bundled = __dirname;"#;
20644 assert!(source_declares_binding(source, "__dirname"));
20645 }
20646
20647 #[test]
20648 fn source_declares_binding_ignores_nested_bindings() {
20649 let source = r#"
20650const topLevel = true;
20651function outer() {
20652 function module() {}
20653 var exports = {};
20654 const require = () => "nested";
20655 return { module, exports, require };
20656}
20657"#;
20658
20659 assert!(!source_declares_binding(source, "module"));
20660 assert!(!source_declares_binding(source, "exports"));
20661 assert!(!source_declares_binding(source, "require"));
20662 }
20663
20664 #[test]
20665 fn maybe_cjs_to_esm_injects_module_for_nested_false_positive_bundle_bindings() {
20666 let source = concat!(
20667 "\n",
20668 "var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);\n",
20669 "var require_demo = __commonJS((exports, module) => {\n",
20670 " module.exports = { ok: true };\n",
20671 "});\n",
20672 "function outer() {\n",
20673 " function module() {}\n",
20674 " var exports = {};\n",
20675 "}\n",
20676 "export const loaded = require_demo();\n",
20677 );
20678
20679 let rewritten = maybe_cjs_to_esm(source);
20680 assert!(
20681 rewritten.contains("const module = { exports: {} };"),
20682 "nested bundle bindings must not suppress the CJS module shim:\n{rewritten}"
20683 );
20684 assert!(
20685 rewritten.contains("const exports = module.exports;"),
20686 "nested bundle bindings must not suppress the CJS exports shim:\n{rewritten}"
20687 );
20688 }
20689
20690 #[test]
20691 fn maybe_cjs_to_esm_leaves_inline_doom_style_dirname_module_alone() {
20692 let source = r#"import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); export const bundled = join(__dirname, "doom1.wad");"#;
20693
20694 let rewritten = maybe_cjs_to_esm(source);
20695 assert!(
20696 !rewritten.contains("const __filename ="),
20697 "inline declared __dirname should not trigger __filename shim:\n{rewritten}"
20698 );
20699 assert!(
20700 !rewritten.contains("const __dirname = (() =>"),
20701 "inline declared __dirname should not be replaced:\n{rewritten}"
20702 );
20703 }
20704
20705 #[test]
20706 fn maybe_cjs_to_esm_injects_dirname_without_filename_for_free_dirname() {
20707 let source = r"
20708export const currentDir = __dirname;
20709";
20710
20711 let rewritten = maybe_cjs_to_esm(source);
20712 assert!(
20713 rewritten.contains("const __dirname = (() =>"),
20714 "free __dirname should get a dirname shim:\n{rewritten}"
20715 );
20716 assert!(
20717 !rewritten.contains("const __filename ="),
20718 "free __dirname alone should not force a __filename shim:\n{rewritten}"
20719 );
20720 }
20721
20722 #[test]
20723 fn extract_import_names_handles_default_plus_named_imports() {
20724 let source = r#"
20725import Ajv, {
20726 KeywordDefinition,
20727 type AnySchema,
20728 ValidationError as AjvValidationError,
20729} from "ajv";
20730"#;
20731
20732 let names = extract_import_names(source, "ajv");
20733 assert_eq!(
20734 names,
20735 vec![
20736 "KeywordDefinition".to_string(),
20737 "ValidationError".to_string()
20738 ]
20739 );
20740 }
20741
20742 #[test]
20743 fn extract_builtin_import_names_collects_node_aliases() {
20744 let source = r#"
20745import { isIP } from "net";
20746import { isIPv4 as netIsIpv4 } from "node:net";
20747"#;
20748 let names = extract_builtin_import_names(source, "node:net", "node:net");
20749 assert_eq!(
20750 names.into_iter().collect::<Vec<_>>(),
20751 vec!["isIP".to_string(), "isIPv4".to_string()]
20752 );
20753 }
20754
20755 #[test]
20756 fn builtin_overlay_generation_scopes_exports_per_importing_module() {
20757 let temp_dir = tempfile::tempdir().expect("tempdir");
20758 let base_a = temp_dir.path().join("a.mjs");
20759 let base_b = temp_dir.path().join("b.mjs");
20760 std::fs::write(&base_a, r#"import { isIP } from "net";"#).expect("write a");
20761 std::fs::write(&base_b, r#"import { isIPv6 } from "node:net";"#).expect("write b");
20762
20763 let mut state = PiJsModuleState::new();
20764 let overlay_a = maybe_register_builtin_compat_overlay(
20765 &mut state,
20766 base_a.to_string_lossy().as_ref(),
20767 "net",
20768 "node:net",
20769 )
20770 .expect("overlay key for a");
20771 let overlay_b = maybe_register_builtin_compat_overlay(
20772 &mut state,
20773 base_b.to_string_lossy().as_ref(),
20774 "node:net",
20775 "node:net",
20776 )
20777 .expect("overlay key for b");
20778 assert!(overlay_a.starts_with("pijs-compat://builtin/node:net/"));
20779 assert!(overlay_b.starts_with("pijs-compat://builtin/node:net/"));
20780 assert_ne!(overlay_a, overlay_b);
20781
20782 let exported_names_a = state
20783 .dynamic_virtual_named_exports
20784 .get(&overlay_a)
20785 .expect("export names for a");
20786 assert!(exported_names_a.contains("isIP"));
20787 assert!(!exported_names_a.contains("isIPv6"));
20788
20789 let exported_names_b = state
20790 .dynamic_virtual_named_exports
20791 .get(&overlay_b)
20792 .expect("export names for b");
20793 assert!(exported_names_b.contains("isIPv6"));
20794 assert!(!exported_names_b.contains("isIP"));
20795
20796 let overlay_source_a = state
20797 .dynamic_virtual_modules
20798 .get(&overlay_a)
20799 .expect("overlay source for a");
20800 assert!(overlay_source_a.contains(r#"import * as __pijs_builtin_ns from "node:net";"#));
20801 assert!(overlay_source_a.contains("export const isIP ="));
20802 assert!(!overlay_source_a.contains("export const isIPv6 ="));
20803
20804 let overlay_source_b = state
20805 .dynamic_virtual_modules
20806 .get(&overlay_b)
20807 .expect("overlay source for b");
20808 assert!(overlay_source_b.contains("export const isIPv6 ="));
20809 assert!(!overlay_source_b.contains("export const isIP ="));
20810 }
20811
20812 #[test]
20813 fn hostcall_completions_run_before_due_timers() {
20814 let clock = Arc::new(ManualClock::new(1_000));
20815 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
20816
20817 let _timer = loop_state.set_timeout(0);
20818 loop_state.enqueue_hostcall_completion("call-1");
20819
20820 let mut seen = Vec::new();
20821 let result = loop_state.tick(|task| seen.push(task.kind), || false);
20822
20823 assert!(result.ran_macrotask);
20824 assert_eq!(
20825 seen,
20826 vec![MacrotaskKind::HostcallComplete {
20827 call_id: "call-1".to_string()
20828 }]
20829 );
20830 }
20831
20832 #[test]
20833 fn hostcall_request_queue_spills_to_overflow_with_stable_order() {
20834 fn req(id: usize) -> HostcallRequest {
20835 HostcallRequest {
20836 call_id: format!("call-{id}"),
20837 kind: HostcallKind::Log,
20838 payload: serde_json::json!({ "n": id }),
20839 trace_id: u64::try_from(id).unwrap_or(u64::MAX),
20840 extension_id: Some("ext.queue".to_string()),
20841 }
20842 }
20843
20844 let mut queue = HostcallRequestQueue::with_capacities(2, 4);
20845 assert!(matches!(
20846 queue.push_back(req(0)),
20847 HostcallQueueEnqueueResult::FastPath { .. }
20848 ));
20849 assert!(matches!(
20850 queue.push_back(req(1)),
20851 HostcallQueueEnqueueResult::FastPath { .. }
20852 ));
20853 assert!(matches!(
20854 queue.push_back(req(2)),
20855 HostcallQueueEnqueueResult::OverflowPath { .. }
20856 ));
20857
20858 let snapshot = queue.snapshot();
20859 assert_eq!(snapshot.fast_depth, 2);
20860 assert_eq!(snapshot.overflow_depth, 1);
20861 assert_eq!(snapshot.total_depth, 3);
20862 assert_eq!(snapshot.overflow_enqueued_total, 1);
20863
20864 let drained = queue.drain_all();
20865 let drained_ids: Vec<_> = drained.into_iter().map(|item| item.call_id).collect();
20866 assert_eq!(
20867 drained_ids,
20868 vec![
20869 "call-0".to_string(),
20870 "call-1".to_string(),
20871 "call-2".to_string()
20872 ]
20873 );
20874 }
20875
20876 #[test]
20877 fn hostcall_request_queue_rejects_when_overflow_capacity_reached() {
20878 fn req(id: usize) -> HostcallRequest {
20879 HostcallRequest {
20880 call_id: format!("reject-{id}"),
20881 kind: HostcallKind::Log,
20882 payload: serde_json::json!({ "n": id }),
20883 trace_id: u64::try_from(id).unwrap_or(u64::MAX),
20884 extension_id: None,
20885 }
20886 }
20887
20888 let mut queue = HostcallRequestQueue::with_capacities(1, 1);
20889 assert!(matches!(
20890 queue.push_back(req(0)),
20891 HostcallQueueEnqueueResult::FastPath { .. }
20892 ));
20893 assert!(matches!(
20894 queue.push_back(req(1)),
20895 HostcallQueueEnqueueResult::OverflowPath { .. }
20896 ));
20897 let reject = queue.push_back(req(2));
20898 assert!(matches!(
20899 reject,
20900 HostcallQueueEnqueueResult::Rejected { .. }
20901 ));
20902
20903 let snapshot = queue.snapshot();
20904 assert_eq!(snapshot.total_depth, 2);
20905 assert_eq!(snapshot.overflow_depth, 1);
20906 assert_eq!(snapshot.overflow_rejected_total, 1);
20907 }
20908
20909 #[test]
20910 fn timers_order_by_deadline_then_schedule_seq() {
20911 let clock = Arc::new(ManualClock::new(0));
20912 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock.clone()));
20913
20914 let t1 = loop_state.set_timeout(10);
20915 let t2 = loop_state.set_timeout(10);
20916 let t3 = loop_state.set_timeout(5);
20917 clock.set(10);
20918
20919 let mut fired = Vec::new();
20920 for _ in 0..3 {
20921 loop_state.tick(
20922 |task| {
20923 if let MacrotaskKind::TimerFired { timer_id } = task.kind {
20924 fired.push(timer_id);
20925 }
20926 },
20927 || false,
20928 );
20929 }
20930
20931 assert_eq!(fired, vec![t3, t1, t2]);
20932 }
20933
20934 #[test]
20935 fn clear_timeout_prevents_fire() {
20936 let clock = Arc::new(ManualClock::new(0));
20937 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock.clone()));
20938
20939 let timer_id = loop_state.set_timeout(5);
20940 assert!(loop_state.clear_timeout(timer_id));
20941 clock.set(10);
20942
20943 let mut fired = Vec::new();
20944 let result = loop_state.tick(
20945 |task| {
20946 if let MacrotaskKind::TimerFired { timer_id } = task.kind {
20947 fired.push(timer_id);
20948 }
20949 },
20950 || false,
20951 );
20952
20953 assert!(!result.ran_macrotask);
20954 assert!(fired.is_empty());
20955 }
20956
20957 #[test]
20958 fn clear_timeout_nonexistent_returns_false_and_does_not_pollute_cancelled_set() {
20959 let clock = Arc::new(ManualClock::new(0));
20960 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
20961
20962 assert!(!loop_state.clear_timeout(42));
20963 assert!(
20964 loop_state.cancelled_timers.is_empty(),
20965 "unknown timer ids should not be retained"
20966 );
20967 }
20968
20969 #[test]
20970 fn clear_timeout_double_cancel_returns_false() {
20971 let clock = Arc::new(ManualClock::new(0));
20972 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
20973
20974 let timer_id = loop_state.set_timeout(10);
20975 assert!(loop_state.clear_timeout(timer_id));
20976 assert!(!loop_state.clear_timeout(timer_id));
20977 }
20978
20979 #[test]
20980 fn pi_event_loop_timer_id_saturates_at_u64_max() {
20981 let clock = Arc::new(ManualClock::new(0));
20982 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
20983 loop_state.next_timer_id = u64::MAX;
20984
20985 let first = loop_state.set_timeout(10);
20986 let second = loop_state.set_timeout(20);
20987
20988 assert_eq!(first, u64::MAX);
20989 assert_eq!(second, u64::MAX);
20990 }
20991
20992 #[test]
20993 fn audit_ledger_sequence_saturates_at_u64_max() {
20994 let mut ledger = AuditLedger::new();
20995 ledger.next_sequence = u64::MAX;
20996
20997 let first = ledger.append(
20998 1_700_000_000_000,
20999 "ext-a",
21000 AuditEntryKind::Analysis,
21001 "first".to_string(),
21002 Vec::new(),
21003 );
21004 let second = ledger.append(
21005 1_700_000_000_100,
21006 "ext-a",
21007 AuditEntryKind::ProposalGenerated,
21008 "second".to_string(),
21009 Vec::new(),
21010 );
21011
21012 assert_eq!(first, u64::MAX);
21013 assert_eq!(second, u64::MAX);
21014 assert_eq!(ledger.len(), 2);
21015 }
21016
21017 #[test]
21018 fn microtasks_drain_to_fixpoint_after_macrotask() {
21019 let clock = Arc::new(ManualClock::new(0));
21020 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
21021
21022 loop_state.enqueue_inbound_event("evt-1");
21023
21024 let mut drain_calls = 0;
21025 let result = loop_state.tick(
21026 |_task| {},
21027 || {
21028 drain_calls += 1;
21029 drain_calls <= 2
21030 },
21031 );
21032
21033 assert!(result.ran_macrotask);
21034 assert_eq!(result.microtasks_drained, 2);
21035 assert_eq!(drain_calls, 3);
21036 }
21037
21038 #[test]
21039 fn compile_module_source_reports_missing_file() {
21040 let temp_dir = tempfile::tempdir().expect("tempdir");
21041 let missing_path = temp_dir.path().join("missing.js");
21042 let err = compile_module_source(
21043 &HashMap::new(),
21044 &HashMap::new(),
21045 missing_path.to_string_lossy().as_ref(),
21046 )
21047 .expect_err("missing module should error");
21048 let message = err.to_string();
21049 assert!(
21050 message.contains("Module is not a file"),
21051 "unexpected error: {message}"
21052 );
21053 }
21054
21055 #[test]
21056 fn compile_module_source_reports_unsupported_extension() {
21057 let temp_dir = tempfile::tempdir().expect("tempdir");
21058 let bad_path = temp_dir.path().join("module.txt");
21059 std::fs::write(&bad_path, "hello").expect("write module.txt");
21060
21061 let err = compile_module_source(
21062 &HashMap::new(),
21063 &HashMap::new(),
21064 bad_path.to_string_lossy().as_ref(),
21065 )
21066 .expect_err("unsupported extension should error");
21067 let message = err.to_string();
21068 assert!(
21069 message.contains("Unsupported module extension"),
21070 "unexpected error: {message}"
21071 );
21072 }
21073
21074 #[test]
21075 fn module_cache_key_changes_when_virtual_module_changes() {
21076 let static_modules = HashMap::new();
21077 let mut dynamic_modules = HashMap::new();
21078 dynamic_modules.insert("pijs://virt".to_string(), "export const x = 1;".to_string());
21079
21080 let key_before = module_cache_key(&static_modules, &dynamic_modules, "pijs://virt")
21081 .expect("virtual key should exist");
21082
21083 dynamic_modules.insert("pijs://virt".to_string(), "export const x = 2;".to_string());
21084 let key_after = module_cache_key(&static_modules, &dynamic_modules, "pijs://virt")
21085 .expect("virtual key should exist");
21086
21087 assert_ne!(key_before, key_after);
21088 }
21089
21090 #[test]
21091 fn module_cache_key_changes_when_file_size_changes() {
21092 let temp_dir = tempfile::tempdir().expect("tempdir");
21093 let module_path = temp_dir.path().join("module.js");
21094 std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
21095 let name = module_path.to_string_lossy().to_string();
21096
21097 let key_before =
21098 module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("file key");
21099
21100 std::fs::write(&module_path, "export const xyz = 123456;\n").expect("rewrite module");
21101 let key_after =
21102 module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("file key");
21103
21104 assert_ne!(key_before, key_after);
21105 }
21106
21107 #[test]
21108 fn load_compiled_module_source_tracks_hit_miss_and_invalidation_counters() {
21109 let temp_dir = tempfile::tempdir().expect("tempdir");
21110 let module_path = temp_dir.path().join("module.js");
21111 std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
21112 let name = module_path.to_string_lossy().to_string();
21113
21114 let mut state = PiJsModuleState::new();
21115
21116 let _first = load_compiled_module_source(&mut state, &name).expect("first compile");
21117 assert_eq!(state.module_cache_counters.hits, 0);
21118 assert_eq!(state.module_cache_counters.misses, 1);
21119 assert_eq!(state.module_cache_counters.invalidations, 0);
21120 assert_eq!(state.compiled_sources.len(), 1);
21121
21122 let _second = load_compiled_module_source(&mut state, &name).expect("cache hit");
21123 assert_eq!(state.module_cache_counters.hits, 1);
21124 assert_eq!(state.module_cache_counters.misses, 1);
21125 assert_eq!(state.module_cache_counters.invalidations, 0);
21126
21127 std::fs::write(&module_path, "export const xyz = 123456;\n").expect("rewrite module");
21128 let _third = load_compiled_module_source(&mut state, &name).expect("recompile");
21129 assert_eq!(state.module_cache_counters.hits, 1);
21130 assert_eq!(state.module_cache_counters.misses, 2);
21131 assert_eq!(state.module_cache_counters.invalidations, 1);
21132 }
21133
21134 #[test]
21135 fn load_compiled_module_source_uses_disk_cache_between_states() {
21136 let temp_dir = tempfile::tempdir().expect("tempdir");
21137 let cache_dir = temp_dir.path().join("cache");
21138 let module_path = temp_dir.path().join("module.js");
21139 std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
21140 let name = module_path.to_string_lossy().to_string();
21141
21142 let mut first_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir.clone()));
21143 let first = load_compiled_module_source(&mut first_state, &name).expect("first compile");
21144 assert_eq!(first_state.module_cache_counters.misses, 1);
21145 assert_eq!(first_state.module_cache_counters.disk_hits, 0);
21146
21147 let key = module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("file key");
21148 let cache_path = disk_cache_path(&cache_dir, &key);
21149 assert!(
21150 cache_path.exists(),
21151 "expected persisted cache at {cache_path:?}"
21152 );
21153
21154 let mut second_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir));
21155 let second =
21156 load_compiled_module_source(&mut second_state, &name).expect("load from disk cache");
21157 assert_eq!(second_state.module_cache_counters.disk_hits, 1);
21158 assert_eq!(second_state.module_cache_counters.misses, 0);
21159 assert_eq!(second_state.module_cache_counters.hits, 0);
21160 assert_eq!(first, second);
21161 }
21162
21163 #[test]
21164 fn load_compiled_module_source_disk_cache_invalidates_when_file_changes() {
21165 let temp_dir = tempfile::tempdir().expect("tempdir");
21166 let cache_dir = temp_dir.path().join("cache");
21167 let module_path = temp_dir.path().join("module.js");
21168 std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
21169 let name = module_path.to_string_lossy().to_string();
21170
21171 let mut prime_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir.clone()));
21172 let first = load_compiled_module_source(&mut prime_state, &name).expect("first compile");
21173 let first_key = module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("key");
21174
21175 std::fs::write(
21176 &module_path,
21177 "export const xyz = 1234567890;\nexport const more = true;\n",
21178 )
21179 .expect("rewrite module");
21180 let second_key = module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("key");
21181 assert_ne!(first_key, second_key);
21182
21183 let mut second_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir));
21184 let second = load_compiled_module_source(&mut second_state, &name).expect("recompile");
21185 assert_eq!(second_state.module_cache_counters.disk_hits, 0);
21186 assert_eq!(second_state.module_cache_counters.misses, 1);
21187 assert_ne!(first, second);
21188 }
21189
21190 #[test]
21191 fn warm_reset_clears_extension_registry_state() {
21192 futures::executor::block_on(async {
21193 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
21194 .await
21195 .expect("create runtime");
21196
21197 runtime
21198 .eval(
21199 r#"
21200 __pi_begin_extension("ext.reset", { name: "ext.reset" });
21201 pi.registerTool({
21202 name: "warm_reset_tool",
21203 execute: async (_callId, _input) => ({ ok: true }),
21204 });
21205 pi.registerCommand("warm_reset_cmd", {
21206 handler: async (_args, _ctx) => ({ ok: true }),
21207 });
21208 pi.on("startup", async () => {});
21209 __pi_end_extension();
21210 "#,
21211 )
21212 .await
21213 .expect("register extension state");
21214
21215 let before = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
21216 assert_eq!(before["extensions"], serde_json::json!(1));
21217 assert_eq!(before["tools"], serde_json::json!(1));
21218 assert_eq!(before["commands"], serde_json::json!(1));
21219
21220 let report = runtime
21221 .reset_for_warm_reload()
21222 .await
21223 .expect("warm reset should run");
21224 assert!(report.reused, "expected warm reuse, got report: {report:?}");
21225 assert!(
21226 report.reason_code.is_none(),
21227 "unexpected warm-reset reason: {:?}",
21228 report.reason_code
21229 );
21230
21231 let after = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
21232 assert_eq!(after["extensions"], serde_json::json!(0));
21233 assert_eq!(after["tools"], serde_json::json!(0));
21234 assert_eq!(after["commands"], serde_json::json!(0));
21235 assert_eq!(after["hooks"], serde_json::json!(0));
21236 assert_eq!(after["pendingTasks"], serde_json::json!(0));
21237 assert_eq!(after["pendingHostcalls"], serde_json::json!(0));
21238 });
21239 }
21240
21241 #[test]
21242 fn warm_reset_reports_pending_rust_work() {
21243 futures::executor::block_on(async {
21244 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
21245 .await
21246 .expect("create runtime");
21247 let _timer = runtime.set_timeout(10);
21248
21249 let report = runtime
21250 .reset_for_warm_reload()
21251 .await
21252 .expect("warm reset should return report");
21253 assert!(!report.reused);
21254 assert_eq!(report.reason_code.as_deref(), Some("pending_rust_work"));
21255 });
21256 }
21257
21258 #[test]
21259 fn warm_reset_reports_pending_js_work() {
21260 futures::executor::block_on(async {
21261 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
21262 .await
21263 .expect("create runtime");
21264
21265 runtime
21266 .eval(
21267 r#"
21268 __pi_tasks.set("pending-task", { status: "pending" });
21269 "#,
21270 )
21271 .await
21272 .expect("inject pending JS task");
21273
21274 let report = runtime
21275 .reset_for_warm_reload()
21276 .await
21277 .expect("warm reset should return report");
21278 assert!(!report.reused);
21279 assert_eq!(report.reason_code.as_deref(), Some("pending_js_work"));
21280
21281 let after = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
21282 assert_eq!(after["pendingTasks"], serde_json::json!(0));
21283 });
21284 }
21285
21286 #[test]
21287 #[allow(clippy::too_many_lines)]
21288 fn reset_transient_state_preserves_compiled_cache_and_clears_transient_state() {
21289 futures::executor::block_on(async {
21290 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
21291 .await
21292 .expect("create runtime");
21293
21294 let cache_key = "pijs://virtual".to_string();
21295 {
21296 let mut state = runtime.module_state.borrow_mut();
21297 let extension_root = PathBuf::from("/tmp/ext-root");
21298 state.extension_roots.push(extension_root.clone());
21299 state
21300 .extension_root_tiers
21301 .insert(extension_root.clone(), ProxyStubSourceTier::Community);
21302 state
21303 .extension_root_scopes
21304 .insert(extension_root, "@scope".to_string());
21305 state
21306 .dynamic_virtual_modules
21307 .insert(cache_key.clone(), "export const v = 1;".to_string());
21308 let mut exports = BTreeSet::new();
21309 exports.insert("v".to_string());
21310 state
21311 .dynamic_virtual_named_exports
21312 .insert(cache_key.clone(), exports);
21313 state.compiled_sources.insert(
21314 cache_key.clone(),
21315 CompiledModuleCacheEntry {
21316 cache_key: Some("cache-v1".to_string()),
21317 source: b"compiled-source".to_vec().into(),
21318 },
21319 );
21320 state.module_cache_counters = ModuleCacheCounters {
21321 hits: 3,
21322 misses: 4,
21323 invalidations: 5,
21324 disk_hits: 6,
21325 };
21326 }
21327
21328 runtime
21329 .hostcall_queue
21330 .borrow_mut()
21331 .push_back(HostcallRequest {
21332 call_id: "call-1".to_string(),
21333 kind: HostcallKind::Tool {
21334 name: "read".to_string(),
21335 },
21336 payload: serde_json::json!({}),
21337 trace_id: 1,
21338 extension_id: Some("ext.reset".to_string()),
21339 });
21340 runtime
21341 .hostcall_tracker
21342 .borrow_mut()
21343 .register("call-1".to_string(), Some(42), 0);
21344 runtime
21345 .hostcalls_total
21346 .store(11, std::sync::atomic::Ordering::SeqCst);
21347 runtime
21348 .hostcalls_timed_out
21349 .store(2, std::sync::atomic::Ordering::SeqCst);
21350 runtime
21351 .tick_counter
21352 .store(7, std::sync::atomic::Ordering::SeqCst);
21353
21354 runtime.reset_transient_state();
21355
21356 {
21357 let state = runtime.module_state.borrow();
21358 assert!(state.extension_roots.is_empty());
21359 assert!(state.canonical_extension_roots.is_empty());
21360 assert!(state.extension_root_tiers.is_empty());
21361 assert!(state.extension_root_scopes.is_empty());
21362 assert!(state.extension_roots_by_id.is_empty());
21363 assert!(state.extension_roots_without_id.is_empty());
21364 assert!(state.dynamic_virtual_modules.is_empty());
21365 assert!(state.dynamic_virtual_named_exports.is_empty());
21366
21367 let cached = state
21368 .compiled_sources
21369 .get(&cache_key)
21370 .expect("compiled source should persist across reset");
21371 assert_eq!(cached.cache_key.as_deref(), Some("cache-v1"));
21372 assert_eq!(cached.source.as_ref(), b"compiled-source");
21373
21374 assert_eq!(state.module_cache_counters.hits, 0);
21375 assert_eq!(state.module_cache_counters.misses, 0);
21376 assert_eq!(state.module_cache_counters.invalidations, 0);
21377 assert_eq!(state.module_cache_counters.disk_hits, 0);
21378 }
21379
21380 assert!(runtime.hostcall_queue.borrow().is_empty());
21381 assert_eq!(runtime.hostcall_tracker.borrow().pending_count(), 0);
21382 assert_eq!(
21383 runtime
21384 .hostcalls_total
21385 .load(std::sync::atomic::Ordering::SeqCst),
21386 0
21387 );
21388 assert_eq!(
21389 runtime
21390 .hostcalls_timed_out
21391 .load(std::sync::atomic::Ordering::SeqCst),
21392 0
21393 );
21394 assert_eq!(
21395 runtime
21396 .tick_counter
21397 .load(std::sync::atomic::Ordering::SeqCst),
21398 0
21399 );
21400 });
21401 }
21402
21403 #[test]
21404 fn warm_isolate_pool_tracks_created_and_reset_counts() {
21405 let cache_dir = tempfile::tempdir().expect("tempdir");
21406 let template = PiJsRuntimeConfig {
21407 cwd: "/tmp/warm-pool".to_string(),
21408 args: vec!["--flag".to_string()],
21409 env: HashMap::from([("PI_POOL".to_string(), "yes".to_string())]),
21410 deny_env: false,
21411 disk_cache_dir: Some(cache_dir.path().join("module-cache")),
21412 ..PiJsRuntimeConfig::default()
21413 };
21414 let expected_disk_cache_dir = template.disk_cache_dir.clone();
21415
21416 let pool = WarmIsolatePool::new(template.clone());
21417 assert_eq!(pool.created_count(), 0);
21418 assert_eq!(pool.reset_count(), 0);
21419
21420 let cfg_a = pool.make_config();
21421 let cfg_b = pool.make_config();
21422 assert_eq!(pool.created_count(), 2);
21423 assert_eq!(cfg_a.cwd, template.cwd);
21424 assert_eq!(cfg_b.cwd, template.cwd);
21425 assert_eq!(cfg_a.args, template.args);
21426 assert_eq!(cfg_a.env.get("PI_POOL"), Some(&"yes".to_string()));
21427 assert_eq!(cfg_a.deny_env, template.deny_env);
21428 assert_eq!(cfg_a.disk_cache_dir, expected_disk_cache_dir);
21429
21430 pool.record_reset();
21431 pool.record_reset();
21432 assert_eq!(pool.reset_count(), 2);
21433 }
21434
21435 #[test]
21436 fn warm_reset_clears_canonical_and_per_extension_roots() {
21437 futures::executor::block_on(async {
21438 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
21439 .await
21440 .expect("create runtime");
21441
21442 let temp_dir = tempfile::tempdir().expect("tempdir");
21443 let root = temp_dir.path().join("ext");
21444 std::fs::create_dir_all(&root).expect("mkdir ext");
21445 runtime.add_extension_root_with_id(root.clone(), Some("ext.reset.roots"));
21446
21447 let report = runtime
21448 .reset_for_warm_reload()
21449 .await
21450 .expect("warm reset should run");
21451 assert!(report.reused, "expected warm reuse, got report: {report:?}");
21452
21453 let state = runtime.module_state.borrow();
21454 assert!(state.extension_roots.is_empty());
21455 assert!(state.canonical_extension_roots.is_empty());
21456 assert!(state.extension_roots_by_id.is_empty());
21457 assert!(state.extension_roots_without_id.is_empty());
21458 });
21459 }
21460
21461 #[test]
21462 fn resolver_error_messages_are_classified_deterministically() {
21463 assert_eq!(
21464 unsupported_module_specifier_message("left-pad"),
21465 "Package module specifiers are not supported in PiJS: left-pad"
21466 );
21467 assert_eq!(
21468 unsupported_module_specifier_message("https://example.com/mod.js"),
21469 "Network module imports are not supported in PiJS: https://example.com/mod.js"
21470 );
21471 assert_eq!(
21472 unsupported_module_specifier_message("pi:internal/foo"),
21473 "Unsupported module specifier: pi:internal/foo"
21474 );
21475 }
21476
21477 #[test]
21478 fn resolve_module_path_uses_documented_candidate_order() {
21479 let temp_dir = tempfile::tempdir().expect("tempdir");
21480 let root = temp_dir.path();
21481 let base = root.join("entry.ts");
21482 std::fs::write(&base, "export {};\n").expect("write base");
21483
21484 let pkg_dir = root.join("pkg");
21485 std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg");
21486 let pkg_index_js = pkg_dir.join("index.js");
21487 let pkg_index_ts = pkg_dir.join("index.ts");
21488 std::fs::write(&pkg_index_js, "export const js = true;\n").expect("write index.js");
21489 std::fs::write(&pkg_index_ts, "export const ts = true;\n").expect("write index.ts");
21490
21491 let module_js = root.join("module.js");
21492 let module_ts = root.join("module.ts");
21493 std::fs::write(&module_js, "export const js = true;\n").expect("write module.js");
21494 std::fs::write(&module_ts, "export const ts = true;\n").expect("write module.ts");
21495
21496 let only_json = root.join("only_json.json");
21497 std::fs::write(&only_json, "{\"ok\":true}\n").expect("write only_json.json");
21498
21499 let mode = RepairMode::default();
21500 let roots = [root.to_path_buf()];
21501 let canonical_roots = roots
21502 .iter()
21503 .map(|p| crate::extensions::safe_canonicalize(p))
21504 .collect::<Vec<_>>();
21505
21506 let resolved_pkg = resolve_module_path(
21507 base.to_string_lossy().as_ref(),
21508 "./pkg",
21509 mode,
21510 &canonical_roots,
21511 )
21512 .expect("resolve ./pkg");
21513 assert_eq!(resolved_pkg, pkg_index_ts);
21514
21515 let resolved_module = resolve_module_path(
21516 base.to_string_lossy().as_ref(),
21517 "./module",
21518 mode,
21519 &canonical_roots,
21520 )
21521 .expect("resolve ./module");
21522 assert_eq!(resolved_module, module_ts);
21523
21524 let resolved_json = resolve_module_path(
21525 base.to_string_lossy().as_ref(),
21526 "./only_json",
21527 mode,
21528 &canonical_roots,
21529 )
21530 .expect("resolve ./only_json");
21531 assert_eq!(resolved_json, only_json);
21532
21533 let file_url = format!("file://{}", module_ts.display());
21534 let resolved_file_url = resolve_module_path(
21535 base.to_string_lossy().as_ref(),
21536 &file_url,
21537 mode,
21538 &canonical_roots,
21539 )
21540 .expect("file://");
21541 assert_eq!(resolved_file_url, module_ts);
21542 }
21543
21544 #[test]
21545 fn resolve_module_path_blocks_file_url_outside_extension_root() {
21546 let temp_dir = tempfile::tempdir().expect("tempdir");
21547 let root = temp_dir.path();
21548 let extension_root = root.join("ext");
21549 std::fs::create_dir_all(&extension_root).expect("mkdir ext");
21550
21551 let base = extension_root.join("index.ts");
21552 std::fs::write(&base, "export {};\n").expect("write base");
21553
21554 let outside = root.join("secret.ts");
21555 std::fs::write(&outside, "export const secret = 1;\n").expect("write outside");
21556
21557 let mode = RepairMode::default();
21558 let roots = [extension_root];
21559 let canonical_roots = roots
21560 .iter()
21561 .map(|p| crate::extensions::safe_canonicalize(p))
21562 .collect::<Vec<_>>();
21563 let file_url = format!("file://{}", outside.display());
21564 let resolved = resolve_module_path(
21565 base.to_string_lossy().as_ref(),
21566 &file_url,
21567 mode,
21568 &canonical_roots,
21569 );
21570 assert!(
21571 resolved.is_none(),
21572 "file:// import outside extension root should be blocked, got {resolved:?}"
21573 );
21574 }
21575
21576 #[test]
21577 fn resolve_module_path_allows_file_url_inside_extension_root() {
21578 let temp_dir = tempfile::tempdir().expect("tempdir");
21579 let root = temp_dir.path();
21580 let extension_root = root.join("ext");
21581 std::fs::create_dir_all(&extension_root).expect("mkdir ext");
21582
21583 let base = extension_root.join("index.ts");
21584 std::fs::write(&base, "export {};\n").expect("write base");
21585
21586 let inside = extension_root.join("module.ts");
21587 std::fs::write(&inside, "export const ok = 1;\n").expect("write inside");
21588
21589 let mode = RepairMode::default();
21590 let roots = [extension_root];
21591 let canonical_roots = roots
21592 .iter()
21593 .map(|p| crate::extensions::safe_canonicalize(p))
21594 .collect::<Vec<_>>();
21595 let file_url = format!("file://{}", inside.display());
21596 let resolved = resolve_module_path(
21597 base.to_string_lossy().as_ref(),
21598 &file_url,
21599 mode,
21600 &canonical_roots,
21601 );
21602 assert_eq!(resolved, Some(inside));
21603 }
21604
21605 #[test]
21606 fn pijs_dynamic_import_reports_deterministic_package_error() {
21607 futures::executor::block_on(async {
21608 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
21609 .await
21610 .expect("create runtime");
21611
21612 runtime
21613 .eval(
21614 r"
21615 globalThis.packageImportError = {};
21616 import('left-pad')
21617 .then(() => {
21618 globalThis.packageImportError.done = true;
21619 globalThis.packageImportError.message = '';
21620 })
21621 .catch((err) => {
21622 globalThis.packageImportError.done = true;
21623 globalThis.packageImportError.message = String((err && err.message) || err || '');
21624 });
21625 ",
21626 )
21627 .await
21628 .expect("eval package import");
21629
21630 let result = get_global_json(&runtime, "packageImportError").await;
21631 assert_eq!(result["done"], serde_json::json!(true));
21632 let message = result["message"].as_str().unwrap_or_default();
21633 assert!(
21634 message.contains("Package module specifiers are not supported in PiJS: left-pad"),
21635 "unexpected message: {message}"
21636 );
21637 });
21638 }
21639
21640 #[test]
21641 fn proxy_stub_allowlist_blocks_sensitive_packages() {
21642 assert!(is_proxy_blocklisted_package("node:fs"));
21643 assert!(is_proxy_blocklisted_package("fs"));
21644 assert!(is_proxy_blocklisted_package("child_process"));
21645 assert!(!is_proxy_blocklisted_package("@aliou/pi-utils-settings"));
21646 }
21647
21648 #[test]
21649 fn proxy_stub_allowlist_accepts_curated_scope_and_pi_pattern() {
21650 assert!(is_proxy_allowlisted_package("@sourcegraph/scip-python"));
21651 assert!(is_proxy_allowlisted_package("@aliou/pi-utils-settings"));
21652 assert!(is_proxy_allowlisted_package("@example/pi-helpers"));
21653 assert!(!is_proxy_allowlisted_package("left-pad"));
21654 }
21655
21656 #[test]
21657 fn proxy_stub_allows_same_scope_packages_for_extension() {
21658 let temp_dir = tempfile::tempdir().expect("tempdir");
21659 let root = temp_dir.path().join("community").join("scope-ext");
21660 std::fs::create_dir_all(&root).expect("mkdir root");
21661 std::fs::write(
21662 root.join("package.json"),
21663 r#"{ "name": "@qualisero/my-ext", "version": "1.0.0" }"#,
21664 )
21665 .expect("write package.json");
21666 let base = root.join("index.mjs");
21667 std::fs::write(&base, "export {};\n").expect("write base");
21668
21669 let mut tiers = HashMap::new();
21670 tiers.insert(root.clone(), ProxyStubSourceTier::Community);
21671 let mut scopes = HashMap::new();
21672 scopes.insert(root.clone(), "@qualisero".to_string());
21673
21674 assert!(should_auto_stub_package(
21675 "@qualisero/shared-lib",
21676 base.to_string_lossy().as_ref(),
21677 &[root],
21678 &tiers,
21679 &scopes,
21680 ));
21681 }
21682
21683 #[test]
21684 fn proxy_stub_allows_non_blocklisted_package_for_community_tier() {
21685 let temp_dir = tempfile::tempdir().expect("tempdir");
21686 let root = temp_dir.path().join("community").join("generic-ext");
21687 std::fs::create_dir_all(&root).expect("mkdir root");
21688 let base = root.join("index.mjs");
21689 std::fs::write(&base, "export {};\n").expect("write base");
21690
21691 let mut tiers = HashMap::new();
21692 tiers.insert(root.clone(), ProxyStubSourceTier::Community);
21693
21694 assert!(should_auto_stub_package(
21695 "left-pad",
21696 base.to_string_lossy().as_ref(),
21697 &[root],
21698 &tiers,
21699 &HashMap::new(),
21700 ));
21701 }
21702
21703 #[test]
21704 fn proxy_stub_disallowed_for_official_tier() {
21705 let temp_dir = tempfile::tempdir().expect("tempdir");
21706 let root = temp_dir.path().join("official-pi-mono").join("my-ext");
21707 std::fs::create_dir_all(&root).expect("mkdir root");
21708 let base = root.join("index.mjs");
21709 std::fs::write(&base, "export {};\n").expect("write base");
21710
21711 let mut tiers = HashMap::new();
21712 tiers.insert(root.clone(), ProxyStubSourceTier::Official);
21713
21714 assert!(!should_auto_stub_package(
21715 "left-pad",
21716 base.to_string_lossy().as_ref(),
21717 &[root],
21718 &tiers,
21719 &HashMap::new(),
21720 ));
21721 }
21722
21723 #[test]
21724 fn pijs_dynamic_import_autostrict_allows_missing_npm_proxy_stub() {
21725 const TEST_PKG: &str = "@aliou/pi-missing-proxy-test";
21726 futures::executor::block_on(async {
21727 let temp_dir = tempfile::tempdir().expect("tempdir");
21728 let ext_dir = temp_dir.path().join("community").join("proxy-ext");
21729 std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
21730 let entry = ext_dir.join("index.mjs");
21731 std::fs::write(
21732 &entry,
21733 r#"
21734import dep from "@aliou/pi-missing-proxy-test";
21735globalThis.__proxyProbe = {
21736 kind: typeof dep,
21737 chain: typeof dep.foo.bar(),
21738 primitive: String(dep),
21739};
21740export default dep;
21741"#,
21742 )
21743 .expect("write extension module");
21744
21745 let config = PiJsRuntimeConfig {
21746 repair_mode: RepairMode::AutoStrict,
21747 ..PiJsRuntimeConfig::default()
21748 };
21749 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
21750 DeterministicClock::new(0),
21751 config,
21752 None,
21753 )
21754 .await
21755 .expect("create runtime");
21756 runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/proxy-ext"));
21757
21758 let entry_spec = format!("file://{}", entry.display());
21759 let script = format!(
21760 r#"
21761 globalThis.proxyImport = {{}};
21762 import({entry_spec:?})
21763 .then(() => {{
21764 globalThis.proxyImport.done = true;
21765 globalThis.proxyImport.error = "";
21766 }})
21767 .catch((err) => {{
21768 globalThis.proxyImport.done = true;
21769 globalThis.proxyImport.error = String((err && err.message) || err || "");
21770 }});
21771 "#
21772 );
21773 runtime.eval(&script).await.expect("eval import");
21774
21775 let result = get_global_json(&runtime, "proxyImport").await;
21776 assert_eq!(result["done"], serde_json::json!(true));
21777 assert_eq!(result["error"], serde_json::json!(""));
21778
21779 let probe = get_global_json(&runtime, "__proxyProbe").await;
21780 assert_eq!(probe["kind"], serde_json::json!("function"));
21781 assert_eq!(probe["chain"], serde_json::json!("function"));
21782 assert_eq!(probe["primitive"], serde_json::json!(""));
21783
21784 let events = runtime.drain_repair_events();
21785 assert!(events.iter().any(|event| {
21786 event.pattern == RepairPattern::MissingNpmDep
21787 && event.repair_action.contains(TEST_PKG)
21788 }));
21789 });
21790 }
21791
21792 #[test]
21793 fn pijs_dynamic_import_autosafe_rejects_missing_npm_proxy_stub() {
21794 const TEST_PKG: &str = "@aliou/pi-missing-proxy-test-safe";
21795 futures::executor::block_on(async {
21796 let temp_dir = tempfile::tempdir().expect("tempdir");
21797 let ext_dir = temp_dir.path().join("community").join("proxy-ext-safe");
21798 std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
21799 let entry = ext_dir.join("index.mjs");
21800 std::fs::write(
21801 &entry,
21802 r#"import dep from "@aliou/pi-missing-proxy-test-safe"; export default dep;"#,
21803 )
21804 .expect("write extension module");
21805
21806 let config = PiJsRuntimeConfig {
21807 repair_mode: RepairMode::AutoSafe,
21808 ..PiJsRuntimeConfig::default()
21809 };
21810 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
21811 DeterministicClock::new(0),
21812 config,
21813 None,
21814 )
21815 .await
21816 .expect("create runtime");
21817 runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/proxy-ext-safe"));
21818
21819 let entry_spec = format!("file://{}", entry.display());
21820 let script = format!(
21821 r#"
21822 globalThis.proxySafeImport = {{}};
21823 import({entry_spec:?})
21824 .then(() => {{
21825 globalThis.proxySafeImport.done = true;
21826 globalThis.proxySafeImport.error = "";
21827 }})
21828 .catch((err) => {{
21829 globalThis.proxySafeImport.done = true;
21830 globalThis.proxySafeImport.error = String((err && err.message) || err || "");
21831 }});
21832 "#
21833 );
21834 runtime.eval(&script).await.expect("eval import");
21835
21836 let result = get_global_json(&runtime, "proxySafeImport").await;
21837 assert_eq!(result["done"], serde_json::json!(true));
21838 let message = result["error"].as_str().unwrap_or_default();
21839 assert!(
21843 message.contains("Package module specifiers are not supported in PiJS"),
21844 "unexpected message: {message}"
21845 );
21846 });
21847 }
21848
21849 #[test]
21850 fn pijs_dynamic_import_existing_virtual_module_does_not_emit_missing_npm_repair() {
21851 futures::executor::block_on(async {
21852 let temp_dir = tempfile::tempdir().expect("tempdir");
21853 let ext_dir = temp_dir.path().join("community").join("proxy-ext-existing");
21854 std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
21855 let entry = ext_dir.join("index.mjs");
21856 std::fs::write(
21857 &entry,
21858 r#"
21859import { ConfigLoader } from "@aliou/pi-utils-settings";
21860globalThis.__existingVirtualProbe = typeof ConfigLoader;
21861export default ConfigLoader;
21862"#,
21863 )
21864 .expect("write extension module");
21865
21866 let config = PiJsRuntimeConfig {
21867 repair_mode: RepairMode::AutoStrict,
21868 ..PiJsRuntimeConfig::default()
21869 };
21870 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
21871 DeterministicClock::new(0),
21872 config,
21873 None,
21874 )
21875 .await
21876 .expect("create runtime");
21877 runtime
21878 .add_extension_root_with_id(ext_dir.clone(), Some("community/proxy-ext-existing"));
21879
21880 let entry_spec = format!("file://{}", entry.display());
21881 let script = format!(
21882 r#"
21883 globalThis.proxyExistingImport = {{}};
21884 import({entry_spec:?})
21885 .then(() => {{
21886 globalThis.proxyExistingImport.done = true;
21887 globalThis.proxyExistingImport.error = "";
21888 }})
21889 .catch((err) => {{
21890 globalThis.proxyExistingImport.done = true;
21891 globalThis.proxyExistingImport.error = String((err && err.message) || err || "");
21892 }});
21893 "#
21894 );
21895 runtime.eval(&script).await.expect("eval import");
21896
21897 let result = get_global_json(&runtime, "proxyExistingImport").await;
21898 assert_eq!(result["done"], serde_json::json!(true));
21899 assert_eq!(result["error"], serde_json::json!(""));
21900
21901 let probe = get_global_json(&runtime, "__existingVirtualProbe").await;
21902 assert_eq!(probe, serde_json::json!("function"));
21903
21904 let events = runtime.drain_repair_events();
21905 assert!(
21906 !events
21907 .iter()
21908 .any(|event| event.pattern == RepairPattern::MissingNpmDep),
21909 "existing virtual module should suppress missing_npm_dep repair events"
21910 );
21911 });
21912 }
21913
21914 #[test]
21915 fn pijs_dynamic_import_loads_doom_style_wad_finder_module() {
21916 futures::executor::block_on(async {
21917 let temp_dir = tempfile::tempdir().expect("tempdir");
21918 let ext_dir = temp_dir.path().join("community").join("doom-like");
21919 std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
21920 let entry = ext_dir.join("wad-finder.ts");
21921 std::fs::write(
21922 &entry,
21923 r#"
21924import { dirname, join } from "node:path";
21925import { fileURLToPath } from "node:url";
21926
21927const __dirname = dirname(fileURLToPath(import.meta.url));
21928globalThis.__doomWadFinderProbe = {
21929 bundled: join(__dirname, "doom1.wad"),
21930};
21931
21932export const bundled = globalThis.__doomWadFinderProbe.bundled;
21933"#,
21934 )
21935 .expect("write extension module");
21936
21937 let config = PiJsRuntimeConfig {
21938 repair_mode: RepairMode::AutoStrict,
21939 ..PiJsRuntimeConfig::default()
21940 };
21941 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
21942 DeterministicClock::new(0),
21943 config,
21944 None,
21945 )
21946 .await
21947 .expect("create runtime");
21948 runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/doom-like"));
21949
21950 let entry_spec = format!("file://{}", entry.display());
21951 let script = format!(
21952 r#"
21953 globalThis.doomLikeImport = {{}};
21954 import({entry_spec:?})
21955 .then(() => {{
21956 globalThis.doomLikeImport.done = true;
21957 globalThis.doomLikeImport.error = "";
21958 }})
21959 .catch((err) => {{
21960 globalThis.doomLikeImport.done = true;
21961 globalThis.doomLikeImport.error = String((err && err.message) || err || "");
21962 }});
21963 "#
21964 );
21965 runtime.eval(&script).await.expect("eval import");
21966
21967 let result = get_global_json(&runtime, "doomLikeImport").await;
21968 assert_eq!(result["done"], serde_json::json!(true));
21969 assert_eq!(result["error"], serde_json::json!(""));
21970
21971 let probe = get_global_json(&runtime, "__doomWadFinderProbe").await;
21972 let bundled = probe["bundled"].as_str().unwrap_or_default();
21973 assert!(
21974 bundled.ends_with("/doom1.wad"),
21975 "unexpected doom wad probe: {probe}"
21976 );
21977 });
21978 }
21979
21980 #[test]
21981 fn pijs_dynamic_import_loads_real_doom_wad_finder_module() {
21982 futures::executor::block_on(async {
21983 let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
21984 let ext_dir = repo_root.join("tests/ext_conformance/artifacts/doom-overlay");
21985 let entry = ext_dir.join("wad-finder.ts");
21986 assert!(entry.is_file(), "missing doom wad-finder at {entry:?}");
21987
21988 let config = PiJsRuntimeConfig {
21989 repair_mode: RepairMode::AutoStrict,
21990 ..PiJsRuntimeConfig::default()
21991 };
21992 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
21993 DeterministicClock::new(0),
21994 config,
21995 None,
21996 )
21997 .await
21998 .expect("create runtime");
21999 runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/doom-overlay"));
22000
22001 let entry_spec = format!("file://{}", entry.display());
22002 let script = format!(
22003 r#"
22004 globalThis.realDoomWadFinderImport = {{}};
22005 import({entry_spec:?})
22006 .then((mod) => {{
22007 globalThis.realDoomWadFinderImport.done = true;
22008 globalThis.realDoomWadFinderImport.error = "";
22009 globalThis.realDoomWadFinderImport.exportType = typeof mod.findWadFile;
22010 }})
22011 .catch((err) => {{
22012 globalThis.realDoomWadFinderImport.done = true;
22013 globalThis.realDoomWadFinderImport.error = String((err && err.message) || err || "");
22014 }});
22015 "#
22016 );
22017 runtime.eval(&script).await.expect("eval import");
22018
22019 let result = get_global_json(&runtime, "realDoomWadFinderImport").await;
22020 assert_eq!(result["done"], serde_json::json!(true));
22021 assert_eq!(result["error"], serde_json::json!(""));
22022 assert_eq!(result["exportType"], serde_json::json!("function"));
22023 });
22024 }
22025
22026 #[test]
22027 fn pijs_loads_real_doom_extension_entry() {
22028 futures::executor::block_on(async {
22029 let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
22030 let ext_dir = repo_root.join("tests/ext_conformance/artifacts/doom-overlay");
22031 let entry = ext_dir.join("index.ts");
22032 assert!(entry.is_file(), "missing doom entry at {entry:?}");
22033
22034 let config = PiJsRuntimeConfig {
22035 repair_mode: RepairMode::AutoStrict,
22036 ..PiJsRuntimeConfig::default()
22037 };
22038 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
22039 DeterministicClock::new(0),
22040 config,
22041 None,
22042 )
22043 .await
22044 .expect("create runtime");
22045 runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/doom-overlay"));
22046
22047 let entry_spec = format!("file://{}", entry.display());
22048 let script = format!(
22049 r#"
22050 globalThis.realDoomEntryLoad = {{}};
22051 __pi_load_extension("community/doom-overlay", {entry_spec:?}, {{ name: "doom-overlay" }})
22052 .then(() => {{
22053 globalThis.realDoomEntryLoad.done = true;
22054 globalThis.realDoomEntryLoad.error = "";
22055 }})
22056 .catch((err) => {{
22057 globalThis.realDoomEntryLoad.done = true;
22058 globalThis.realDoomEntryLoad.error = String((err && err.message) || err || "");
22059 }});
22060 "#
22061 );
22062 runtime.eval(&script).await.expect("eval load_extension");
22063
22064 let result = get_global_json(&runtime, "realDoomEntryLoad").await;
22065 assert_eq!(result["done"], serde_json::json!(true));
22066 assert_eq!(result["error"], serde_json::json!(""));
22067
22068 let snapshot = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
22069 assert_eq!(snapshot["extensions"], serde_json::json!(1));
22070 assert_eq!(snapshot["commands"], serde_json::json!(1));
22071 });
22072 }
22073
22074 #[test]
22075 fn pijs_dynamic_import_reports_deterministic_network_error() {
22076 futures::executor::block_on(async {
22077 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22078 .await
22079 .expect("create runtime");
22080
22081 runtime
22082 .eval(
22083 r"
22084 globalThis.networkImportError = {};
22085 import('https://example.com/mod.js')
22086 .then(() => {
22087 globalThis.networkImportError.done = true;
22088 globalThis.networkImportError.message = '';
22089 })
22090 .catch((err) => {
22091 globalThis.networkImportError.done = true;
22092 globalThis.networkImportError.message = String((err && err.message) || err || '');
22093 });
22094 ",
22095 )
22096 .await
22097 .expect("eval network import");
22098
22099 let result = get_global_json(&runtime, "networkImportError").await;
22100 assert_eq!(result["done"], serde_json::json!(true));
22101 let message = result["message"].as_str().unwrap_or_default();
22102 assert!(
22103 message.contains(
22104 "Network module imports are not supported in PiJS: https://example.com/mod.js"
22105 ),
22106 "unexpected message: {message}"
22107 );
22108 });
22109 }
22110
22111 #[test]
22114 fn pijs_runtime_creates_hostcall_request() {
22115 futures::executor::block_on(async {
22116 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22117 .await
22118 .expect("create runtime");
22119
22120 runtime
22122 .eval(r#"pi.tool("read", { path: "test.txt" });"#)
22123 .await
22124 .expect("eval");
22125
22126 let requests = runtime.drain_hostcall_requests();
22128 assert_eq!(requests.len(), 1);
22129 let req = &requests[0];
22130 assert!(matches!(&req.kind, HostcallKind::Tool { name } if name == "read"));
22131 assert_eq!(req.payload["path"], "test.txt");
22132 assert_eq!(req.extension_id.as_deref(), None);
22133 });
22134 }
22135
22136 #[test]
22137 fn pijs_runtime_hostcall_request_captures_extension_id() {
22138 futures::executor::block_on(async {
22139 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22140 .await
22141 .expect("create runtime");
22142
22143 runtime
22144 .eval(
22145 r#"
22146 __pi_begin_extension("ext.test", { name: "Test" });
22147 pi.tool("read", { path: "test.txt" });
22148 __pi_end_extension();
22149 "#,
22150 )
22151 .await
22152 .expect("eval");
22153
22154 let requests = runtime.drain_hostcall_requests();
22155 assert_eq!(requests.len(), 1);
22156 assert_eq!(requests[0].extension_id.as_deref(), Some("ext.test"));
22157 });
22158 }
22159
22160 #[test]
22161 fn pijs_runtime_log_hostcall_request_shape() {
22162 futures::executor::block_on(async {
22163 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22164 .await
22165 .expect("create runtime");
22166
22167 runtime
22168 .eval(
22169 r#"
22170 pi.log({
22171 level: "info",
22172 event: "unit.test",
22173 message: "hello",
22174 correlation: { scenario_id: "scn-1" }
22175 });
22176 "#,
22177 )
22178 .await
22179 .expect("eval");
22180
22181 let requests = runtime.drain_hostcall_requests();
22182 assert_eq!(requests.len(), 1);
22183 let req = &requests[0];
22184 assert!(matches!(&req.kind, HostcallKind::Log));
22185 assert_eq!(req.payload["level"], "info");
22186 assert_eq!(req.payload["event"], "unit.test");
22187 assert_eq!(req.payload["message"], "hello");
22188 });
22189 }
22190
22191 #[test]
22192 fn pijs_runtime_get_registered_tools_empty() {
22193 futures::executor::block_on(async {
22194 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22195 .await
22196 .expect("create runtime");
22197
22198 let tools = runtime.get_registered_tools().await.expect("get tools");
22199 assert!(tools.is_empty());
22200 });
22201 }
22202
22203 #[test]
22204 fn pijs_runtime_get_registered_tools_single_tool() {
22205 futures::executor::block_on(async {
22206 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22207 .await
22208 .expect("create runtime");
22209
22210 runtime
22211 .eval(
22212 r"
22213 __pi_begin_extension('ext.test', { name: 'Test' });
22214 pi.registerTool({
22215 name: 'my_tool',
22216 label: 'My Tool',
22217 description: 'Does stuff',
22218 parameters: { type: 'object', properties: { path: { type: 'string' } } },
22219 execute: async (_callId, _input) => { return { ok: true }; },
22220 });
22221 __pi_end_extension();
22222 ",
22223 )
22224 .await
22225 .expect("eval");
22226
22227 let tools = runtime.get_registered_tools().await.expect("get tools");
22228 assert_eq!(tools.len(), 1);
22229 assert_eq!(
22230 tools[0],
22231 ExtensionToolDef {
22232 name: "my_tool".to_string(),
22233 label: Some("My Tool".to_string()),
22234 description: "Does stuff".to_string(),
22235 parameters: serde_json::json!({
22236 "type": "object",
22237 "properties": {
22238 "path": { "type": "string" }
22239 }
22240 }),
22241 }
22242 );
22243 });
22244 }
22245
22246 #[test]
22247 fn pijs_runtime_get_registered_tools_sorts_by_name() {
22248 futures::executor::block_on(async {
22249 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22250 .await
22251 .expect("create runtime");
22252
22253 runtime
22254 .eval(
22255 r"
22256 __pi_begin_extension('ext.test', { name: 'Test' });
22257 pi.registerTool({ name: 'b', execute: async (_callId, _input) => { return {}; } });
22258 pi.registerTool({ name: 'a', execute: async (_callId, _input) => { return {}; } });
22259 __pi_end_extension();
22260 ",
22261 )
22262 .await
22263 .expect("eval");
22264
22265 let tools = runtime.get_registered_tools().await.expect("get tools");
22266 assert_eq!(
22267 tools
22268 .iter()
22269 .map(|tool| tool.name.as_str())
22270 .collect::<Vec<_>>(),
22271 vec!["a", "b"]
22272 );
22273 });
22274 }
22275
22276 #[test]
22277 fn pijs_validate_tool_input_allows_null_when_schema_allows_null() {
22278 futures::executor::block_on(async {
22279 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22280 .await
22281 .expect("create runtime");
22282
22283 runtime
22284 .eval(
22285 r#"
22286 __pi_validate_tool_input({
22287 type: ["object", "null"],
22288 properties: { name: { type: "string" } },
22289 required: ["name"]
22290 }, null);
22291 "#,
22292 )
22293 .await
22294 .expect("null input should be allowed when schema permits null");
22295 });
22296 }
22297
22298 #[test]
22299 fn pijs_validate_tool_input_rejects_missing_required_object() {
22300 futures::executor::block_on(async {
22301 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22302 .await
22303 .expect("create runtime");
22304
22305 let err = runtime
22306 .eval(
22307 r#"
22308 __pi_validate_tool_input({
22309 type: ["object", "null"],
22310 properties: { name: { type: "string" } },
22311 required: ["name"]
22312 }, {});
22313 "#,
22314 )
22315 .await
22316 .expect_err("missing required field should throw");
22317
22318 assert!(err.to_string().contains("missing required"));
22319 });
22320 }
22321
22322 #[test]
22323 fn pijs_validate_tool_input_rejects_non_object_when_schema_is_object() {
22324 futures::executor::block_on(async {
22325 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22326 .await
22327 .expect("create runtime");
22328
22329 let err = runtime
22330 .eval(
22331 r#"
22332 __pi_validate_tool_input({
22333 type: "object",
22334 properties: { name: { type: "string" } }
22335 }, "nope");
22336 "#,
22337 )
22338 .await
22339 .expect_err("non-object input should throw");
22340
22341 assert!(err.to_string().contains("Tool input must be an object"));
22342 });
22343 }
22344
22345 #[test]
22346 fn pijs_validate_tool_input_allows_non_object_when_schema_allows_string() {
22347 futures::executor::block_on(async {
22348 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22349 .await
22350 .expect("create runtime");
22351
22352 runtime
22353 .eval(
22354 r#"
22355 __pi_validate_tool_input({
22356 type: ["object", "string"],
22357 properties: { name: { type: "string" } },
22358 required: ["name"]
22359 }, "ok");
22360 "#,
22361 )
22362 .await
22363 .expect("string input should be allowed when schema permits string");
22364 });
22365 }
22366
22367 #[test]
22368 fn pijs_validate_tool_input_rejects_null_when_schema_disallows_null() {
22369 futures::executor::block_on(async {
22370 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22371 .await
22372 .expect("create runtime");
22373
22374 let err = runtime
22375 .eval(
22376 r#"
22377 __pi_validate_tool_input({
22378 type: ["object", "string"],
22379 properties: { name: { type: "string" } }
22380 }, null);
22381 "#,
22382 )
22383 .await
22384 .expect_err("null input should be rejected when schema disallows null");
22385
22386 assert!(err.to_string().contains("Tool input must be an object"));
22387 });
22388 }
22389
22390 #[test]
22391 fn pijs_validate_tool_input_rejects_number_when_schema_only_string() {
22392 futures::executor::block_on(async {
22393 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22394 .await
22395 .expect("create runtime");
22396
22397 let err = runtime
22398 .eval(
22399 r#"
22400 __pi_validate_tool_input({
22401 type: ["object", "string"],
22402 properties: { name: { type: "string" } }
22403 }, 42);
22404 "#,
22405 )
22406 .await
22407 .expect_err("number input should be rejected when schema allows only string");
22408
22409 assert!(err.to_string().contains("Tool input must be an object"));
22410 });
22411 }
22412
22413 #[test]
22414 fn pijs_validate_tool_input_allows_undefined_when_no_required() {
22415 futures::executor::block_on(async {
22416 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22417 .await
22418 .expect("create runtime");
22419
22420 runtime
22421 .eval(
22422 r#"
22423 __pi_validate_tool_input({
22424 type: "object",
22425 properties: { name: { type: "string" } }
22426 }, undefined);
22427 "#,
22428 )
22429 .await
22430 .expect("undefined input should be allowed when no required fields");
22431 });
22432 }
22433
22434 #[test]
22435 fn hostcall_params_hash_is_stable_for_key_ordering() {
22436 let first = serde_json::json!({ "b": 2, "a": 1 });
22437 let second = serde_json::json!({ "a": 1, "b": 2 });
22438
22439 assert_eq!(
22440 hostcall_params_hash("http", &first),
22441 hostcall_params_hash("http", &second)
22442 );
22443 assert_ne!(
22444 hostcall_params_hash("http", &first),
22445 hostcall_params_hash("tool", &first)
22446 );
22447 }
22448
22449 #[test]
22450 #[allow(clippy::too_many_lines)]
22451 fn hostcall_request_params_for_hash_uses_canonical_shapes() {
22452 let cases = vec![
22453 (
22454 HostcallRequest {
22455 call_id: "tool-case".to_string(),
22456 kind: HostcallKind::Tool {
22457 name: "read".to_string(),
22458 },
22459 payload: serde_json::json!({ "path": "README.md" }),
22460 trace_id: 0,
22461 extension_id: None,
22462 },
22463 serde_json::json!({ "name": "read", "input": { "path": "README.md" } }),
22464 ),
22465 (
22466 HostcallRequest {
22467 call_id: "exec-case".to_string(),
22468 kind: HostcallKind::Exec {
22469 cmd: "echo".to_string(),
22470 },
22471 payload: serde_json::json!({
22472 "command": "legacy alias should be dropped",
22473 "args": ["hello"],
22474 "options": { "timeout": 1000 }
22475 }),
22476 trace_id: 0,
22477 extension_id: None,
22478 },
22479 serde_json::json!({
22480 "cmd": "echo",
22481 "args": ["hello"],
22482 "options": { "timeout": 1000 }
22483 }),
22484 ),
22485 (
22486 HostcallRequest {
22487 call_id: "session-object".to_string(),
22488 kind: HostcallKind::Session {
22489 op: "set_model".to_string(),
22490 },
22491 payload: serde_json::json!({
22492 "provider": "openai",
22493 "modelId": "gpt-4o"
22494 }),
22495 trace_id: 0,
22496 extension_id: None,
22497 },
22498 serde_json::json!({
22499 "op": "set_model",
22500 "provider": "openai",
22501 "modelId": "gpt-4o"
22502 }),
22503 ),
22504 (
22505 HostcallRequest {
22506 call_id: "ui-non-object".to_string(),
22507 kind: HostcallKind::Ui {
22508 op: "set_status".to_string(),
22509 },
22510 payload: serde_json::json!("ready"),
22511 trace_id: 0,
22512 extension_id: None,
22513 },
22514 serde_json::json!({ "op": "set_status", "payload": "ready" }),
22515 ),
22516 (
22517 HostcallRequest {
22518 call_id: "events-non-object".to_string(),
22519 kind: HostcallKind::Events {
22520 op: "emit".to_string(),
22521 },
22522 payload: serde_json::json!(42),
22523 trace_id: 0,
22524 extension_id: None,
22525 },
22526 serde_json::json!({ "op": "emit", "payload": 42 }),
22527 ),
22528 (
22529 HostcallRequest {
22530 call_id: "session-null".to_string(),
22531 kind: HostcallKind::Session {
22532 op: "get_state".to_string(),
22533 },
22534 payload: serde_json::Value::Null,
22535 trace_id: 0,
22536 extension_id: None,
22537 },
22538 serde_json::json!({ "op": "get_state" }),
22539 ),
22540 (
22541 HostcallRequest {
22542 call_id: "log-entry".to_string(),
22543 kind: HostcallKind::Log,
22544 payload: serde_json::json!({
22545 "level": "info",
22546 "event": "unit.test",
22547 "message": "hello",
22548 "correlation": { "scenario_id": "scn-1" }
22549 }),
22550 trace_id: 0,
22551 extension_id: None,
22552 },
22553 serde_json::json!({
22554 "level": "info",
22555 "event": "unit.test",
22556 "message": "hello",
22557 "correlation": { "scenario_id": "scn-1" }
22558 }),
22559 ),
22560 ];
22561
22562 for (request, expected) in cases {
22563 assert_eq!(
22564 request.params_for_hash(),
22565 expected,
22566 "canonical params mismatch for {}",
22567 request.call_id
22568 );
22569 }
22570 }
22571
22572 #[test]
22573 fn hostcall_request_params_hash_matches_wasm_contract_for_canonical_requests() {
22574 let requests = vec![
22575 HostcallRequest {
22576 call_id: "hash-session".to_string(),
22577 kind: HostcallKind::Session {
22578 op: "set_model".to_string(),
22579 },
22580 payload: serde_json::json!({
22581 "modelId": "gpt-4o",
22582 "provider": "openai"
22583 }),
22584 trace_id: 0,
22585 extension_id: Some("ext.test".to_string()),
22586 },
22587 HostcallRequest {
22588 call_id: "hash-ui".to_string(),
22589 kind: HostcallKind::Ui {
22590 op: "set_status".to_string(),
22591 },
22592 payload: serde_json::json!("thinking"),
22593 trace_id: 0,
22594 extension_id: Some("ext.test".to_string()),
22595 },
22596 HostcallRequest {
22597 call_id: "hash-log".to_string(),
22598 kind: HostcallKind::Log,
22599 payload: serde_json::json!({
22600 "level": "warn",
22601 "event": "log.test",
22602 "message": "warn line",
22603 "correlation": { "scenario_id": "scn-2" }
22604 }),
22605 trace_id: 0,
22606 extension_id: Some("ext.test".to_string()),
22607 },
22608 ];
22609
22610 for request in requests {
22611 let params = request.params_for_hash();
22612 let js_hash = request.params_hash();
22613
22614 let wasm_contract_hash =
22616 crate::extensions::hostcall_params_hash(request.method(), ¶ms);
22617
22618 assert_eq!(
22619 js_hash, wasm_contract_hash,
22620 "hash parity mismatch for {}",
22621 request.call_id
22622 );
22623 }
22624 }
22625
22626 #[test]
22627 fn hostcall_request_io_uring_capability_and_hint_mappings_are_deterministic() {
22628 let cases = vec![
22629 (
22630 HostcallRequest {
22631 call_id: "io-read".to_string(),
22632 kind: HostcallKind::Tool {
22633 name: "read".to_string(),
22634 },
22635 payload: serde_json::Value::Null,
22636 trace_id: 0,
22637 extension_id: None,
22638 },
22639 HostcallCapabilityClass::Filesystem,
22640 HostcallIoHint::IoHeavy,
22641 ),
22642 (
22643 HostcallRequest {
22644 call_id: "io-bash".to_string(),
22645 kind: HostcallKind::Tool {
22646 name: "bash".to_string(),
22647 },
22648 payload: serde_json::Value::Null,
22649 trace_id: 0,
22650 extension_id: None,
22651 },
22652 HostcallCapabilityClass::Execution,
22653 HostcallIoHint::CpuBound,
22654 ),
22655 (
22656 HostcallRequest {
22657 call_id: "io-http".to_string(),
22658 kind: HostcallKind::Http,
22659 payload: serde_json::Value::Null,
22660 trace_id: 0,
22661 extension_id: None,
22662 },
22663 HostcallCapabilityClass::Network,
22664 HostcallIoHint::IoHeavy,
22665 ),
22666 (
22667 HostcallRequest {
22668 call_id: "io-session".to_string(),
22669 kind: HostcallKind::Session {
22670 op: "get_state".to_string(),
22671 },
22672 payload: serde_json::Value::Null,
22673 trace_id: 0,
22674 extension_id: None,
22675 },
22676 HostcallCapabilityClass::Session,
22677 HostcallIoHint::Unknown,
22678 ),
22679 (
22680 HostcallRequest {
22681 call_id: "io-log".to_string(),
22682 kind: HostcallKind::Log,
22683 payload: serde_json::Value::Null,
22684 trace_id: 0,
22685 extension_id: None,
22686 },
22687 HostcallCapabilityClass::Telemetry,
22688 HostcallIoHint::Unknown,
22689 ),
22690 ];
22691
22692 for (request, expected_capability, expected_hint) in cases {
22693 assert_eq!(
22694 request.io_uring_capability_class(),
22695 expected_capability,
22696 "capability mismatch for {}",
22697 request.call_id
22698 );
22699 assert_eq!(
22700 request.io_uring_io_hint(),
22701 expected_hint,
22702 "io hint mismatch for {}",
22703 request.call_id
22704 );
22705 }
22706 }
22707
22708 #[test]
22709 fn hostcall_request_io_uring_lane_input_preserves_queue_and_force_flags() {
22710 let request = HostcallRequest {
22711 call_id: "io-lane-input".to_string(),
22712 kind: HostcallKind::Tool {
22713 name: "write".to_string(),
22714 },
22715 payload: serde_json::json!({ "path": "notes.txt", "content": "ok" }),
22716 trace_id: 0,
22717 extension_id: Some("ext.test".to_string()),
22718 };
22719
22720 let input = request.io_uring_lane_input(17, true);
22721 assert_eq!(input.capability, HostcallCapabilityClass::Filesystem);
22722 assert_eq!(input.io_hint, HostcallIoHint::IoHeavy);
22723 assert_eq!(input.queue_depth, 17);
22724 assert!(input.force_compat_lane);
22725 }
22726
22727 #[test]
22728 fn pijs_runtime_multiple_hostcalls() {
22729 futures::executor::block_on(async {
22730 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22731 .await
22732 .expect("create runtime");
22733
22734 runtime
22735 .eval(
22736 r#"
22737 pi.tool("read", { path: "a.txt" });
22738 pi.exec("ls", ["-la"]);
22739 pi.http({ url: "https://example.com" });
22740 "#,
22741 )
22742 .await
22743 .expect("eval");
22744
22745 let requests = runtime.drain_hostcall_requests();
22746 let kinds = requests
22747 .iter()
22748 .map(|req| format!("{:?}", req.kind))
22749 .collect::<Vec<_>>();
22750 assert_eq!(requests.len(), 3, "hostcalls: {kinds:?}");
22751
22752 assert!(matches!(&requests[0].kind, HostcallKind::Tool { name } if name == "read"));
22753 assert!(matches!(&requests[1].kind, HostcallKind::Exec { cmd } if cmd == "ls"));
22754 assert!(matches!(&requests[2].kind, HostcallKind::Http));
22755 });
22756 }
22757
22758 #[test]
22759 fn pijs_fetch_binary_body_uses_body_bytes_hostcall() {
22760 futures::executor::block_on(async {
22761 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22762 .await
22763 .expect("create runtime");
22764
22765 runtime
22766 .eval(
22767 r#"
22768 fetch("https://example.com/upload", {
22769 method: "POST",
22770 headers: { "content-type": "application/octet-stream" },
22771 body: new Uint8Array([0, 1, 2, 255]),
22772 });
22773 "#,
22774 )
22775 .await
22776 .expect("eval");
22777
22778 let requests = runtime.drain_hostcall_requests();
22779 assert_eq!(requests.len(), 1);
22780 assert!(matches!(&requests[0].kind, HostcallKind::Http));
22781
22782 let payload = requests[0]
22783 .payload
22784 .as_object()
22785 .expect("http payload object");
22786 assert_eq!(
22787 payload.get("method").and_then(serde_json::Value::as_str),
22788 Some("POST")
22789 );
22790 assert_eq!(
22791 payload
22792 .get("body_bytes")
22793 .and_then(serde_json::Value::as_str),
22794 Some("AAEC/w==")
22795 );
22796 assert!(
22797 payload.get("body").is_none(),
22798 "binary fetch bodies must use body_bytes instead of text coercion: {payload:?}"
22799 );
22800 });
22801 }
22802
22803 #[test]
22804 fn pijs_runtime_hostcall_completion_resolves_promise() {
22805 futures::executor::block_on(async {
22806 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22807 .await
22808 .expect("create runtime");
22809
22810 runtime
22812 .eval(
22813 r#"
22814 globalThis.result = null;
22815 pi.tool("read", { path: "test.txt" }).then(r => {
22816 globalThis.result = r;
22817 });
22818 "#,
22819 )
22820 .await
22821 .expect("eval");
22822
22823 let requests = runtime.drain_hostcall_requests();
22825 assert_eq!(requests.len(), 1);
22826 let call_id = requests[0].call_id.clone();
22827
22828 runtime.complete_hostcall(
22830 call_id,
22831 HostcallOutcome::Success(serde_json::json!({ "content": "hello world" })),
22832 );
22833
22834 let stats = runtime.tick().await.expect("tick");
22836 assert!(stats.ran_macrotask);
22837
22838 runtime
22840 .eval(
22841 r#"
22842 if (globalThis.result === null) {
22843 throw new Error("Promise not resolved");
22844 }
22845 if (globalThis.result.content !== "hello world") {
22846 throw new Error("Wrong result: " + JSON.stringify(globalThis.result));
22847 }
22848 "#,
22849 )
22850 .await
22851 .expect("verify result");
22852 });
22853 }
22854
22855 #[test]
22856 fn pijs_runtime_hostcall_error_rejects_promise() {
22857 futures::executor::block_on(async {
22858 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22859 .await
22860 .expect("create runtime");
22861
22862 runtime
22864 .eval(
22865 r#"
22866 globalThis.error = null;
22867 pi.tool("read", { path: "nonexistent.txt" }).catch(e => {
22868 globalThis.error = { code: e.code, message: e.message };
22869 });
22870 "#,
22871 )
22872 .await
22873 .expect("eval");
22874
22875 let requests = runtime.drain_hostcall_requests();
22876 let call_id = requests[0].call_id.clone();
22877
22878 runtime.complete_hostcall(
22880 call_id,
22881 HostcallOutcome::Error {
22882 code: "ENOENT".to_string(),
22883 message: "File not found".to_string(),
22884 },
22885 );
22886
22887 runtime.tick().await.expect("tick");
22888
22889 runtime
22891 .eval(
22892 r#"
22893 if (globalThis.error === null) {
22894 throw new Error("Promise not rejected");
22895 }
22896 if (globalThis.error.code !== "ENOENT") {
22897 throw new Error("Wrong error code: " + globalThis.error.code);
22898 }
22899 "#,
22900 )
22901 .await
22902 .expect("verify error");
22903 });
22904 }
22905
22906 #[test]
22907 fn pijs_runtime_tick_stats() {
22908 futures::executor::block_on(async {
22909 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22910 .await
22911 .expect("create runtime");
22912
22913 let stats = runtime.tick().await.expect("tick");
22915 assert!(!stats.ran_macrotask);
22916 assert_eq!(stats.pending_hostcalls, 0);
22917
22918 runtime.eval(r#"pi.tool("test", {});"#).await.expect("eval");
22920
22921 let requests = runtime.drain_hostcall_requests();
22922 assert_eq!(requests.len(), 1);
22923
22924 runtime.complete_hostcall(
22926 requests[0].call_id.clone(),
22927 HostcallOutcome::Success(serde_json::json!(null)),
22928 );
22929
22930 let stats = runtime.tick().await.expect("tick");
22931 assert!(stats.ran_macrotask);
22932 });
22933 }
22934
22935 #[test]
22936 #[allow(clippy::too_many_lines)]
22937 fn pijs_custom_ui_width_updates_trigger_reflow() {
22938 futures::executor::block_on(async {
22939 let clock = Arc::new(DeterministicClock::new(0));
22940 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22941 .await
22942 .expect("create runtime");
22943
22944 runtime
22945 .eval(
22946 r"
22947 globalThis.renderWidths = [];
22948 const ui = __pi_make_extension_ui(true);
22949 void ui.custom((_tui, _theme, _keybindings, onDone) => ({
22950 render(width) {
22951 globalThis.renderWidths.push(width);
22952 if (width === 40) {
22953 onDone(width);
22954 }
22955 return [`width:${width}`];
22956 }
22957 }), { width: 80 });
22958 ",
22959 )
22960 .await
22961 .expect("start custom ui");
22962
22963 let initial_requests = runtime.drain_hostcall_requests();
22964 assert_eq!(
22965 initial_requests.len(),
22966 2,
22967 "custom UI should issue an initial poll and first frame"
22968 );
22969
22970 let mut initial_frame_call = None;
22971 let mut initial_poll_call = None;
22972 let mut unexpected_initial_hostcall = None;
22973 for request in initial_requests {
22974 match &request.kind {
22975 HostcallKind::Ui { op } if op == "setWidget" => {
22976 initial_frame_call = Some(request);
22977 }
22978 HostcallKind::Ui { op } if op == "custom" => {
22979 initial_poll_call = Some(request);
22980 }
22981 other => {
22982 unexpected_initial_hostcall = Some(format!("{other:?}"));
22983 }
22984 }
22985 }
22986 assert_eq!(
22987 unexpected_initial_hostcall, None,
22988 "unexpected initial hostcall"
22989 );
22990
22991 let initial_frame_call = initial_frame_call.expect("initial frame hostcall");
22992 assert_eq!(
22993 initial_frame_call.payload["lines"],
22994 serde_json::json!(["width:80"])
22995 );
22996 runtime.complete_hostcall(
22997 initial_frame_call.call_id,
22998 HostcallOutcome::Success(serde_json::json!(null)),
22999 );
23000
23001 let initial_poll_call = initial_poll_call.expect("initial poll hostcall");
23002 runtime.complete_hostcall(
23003 initial_poll_call.call_id,
23004 HostcallOutcome::Success(serde_json::json!({ "width": 80 })),
23005 );
23006
23007 runtime
23008 .tick()
23009 .await
23010 .expect("deliver initial frame completion");
23011 runtime
23012 .tick()
23013 .await
23014 .expect("deliver initial poll completion");
23015 assert_eq!(
23016 get_global_json(&runtime, "renderWidths").await,
23017 serde_json::json!([80])
23018 );
23019
23020 let mut saw_post_startup_poll = false;
23021 for step in 0..12 {
23022 let next_deadline = runtime
23023 .scheduler
23024 .borrow()
23025 .next_timer_deadline()
23026 .expect("custom UI should keep timers alive");
23027 clock.set(next_deadline);
23028
23029 let stats = runtime.tick().await.expect("tick timer");
23030 assert!(
23031 stats.ran_macrotask,
23032 "expected timer macrotask at step {step}"
23033 );
23034
23035 let requests = runtime.drain_hostcall_requests();
23036 if requests.is_empty() {
23037 continue;
23038 }
23039 assert_eq!(requests.len(), 1, "expected one hostcall at step {step}");
23040 let request = requests.into_iter().next().expect("hostcall request");
23041
23042 match &request.kind {
23043 HostcallKind::Ui { op } if op == "custom" => {
23044 saw_post_startup_poll = true;
23045 runtime.complete_hostcall(
23046 request.call_id,
23047 HostcallOutcome::Success(serde_json::json!({ "width": 40 })),
23048 );
23049 runtime.tick().await.expect("deliver poll completion");
23050 }
23051 HostcallKind::Ui { op } if op == "setWidget" => {
23052 assert!(
23053 saw_post_startup_poll,
23054 "startup should not enqueue a redundant timer-driven frame"
23055 );
23056 assert_eq!(
23057 request.payload["lines"],
23058 serde_json::json!(["width:40"]),
23059 "width change should trigger a reflow frame"
23060 );
23061 return;
23062 }
23063 other => panic!("unexpected hostcall at step {step}: {other:?}"),
23064 }
23065 }
23066
23067 panic!("did not observe a width-change reflow frame");
23068 });
23069 }
23070
23071 #[test]
23072 fn pijs_hostcall_timeout_rejects_promise() {
23073 futures::executor::block_on(async {
23074 let clock = Arc::new(DeterministicClock::new(0));
23075 let mut config = PiJsRuntimeConfig::default();
23076 config.limits.hostcall_timeout_ms = Some(50);
23077
23078 let runtime =
23079 PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
23080 .await
23081 .expect("create runtime");
23082
23083 runtime
23084 .eval(
23085 r#"
23086 globalThis.done = false;
23087 globalThis.code = null;
23088 pi.tool("read", { path: "test.txt" })
23089 .then(() => { globalThis.done = true; })
23090 .catch((e) => { globalThis.code = e.code; globalThis.done = true; });
23091 "#,
23092 )
23093 .await
23094 .expect("eval");
23095
23096 let requests = runtime.drain_hostcall_requests();
23097 assert_eq!(requests.len(), 1);
23098
23099 clock.set(50);
23100 let stats = runtime.tick().await.expect("tick");
23101 assert!(stats.ran_macrotask);
23102 assert_eq!(stats.hostcalls_timed_out, 1);
23103 assert_eq!(
23104 get_global_json(&runtime, "done").await,
23105 serde_json::json!(true)
23106 );
23107 assert_eq!(
23108 get_global_json(&runtime, "code").await,
23109 serde_json::json!("timeout")
23110 );
23111
23112 runtime.complete_hostcall(
23114 requests[0].call_id.clone(),
23115 HostcallOutcome::Success(serde_json::json!({ "ok": true })),
23116 );
23117 let stats = runtime.tick().await.expect("tick late completion");
23118 assert!(stats.ran_macrotask);
23119 assert_eq!(stats.hostcalls_timed_out, 1);
23120 });
23121 }
23122
23123 #[test]
23124 fn pijs_interrupt_budget_aborts_eval() {
23125 futures::executor::block_on(async {
23126 let mut config = PiJsRuntimeConfig::default();
23127 config.limits.interrupt_budget = Some(0);
23128
23129 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
23130 DeterministicClock::new(0),
23131 config,
23132 None,
23133 )
23134 .await
23135 .expect("create runtime");
23136
23137 let err = runtime
23138 .eval(
23139 r"
23140 let sum = 0;
23141 for (let i = 0; i < 1000000; i++) { sum += i; }
23142 ",
23143 )
23144 .await
23145 .expect_err("expected budget exceed");
23146
23147 assert!(err.to_string().contains("PiJS execution budget exceeded"));
23148 });
23149 }
23150
23151 #[test]
23152 fn pijs_microtasks_drain_before_next_macrotask() {
23153 futures::executor::block_on(async {
23154 let clock = Arc::new(DeterministicClock::new(0));
23155 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23156 .await
23157 .expect("create runtime");
23158
23159 runtime
23160 .eval(r"globalThis.order = []; globalThis.__pi_done = false;")
23161 .await
23162 .expect("init order");
23163
23164 let timer_id = runtime.set_timeout(10);
23165 runtime
23166 .eval(&format!(
23167 r#"__pi_register_timer({timer_id}, () => {{
23168 globalThis.order.push("timer");
23169 Promise.resolve().then(() => globalThis.order.push("timer-micro"));
23170 }});"#
23171 ))
23172 .await
23173 .expect("register timer");
23174
23175 runtime
23176 .eval(
23177 r#"
23178 pi.tool("read", {}).then(() => {
23179 globalThis.order.push("hostcall");
23180 Promise.resolve().then(() => globalThis.order.push("hostcall-micro"));
23181 });
23182 "#,
23183 )
23184 .await
23185 .expect("enqueue hostcall");
23186
23187 let requests = runtime.drain_hostcall_requests();
23188 let call_id = requests
23189 .into_iter()
23190 .next()
23191 .expect("hostcall request")
23192 .call_id;
23193
23194 runtime.complete_hostcall(call_id, HostcallOutcome::Success(serde_json::json!(null)));
23195
23196 clock.set(10);
23198
23199 runtime.tick().await.expect("tick hostcall");
23201 let after_first = get_global_json(&runtime, "order").await;
23202 assert_eq!(
23203 after_first,
23204 serde_json::json!(["hostcall", "hostcall-micro"])
23205 );
23206
23207 runtime.tick().await.expect("tick timer");
23209 let after_second = get_global_json(&runtime, "order").await;
23210 assert_eq!(
23211 after_second,
23212 serde_json::json!(["hostcall", "hostcall-micro", "timer", "timer-micro"])
23213 );
23214 });
23215 }
23216
23217 #[test]
23218 fn pijs_clear_timeout_prevents_timer_callback() {
23219 futures::executor::block_on(async {
23220 let clock = Arc::new(DeterministicClock::new(0));
23221 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23222 .await
23223 .expect("create runtime");
23224
23225 runtime
23226 .eval(r"globalThis.order = []; ")
23227 .await
23228 .expect("init order");
23229
23230 let timer_id = runtime.set_timeout(10);
23231 runtime
23232 .eval(&format!(
23233 r#"__pi_register_timer({timer_id}, () => globalThis.order.push("timer"));"#
23234 ))
23235 .await
23236 .expect("register timer");
23237
23238 assert!(runtime.clear_timeout(timer_id));
23239 clock.set(10);
23240
23241 let stats = runtime.tick().await.expect("tick");
23242 assert!(!stats.ran_macrotask);
23243
23244 let order = get_global_json(&runtime, "order").await;
23245 assert_eq!(order, serde_json::json!([]));
23246 });
23247 }
23248
23249 #[test]
23250 fn pijs_env_get_honors_allowlist() {
23251 futures::executor::block_on(async {
23252 let clock = Arc::new(DeterministicClock::new(0));
23253 let mut env = HashMap::new();
23254 env.insert("HOME".to_string(), "/virtual/home".to_string());
23255 env.insert("PI_IMAGE_SAVE_MODE".to_string(), "tmp".to_string());
23256 env.insert(
23257 "AWS_SECRET_ACCESS_KEY".to_string(),
23258 "nope-do-not-expose".to_string(),
23259 );
23260 let config = PiJsRuntimeConfig {
23261 cwd: "/virtual/cwd".to_string(),
23262 args: vec!["--flag".to_string()],
23263 env,
23264 limits: PiJsRuntimeLimits::default(),
23265 repair_mode: RepairMode::default(),
23266 allow_unsafe_sync_exec: false,
23267 deny_env: false,
23268 disk_cache_dir: None,
23269 };
23270 let runtime =
23271 PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
23272 .await
23273 .expect("create runtime");
23274
23275 runtime
23276 .eval(
23277 r#"
23278 globalThis.home = pi.env.get("HOME");
23279 globalThis.mode = pi.env.get("PI_IMAGE_SAVE_MODE");
23280 globalThis.missing_is_undefined = (pi.env.get("NOPE") === undefined);
23281 globalThis.secret_is_undefined = (pi.env.get("AWS_SECRET_ACCESS_KEY") === undefined);
23282 globalThis.process_secret_is_undefined = (process.env.AWS_SECRET_ACCESS_KEY === undefined);
23283 globalThis.secret_in_env = ("AWS_SECRET_ACCESS_KEY" in process.env);
23284 "#,
23285 )
23286 .await
23287 .expect("eval env");
23288
23289 assert_eq!(
23290 get_global_json(&runtime, "home").await,
23291 serde_json::json!("/virtual/home")
23292 );
23293 assert_eq!(
23294 get_global_json(&runtime, "mode").await,
23295 serde_json::json!("tmp")
23296 );
23297 assert_eq!(
23298 get_global_json(&runtime, "missing_is_undefined").await,
23299 serde_json::json!(true)
23300 );
23301 assert_eq!(
23302 get_global_json(&runtime, "secret_is_undefined").await,
23303 serde_json::json!(true)
23304 );
23305 assert_eq!(
23306 get_global_json(&runtime, "process_secret_is_undefined").await,
23307 serde_json::json!(true)
23308 );
23309 assert_eq!(
23310 get_global_json(&runtime, "secret_in_env").await,
23311 serde_json::json!(false)
23312 );
23313 });
23314 }
23315
23316 #[test]
23317 fn pijs_process_path_crypto_time_apis_smoke() {
23318 futures::executor::block_on(async {
23319 let clock = Arc::new(DeterministicClock::new(123));
23320 let config = PiJsRuntimeConfig {
23321 cwd: "/virtual/cwd".to_string(),
23322 args: vec!["a".to_string(), "b".to_string()],
23323 env: HashMap::new(),
23324 limits: PiJsRuntimeLimits::default(),
23325 repair_mode: RepairMode::default(),
23326 allow_unsafe_sync_exec: false,
23327 deny_env: false,
23328 disk_cache_dir: None,
23329 };
23330 let runtime =
23331 PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
23332 .await
23333 .expect("create runtime");
23334
23335 runtime
23336 .eval(
23337 r#"
23338 globalThis.cwd = pi.process.cwd;
23339 globalThis.args = pi.process.args;
23340 globalThis.pi_process_is_frozen = Object.isFrozen(pi.process);
23341 globalThis.pi_args_is_frozen = Object.isFrozen(pi.process.args);
23342 try { pi.process.cwd = "/hacked"; } catch (_) {}
23343 try { pi.process.args.push("c"); } catch (_) {}
23344 globalThis.cwd_after_mut = pi.process.cwd;
23345 globalThis.args_after_mut = pi.process.args;
23346
23347 globalThis.joined = pi.path.join("/a", "b", "..", "c");
23348 globalThis.base = pi.path.basename("/a/b/c.txt");
23349 globalThis.norm = pi.path.normalize("/a/./b//../c/");
23350
23351 globalThis.bytes = pi.crypto.randomBytes(32);
23352
23353 globalThis.now = pi.time.nowMs();
23354 globalThis.done = false;
23355 pi.time.sleep(10).then(() => { globalThis.done = true; });
23356 "#,
23357 )
23358 .await
23359 .expect("eval apis");
23360
23361 for (key, expected) in [
23362 ("cwd", serde_json::json!("/virtual/cwd")),
23363 ("args", serde_json::json!(["a", "b"])),
23364 ("pi_process_is_frozen", serde_json::json!(true)),
23365 ("pi_args_is_frozen", serde_json::json!(true)),
23366 ("cwd_after_mut", serde_json::json!("/virtual/cwd")),
23367 ("args_after_mut", serde_json::json!(["a", "b"])),
23368 ("joined", serde_json::json!("/a/c")),
23369 ("base", serde_json::json!("c.txt")),
23370 ("norm", serde_json::json!("/a/c")),
23371 ] {
23372 assert_eq!(get_global_json(&runtime, key).await, expected);
23373 }
23374
23375 let bytes = get_global_json(&runtime, "bytes").await;
23376 let bytes_arr = bytes.as_array().expect("bytes array");
23377 assert_eq!(bytes_arr.len(), 32);
23378 assert!(
23379 bytes_arr
23380 .iter()
23381 .all(|value| value.as_u64().is_some_and(|n| n <= 255)),
23382 "bytes must be numbers in 0..=255: {bytes}"
23383 );
23384
23385 assert_eq!(
23386 get_global_json(&runtime, "now").await,
23387 serde_json::json!(123)
23388 );
23389 assert_eq!(
23390 get_global_json(&runtime, "done").await,
23391 serde_json::json!(false)
23392 );
23393
23394 clock.set(133);
23395 runtime.tick().await.expect("tick sleep");
23396 assert_eq!(
23397 get_global_json(&runtime, "done").await,
23398 serde_json::json!(true)
23399 );
23400 });
23401 }
23402
23403 #[test]
23404 fn pijs_random_bytes_helper_propagates_fill_errors() {
23405 let err = fill_random_bytes_with(16, |_| Err("entropy unavailable")).unwrap_err();
23406 assert_eq!(err, "entropy unavailable");
23407 }
23408
23409 #[test]
23410 fn pijs_crypto_random_bytes_are_not_uuid_patterned() {
23411 futures::executor::block_on(async {
23412 let clock = Arc::new(DeterministicClock::new(0));
23413 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23414 .await
23415 .expect("create runtime");
23416
23417 runtime
23418 .eval(
23419 r"
23420 const bytes = pi.crypto.randomBytes(128);
23421 const blocks = [];
23422 for (let i = 0; i < bytes.length; i += 16) {
23423 blocks.push({
23424 versionNibble: (bytes[i + 6] >> 4) & 0x0f,
23425 variantBits: (bytes[i + 8] >> 6) & 0x03,
23426 });
23427 }
23428 globalThis.randomBytesLookLikeUuidBlocks = blocks.every(
23429 (block) => block.versionNibble === 4 && block.variantBits === 2,
23430 );
23431 ",
23432 )
23433 .await
23434 .expect("eval random bytes pattern");
23435
23436 assert_eq!(
23437 get_global_json(&runtime, "randomBytesLookLikeUuidBlocks").await,
23438 serde_json::json!(false)
23439 );
23440 });
23441 }
23442
23443 #[test]
23444 fn pijs_inbound_event_fifo_and_microtask_fixpoint() {
23445 futures::executor::block_on(async {
23446 let clock = Arc::new(DeterministicClock::new(0));
23447 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23448 .await
23449 .expect("create runtime");
23450
23451 runtime
23452 .eval(
23453 r#"
23454 globalThis.order = [];
23455 __pi_add_event_listener("evt", (payload) => {
23456 globalThis.order.push(payload.n);
23457 Promise.resolve().then(() => globalThis.order.push(payload.n + 1000));
23458 });
23459 "#,
23460 )
23461 .await
23462 .expect("install listener");
23463
23464 runtime.enqueue_event("evt", serde_json::json!({ "n": 1 }));
23465 runtime.enqueue_event("evt", serde_json::json!({ "n": 2 }));
23466
23467 runtime.tick().await.expect("tick 1");
23468 let after_first = get_global_json(&runtime, "order").await;
23469 assert_eq!(after_first, serde_json::json!([1, 1001]));
23470
23471 runtime.tick().await.expect("tick 2");
23472 let after_second = get_global_json(&runtime, "order").await;
23473 assert_eq!(after_second, serde_json::json!([1, 1001, 2, 1002]));
23474 });
23475 }
23476
23477 #[derive(Debug, Clone)]
23478 struct XorShift64 {
23479 state: u64,
23480 }
23481
23482 impl XorShift64 {
23483 const fn new(seed: u64) -> Self {
23484 let seed = seed ^ 0x9E37_79B9_7F4A_7C15;
23485 Self { state: seed }
23486 }
23487
23488 fn next_u64(&mut self) -> u64 {
23489 let mut x = self.state;
23490 x ^= x << 13;
23491 x ^= x >> 7;
23492 x ^= x << 17;
23493 self.state = x;
23494 x
23495 }
23496
23497 fn next_range_u64(&mut self, upper_exclusive: u64) -> u64 {
23498 if upper_exclusive == 0 {
23499 return 0;
23500 }
23501 self.next_u64() % upper_exclusive
23502 }
23503
23504 fn next_usize(&mut self, upper_exclusive: usize) -> usize {
23505 let upper = u64::try_from(upper_exclusive).expect("usize fits u64");
23506 let value = self.next_range_u64(upper);
23507 usize::try_from(value).expect("value < upper_exclusive")
23508 }
23509 }
23510
23511 #[allow(clippy::future_not_send)]
23512 async fn run_seeded_runtime_trace(seed: u64) -> serde_json::Value {
23513 let clock = Arc::new(DeterministicClock::new(0));
23514 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23515 .await
23516 .expect("create runtime");
23517
23518 runtime
23519 .eval(
23520 r#"
23521 globalThis.order = [];
23522 __pi_add_event_listener("evt", (payload) => {
23523 globalThis.order.push("event:" + payload.step);
23524 Promise.resolve().then(() => globalThis.order.push("event-micro:" + payload.step));
23525 });
23526 "#,
23527 )
23528 .await
23529 .expect("init");
23530
23531 let mut rng = XorShift64::new(seed);
23532 let mut timers = Vec::new();
23533
23534 for step in 0..64u64 {
23535 match rng.next_range_u64(6) {
23536 0 => {
23537 runtime
23538 .eval(&format!(
23539 r#"
23540 pi.tool("test", {{ step: {step} }}).then(() => {{
23541 globalThis.order.push("hostcall:{step}");
23542 Promise.resolve().then(() => globalThis.order.push("hostcall-micro:{step}"));
23543 }});
23544 "#
23545 ))
23546 .await
23547 .expect("enqueue hostcall");
23548
23549 for request in runtime.drain_hostcall_requests() {
23550 runtime.complete_hostcall(
23551 request.call_id,
23552 HostcallOutcome::Success(serde_json::json!({ "step": step })),
23553 );
23554 }
23555 }
23556 1 => {
23557 let delay_ms = rng.next_range_u64(25);
23558 let timer_id = runtime.set_timeout(delay_ms);
23559 timers.push(timer_id);
23560 runtime
23561 .eval(&format!(
23562 r#"__pi_register_timer({timer_id}, () => {{
23563 globalThis.order.push("timer:{step}");
23564 Promise.resolve().then(() => globalThis.order.push("timer-micro:{step}"));
23565 }});"#
23566 ))
23567 .await
23568 .expect("register timer");
23569 }
23570 2 => {
23571 runtime.enqueue_event("evt", serde_json::json!({ "step": step }));
23572 }
23573 3 if !timers.is_empty() => {
23574 let idx = rng.next_usize(timers.len());
23575 let _ = runtime.clear_timeout(timers[idx]);
23576 }
23577 4 => {
23578 let delta_ms = rng.next_range_u64(50);
23579 clock.advance(delta_ms);
23580 }
23581 _ => {}
23582 }
23583
23584 for _ in 0..3 {
23586 if !runtime.has_pending() {
23587 break;
23588 }
23589 let _ = runtime.tick().await.expect("tick");
23590 }
23591 }
23592
23593 drain_until_idle(&runtime, &clock).await;
23594 get_global_json(&runtime, "order").await
23595 }
23596
23597 #[test]
23598 fn pijs_seeded_trace_is_deterministic() {
23599 futures::executor::block_on(async {
23600 let a = run_seeded_runtime_trace(0x00C0_FFEE).await;
23601 let b = run_seeded_runtime_trace(0x00C0_FFEE).await;
23602 assert_eq!(a, b);
23603 });
23604 }
23605
23606 #[test]
23607 fn pijs_events_on_returns_unsubscribe_and_removes_handler() {
23608 futures::executor::block_on(async {
23609 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23610 .await
23611 .expect("create runtime");
23612
23613 runtime
23614 .eval(
23615 r#"
23616 globalThis.seen = [];
23617 globalThis.done = false;
23618
23619 __pi_begin_extension("ext.b", { name: "ext.b" });
23620 const off = pi.events.on("custom_event", (payload, _ctx) => { globalThis.seen.push(payload); });
23621 if (typeof off !== "function") throw new Error("expected unsubscribe function");
23622 __pi_end_extension();
23623
23624 (async () => {
23625 await __pi_dispatch_extension_event("custom_event", { n: 1 }, {});
23626 off();
23627 await __pi_dispatch_extension_event("custom_event", { n: 2 }, {});
23628 globalThis.done = true;
23629 })();
23630 "#,
23631 )
23632 .await
23633 .expect("eval");
23634
23635 assert_eq!(
23636 get_global_json(&runtime, "done").await,
23637 serde_json::Value::Bool(true)
23638 );
23639 assert_eq!(
23640 get_global_json(&runtime, "seen").await,
23641 serde_json::json!([{ "n": 1 }])
23642 );
23643 });
23644 }
23645
23646 #[test]
23647 fn pijs_event_dispatch_continues_after_handler_error() {
23648 futures::executor::block_on(async {
23649 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23650 .await
23651 .expect("create runtime");
23652
23653 runtime
23654 .eval(
23655 r#"
23656 globalThis.seen = [];
23657 globalThis.done = false;
23658
23659 __pi_begin_extension("ext.err", { name: "ext.err" });
23660 pi.events.on("custom_event", (_payload, _ctx) => { throw new Error("boom"); });
23661 __pi_end_extension();
23662
23663 __pi_begin_extension("ext.ok", { name: "ext.ok" });
23664 pi.events.on("custom_event", (payload, _ctx) => { globalThis.seen.push(payload); });
23665 __pi_end_extension();
23666
23667 (async () => {
23668 await __pi_dispatch_extension_event("custom_event", { hello: "world" }, {});
23669 globalThis.done = true;
23670 })();
23671 "#,
23672 )
23673 .await
23674 .expect("eval");
23675
23676 assert_eq!(
23677 get_global_json(&runtime, "done").await,
23678 serde_json::Value::Bool(true)
23679 );
23680 assert_eq!(
23681 get_global_json(&runtime, "seen").await,
23682 serde_json::json!([{ "hello": "world" }])
23683 );
23684 });
23685 }
23686
23687 #[test]
23690 fn pijs_crash_register_throw_host_continues() {
23691 futures::executor::block_on(async {
23692 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23693 .await
23694 .expect("create runtime");
23695
23696 runtime
23698 .eval(
23699 r#"
23700 globalThis.postCrashResult = null;
23701
23702 __pi_begin_extension("ext.crash", { name: "ext.crash" });
23703 // Simulate a throw during registration by registering a handler then
23704 // throwing - the handler should still be partially registered
23705 throw new Error("registration boom");
23706 "#,
23707 )
23708 .await
23709 .ok(); runtime.eval(r"__pi_end_extension();").await.ok();
23713
23714 runtime
23716 .eval(
23717 r#"
23718 __pi_begin_extension("ext.ok", { name: "ext.ok" });
23719 pi.events.on("test_event", (p, _) => { globalThis.postCrashResult = p; });
23720 __pi_end_extension();
23721 "#,
23722 )
23723 .await
23724 .expect("second extension should load");
23725
23726 runtime
23728 .eval(
23729 r#"
23730 (async () => {
23731 await __pi_dispatch_extension_event("test_event", { ok: true }, {});
23732 })();
23733 "#,
23734 )
23735 .await
23736 .expect("dispatch");
23737
23738 assert_eq!(
23739 get_global_json(&runtime, "postCrashResult").await,
23740 serde_json::json!({ "ok": true })
23741 );
23742 });
23743 }
23744
23745 #[test]
23746 fn pijs_crash_handler_throw_other_handlers_run() {
23747 futures::executor::block_on(async {
23748 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23749 .await
23750 .expect("create runtime");
23751
23752 runtime
23753 .eval(
23754 r#"
23755 globalThis.handlerResults = [];
23756 globalThis.dispatchDone = false;
23757
23758 // Extension A: will throw
23759 __pi_begin_extension("ext.a", { name: "ext.a" });
23760 pi.events.on("multi_test", (_p, _c) => {
23761 globalThis.handlerResults.push("a-before-throw");
23762 throw new Error("handler crash");
23763 });
23764 __pi_end_extension();
23765
23766 // Extension B: should still run
23767 __pi_begin_extension("ext.b", { name: "ext.b" });
23768 pi.events.on("multi_test", (_p, _c) => {
23769 globalThis.handlerResults.push("b-ok");
23770 });
23771 __pi_end_extension();
23772
23773 // Extension C: should also still run
23774 __pi_begin_extension("ext.c", { name: "ext.c" });
23775 pi.events.on("multi_test", (_p, _c) => {
23776 globalThis.handlerResults.push("c-ok");
23777 });
23778 __pi_end_extension();
23779
23780 (async () => {
23781 await __pi_dispatch_extension_event("multi_test", {}, {});
23782 globalThis.dispatchDone = true;
23783 })();
23784 "#,
23785 )
23786 .await
23787 .expect("eval");
23788
23789 assert_eq!(
23790 get_global_json(&runtime, "dispatchDone").await,
23791 serde_json::Value::Bool(true)
23792 );
23793
23794 let results = get_global_json(&runtime, "handlerResults").await;
23795 let arr = results.as_array().expect("should be array");
23796 assert!(
23798 arr.iter().any(|v| v == "a-before-throw"),
23799 "Handler A should have run before throwing"
23800 );
23801 assert!(
23803 arr.iter().any(|v| v == "b-ok"),
23804 "Handler B should run after A crashes"
23805 );
23806 assert!(
23807 arr.iter().any(|v| v == "c-ok"),
23808 "Handler C should run after A crashes"
23809 );
23810 });
23811 }
23812
23813 #[test]
23814 fn pijs_crash_invalid_hostcall_returns_error_not_panic() {
23815 futures::executor::block_on(async {
23816 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23817 .await
23818 .expect("create runtime");
23819
23820 runtime
23822 .eval(
23823 r#"
23824 globalThis.invalidResult = null;
23825 globalThis.errCode = null;
23826
23827 __pi_begin_extension("ext.bad", { name: "ext.bad" });
23828 pi.tool("completely_nonexistent_tool_xyz", { junk: true })
23829 .then((r) => { globalThis.invalidResult = r; })
23830 .catch((e) => { globalThis.errCode = e.code || "unknown"; });
23831 __pi_end_extension();
23832 "#,
23833 )
23834 .await
23835 .expect("eval");
23836
23837 let requests = runtime.drain_hostcall_requests();
23839 assert_eq!(requests.len(), 1, "Hostcall should be queued");
23840
23841 runtime
23843 .eval(
23844 r"
23845 globalThis.hostStillAlive = true;
23846 ",
23847 )
23848 .await
23849 .expect("host should still work");
23850
23851 assert_eq!(
23852 get_global_json(&runtime, "hostStillAlive").await,
23853 serde_json::Value::Bool(true)
23854 );
23855 });
23856 }
23857
23858 #[test]
23859 fn pijs_crash_after_crash_new_extensions_load() {
23860 futures::executor::block_on(async {
23861 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23862 .await
23863 .expect("create runtime");
23864
23865 runtime
23867 .eval(
23868 r#"
23869 globalThis.loadOrder = [];
23870
23871 // Extension 1: loads fine
23872 __pi_begin_extension("ext.1", { name: "ext.1" });
23873 globalThis.loadOrder.push("1-loaded");
23874 __pi_end_extension();
23875 "#,
23876 )
23877 .await
23878 .expect("ext 1");
23879
23880 runtime
23882 .eval(
23883 r#"
23884 __pi_begin_extension("ext.2", { name: "ext.2" });
23885 globalThis.loadOrder.push("2-before-crash");
23886 throw new Error("ext 2 crash");
23887 "#,
23888 )
23889 .await
23890 .ok(); runtime.eval(r"__pi_end_extension();").await.ok();
23893
23894 runtime
23896 .eval(
23897 r#"
23898 __pi_begin_extension("ext.3", { name: "ext.3" });
23899 globalThis.loadOrder.push("3-loaded");
23900 __pi_end_extension();
23901 "#,
23902 )
23903 .await
23904 .expect("ext 3 should load after crash");
23905
23906 runtime
23908 .eval(
23909 r#"
23910 __pi_begin_extension("ext.4", { name: "ext.4" });
23911 globalThis.loadOrder.push("4-loaded");
23912 __pi_end_extension();
23913 "#,
23914 )
23915 .await
23916 .expect("ext 4 should load");
23917
23918 let order = get_global_json(&runtime, "loadOrder").await;
23919 let arr = order.as_array().expect("should be array");
23920 assert!(
23921 arr.iter().any(|v| v == "1-loaded"),
23922 "Extension 1 should have loaded"
23923 );
23924 assert!(
23925 arr.iter().any(|v| v == "3-loaded"),
23926 "Extension 3 should load after crash"
23927 );
23928 assert!(
23929 arr.iter().any(|v| v == "4-loaded"),
23930 "Extension 4 should load after crash"
23931 );
23932 });
23933 }
23934
23935 #[test]
23936 fn pijs_crash_no_cross_contamination_between_extensions() {
23937 futures::executor::block_on(async {
23938 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23939 .await
23940 .expect("create runtime");
23941
23942 runtime
23943 .eval(
23944 r#"
23945 globalThis.extAData = null;
23946 globalThis.extBData = null;
23947 globalThis.eventsDone = false;
23948
23949 // Extension A: sets its own state
23950 __pi_begin_extension("ext.isolated.a", { name: "ext.isolated.a" });
23951 pi.events.on("isolation_test", (_p, _c) => {
23952 globalThis.extAData = "from-A";
23953 });
23954 __pi_end_extension();
23955
23956 // Extension B: sets its own state independently
23957 __pi_begin_extension("ext.isolated.b", { name: "ext.isolated.b" });
23958 pi.events.on("isolation_test", (_p, _c) => {
23959 globalThis.extBData = "from-B";
23960 });
23961 __pi_end_extension();
23962
23963 (async () => {
23964 await __pi_dispatch_extension_event("isolation_test", {}, {});
23965 globalThis.eventsDone = true;
23966 })();
23967 "#,
23968 )
23969 .await
23970 .expect("eval");
23971
23972 assert_eq!(
23973 get_global_json(&runtime, "eventsDone").await,
23974 serde_json::Value::Bool(true)
23975 );
23976 assert_eq!(
23978 get_global_json(&runtime, "extAData").await,
23979 serde_json::json!("from-A")
23980 );
23981 assert_eq!(
23982 get_global_json(&runtime, "extBData").await,
23983 serde_json::json!("from-B")
23984 );
23985 });
23986 }
23987
23988 #[test]
23989 fn pijs_host_read_denies_cross_extension_root_access() {
23990 futures::executor::block_on(async {
23991 let temp_dir = tempfile::tempdir().expect("tempdir");
23992 let workspace = temp_dir.path().join("workspace");
23993 let ext_a = temp_dir.path().join("ext-a");
23994 let ext_b = temp_dir.path().join("ext-b");
23995 std::fs::create_dir_all(&workspace).expect("mkdir workspace");
23996 std::fs::create_dir_all(&ext_a).expect("mkdir ext-a");
23997 std::fs::create_dir_all(&ext_b).expect("mkdir ext-b");
23998 let secret_path = ext_a.join("secret.txt");
23999 std::fs::write(&secret_path, "top-secret").expect("write secret");
24000
24001 let config = PiJsRuntimeConfig {
24002 cwd: workspace.display().to_string(),
24003 ..PiJsRuntimeConfig::default()
24004 };
24005 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
24006 DeterministicClock::new(0),
24007 config,
24008 None,
24009 )
24010 .await
24011 .expect("create runtime");
24012 runtime.add_extension_root_with_id(ext_a, Some("ext.a"));
24013 runtime.add_extension_root_with_id(ext_b, Some("ext.b"));
24014
24015 let script = format!(
24016 r#"
24017 globalThis.crossExtensionRead = {{}};
24018 import('node:module').then(({{ createRequire }}) => {{
24019 const require = createRequire('/tmp/example.js');
24020 const fs = require('node:fs');
24021 return __pi_with_extension_async("ext.b", async () => {{
24022 try {{
24023 globalThis.crossExtensionRead.value = fs.readFileSync({secret_path:?}, 'utf8');
24024 globalThis.crossExtensionRead.ok = true;
24025 }} catch (err) {{
24026 globalThis.crossExtensionRead.ok = false;
24027 globalThis.crossExtensionRead.error = String((err && err.message) || err || '');
24028 }}
24029 }});
24030 }}).finally(() => {{
24031 globalThis.crossExtensionRead.done = true;
24032 }});
24033 "#
24034 );
24035 runtime
24036 .eval(&script)
24037 .await
24038 .expect("eval cross-extension read");
24039
24040 let result = get_global_json(&runtime, "crossExtensionRead").await;
24041 assert_eq!(result["done"], serde_json::json!(true));
24042 assert_eq!(result["ok"], serde_json::json!(false));
24043 let error = result["error"].as_str().unwrap_or_default();
24044 assert!(
24045 error.contains("host read denied"),
24046 "expected host read denial, got: {error}"
24047 );
24048 });
24049 }
24050
24051 #[test]
24052 fn pijs_host_read_allows_idless_extension_root_for_active_extension() {
24053 futures::executor::block_on(async {
24054 let temp_dir = tempfile::tempdir().expect("tempdir");
24055 let workspace = temp_dir.path().join("workspace");
24056 let ext_root = temp_dir.path().join("ext");
24057 std::fs::create_dir_all(&workspace).expect("mkdir workspace");
24058 std::fs::create_dir_all(&ext_root).expect("mkdir ext");
24059 let asset_path = ext_root.join("asset.txt");
24060 std::fs::write(&asset_path, "legacy-root-access").expect("write asset");
24061
24062 let config = PiJsRuntimeConfig {
24063 cwd: workspace.display().to_string(),
24064 ..PiJsRuntimeConfig::default()
24065 };
24066 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
24067 DeterministicClock::new(0),
24068 config,
24069 None,
24070 )
24071 .await
24072 .expect("create runtime");
24073 runtime.add_extension_root(ext_root);
24074
24075 let script = format!(
24076 r#"
24077 globalThis.legacyRootRead = {{}};
24078 import('node:module').then(({{ createRequire }}) => {{
24079 const require = createRequire('/tmp/example.js');
24080 const fs = require('node:fs');
24081 return __pi_with_extension_async("ext.legacy", async () => {{
24082 try {{
24083 globalThis.legacyRootRead.value = fs.readFileSync({asset_path:?}, 'utf8');
24084 globalThis.legacyRootRead.ok = true;
24085 }} catch (err) {{
24086 globalThis.legacyRootRead.ok = false;
24087 globalThis.legacyRootRead.error = String((err && err.message) || err || '');
24088 }}
24089 }});
24090 }}).finally(() => {{
24091 globalThis.legacyRootRead.done = true;
24092 }});
24093 "#
24094 );
24095 runtime
24096 .eval(&script)
24097 .await
24098 .expect("eval id-less extension root read");
24099
24100 let result = get_global_json(&runtime, "legacyRootRead").await;
24101 assert_eq!(result["done"], serde_json::json!(true));
24102 assert_eq!(result["ok"], serde_json::json!(true));
24103 assert_eq!(result["value"], serde_json::json!("legacy-root-access"));
24104 });
24105 }
24106
24107 #[test]
24108 fn pijs_host_write_denies_cross_extension_root_access() {
24109 futures::executor::block_on(async {
24110 let temp_dir = tempfile::tempdir().expect("tempdir");
24111 let workspace = temp_dir.path().join("workspace");
24112 let ext_a = temp_dir.path().join("ext-a");
24113 let ext_b = temp_dir.path().join("ext-b");
24114 std::fs::create_dir_all(&workspace).expect("mkdir workspace");
24115 std::fs::create_dir_all(&ext_a).expect("mkdir ext-a");
24116 std::fs::create_dir_all(&ext_b).expect("mkdir ext-b");
24117 let target_path = ext_a.join("owned.txt");
24118
24119 let config = PiJsRuntimeConfig {
24120 cwd: workspace.display().to_string(),
24121 ..PiJsRuntimeConfig::default()
24122 };
24123 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
24124 DeterministicClock::new(0),
24125 config,
24126 None,
24127 )
24128 .await
24129 .expect("create runtime");
24130 runtime.add_extension_root_with_id(ext_a, Some("ext.a"));
24131 runtime.add_extension_root_with_id(ext_b, Some("ext.b"));
24132
24133 let script = format!(
24134 r#"
24135 globalThis.crossExtensionWrite = {{}};
24136 import('node:module').then(({{ createRequire }}) => {{
24137 const require = createRequire('/tmp/example.js');
24138 const fs = require('node:fs');
24139 return __pi_with_extension_async("ext.b", async () => {{
24140 try {{
24141 fs.writeFileSync({target_path:?}, 'owned');
24142 globalThis.crossExtensionWrite.ok = true;
24143 }} catch (err) {{
24144 globalThis.crossExtensionWrite.ok = false;
24145 globalThis.crossExtensionWrite.error = String((err && err.message) || err || '');
24146 }}
24147 globalThis.crossExtensionWrite.exists = fs.existsSync({target_path:?});
24148 }});
24149 }}).finally(() => {{
24150 globalThis.crossExtensionWrite.done = true;
24151 }});
24152 "#
24153 );
24154 runtime
24155 .eval(&script)
24156 .await
24157 .expect("eval cross-extension write");
24158
24159 let result = get_global_json(&runtime, "crossExtensionWrite").await;
24160 assert_eq!(result["done"], serde_json::json!(true));
24161 assert_eq!(result["ok"], serde_json::json!(false));
24162 assert_eq!(result["exists"], serde_json::json!(false));
24163 let error = result["error"].as_str().unwrap_or_default();
24164 assert!(
24165 error.contains("host write denied"),
24166 "expected host write denial, got: {error}"
24167 );
24168 });
24169 }
24170
24171 #[test]
24172 fn pijs_crash_interrupt_budget_stops_infinite_loop() {
24173 futures::executor::block_on(async {
24174 let config = PiJsRuntimeConfig {
24175 limits: PiJsRuntimeLimits {
24176 interrupt_budget: Some(1000),
24178 ..Default::default()
24179 },
24180 ..Default::default()
24181 };
24182 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
24183 DeterministicClock::new(0),
24184 config,
24185 None,
24186 )
24187 .await
24188 .expect("create runtime");
24189
24190 let result = runtime
24192 .eval(
24193 r"
24194 let i = 0;
24195 while (true) { i++; }
24196 globalThis.loopResult = i;
24197 ",
24198 )
24199 .await;
24200
24201 assert!(
24203 result.is_err(),
24204 "Infinite loop should be interrupted by budget"
24205 );
24206
24207 let alive_result = runtime.eval(r#"globalThis.postInterrupt = "alive";"#).await;
24209 if alive_result.is_ok() {
24212 assert_eq!(
24213 get_global_json(&runtime, "postInterrupt").await,
24214 serde_json::json!("alive")
24215 );
24216 }
24217 });
24218 }
24219
24220 #[test]
24221 fn pijs_events_emit_queues_events_hostcall() {
24222 futures::executor::block_on(async {
24223 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
24224 .await
24225 .expect("create runtime");
24226
24227 runtime
24228 .eval(
24229 r#"
24230 __pi_begin_extension("ext.test", { name: "Test" });
24231 pi.events.emit("custom_event", { a: 1 });
24232 __pi_end_extension();
24233 "#,
24234 )
24235 .await
24236 .expect("eval");
24237
24238 let requests = runtime.drain_hostcall_requests();
24239 assert_eq!(requests.len(), 1);
24240
24241 let req = &requests[0];
24242 assert_eq!(req.extension_id.as_deref(), Some("ext.test"));
24243 assert!(
24244 matches!(&req.kind, HostcallKind::Events { op } if op == "emit"),
24245 "unexpected hostcall kind: {:?}",
24246 req.kind
24247 );
24248 assert_eq!(
24249 req.payload,
24250 serde_json::json!({ "event": "custom_event", "data": { "a": 1 } })
24251 );
24252 });
24253 }
24254
24255 #[test]
24256 fn pijs_console_global_is_defined_and_callable() {
24257 futures::executor::block_on(async {
24258 let clock = Arc::new(DeterministicClock::new(0));
24259 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24260 .await
24261 .expect("create runtime");
24262
24263 runtime
24265 .eval(
24266 r"
24267 globalThis.console_exists = typeof globalThis.console === 'object';
24268 globalThis.has_log = typeof console.log === 'function';
24269 globalThis.has_warn = typeof console.warn === 'function';
24270 globalThis.has_error = typeof console.error === 'function';
24271 globalThis.has_info = typeof console.info === 'function';
24272 globalThis.has_debug = typeof console.debug === 'function';
24273 globalThis.has_trace = typeof console.trace === 'function';
24274 globalThis.has_dir = typeof console.dir === 'function';
24275 globalThis.has_assert = typeof console.assert === 'function';
24276 globalThis.has_table = typeof console.table === 'function';
24277
24278 // Call each method to ensure they don't throw
24279 console.log('test log', 42, { key: 'value' });
24280 console.warn('test warn');
24281 console.error('test error');
24282 console.info('test info');
24283 console.debug('test debug');
24284 console.trace('test trace');
24285 console.dir({ a: 1 });
24286 console.assert(true, 'should not appear');
24287 console.assert(false, 'assertion failed message');
24288 console.table([1, 2, 3]);
24289 console.time();
24290 console.timeEnd();
24291 console.group();
24292 console.groupEnd();
24293 console.clear();
24294
24295 globalThis.calls_succeeded = true;
24296 ",
24297 )
24298 .await
24299 .expect("eval console tests");
24300
24301 assert_eq!(
24302 get_global_json(&runtime, "console_exists").await,
24303 serde_json::json!(true)
24304 );
24305 assert_eq!(
24306 get_global_json(&runtime, "has_log").await,
24307 serde_json::json!(true)
24308 );
24309 assert_eq!(
24310 get_global_json(&runtime, "has_warn").await,
24311 serde_json::json!(true)
24312 );
24313 assert_eq!(
24314 get_global_json(&runtime, "has_error").await,
24315 serde_json::json!(true)
24316 );
24317 assert_eq!(
24318 get_global_json(&runtime, "has_info").await,
24319 serde_json::json!(true)
24320 );
24321 assert_eq!(
24322 get_global_json(&runtime, "has_debug").await,
24323 serde_json::json!(true)
24324 );
24325 assert_eq!(
24326 get_global_json(&runtime, "has_trace").await,
24327 serde_json::json!(true)
24328 );
24329 assert_eq!(
24330 get_global_json(&runtime, "has_dir").await,
24331 serde_json::json!(true)
24332 );
24333 assert_eq!(
24334 get_global_json(&runtime, "has_assert").await,
24335 serde_json::json!(true)
24336 );
24337 assert_eq!(
24338 get_global_json(&runtime, "has_table").await,
24339 serde_json::json!(true)
24340 );
24341 assert_eq!(
24342 get_global_json(&runtime, "calls_succeeded").await,
24343 serde_json::json!(true)
24344 );
24345 });
24346 }
24347
24348 #[test]
24349 fn pijs_node_events_module_provides_event_emitter() {
24350 futures::executor::block_on(async {
24351 let clock = Arc::new(DeterministicClock::new(0));
24352 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24353 .await
24354 .expect("create runtime");
24355
24356 runtime
24358 .eval(
24359 r"
24360 globalThis.results = [];
24361 globalThis.testDone = false;
24362
24363 import('node:events').then(({ EventEmitter }) => {
24364 const emitter = new EventEmitter();
24365
24366 emitter.on('data', (val) => globalThis.results.push('data:' + val));
24367 emitter.once('done', () => globalThis.results.push('done'));
24368
24369 emitter.emit('data', 1);
24370 emitter.emit('data', 2);
24371 emitter.emit('done');
24372 emitter.emit('done'); // should not fire again
24373
24374 globalThis.listenerCount = emitter.listenerCount('data');
24375 globalThis.eventNames = emitter.eventNames();
24376 globalThis.testDone = true;
24377 });
24378 ",
24379 )
24380 .await
24381 .expect("eval EventEmitter test");
24382
24383 assert_eq!(
24384 get_global_json(&runtime, "testDone").await,
24385 serde_json::json!(true)
24386 );
24387 assert_eq!(
24388 get_global_json(&runtime, "results").await,
24389 serde_json::json!(["data:1", "data:2", "done"])
24390 );
24391 assert_eq!(
24392 get_global_json(&runtime, "listenerCount").await,
24393 serde_json::json!(1)
24394 );
24395 assert_eq!(
24396 get_global_json(&runtime, "eventNames").await,
24397 serde_json::json!(["data"])
24398 );
24399 });
24400 }
24401
24402 #[test]
24403 fn pijs_bare_module_aliases_resolve_correctly() {
24404 futures::executor::block_on(async {
24405 let clock = Arc::new(DeterministicClock::new(0));
24406 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24407 .await
24408 .expect("create runtime");
24409
24410 runtime
24412 .eval(
24413 r"
24414 globalThis.bare_events_ok = false;
24415 import('events').then((mod) => {
24416 const e = new mod.default();
24417 globalThis.bare_events_ok = typeof e.on === 'function';
24418 });
24419 ",
24420 )
24421 .await
24422 .expect("eval bare events import");
24423
24424 assert_eq!(
24425 get_global_json(&runtime, "bare_events_ok").await,
24426 serde_json::json!(true)
24427 );
24428 });
24429 }
24430
24431 #[test]
24432 fn pijs_path_extended_functions() {
24433 futures::executor::block_on(async {
24434 let clock = Arc::new(DeterministicClock::new(0));
24435 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24436 .await
24437 .expect("create runtime");
24438
24439 runtime
24440 .eval(
24441 r"
24442 globalThis.pathResults = {};
24443 import('node:path').then((path) => {
24444 globalThis.pathResults.isAbsRoot = path.isAbsolute('/foo/bar');
24445 globalThis.pathResults.isAbsRel = path.isAbsolute('foo/bar');
24446 globalThis.pathResults.extJs = path.extname('/a/b/file.js');
24447 globalThis.pathResults.extNone = path.extname('/a/b/noext');
24448 globalThis.pathResults.extDot = path.extname('.hidden');
24449 globalThis.pathResults.norm = path.normalize('/a/b/../c/./d');
24450 globalThis.pathResults.parseBase = path.parse('/home/user/file.txt').base;
24451 globalThis.pathResults.parseExt = path.parse('/home/user/file.txt').ext;
24452 globalThis.pathResults.parseName = path.parse('/home/user/file.txt').name;
24453 globalThis.pathResults.parseDir = path.parse('/home/user/file.txt').dir;
24454 globalThis.pathResults.hasPosix = typeof path.posix === 'object';
24455 globalThis.pathResults.done = true;
24456 });
24457 ",
24458 )
24459 .await
24460 .expect("eval path extended");
24461
24462 let r = get_global_json(&runtime, "pathResults").await;
24463 assert_eq!(r["done"], serde_json::json!(true));
24464 assert_eq!(r["isAbsRoot"], serde_json::json!(true));
24465 assert_eq!(r["isAbsRel"], serde_json::json!(false));
24466 assert_eq!(r["extJs"], serde_json::json!(".js"));
24467 assert_eq!(r["extNone"], serde_json::json!(""));
24468 assert_eq!(r["extDot"], serde_json::json!(""));
24469 assert_eq!(r["norm"], serde_json::json!("/a/c/d"));
24470 assert_eq!(r["parseBase"], serde_json::json!("file.txt"));
24471 assert_eq!(r["parseExt"], serde_json::json!(".txt"));
24472 assert_eq!(r["parseName"], serde_json::json!("file"));
24473 assert_eq!(r["parseDir"], serde_json::json!("/home/user"));
24474 assert_eq!(r["hasPosix"], serde_json::json!(true));
24475 });
24476 }
24477
24478 #[test]
24479 fn pijs_fs_callback_apis() {
24480 futures::executor::block_on(async {
24481 let clock = Arc::new(DeterministicClock::new(0));
24482 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24483 .await
24484 .expect("create runtime");
24485
24486 runtime
24487 .eval(
24488 r"
24489 globalThis.fsResults = {};
24490 import('node:fs').then((fs) => {
24491 fs.writeFileSync('/fake', '');
24492 // readFile callback
24493 fs.readFile('/fake', 'utf8', (err, data) => {
24494 globalThis.fsResults.readFileCallbackCalled = true;
24495 globalThis.fsResults.readFileData = data;
24496 });
24497 // writeFile callback
24498 fs.writeFile('/fake', 'data', (err) => {
24499 globalThis.fsResults.writeFileCallbackCalled = true;
24500 });
24501 // accessSync throws
24502 try {
24503 fs.accessSync('/nonexistent');
24504 globalThis.fsResults.accessSyncThrew = false;
24505 } catch (e) {
24506 globalThis.fsResults.accessSyncThrew = true;
24507 }
24508 // access callback with error
24509 fs.access('/nonexistent', (err) => {
24510 globalThis.fsResults.accessCallbackErr = !!err;
24511 });
24512 globalThis.fsResults.hasLstatSync = typeof fs.lstatSync === 'function';
24513 globalThis.fsResults.done = true;
24514 });
24515 ",
24516 )
24517 .await
24518 .expect("eval fs callbacks");
24519
24520 let r = get_global_json(&runtime, "fsResults").await;
24521 assert_eq!(r["done"], serde_json::json!(true));
24522 assert_eq!(r["readFileCallbackCalled"], serde_json::json!(true));
24523 assert_eq!(r["readFileData"], serde_json::json!(""));
24524 assert_eq!(r["writeFileCallbackCalled"], serde_json::json!(true));
24525 assert_eq!(r["accessSyncThrew"], serde_json::json!(true));
24526 assert_eq!(r["accessCallbackErr"], serde_json::json!(true));
24527 assert_eq!(r["hasLstatSync"], serde_json::json!(true));
24528 });
24529 }
24530
24531 #[test]
24532 fn pijs_fs_sync_roundtrip_and_dirents() {
24533 futures::executor::block_on(async {
24534 let clock = Arc::new(DeterministicClock::new(0));
24535 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24536 .await
24537 .expect("create runtime");
24538
24539 runtime
24540 .eval(
24541 r"
24542 globalThis.fsRoundTrip = {};
24543 import('node:fs').then((fs) => {
24544 fs.mkdirSync('/tmp/demo', { recursive: true });
24545 fs.writeFileSync('/tmp/demo/hello.txt', 'hello world');
24546 fs.writeFileSync('/tmp/demo/raw.bin', Buffer.from([1, 2, 3, 4]));
24547
24548 globalThis.fsRoundTrip.exists = fs.existsSync('/tmp/demo/hello.txt');
24549 globalThis.fsRoundTrip.readText = fs.readFileSync('/tmp/demo/hello.txt', 'utf8');
24550 const raw = fs.readFileSync('/tmp/demo/raw.bin');
24551 globalThis.fsRoundTrip.rawLen = raw.length;
24552
24553 const names = fs.readdirSync('/tmp/demo');
24554 globalThis.fsRoundTrip.names = names;
24555
24556 const dirents = fs.readdirSync('/tmp/demo', { withFileTypes: true });
24557 globalThis.fsRoundTrip.direntHasMethods =
24558 typeof dirents[0].isFile === 'function' &&
24559 typeof dirents[0].isDirectory === 'function';
24560
24561 const dirStat = fs.statSync('/tmp/demo');
24562 const fileStat = fs.statSync('/tmp/demo/hello.txt');
24563 globalThis.fsRoundTrip.isDir = dirStat.isDirectory();
24564 globalThis.fsRoundTrip.isFile = fileStat.isFile();
24565 globalThis.fsRoundTrip.done = true;
24566 });
24567 ",
24568 )
24569 .await
24570 .expect("eval fs sync roundtrip");
24571
24572 let r = get_global_json(&runtime, "fsRoundTrip").await;
24573 assert_eq!(r["done"], serde_json::json!(true));
24574 assert_eq!(r["exists"], serde_json::json!(true));
24575 assert_eq!(r["readText"], serde_json::json!("hello world"));
24576 assert_eq!(r["rawLen"], serde_json::json!(4));
24577 assert_eq!(r["isDir"], serde_json::json!(true));
24578 assert_eq!(r["isFile"], serde_json::json!(true));
24579 assert_eq!(r["direntHasMethods"], serde_json::json!(true));
24580 assert_eq!(r["names"], serde_json::json!(["hello.txt", "raw.bin"]));
24581 });
24582 }
24583
24584 #[test]
24585 fn pijs_create_require_supports_node_builtins() {
24586 futures::executor::block_on(async {
24587 let clock = Arc::new(DeterministicClock::new(0));
24588 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24589 .await
24590 .expect("create runtime");
24591
24592 runtime
24593 .eval(
24594 r"
24595 globalThis.requireResults = {};
24596 import('node:module').then(({ createRequire }) => {
24597 const require = createRequire('/tmp/example.js');
24598 const path = require('path');
24599 const fs = require('node:fs');
24600 const crypto = require('crypto');
24601 const http2 = require('http2');
24602
24603 globalThis.requireResults.pathJoinWorks = path.join('a', 'b') === 'a/b';
24604 globalThis.requireResults.fsReadFileSync = typeof fs.readFileSync === 'function';
24605 globalThis.requireResults.cryptoHasRandomUUID = typeof crypto.randomUUID === 'function';
24606 globalThis.requireResults.http2HasConnect = typeof http2.connect === 'function';
24607 globalThis.requireResults.http2PathHeader = http2.constants.HTTP2_HEADER_PATH;
24608
24609 try {
24610 const missing = require('left-pad');
24611 globalThis.requireResults.missingModuleThrows = false;
24612 globalThis.requireResults.missingModuleIsStub =
24613 typeof missing === 'function' &&
24614 typeof missing.default === 'function' &&
24615 typeof missing.anyNestedProperty === 'function';
24616 } catch (err) {
24617 globalThis.requireResults.missingModuleThrows = true;
24618 globalThis.requireResults.missingModuleIsStub = false;
24619 }
24620 globalThis.requireResults.done = true;
24621 });
24622 ",
24623 )
24624 .await
24625 .expect("eval createRequire test");
24626
24627 let r = get_global_json(&runtime, "requireResults").await;
24628 assert_eq!(r["done"], serde_json::json!(true));
24629 assert_eq!(r["pathJoinWorks"], serde_json::json!(true));
24630 assert_eq!(r["fsReadFileSync"], serde_json::json!(true));
24631 assert_eq!(r["cryptoHasRandomUUID"], serde_json::json!(true));
24632 assert_eq!(r["http2HasConnect"], serde_json::json!(true));
24633 assert_eq!(r["http2PathHeader"], serde_json::json!(":path"));
24634 assert_eq!(r["missingModuleThrows"], serde_json::json!(false));
24635 assert_eq!(r["missingModuleIsStub"], serde_json::json!(true));
24636 });
24637 }
24638
24639 #[test]
24640 fn pijs_fs_promises_delegates_to_node_fs_promises_api() {
24641 futures::executor::block_on(async {
24642 let clock = Arc::new(DeterministicClock::new(0));
24643 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24644 .await
24645 .expect("create runtime");
24646
24647 runtime
24648 .eval(
24649 r"
24650 globalThis.fsPromisesResults = {};
24651 import('node:fs/promises').then(async (fsp) => {
24652 await fsp.mkdir('/tmp/promise-demo', { recursive: true });
24653 await fsp.writeFile('/tmp/promise-demo/value.txt', 'value');
24654 const text = await fsp.readFile('/tmp/promise-demo/value.txt', 'utf8');
24655 const names = await fsp.readdir('/tmp/promise-demo');
24656
24657 globalThis.fsPromisesResults.readText = text;
24658 globalThis.fsPromisesResults.names = names;
24659 globalThis.fsPromisesResults.done = true;
24660 });
24661 ",
24662 )
24663 .await
24664 .expect("eval fs promises test");
24665
24666 let r = get_global_json(&runtime, "fsPromisesResults").await;
24667 assert_eq!(r["done"], serde_json::json!(true));
24668 assert_eq!(r["readText"], serde_json::json!("value"));
24669 assert_eq!(r["names"], serde_json::json!(["value.txt"]));
24670 });
24671 }
24672
24673 #[test]
24674 fn pijs_child_process_spawn_emits_data_and_close() {
24675 futures::executor::block_on(async {
24676 let clock = Arc::new(DeterministicClock::new(0));
24677 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24678 .await
24679 .expect("create runtime");
24680
24681 runtime
24682 .eval(
24683 r"
24684 globalThis.childProcessResult = { events: [] };
24685 import('node:child_process').then(({ spawn }) => {
24686 const child = spawn('pi', ['--version'], {
24687 shell: false,
24688 stdio: ['ignore', 'pipe', 'pipe'],
24689 });
24690 let stdout = '';
24691 let stderr = '';
24692 child.stdout?.on('data', (chunk) => {
24693 stdout += chunk.toString();
24694 globalThis.childProcessResult.events.push('stdout');
24695 });
24696 child.stderr?.on('data', (chunk) => {
24697 stderr += chunk.toString();
24698 globalThis.childProcessResult.events.push('stderr');
24699 });
24700 child.on('error', (err) => {
24701 globalThis.childProcessResult.error =
24702 String((err && err.message) || err || '');
24703 globalThis.childProcessResult.done = true;
24704 });
24705 child.on('exit', (code, signal) => {
24706 globalThis.childProcessResult.events.push('exit');
24707 globalThis.childProcessResult.exitCode = code;
24708 globalThis.childProcessResult.exitSignal = signal;
24709 });
24710 child.on('close', (code) => {
24711 globalThis.childProcessResult.events.push('close');
24712 globalThis.childProcessResult.code = code;
24713 globalThis.childProcessResult.stdout = stdout;
24714 globalThis.childProcessResult.stderr = stderr;
24715 globalThis.childProcessResult.killed = child.killed;
24716 globalThis.childProcessResult.pid = child.pid;
24717 globalThis.childProcessResult.done = true;
24718 });
24719 });
24720 ",
24721 )
24722 .await
24723 .expect("eval child_process spawn script");
24724
24725 let mut requests = runtime.drain_hostcall_requests();
24726 assert_eq!(requests.len(), 1);
24727 let request = requests.pop_front().expect("exec hostcall");
24728 assert!(
24729 matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "pi"),
24730 "unexpected hostcall kind: {:?}",
24731 request.kind
24732 );
24733
24734 runtime.complete_hostcall(
24735 request.call_id,
24736 HostcallOutcome::Success(serde_json::json!({
24737 "stdout": "line-1\n",
24738 "stderr": "warn-1\n",
24739 "code": 0,
24740 "killed": false
24741 })),
24742 );
24743
24744 drain_until_idle(&runtime, &clock).await;
24745 let r = get_global_json(&runtime, "childProcessResult").await;
24746 assert_eq!(r["done"], serde_json::json!(true));
24747 assert_eq!(r["code"], serde_json::json!(0));
24748 assert_eq!(r["exitCode"], serde_json::json!(0));
24749 assert_eq!(r["exitSignal"], serde_json::Value::Null);
24750 assert_eq!(r["stdout"], serde_json::json!("line-1\n"));
24751 assert_eq!(r["stderr"], serde_json::json!("warn-1\n"));
24752 assert_eq!(r["killed"], serde_json::json!(false));
24753 assert_eq!(
24754 r["events"],
24755 serde_json::json!(["stdout", "stderr", "exit", "close"])
24756 );
24757 });
24758 }
24759
24760 #[test]
24761 fn pijs_child_process_spawn_forwards_timeout_option_to_hostcall() {
24762 futures::executor::block_on(async {
24763 let clock = Arc::new(DeterministicClock::new(0));
24764 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24765 .await
24766 .expect("create runtime");
24767
24768 runtime
24769 .eval(
24770 r"
24771 globalThis.childTimeoutResult = {};
24772 import('node:child_process').then(({ spawn }) => {
24773 const child = spawn('pi', ['--version'], {
24774 shell: false,
24775 timeout: 250,
24776 stdio: ['ignore', 'pipe', 'pipe'],
24777 });
24778 child.on('close', (code) => {
24779 globalThis.childTimeoutResult.code = code;
24780 globalThis.childTimeoutResult.killed = child.killed;
24781 globalThis.childTimeoutResult.done = true;
24782 });
24783 });
24784 ",
24785 )
24786 .await
24787 .expect("eval child_process timeout script");
24788
24789 let mut requests = runtime.drain_hostcall_requests();
24790 assert_eq!(requests.len(), 1);
24791 let request = requests.pop_front().expect("exec hostcall");
24792 assert!(
24793 matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "pi"),
24794 "unexpected hostcall kind: {:?}",
24795 request.kind
24796 );
24797 assert_eq!(
24798 request.payload["options"]["timeout"].as_i64(),
24799 Some(250),
24800 "spawn timeout should be forwarded to hostcall options"
24801 );
24802
24803 runtime.complete_hostcall(
24804 request.call_id,
24805 HostcallOutcome::Success(serde_json::json!({
24806 "stdout": "",
24807 "stderr": "",
24808 "code": 0,
24809 "killed": true
24810 })),
24811 );
24812
24813 drain_until_idle(&runtime, &clock).await;
24814 let r = get_global_json(&runtime, "childTimeoutResult").await;
24815 assert_eq!(r["done"], serde_json::json!(true));
24816 assert_eq!(r["killed"], serde_json::json!(true));
24817 assert_eq!(r["code"], serde_json::Value::Null);
24818 });
24819 }
24820
24821 #[test]
24822 fn pijs_child_process_exec_returns_child_and_forwards_timeout() {
24823 futures::executor::block_on(async {
24824 let clock = Arc::new(DeterministicClock::new(0));
24825 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24826 .await
24827 .expect("create runtime");
24828
24829 runtime
24830 .eval(
24831 r"
24832 globalThis.execShimResult = {};
24833 import('node:child_process').then(({ exec }) => {
24834 const child = exec('echo hello-exec', { timeout: 321 }, (err, stdout, stderr) => {
24835 globalThis.execShimResult.cbDone = true;
24836 globalThis.execShimResult.cbErr = err ? String((err && err.message) || err) : null;
24837 globalThis.execShimResult.stdout = stdout;
24838 globalThis.execShimResult.stderr = stderr;
24839 });
24840 globalThis.execShimResult.hasPid = typeof child.pid === 'number';
24841 globalThis.execShimResult.hasKill = typeof child.kill === 'function';
24842 child.on('close', () => {
24843 globalThis.execShimResult.closed = true;
24844 });
24845 });
24846 ",
24847 )
24848 .await
24849 .expect("eval child_process exec script");
24850
24851 let mut requests = runtime.drain_hostcall_requests();
24852 assert_eq!(requests.len(), 1);
24853 let request = requests.pop_front().expect("exec hostcall");
24854 assert!(
24855 matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "sh"),
24856 "unexpected hostcall kind: {:?}",
24857 request.kind
24858 );
24859 assert_eq!(
24860 request.payload["args"],
24861 serde_json::json!(["-c", "echo hello-exec"])
24862 );
24863 assert_eq!(request.payload["options"]["timeout"].as_i64(), Some(321));
24864
24865 runtime.complete_hostcall(
24866 request.call_id,
24867 HostcallOutcome::Success(serde_json::json!({
24868 "stdout": "hello-exec\n",
24869 "stderr": "",
24870 "code": 0,
24871 "killed": false
24872 })),
24873 );
24874
24875 drain_until_idle(&runtime, &clock).await;
24876 let r = get_global_json(&runtime, "execShimResult").await;
24877 assert_eq!(r["hasPid"], serde_json::json!(true));
24878 assert_eq!(r["hasKill"], serde_json::json!(true));
24879 assert_eq!(r["closed"], serde_json::json!(true));
24880 assert_eq!(r["cbDone"], serde_json::json!(true));
24881 assert_eq!(r["cbErr"], serde_json::Value::Null);
24882 assert_eq!(r["stdout"], serde_json::json!("hello-exec\n"));
24883 assert_eq!(r["stderr"], serde_json::json!(""));
24884 });
24885 }
24886
24887 #[test]
24888 fn pijs_child_process_exec_file_returns_child_and_forwards_timeout() {
24889 futures::executor::block_on(async {
24890 let clock = Arc::new(DeterministicClock::new(0));
24891 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24892 .await
24893 .expect("create runtime");
24894
24895 runtime
24896 .eval(
24897 r"
24898 globalThis.execFileShimResult = {};
24899 import('node:child_process').then(({ execFile }) => {
24900 const child = execFile('echo', ['hello-file'], { timeout: 222 }, (err, stdout, stderr) => {
24901 globalThis.execFileShimResult.cbDone = true;
24902 globalThis.execFileShimResult.cbErr = err ? String((err && err.message) || err) : null;
24903 globalThis.execFileShimResult.stdout = stdout;
24904 globalThis.execFileShimResult.stderr = stderr;
24905 });
24906 globalThis.execFileShimResult.hasPid = typeof child.pid === 'number';
24907 globalThis.execFileShimResult.hasKill = typeof child.kill === 'function';
24908 });
24909 ",
24910 )
24911 .await
24912 .expect("eval child_process execFile script");
24913
24914 let mut requests = runtime.drain_hostcall_requests();
24915 assert_eq!(requests.len(), 1);
24916 let request = requests.pop_front().expect("execFile hostcall");
24917 assert!(
24918 matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "echo"),
24919 "unexpected hostcall kind: {:?}",
24920 request.kind
24921 );
24922 assert_eq!(request.payload["args"], serde_json::json!(["hello-file"]));
24923 assert_eq!(request.payload["options"]["timeout"].as_i64(), Some(222));
24924
24925 runtime.complete_hostcall(
24926 request.call_id,
24927 HostcallOutcome::Success(serde_json::json!({
24928 "stdout": "hello-file\n",
24929 "stderr": "",
24930 "code": 0,
24931 "killed": false
24932 })),
24933 );
24934
24935 drain_until_idle(&runtime, &clock).await;
24936 let r = get_global_json(&runtime, "execFileShimResult").await;
24937 assert_eq!(r["hasPid"], serde_json::json!(true));
24938 assert_eq!(r["hasKill"], serde_json::json!(true));
24939 assert_eq!(r["cbDone"], serde_json::json!(true));
24940 assert_eq!(r["cbErr"], serde_json::Value::Null);
24941 assert_eq!(r["stdout"], serde_json::json!("hello-file\n"));
24942 assert_eq!(r["stderr"], serde_json::json!(""));
24943 });
24944 }
24945
24946 #[test]
24947 fn pijs_child_process_process_kill_targets_spawned_pid() {
24948 futures::executor::block_on(async {
24949 let clock = Arc::new(DeterministicClock::new(0));
24950 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24951 .await
24952 .expect("create runtime");
24953
24954 runtime
24955 .eval(
24956 r"
24957 globalThis.childKillResult = {};
24958 import('node:child_process').then(({ spawn }) => {
24959 const child = spawn('pi', ['--version'], {
24960 shell: false,
24961 detached: true,
24962 stdio: ['ignore', 'pipe', 'pipe'],
24963 });
24964 globalThis.childKillResult.pid = child.pid;
24965 child.on('close', (code) => {
24966 globalThis.childKillResult.code = code;
24967 globalThis.childKillResult.killed = child.killed;
24968 globalThis.childKillResult.done = true;
24969 });
24970 try {
24971 globalThis.childKillResult.killOk = process.kill(-child.pid, 'SIGKILL') === true;
24972 } catch (err) {
24973 globalThis.childKillResult.killErrorCode = String((err && err.code) || '');
24974 globalThis.childKillResult.killErrorMessage = String((err && err.message) || err || '');
24975 }
24976 });
24977 ",
24978 )
24979 .await
24980 .expect("eval child_process kill script");
24981
24982 let mut requests = runtime.drain_hostcall_requests();
24983 assert_eq!(requests.len(), 1);
24984 let request = requests.pop_front().expect("exec hostcall");
24985 runtime.complete_hostcall(
24986 request.call_id,
24987 HostcallOutcome::Success(serde_json::json!({
24988 "stdout": "",
24989 "stderr": "",
24990 "code": 0,
24991 "killed": false
24992 })),
24993 );
24994
24995 drain_until_idle(&runtime, &clock).await;
24996 let r = get_global_json(&runtime, "childKillResult").await;
24997 assert_eq!(r["killOk"], serde_json::json!(true));
24998 assert_eq!(r["killed"], serde_json::json!(true));
24999 assert_eq!(r["code"], serde_json::Value::Null);
25000 assert_eq!(r["done"], serde_json::json!(true));
25001 });
25002 }
25003
25004 #[test]
25005 fn pijs_child_process_denied_exec_emits_error_and_close() {
25006 futures::executor::block_on(async {
25007 let clock = Arc::new(DeterministicClock::new(0));
25008 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25009 .await
25010 .expect("create runtime");
25011
25012 runtime
25013 .eval(
25014 r"
25015 globalThis.childDeniedResult = {};
25016 import('node:child_process').then(({ spawn }) => {
25017 const child = spawn('pi', ['--version'], {
25018 shell: false,
25019 stdio: ['ignore', 'pipe', 'pipe'],
25020 });
25021 child.on('error', (err) => {
25022 globalThis.childDeniedResult.errorCode = String((err && err.code) || '');
25023 globalThis.childDeniedResult.errorMessage = String((err && err.message) || err || '');
25024 });
25025 child.on('close', (code) => {
25026 globalThis.childDeniedResult.code = code;
25027 globalThis.childDeniedResult.killed = child.killed;
25028 globalThis.childDeniedResult.done = true;
25029 });
25030 });
25031 ",
25032 )
25033 .await
25034 .expect("eval child_process denied script");
25035
25036 let mut requests = runtime.drain_hostcall_requests();
25037 assert_eq!(requests.len(), 1);
25038 let request = requests.pop_front().expect("exec hostcall");
25039 runtime.complete_hostcall(
25040 request.call_id,
25041 HostcallOutcome::Error {
25042 code: "denied".to_string(),
25043 message: "Capability 'exec' denied by policy".to_string(),
25044 },
25045 );
25046
25047 drain_until_idle(&runtime, &clock).await;
25048 let r = get_global_json(&runtime, "childDeniedResult").await;
25049 assert_eq!(r["done"], serde_json::json!(true));
25050 assert_eq!(r["errorCode"], serde_json::json!("denied"));
25051 assert_eq!(
25052 r["errorMessage"],
25053 serde_json::json!("Capability 'exec' denied by policy")
25054 );
25055 assert_eq!(r["code"], serde_json::json!(1));
25056 assert_eq!(r["killed"], serde_json::json!(false));
25057 });
25058 }
25059
25060 #[test]
25061 fn pijs_child_process_rejects_unsupported_shell_option() {
25062 futures::executor::block_on(async {
25063 let clock = Arc::new(DeterministicClock::new(0));
25064 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25065 .await
25066 .expect("create runtime");
25067
25068 runtime
25069 .eval(
25070 r"
25071 globalThis.childOptionResult = {};
25072 import('node:child_process').then(({ spawn }) => {
25073 try {
25074 spawn('pi', ['--version'], { shell: true });
25075 globalThis.childOptionResult.threw = false;
25076 } catch (err) {
25077 globalThis.childOptionResult.threw = true;
25078 globalThis.childOptionResult.message = String((err && err.message) || err || '');
25079 }
25080 globalThis.childOptionResult.done = true;
25081 });
25082 ",
25083 )
25084 .await
25085 .expect("eval child_process unsupported shell script");
25086
25087 drain_until_idle(&runtime, &clock).await;
25088 let r = get_global_json(&runtime, "childOptionResult").await;
25089 assert_eq!(r["done"], serde_json::json!(true));
25090 assert_eq!(r["threw"], serde_json::json!(true));
25091 assert_eq!(
25092 r["message"],
25093 serde_json::json!(
25094 "node:child_process.spawn: only shell=false is supported in PiJS"
25095 )
25096 );
25097 assert_eq!(runtime.drain_hostcall_requests().len(), 0);
25098 });
25099 }
25100
25101 #[test]
25106 fn pijs_node_os_module_exports() {
25107 futures::executor::block_on(async {
25108 let clock = Arc::new(DeterministicClock::new(0));
25109 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25110 .await
25111 .expect("create runtime");
25112
25113 runtime
25114 .eval(
25115 r"
25116 globalThis.osResults = {};
25117 import('node:os').then((os) => {
25118 globalThis.osResults.homedir = os.homedir();
25119 globalThis.osResults.tmpdir = os.tmpdir();
25120 globalThis.osResults.hostname = os.hostname();
25121 globalThis.osResults.platform = os.platform();
25122 globalThis.osResults.arch = os.arch();
25123 globalThis.osResults.type = os.type();
25124 globalThis.osResults.release = os.release();
25125 globalThis.osResults.done = true;
25126 });
25127 ",
25128 )
25129 .await
25130 .expect("eval node:os");
25131
25132 let r = get_global_json(&runtime, "osResults").await;
25133 assert_eq!(r["done"], serde_json::json!(true));
25134 assert!(r["homedir"].is_string());
25136 let expected_tmpdir = std::env::temp_dir().display().to_string();
25138 assert_eq!(r["tmpdir"].as_str().unwrap(), expected_tmpdir);
25139 assert!(
25141 r["hostname"].as_str().is_some_and(|s| !s.is_empty()),
25142 "hostname should be non-empty string"
25143 );
25144 let expected_platform = match std::env::consts::OS {
25146 "macos" => "darwin",
25147 "windows" => "win32",
25148 other => other,
25149 };
25150 assert_eq!(r["platform"].as_str().unwrap(), expected_platform);
25151 let expected_arch = match std::env::consts::ARCH {
25152 "x86_64" => "x64",
25153 "aarch64" => "arm64",
25154 other => other,
25155 };
25156 assert_eq!(r["arch"].as_str().unwrap(), expected_arch);
25157 let expected_type = match std::env::consts::OS {
25158 "linux" => "Linux",
25159 "macos" => "Darwin",
25160 "windows" => "Windows_NT",
25161 other => other,
25162 };
25163 assert_eq!(r["type"].as_str().unwrap(), expected_type);
25164 assert_eq!(r["release"], serde_json::json!("6.0.0"));
25165 });
25166 }
25167
25168 #[test]
25169 fn build_node_os_module_produces_valid_js() {
25170 let source = super::build_node_os_module();
25171 assert!(
25173 source.contains("export function platform()"),
25174 "missing platform"
25175 );
25176 assert!(source.contains("export function cpus()"), "missing cpus");
25177 assert!(source.contains("_numCpus"), "missing _numCpus");
25178 for (i, line) in source.lines().enumerate().take(20) {
25180 eprintln!(" {i}: {line}");
25181 }
25182 let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
25183 assert!(
25184 source.contains(&format!("const _numCpus = {num_cpus}")),
25185 "expected _numCpus = {num_cpus} in module"
25186 );
25187 }
25188
25189 #[test]
25190 fn pijs_node_os_native_values_cpus_and_userinfo() {
25191 futures::executor::block_on(async {
25192 let clock = Arc::new(DeterministicClock::new(0));
25193 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25194 .await
25195 .expect("create runtime");
25196
25197 runtime
25198 .eval(
25199 r"
25200 globalThis.nativeOsResults = {};
25201 import('node:os').then((os) => {
25202 globalThis.nativeOsResults.cpuCount = os.cpus().length;
25203 globalThis.nativeOsResults.totalmem = os.totalmem();
25204 globalThis.nativeOsResults.freemem = os.freemem();
25205 globalThis.nativeOsResults.eol = os.EOL;
25206 globalThis.nativeOsResults.endianness = os.endianness();
25207 globalThis.nativeOsResults.devNull = os.devNull;
25208 const ui = os.userInfo();
25209 globalThis.nativeOsResults.uid = ui.uid;
25210 globalThis.nativeOsResults.username = ui.username;
25211 globalThis.nativeOsResults.hasShell = typeof ui.shell === 'string';
25212 globalThis.nativeOsResults.hasHomedir = typeof ui.homedir === 'string';
25213 globalThis.nativeOsResults.done = true;
25214 });
25215 ",
25216 )
25217 .await
25218 .expect("eval node:os native");
25219
25220 let r = get_global_json(&runtime, "nativeOsResults").await;
25221 assert_eq!(r["done"], serde_json::json!(true));
25222 let expected_cpus =
25224 std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
25225 assert_eq!(r["cpuCount"], serde_json::json!(expected_cpus));
25226 assert!(r["totalmem"].as_f64().unwrap() > 0.0);
25228 assert!(r["freemem"].as_f64().unwrap() > 0.0);
25229 let expected_eol = if cfg!(windows) { "\r\n" } else { "\n" };
25231 assert_eq!(r["eol"], serde_json::json!(expected_eol));
25232 assert_eq!(r["endianness"], serde_json::json!("LE"));
25233 let expected_dev_null = if cfg!(windows) {
25234 "\\\\.\\NUL"
25235 } else {
25236 "/dev/null"
25237 };
25238 assert_eq!(r["devNull"], serde_json::json!(expected_dev_null));
25239 assert!(r["uid"].is_number());
25241 assert!(r["username"].as_str().is_some_and(|s| !s.is_empty()));
25242 assert_eq!(r["hasShell"], serde_json::json!(true));
25243 assert_eq!(r["hasHomedir"], serde_json::json!(true));
25244 });
25245 }
25246
25247 #[test]
25248 fn pijs_node_os_bare_import_alias() {
25249 futures::executor::block_on(async {
25250 let clock = Arc::new(DeterministicClock::new(0));
25251 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25252 .await
25253 .expect("create runtime");
25254
25255 runtime
25256 .eval(
25257 r"
25258 globalThis.bare_os_ok = false;
25259 import('os').then((os) => {
25260 globalThis.bare_os_ok = typeof os.homedir === 'function'
25261 && typeof os.platform === 'function';
25262 });
25263 ",
25264 )
25265 .await
25266 .expect("eval bare os import");
25267
25268 assert_eq!(
25269 get_global_json(&runtime, "bare_os_ok").await,
25270 serde_json::json!(true)
25271 );
25272 });
25273 }
25274
25275 #[test]
25276 fn pijs_node_url_module_exports() {
25277 futures::executor::block_on(async {
25278 let clock = Arc::new(DeterministicClock::new(0));
25279 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25280 .await
25281 .expect("create runtime");
25282
25283 runtime
25284 .eval(
25285 r"
25286 globalThis.urlResults = {};
25287 import('node:url').then((url) => {
25288 globalThis.urlResults.fileToPath = url.fileURLToPath('file:///home/user/test.txt');
25289 globalThis.urlResults.pathToFile = url.pathToFileURL('/home/user/test.txt').href;
25290
25291 const u = new url.URL('https://example.com/path?key=val#frag');
25292 globalThis.urlResults.href = u.href;
25293 globalThis.urlResults.protocol = u.protocol;
25294 globalThis.urlResults.hostname = u.hostname;
25295 globalThis.urlResults.pathname = u.pathname;
25296 globalThis.urlResults.toString = u.toString();
25297
25298 globalThis.urlResults.done = true;
25299 });
25300 ",
25301 )
25302 .await
25303 .expect("eval node:url");
25304
25305 let r = get_global_json(&runtime, "urlResults").await;
25306 assert_eq!(r["done"], serde_json::json!(true));
25307 assert_eq!(r["fileToPath"], serde_json::json!("/home/user/test.txt"));
25308 assert_eq!(
25309 r["pathToFile"],
25310 serde_json::json!("file:///home/user/test.txt")
25311 );
25312 assert!(r["href"].as_str().unwrap().starts_with("https://"));
25314 assert_eq!(r["protocol"], serde_json::json!("https:"));
25315 assert_eq!(r["hostname"], serde_json::json!("example.com"));
25316 assert!(r["pathname"].as_str().unwrap().starts_with("/path"));
25318 });
25319 }
25320
25321 #[test]
25322 fn pijs_node_crypto_create_hash_and_uuid() {
25323 futures::executor::block_on(async {
25324 let clock = Arc::new(DeterministicClock::new(0));
25325 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25326 .await
25327 .expect("create runtime");
25328
25329 runtime
25330 .eval(
25331 r"
25332 globalThis.cryptoResults = {};
25333 import('node:crypto').then((crypto) => {
25334 // createHash
25335 const hash = crypto.createHash('sha256');
25336 hash.update('hello');
25337 globalThis.cryptoResults.hexDigest = hash.digest('hex');
25338
25339 // createHash chained
25340 globalThis.cryptoResults.chainedHex = crypto
25341 .createHash('sha256')
25342 .update('world')
25343 .digest('hex');
25344
25345 // randomUUID
25346 const uuid = crypto.randomUUID();
25347 globalThis.cryptoResults.uuidLength = uuid.length;
25348 // UUID v4 format: 8-4-4-4-12
25349 globalThis.cryptoResults.uuidHasDashes = uuid.split('-').length === 5;
25350
25351 globalThis.cryptoResults.done = true;
25352 });
25353 ",
25354 )
25355 .await
25356 .expect("eval node:crypto");
25357
25358 let r = get_global_json(&runtime, "cryptoResults").await;
25359 assert_eq!(r["done"], serde_json::json!(true));
25360 assert!(r["hexDigest"].is_string());
25362 let hex = r["hexDigest"].as_str().unwrap();
25363 assert!(!hex.is_empty());
25365 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
25366 assert!(r["chainedHex"].is_string());
25368 let chained = r["chainedHex"].as_str().unwrap();
25369 assert!(!chained.is_empty());
25370 assert!(chained.chars().all(|c| c.is_ascii_hexdigit()));
25371 assert_ne!(r["hexDigest"], r["chainedHex"]);
25373 assert_eq!(r["uuidLength"], serde_json::json!(36));
25375 assert_eq!(r["uuidHasDashes"], serde_json::json!(true));
25376 });
25377 }
25378
25379 #[test]
25380 fn pijs_web_crypto_get_random_values_smoke() {
25381 futures::executor::block_on(async {
25382 let clock = Arc::new(DeterministicClock::new(0));
25383 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25384 .await
25385 .expect("create runtime");
25386
25387 runtime
25388 .eval(
25389 r"
25390 const bytes = new Uint8Array(32);
25391 crypto.getRandomValues(bytes);
25392 globalThis.cryptoRng = {
25393 len: bytes.length,
25394 inRange: Array.from(bytes).every((n) => Number.isInteger(n) && n >= 0 && n <= 255),
25395 };
25396 ",
25397 )
25398 .await
25399 .expect("eval web crypto getRandomValues");
25400
25401 let r = get_global_json(&runtime, "cryptoRng").await;
25402 assert_eq!(r["len"], serde_json::json!(32));
25403 assert_eq!(r["inRange"], serde_json::json!(true));
25404 });
25405 }
25406
25407 #[test]
25408 fn pijs_buffer_global_operations() {
25409 futures::executor::block_on(async {
25410 let clock = Arc::new(DeterministicClock::new(0));
25411 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25412 .await
25413 .expect("create runtime");
25414
25415 runtime
25416 .eval(
25417 r"
25418 globalThis.bufResults = {};
25419 // Test the global Buffer polyfill (set up during runtime init)
25420 const B = globalThis.Buffer;
25421 globalThis.bufResults.hasBuffer = typeof B === 'function';
25422 globalThis.bufResults.hasFrom = typeof B.from === 'function';
25423
25424 // Buffer.from with array input
25425 const arr = B.from([65, 66, 67]);
25426 globalThis.bufResults.fromArrayLength = arr.length;
25427
25428 // Uint8Array allocation
25429 const zeroed = new Uint8Array(16);
25430 globalThis.bufResults.allocLength = zeroed.length;
25431
25432 globalThis.bufResults.done = true;
25433 ",
25434 )
25435 .await
25436 .expect("eval Buffer");
25437
25438 let r = get_global_json(&runtime, "bufResults").await;
25439 assert_eq!(r["done"], serde_json::json!(true));
25440 assert_eq!(r["hasBuffer"], serde_json::json!(true));
25441 assert_eq!(r["hasFrom"], serde_json::json!(true));
25442 assert_eq!(r["fromArrayLength"], serde_json::json!(3));
25443 assert_eq!(r["allocLength"], serde_json::json!(16));
25444 });
25445 }
25446
25447 #[test]
25448 fn pijs_node_fs_promises_async_roundtrip() {
25449 futures::executor::block_on(async {
25450 let clock = Arc::new(DeterministicClock::new(0));
25451 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25452 .await
25453 .expect("create runtime");
25454
25455 runtime
25456 .eval(
25457 r"
25458 globalThis.fspResults = {};
25459 import('node:fs/promises').then(async (fsp) => {
25460 // Write then read back
25461 await fsp.writeFile('/test/hello.txt', 'async content');
25462 const data = await fsp.readFile('/test/hello.txt', 'utf8');
25463 globalThis.fspResults.readBack = data;
25464
25465 // stat
25466 const st = await fsp.stat('/test/hello.txt');
25467 globalThis.fspResults.statIsFile = st.isFile();
25468 globalThis.fspResults.statSize = st.size;
25469
25470 // mkdir + readdir
25471 await fsp.mkdir('/test/subdir');
25472 await fsp.writeFile('/test/subdir/a.txt', 'aaa');
25473 const entries = await fsp.readdir('/test/subdir');
25474 globalThis.fspResults.dirEntries = entries;
25475
25476 // unlink
25477 await fsp.unlink('/test/subdir/a.txt');
25478 const exists = await fsp.access('/test/subdir/a.txt').then(() => true).catch(() => false);
25479 globalThis.fspResults.deletedFileExists = exists;
25480
25481 globalThis.fspResults.done = true;
25482 });
25483 ",
25484 )
25485 .await
25486 .expect("eval fs/promises");
25487
25488 drain_until_idle(&runtime, &clock).await;
25489
25490 let r = get_global_json(&runtime, "fspResults").await;
25491 assert_eq!(r["done"], serde_json::json!(true));
25492 assert_eq!(r["readBack"], serde_json::json!("async content"));
25493 assert_eq!(r["statIsFile"], serde_json::json!(true));
25494 assert!(r["statSize"].as_u64().unwrap() > 0);
25495 assert_eq!(r["dirEntries"], serde_json::json!(["a.txt"]));
25496 assert_eq!(r["deletedFileExists"], serde_json::json!(false));
25497 });
25498 }
25499
25500 #[test]
25501 fn pijs_node_process_module_exports() {
25502 futures::executor::block_on(async {
25503 let clock = Arc::new(DeterministicClock::new(0));
25504 let config = PiJsRuntimeConfig {
25505 cwd: "/test/project".to_string(),
25506 args: vec!["arg1".to_string(), "arg2".to_string()],
25507 env: HashMap::new(),
25508 limits: PiJsRuntimeLimits::default(),
25509 repair_mode: RepairMode::default(),
25510 allow_unsafe_sync_exec: false,
25511 deny_env: false,
25512 disk_cache_dir: None,
25513 };
25514 let runtime =
25515 PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
25516 .await
25517 .expect("create runtime");
25518
25519 runtime
25520 .eval(
25521 r"
25522 globalThis.procResults = {};
25523 import('node:process').then((proc) => {
25524 globalThis.procResults.platform = proc.platform;
25525 globalThis.procResults.arch = proc.arch;
25526 globalThis.procResults.version = proc.version;
25527 globalThis.procResults.pid = proc.pid;
25528 globalThis.procResults.cwdType = typeof proc.cwd;
25529 globalThis.procResults.cwdValue = typeof proc.cwd === 'function'
25530 ? proc.cwd() : proc.cwd;
25531 globalThis.procResults.hasEnv = typeof proc.env === 'object';
25532 globalThis.procResults.hasStdout = typeof proc.stdout === 'object';
25533 globalThis.procResults.hasStderr = typeof proc.stderr === 'object';
25534 globalThis.procResults.hasNextTick = typeof proc.nextTick === 'function';
25535
25536 // nextTick should schedule microtask
25537 globalThis.procResults.nextTickRan = false;
25538 proc.nextTick(() => { globalThis.procResults.nextTickRan = true; });
25539
25540 // hrtime should return array
25541 const hr = proc.hrtime();
25542 globalThis.procResults.hrtimeIsArray = Array.isArray(hr);
25543 globalThis.procResults.hrtimeLength = hr.length;
25544
25545 globalThis.procResults.done = true;
25546 });
25547 ",
25548 )
25549 .await
25550 .expect("eval node:process");
25551
25552 drain_until_idle(&runtime, &clock).await;
25553
25554 let r = get_global_json(&runtime, "procResults").await;
25555 assert_eq!(r["done"], serde_json::json!(true));
25556 assert!(r["platform"].is_string(), "platform should be a string");
25558 let expected_arch = if cfg!(target_arch = "aarch64") {
25559 "arm64"
25560 } else {
25561 "x64"
25562 };
25563 assert_eq!(r["arch"], serde_json::json!(expected_arch));
25564 assert!(r["version"].is_string());
25565 assert_eq!(r["pid"], serde_json::json!(1));
25566 assert!(r["hasEnv"] == serde_json::json!(true));
25567 assert!(r["hasStdout"] == serde_json::json!(true));
25568 assert!(r["hasStderr"] == serde_json::json!(true));
25569 assert!(r["hasNextTick"] == serde_json::json!(true));
25570 assert_eq!(r["nextTickRan"], serde_json::json!(true));
25572 assert_eq!(r["hrtimeIsArray"], serde_json::json!(true));
25573 assert_eq!(r["hrtimeLength"], serde_json::json!(2));
25574 });
25575 }
25576
25577 #[test]
25578 fn pijs_pi_path_join_behavior() {
25579 futures::executor::block_on(async {
25580 let clock = Arc::new(DeterministicClock::new(0));
25581 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25582 .await
25583 .expect("create runtime");
25584
25585 runtime
25586 .eval(
25587 r"
25588 globalThis.joinResults = {};
25589 globalThis.joinResults.concatAbs = pi.path.join('/a', '/b');
25590 globalThis.joinResults.normal = pi.path.join('a', 'b');
25591 globalThis.joinResults.root = pi.path.join('/', 'a');
25592 globalThis.joinResults.dots = pi.path.join('/a', '..', 'b');
25593 globalThis.joinResults.done = true;
25594 ",
25595 )
25596 .await
25597 .expect("eval pi.path.join");
25598
25599 let r = get_global_json(&runtime, "joinResults").await;
25600 assert_eq!(r["done"], serde_json::json!(true));
25601 assert_eq!(r["concatAbs"], serde_json::json!("/a/b"));
25603 assert_eq!(r["normal"], serde_json::json!("a/b"));
25604 assert_eq!(r["root"], serde_json::json!("/a"));
25605 assert_eq!(r["dots"], serde_json::json!("/b"));
25606 });
25607 }
25608
25609 #[test]
25610 fn pijs_node_path_relative_resolve_format() {
25611 futures::executor::block_on(async {
25612 let clock = Arc::new(DeterministicClock::new(0));
25613 let config = PiJsRuntimeConfig {
25614 cwd: "/home/user/project".to_string(),
25615 args: Vec::new(),
25616 env: HashMap::new(),
25617 limits: PiJsRuntimeLimits::default(),
25618 repair_mode: RepairMode::default(),
25619 allow_unsafe_sync_exec: false,
25620 deny_env: false,
25621 disk_cache_dir: None,
25622 };
25623 let runtime =
25624 PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
25625 .await
25626 .expect("create runtime");
25627
25628 runtime
25629 .eval(
25630 r"
25631 globalThis.pathResults2 = {};
25632 import('node:path').then((path) => {
25633 // relative
25634 globalThis.pathResults2.relSameDir = path.relative('/a/b/c', '/a/b/c/d');
25635 globalThis.pathResults2.relUp = path.relative('/a/b/c', '/a/b');
25636 globalThis.pathResults2.relSame = path.relative('/a/b', '/a/b');
25637
25638 // resolve uses cwd as base
25639 globalThis.pathResults2.resolveAbs = path.resolve('/absolute/path');
25640 globalThis.pathResults2.resolveRel = path.resolve('relative');
25641
25642 // format
25643 globalThis.pathResults2.formatFull = path.format({
25644 dir: '/home/user',
25645 base: 'file.txt'
25646 });
25647
25648 // sep and delimiter constants
25649 globalThis.pathResults2.sep = path.sep;
25650 globalThis.pathResults2.delimiter = path.delimiter;
25651
25652 // dirname edge cases
25653 globalThis.pathResults2.dirnameRoot = path.dirname('/');
25654 globalThis.pathResults2.dirnameNested = path.dirname('/a/b/c');
25655
25656 // join edge cases
25657 globalThis.pathResults2.joinEmpty = path.join();
25658 globalThis.pathResults2.joinDots = path.join('a', '..', 'b');
25659
25660 globalThis.pathResults2.done = true;
25661 });
25662 ",
25663 )
25664 .await
25665 .expect("eval path extended 2");
25666
25667 let r = get_global_json(&runtime, "pathResults2").await;
25668 assert_eq!(r["done"], serde_json::json!(true));
25669 assert_eq!(r["relSameDir"], serde_json::json!("d"));
25670 assert_eq!(r["relUp"], serde_json::json!(".."));
25671 assert_eq!(r["relSame"], serde_json::json!("."));
25672 assert_eq!(r["resolveAbs"], serde_json::json!("/absolute/path"));
25673 assert!(r["resolveRel"].as_str().unwrap().ends_with("/relative"));
25675 assert_eq!(r["formatFull"], serde_json::json!("/home/user/file.txt"));
25676 assert_eq!(r["sep"], serde_json::json!("/"));
25677 assert_eq!(r["delimiter"], serde_json::json!(":"));
25678 assert_eq!(r["dirnameRoot"], serde_json::json!("/"));
25679 assert_eq!(r["dirnameNested"], serde_json::json!("/a/b"));
25680 let join_dots = r["joinDots"].as_str().unwrap();
25682 assert!(join_dots == "b" || join_dots == "a/../b");
25683 });
25684 }
25685
25686 #[test]
25687 fn pijs_node_util_module_exports() {
25688 futures::executor::block_on(async {
25689 let clock = Arc::new(DeterministicClock::new(0));
25690 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25691 .await
25692 .expect("create runtime");
25693
25694 runtime
25695 .eval(
25696 r"
25697 globalThis.utilResults = {};
25698 import('node:util').then((util) => {
25699 globalThis.utilResults.hasInspect = typeof util.inspect === 'function';
25700 globalThis.utilResults.hasPromisify = typeof util.promisify === 'function';
25701 globalThis.utilResults.inspectResult = util.inspect({ a: 1, b: [2, 3] });
25702 globalThis.utilResults.done = true;
25703 });
25704 ",
25705 )
25706 .await
25707 .expect("eval node:util");
25708
25709 let r = get_global_json(&runtime, "utilResults").await;
25710 assert_eq!(r["done"], serde_json::json!(true));
25711 assert_eq!(r["hasInspect"], serde_json::json!(true));
25712 assert_eq!(r["hasPromisify"], serde_json::json!(true));
25713 assert!(r["inspectResult"].is_string());
25715 });
25716 }
25717
25718 #[test]
25719 fn pijs_node_assert_module_pass_and_fail() {
25720 futures::executor::block_on(async {
25721 let clock = Arc::new(DeterministicClock::new(0));
25722 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25723 .await
25724 .expect("create runtime");
25725
25726 runtime
25727 .eval(
25728 r"
25729 globalThis.assertResults = {};
25730 import('node:assert').then((mod) => {
25731 const assert = mod.default;
25732
25733 // Passing assertions should not throw
25734 assert.ok(true);
25735 assert.strictEqual(1, 1);
25736 assert.deepStrictEqual({ a: 1 }, { a: 1 });
25737 assert.notStrictEqual(1, 2);
25738
25739 // Failing assertion should throw
25740 try {
25741 assert.strictEqual(1, 2);
25742 globalThis.assertResults.failDidNotThrow = true;
25743 } catch (e) {
25744 globalThis.assertResults.failThrew = true;
25745 globalThis.assertResults.failMessage = e.message || String(e);
25746 }
25747
25748 globalThis.assertResults.done = true;
25749 });
25750 ",
25751 )
25752 .await
25753 .expect("eval node:assert");
25754
25755 let r = get_global_json(&runtime, "assertResults").await;
25756 assert_eq!(r["done"], serde_json::json!(true));
25757 assert_eq!(r["failThrew"], serde_json::json!(true));
25758 assert!(r["failMessage"].is_string());
25759 });
25760 }
25761
25762 #[test]
25763 fn pijs_node_fs_sync_edge_cases() {
25764 futures::executor::block_on(async {
25765 let clock = Arc::new(DeterministicClock::new(0));
25766 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25767 .await
25768 .expect("create runtime");
25769
25770 runtime
25771 .eval(
25772 r"
25773 globalThis.fsEdge = {};
25774 import('node:fs').then((fs) => {
25775 // Write, overwrite, read back
25776 fs.writeFileSync('/edge/file.txt', 'first');
25777 fs.writeFileSync('/edge/file.txt', 'second');
25778 globalThis.fsEdge.overwrite = fs.readFileSync('/edge/file.txt', 'utf8');
25779
25780 // existsSync for existing vs non-existing
25781 globalThis.fsEdge.existsTrue = fs.existsSync('/edge/file.txt');
25782 globalThis.fsEdge.existsFalse = fs.existsSync('/nonexistent/file.txt');
25783
25784 // mkdirSync + readdirSync with withFileTypes
25785 fs.mkdirSync('/edge/dir');
25786 fs.writeFileSync('/edge/dir/a.txt', 'aaa');
25787 fs.mkdirSync('/edge/dir/sub');
25788 const dirents = fs.readdirSync('/edge/dir', { withFileTypes: true });
25789 globalThis.fsEdge.direntCount = dirents.length;
25790 const fileDirent = dirents.find(d => d.name === 'a.txt');
25791 const dirDirent = dirents.find(d => d.name === 'sub');
25792 globalThis.fsEdge.fileIsFile = fileDirent ? fileDirent.isFile() : null;
25793 globalThis.fsEdge.dirIsDir = dirDirent ? dirDirent.isDirectory() : null;
25794
25795 // rmSync recursive
25796 fs.writeFileSync('/edge/dir/sub/deep.txt', 'deep');
25797 fs.rmSync('/edge/dir', { recursive: true });
25798 globalThis.fsEdge.rmRecursiveGone = !fs.existsSync('/edge/dir');
25799
25800 // accessSync on non-existing file should throw
25801 try {
25802 fs.accessSync('/nope');
25803 globalThis.fsEdge.accessThrew = false;
25804 } catch (e) {
25805 globalThis.fsEdge.accessThrew = true;
25806 }
25807
25808 // statSync on directory
25809 fs.mkdirSync('/edge/statdir');
25810 const dStat = fs.statSync('/edge/statdir');
25811 globalThis.fsEdge.dirStatIsDir = dStat.isDirectory();
25812 globalThis.fsEdge.dirStatIsFile = dStat.isFile();
25813
25814 globalThis.fsEdge.done = true;
25815 });
25816 ",
25817 )
25818 .await
25819 .expect("eval fs edge cases");
25820
25821 let r = get_global_json(&runtime, "fsEdge").await;
25822 assert_eq!(r["done"], serde_json::json!(true));
25823 assert_eq!(r["overwrite"], serde_json::json!("second"));
25824 assert_eq!(r["existsTrue"], serde_json::json!(true));
25825 assert_eq!(r["existsFalse"], serde_json::json!(false));
25826 assert_eq!(r["direntCount"], serde_json::json!(2));
25827 assert_eq!(r["fileIsFile"], serde_json::json!(true));
25828 assert_eq!(r["dirIsDir"], serde_json::json!(true));
25829 assert_eq!(r["rmRecursiveGone"], serde_json::json!(true));
25830 assert_eq!(r["accessThrew"], serde_json::json!(true));
25831 assert_eq!(r["dirStatIsDir"], serde_json::json!(true));
25832 assert_eq!(r["dirStatIsFile"], serde_json::json!(false));
25833 });
25834 }
25835
25836 #[test]
25837 fn pijs_node_net_and_http_stubs_throw() {
25838 futures::executor::block_on(async {
25839 let clock = Arc::new(DeterministicClock::new(0));
25840 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25841 .await
25842 .expect("create runtime");
25843
25844 runtime
25845 .eval(
25846 r"
25847 globalThis.stubResults = {};
25848 (async () => {
25849 // node:net createServer should throw
25850 const net = await import('node:net');
25851 try {
25852 net.createServer();
25853 globalThis.stubResults.netThrew = false;
25854 } catch (e) {
25855 globalThis.stubResults.netThrew = true;
25856 }
25857
25858 // node:http createServer should throw
25859 const http = await import('node:http');
25860 try {
25861 http.createServer();
25862 globalThis.stubResults.httpThrew = false;
25863 } catch (e) {
25864 globalThis.stubResults.httpThrew = true;
25865 }
25866
25867 // node:https createServer should throw
25868 const https = await import('node:https');
25869 try {
25870 https.createServer();
25871 globalThis.stubResults.httpsThrew = false;
25872 } catch (e) {
25873 globalThis.stubResults.httpsThrew = true;
25874 }
25875
25876 globalThis.stubResults.done = true;
25877 })();
25878 ",
25879 )
25880 .await
25881 .expect("eval stub throws");
25882
25883 drain_until_idle(&runtime, &clock).await;
25884
25885 let r = get_global_json(&runtime, "stubResults").await;
25886 assert_eq!(r["done"], serde_json::json!(true));
25887 assert_eq!(r["netThrew"], serde_json::json!(true));
25888 assert_eq!(r["httpThrew"], serde_json::json!(true));
25889 assert_eq!(r["httpsThrew"], serde_json::json!(true));
25890 });
25891 }
25892
25893 #[test]
25894 fn pijs_glob_sync_matches_vfs() {
25895 futures::executor::block_on(async {
25896 let clock = Arc::new(DeterministicClock::new(0));
25897 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25898 .await
25899 .expect("create runtime");
25900
25901 runtime
25902 .eval(
25903 r"
25904 globalThis.globResult = {};
25905 (async () => {
25906 const fs = await import('node:fs');
25907 fs.mkdirSync('/glob');
25908 fs.writeFileSync('/glob/a.txt', 'a');
25909 fs.writeFileSync('/glob/b.md', 'b');
25910 fs.mkdirSync('/glob/sub');
25911 fs.writeFileSync('/glob/sub/c.txt', 'c');
25912
25913 const glob = await import('glob');
25914 globalThis.globResult.txt = glob.globSync('/glob/**/*.txt');
25915 globalThis.globResult.md = glob.globSync('/glob/*.md');
25916 globalThis.globResult.rel = glob.globSync('glob/*.md', { cwd: '/' });
25917 globalThis.globResult.done = true;
25918 })();
25919 ",
25920 )
25921 .await
25922 .expect("eval glob");
25923
25924 drain_until_idle(&runtime, &clock).await;
25925
25926 let r = get_global_json(&runtime, "globResult").await;
25927 assert_eq!(r["done"], serde_json::json!(true));
25928 assert_eq!(
25929 r["txt"],
25930 serde_json::json!(["/glob/a.txt", "/glob/sub/c.txt"])
25931 );
25932 assert_eq!(r["md"], serde_json::json!(["/glob/b.md"]));
25933 assert_eq!(r["rel"], serde_json::json!(["glob/b.md"]));
25934 });
25935 }
25936
25937 #[test]
25938 fn pijs_calculate_cost_updates_usage() {
25939 futures::executor::block_on(async {
25940 let clock = Arc::new(DeterministicClock::new(0));
25941 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25942 .await
25943 .expect("create runtime");
25944
25945 runtime
25946 .eval(
25947 r"
25948 globalThis.costResult = {};
25949 (async () => {
25950 const ai = await import('@mariozechner/pi-ai');
25951 const model = { cost: { input: 2, output: 4, cacheRead: 1, cacheWrite: 3 } };
25952 const usage = {
25953 input: 1000,
25954 output: 2000,
25955 cacheRead: 500,
25956 cacheWrite: 250,
25957 cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
25958 };
25959 const cost = ai.calculateCost(model, usage);
25960 globalThis.costResult = { cost, usage, done: true };
25961 })();
25962 ",
25963 )
25964 .await
25965 .expect("eval cost");
25966
25967 drain_until_idle(&runtime, &clock).await;
25968
25969 let r = get_global_json(&runtime, "costResult").await;
25970 assert_eq!(r["done"], serde_json::json!(true));
25971 let total_tokens = r["usage"]["totalTokens"].as_u64().unwrap_or_default();
25972 assert_eq!(total_tokens, 3750);
25973
25974 let total_cost = r["cost"]["total"].as_f64().unwrap_or_default();
25975 assert!((total_cost - 0.01125).abs() < 1e-9);
25976 let input_cost = r["cost"]["input"].as_f64().unwrap_or_default();
25977 assert!((input_cost - 0.002).abs() < 1e-9);
25978 let output_cost = r["cost"]["output"].as_f64().unwrap_or_default();
25979 assert!((output_cost - 0.008).abs() < 1e-9);
25980 });
25981 }
25982
25983 #[test]
25984 fn pijs_node_readline_stub_exports() {
25985 futures::executor::block_on(async {
25986 let clock = Arc::new(DeterministicClock::new(0));
25987 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25988 .await
25989 .expect("create runtime");
25990
25991 runtime
25992 .eval(
25993 r"
25994 globalThis.rlResult = {};
25995 import('node:readline').then((rl) => {
25996 globalThis.rlResult.hasCreateInterface = typeof rl.createInterface === 'function';
25997 globalThis.rlResult.done = true;
25998 });
25999 ",
26000 )
26001 .await
26002 .expect("eval readline");
26003
26004 let r = get_global_json(&runtime, "rlResult").await;
26005 assert_eq!(r["done"], serde_json::json!(true));
26006 assert_eq!(r["hasCreateInterface"], serde_json::json!(true));
26007 });
26008 }
26009
26010 #[test]
26011 fn pijs_node_test_stub_describe_it_flags() {
26012 futures::executor::block_on(async {
26013 let clock = Arc::new(DeterministicClock::new(0));
26014 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
26015 .await
26016 .expect("create runtime");
26017
26018 runtime
26019 .eval(
26020 r"
26021 globalThis.nodeTest = {};
26022 import('node:test').then((mod) => {
26023 const { test, describe, it } = mod;
26024 globalThis.nodeTest.hasTestSkip = typeof test.skip === 'function';
26025 globalThis.nodeTest.hasDescribeSkip = typeof describe.skip === 'function';
26026 globalThis.nodeTest.hasItSkip = typeof it.skip === 'function';
26027 globalThis.nodeTest.hasDescribeOnly = typeof describe.only === 'function';
26028 globalThis.nodeTest.hasItOnly = typeof it.only === 'function';
26029 globalThis.nodeTest.hasDescribeTodo = typeof describe.todo === 'function';
26030 globalThis.nodeTest.hasItTodo = typeof it.todo === 'function';
26031 globalThis.nodeTest.done = true;
26032 });
26033 ",
26034 )
26035 .await
26036 .expect("eval node:test");
26037
26038 let r = get_global_json(&runtime, "nodeTest").await;
26039 assert_eq!(r["done"], serde_json::json!(true));
26040 assert_eq!(r["hasTestSkip"], serde_json::json!(true));
26041 assert_eq!(r["hasDescribeSkip"], serde_json::json!(true));
26042 assert_eq!(r["hasItSkip"], serde_json::json!(true));
26043 assert_eq!(r["hasDescribeOnly"], serde_json::json!(true));
26044 assert_eq!(r["hasItOnly"], serde_json::json!(true));
26045 assert_eq!(r["hasDescribeTodo"], serde_json::json!(true));
26046 assert_eq!(r["hasItTodo"], serde_json::json!(true));
26047 });
26048 }
26049
26050 #[test]
26051 fn pijs_node_test_runs_basic_cases() {
26052 futures::executor::block_on(async {
26053 let clock = Arc::new(DeterministicClock::new(0));
26054 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
26055 .await
26056 .expect("create runtime");
26057
26058 runtime
26059 .eval(
26060 r#"
26061 globalThis.nodeTestRun = { done: false };
26062 (async () => {
26063 const { test, describe, it, beforeEach, afterEach, run } = await import("node:test");
26064 const order = [];
26065 beforeEach(() => order.push("beforeEach"));
26066 afterEach(() => order.push("afterEach"));
26067 test("passes", () => { order.push("pass"); });
26068 test.skip("skipped", () => { order.push("skip"); });
26069 describe("suite", () => {
26070 it("nested", () => { order.push("nested"); });
26071 });
26072 const result = await run();
26073 globalThis.nodeTestRun.result = result;
26074 globalThis.nodeTestRun.order = order;
26075 globalThis.nodeTestRun.done = true;
26076 })().catch((e) => {
26077 globalThis.nodeTestRun.error = String(e && e.message ? e.message : e);
26078 globalThis.nodeTestRun.done = false;
26079 });
26080 "#,
26081 )
26082 .await
26083 .expect("eval node:test run");
26084
26085 drain_until_idle(&runtime, &clock).await;
26086
26087 let r = get_global_json(&runtime, "nodeTestRun").await;
26088 assert_eq!(r["done"], serde_json::json!(true));
26089 assert_eq!(r["result"]["ok"], serde_json::json!(true));
26090 assert_eq!(r["result"]["summary"]["total"], serde_json::json!(3));
26091 assert_eq!(r["result"]["summary"]["passed"], serde_json::json!(2));
26092 assert_eq!(r["result"]["summary"]["failed"], serde_json::json!(0));
26093 assert_eq!(r["result"]["summary"]["skipped"], serde_json::json!(1));
26094 assert_eq!(
26095 r["order"],
26096 serde_json::json!([
26097 "beforeEach",
26098 "pass",
26099 "afterEach",
26100 "beforeEach",
26101 "nested",
26102 "afterEach"
26103 ])
26104 );
26105 });
26106 }
26107
26108 #[test]
26109 fn pijs_node_stream_promises_pipeline_pass_through() {
26110 futures::executor::block_on(async {
26111 let clock = Arc::new(DeterministicClock::new(0));
26112 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
26113 .await
26114 .expect("create runtime");
26115
26116 runtime
26117 .eval(
26118 r#"
26119 globalThis.streamInterop = { done: false };
26120 (async () => {
26121 const { Readable, PassThrough, Writable } = await import("node:stream");
26122 const { pipeline } = await import("node:stream/promises");
26123
26124 const collected = [];
26125 const source = Readable.from(["alpha", "-", "omega"]);
26126 const through = new PassThrough();
26127 const sink = new Writable({
26128 write(chunk, _encoding, callback) {
26129 collected.push(String(chunk));
26130 callback(null);
26131 }
26132 });
26133
26134 await pipeline(source, through, sink);
26135 globalThis.streamInterop.value = collected.join("");
26136 globalThis.streamInterop.done = true;
26137 })().catch((e) => {
26138 globalThis.streamInterop.error = String(e && e.message ? e.message : e);
26139 globalThis.streamInterop.done = false;
26140 });
26141 "#,
26142 )
26143 .await
26144 .expect("eval node:stream pipeline");
26145
26146 drain_until_idle(&runtime, &clock).await;
26147
26148 let result = get_global_json(&runtime, "streamInterop").await;
26149 assert_eq!(result["done"], serde_json::json!(true));
26150 assert_eq!(result["value"], serde_json::json!("alpha-omega"));
26151 });
26152 }
26153
26154 #[test]
26155 fn pijs_fs_create_stream_pipeline_copies_content() {
26156 futures::executor::block_on(async {
26157 let clock = Arc::new(DeterministicClock::new(0));
26158 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
26159 .await
26160 .expect("create runtime");
26161
26162 runtime
26163 .eval(
26164 r#"
26165 globalThis.fsStreamCopy = { done: false };
26166 (async () => {
26167 const fs = await import("node:fs");
26168 const { pipeline } = await import("node:stream/promises");
26169
26170 fs.writeFileSync("/tmp/source.txt", "stream-data-123");
26171 const src = fs.createReadStream("/tmp/source.txt");
26172 const dst = fs.createWriteStream("/tmp/dest.txt");
26173 await pipeline(src, dst);
26174
26175 globalThis.fsStreamCopy.value = fs.readFileSync("/tmp/dest.txt", "utf8");
26176 globalThis.fsStreamCopy.done = true;
26177 })().catch((e) => {
26178 globalThis.fsStreamCopy.error = String(e && e.message ? e.message : e);
26179 globalThis.fsStreamCopy.done = false;
26180 });
26181 "#,
26182 )
26183 .await
26184 .expect("eval fs stream copy");
26185
26186 drain_until_idle(&runtime, &clock).await;
26187
26188 let result = get_global_json(&runtime, "fsStreamCopy").await;
26189 assert_eq!(result["done"], serde_json::json!(true));
26190 assert_eq!(result["value"], serde_json::json!("stream-data-123"));
26191 });
26192 }
26193
26194 #[test]
26195 fn pijs_node_stream_web_stream_bridge_roundtrip() {
26196 futures::executor::block_on(async {
26197 let clock = Arc::new(DeterministicClock::new(0));
26198 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
26199 .await
26200 .expect("create runtime");
26201
26202 runtime
26203 .eval(
26204 r#"
26205 globalThis.webBridge = { done: false, skipped: false };
26206 (async () => {
26207 if (typeof ReadableStream !== "function" || typeof WritableStream !== "function") {
26208 globalThis.webBridge.skipped = true;
26209 globalThis.webBridge.done = true;
26210 return;
26211 }
26212
26213 const { Readable, Writable } = await import("node:stream");
26214 const { pipeline } = await import("node:stream/promises");
26215
26216 const webReadable = new ReadableStream({
26217 start(controller) {
26218 controller.enqueue("ab");
26219 controller.enqueue("cd");
26220 controller.close();
26221 }
26222 });
26223 const nodeReadable = Readable.fromWeb(webReadable);
26224
26225 const fromWebChunks = [];
26226 const webWritable = new WritableStream({
26227 write(chunk) {
26228 fromWebChunks.push(String(chunk));
26229 }
26230 });
26231 const nodeWritable = Writable.fromWeb(webWritable);
26232 await pipeline(nodeReadable, nodeWritable);
26233
26234 const nodeReadableRoundtrip = Readable.from(["x", "y"]);
26235 const webReadableRoundtrip = Readable.toWeb(nodeReadableRoundtrip);
26236 const reader = webReadableRoundtrip.getReader();
26237 const toWebChunks = [];
26238 while (true) {
26239 const { done, value } = await reader.read();
26240 if (done) break;
26241 toWebChunks.push(String(value));
26242 }
26243
26244 globalThis.webBridge.fromWeb = fromWebChunks.join("");
26245 globalThis.webBridge.toWeb = toWebChunks.join("");
26246 globalThis.webBridge.done = true;
26247 })().catch((e) => {
26248 globalThis.webBridge.error = String(e && e.message ? e.message : e);
26249 globalThis.webBridge.done = false;
26250 });
26251 "#,
26252 )
26253 .await
26254 .expect("eval web stream bridge");
26255
26256 drain_until_idle(&runtime, &clock).await;
26257
26258 let result = get_global_json(&runtime, "webBridge").await;
26259 assert_eq!(result["done"], serde_json::json!(true));
26260 if result["skipped"] == serde_json::json!(true) {
26261 return;
26262 }
26263 assert_eq!(result["fromWeb"], serde_json::json!("abcd"));
26264 assert_eq!(result["toWeb"], serde_json::json!("xy"));
26265 });
26266 }
26267
26268 #[test]
26271 fn pijs_stream_chunks_delivered_via_async_iterator() {
26272 futures::executor::block_on(async {
26273 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
26274 .await
26275 .expect("create runtime");
26276
26277 runtime
26279 .eval(
26280 r#"
26281 globalThis.chunks = [];
26282 globalThis.done = false;
26283 (async () => {
26284 const stream = pi.exec("cat", ["big.txt"], { stream: true });
26285 for await (const chunk of stream) {
26286 globalThis.chunks.push(chunk);
26287 }
26288 globalThis.done = true;
26289 })();
26290 "#,
26291 )
26292 .await
26293 .expect("eval");
26294
26295 let requests = runtime.drain_hostcall_requests();
26296 assert_eq!(requests.len(), 1);
26297 let call_id = requests[0].call_id.clone();
26298
26299 for seq in 0..3 {
26301 runtime.complete_hostcall(
26302 call_id.clone(),
26303 HostcallOutcome::StreamChunk {
26304 sequence: seq,
26305 chunk: serde_json::json!({ "line": seq }),
26306 is_final: false,
26307 },
26308 );
26309 let stats = runtime.tick().await.expect("tick chunk");
26310 assert!(stats.ran_macrotask);
26311 }
26312
26313 assert!(
26315 runtime.hostcall_tracker.borrow().is_pending(&call_id),
26316 "hostcall should still be pending after non-final chunks"
26317 );
26318
26319 runtime.complete_hostcall(
26321 call_id.clone(),
26322 HostcallOutcome::StreamChunk {
26323 sequence: 3,
26324 chunk: serde_json::json!({ "line": 3 }),
26325 is_final: true,
26326 },
26327 );
26328 let stats = runtime.tick().await.expect("tick final");
26329 assert!(stats.ran_macrotask);
26330
26331 assert!(
26333 !runtime.hostcall_tracker.borrow().is_pending(&call_id),
26334 "hostcall should be completed after final chunk"
26335 );
26336
26337 runtime.tick().await.expect("tick settle");
26339
26340 let chunks = get_global_json(&runtime, "chunks").await;
26341 let arr = chunks.as_array().expect("chunks is array");
26342 assert_eq!(arr.len(), 4, "expected 4 chunks, got {arr:?}");
26343 for (i, c) in arr.iter().enumerate() {
26344 assert_eq!(c["line"], serde_json::json!(i), "chunk {i}");
26345 }
26346
26347 let done = get_global_json(&runtime, "done").await;
26348 assert_eq!(
26349 done,
26350 serde_json::json!(true),
26351 "async loop should have completed"
26352 );
26353 });
26354 }
26355
26356 #[test]
26357 fn pijs_stream_error_rejects_async_iterator() {
26358 futures::executor::block_on(async {
26359 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
26360 .await
26361 .expect("create runtime");
26362
26363 runtime
26364 .eval(
26365 r#"
26366 globalThis.chunks = [];
26367 globalThis.errMsg = null;
26368 (async () => {
26369 try {
26370 const stream = pi.exec("fail", [], { stream: true });
26371 for await (const chunk of stream) {
26372 globalThis.chunks.push(chunk);
26373 }
26374 } catch (e) {
26375 globalThis.errMsg = e.message;
26376 }
26377 })();
26378 "#,
26379 )
26380 .await
26381 .expect("eval");
26382
26383 let requests = runtime.drain_hostcall_requests();
26384 let call_id = requests[0].call_id.clone();
26385
26386 runtime.complete_hostcall(
26388 call_id.clone(),
26389 HostcallOutcome::StreamChunk {
26390 sequence: 0,
26391 chunk: serde_json::json!("first"),
26392 is_final: false,
26393 },
26394 );
26395 runtime.tick().await.expect("tick chunk 0");
26396
26397 runtime.complete_hostcall(
26399 call_id,
26400 HostcallOutcome::Error {
26401 code: "STREAM_ERR".into(),
26402 message: "broken pipe".into(),
26403 },
26404 );
26405 runtime.tick().await.expect("tick error");
26406 runtime.tick().await.expect("tick settle");
26407
26408 let chunks = get_global_json(&runtime, "chunks").await;
26409 assert_eq!(
26410 chunks.as_array().expect("array").len(),
26411 1,
26412 "should have received 1 chunk before error"
26413 );
26414
26415 let err = get_global_json(&runtime, "errMsg").await;
26416 assert_eq!(err, serde_json::json!("broken pipe"));
26417 });
26418 }
26419
26420 #[test]
26421 fn pijs_stream_http_returns_async_iterator() {
26422 futures::executor::block_on(async {
26423 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
26424 .await
26425 .expect("create runtime");
26426
26427 runtime
26428 .eval(
26429 r#"
26430 globalThis.chunks = [];
26431 globalThis.done = false;
26432 (async () => {
26433 const stream = pi.http({ url: "http://example.com", stream: true });
26434 for await (const chunk of stream) {
26435 globalThis.chunks.push(chunk);
26436 }
26437 globalThis.done = true;
26438 })();
26439 "#,
26440 )
26441 .await
26442 .expect("eval");
26443
26444 let requests = runtime.drain_hostcall_requests();
26445 assert_eq!(requests.len(), 1);
26446 let call_id = requests[0].call_id.clone();
26447
26448 runtime.complete_hostcall(
26450 call_id.clone(),
26451 HostcallOutcome::StreamChunk {
26452 sequence: 0,
26453 chunk: serde_json::json!("chunk-a"),
26454 is_final: false,
26455 },
26456 );
26457 runtime.tick().await.expect("tick a");
26458
26459 runtime.complete_hostcall(
26460 call_id,
26461 HostcallOutcome::StreamChunk {
26462 sequence: 1,
26463 chunk: serde_json::json!("chunk-b"),
26464 is_final: true,
26465 },
26466 );
26467 runtime.tick().await.expect("tick b");
26468 runtime.tick().await.expect("tick settle");
26469
26470 let chunks = get_global_json(&runtime, "chunks").await;
26471 let arr = chunks.as_array().expect("array");
26472 assert_eq!(arr.len(), 2);
26473 assert_eq!(arr[0], serde_json::json!("chunk-a"));
26474 assert_eq!(arr[1], serde_json::json!("chunk-b"));
26475
26476 assert_eq!(
26477 get_global_json(&runtime, "done").await,
26478 serde_json::json!(true)
26479 );
26480 });
26481 }
26482
26483 #[test]
26484 #[allow(clippy::too_many_lines)]
26485 fn pijs_stream_concurrent_exec_calls_have_independent_lifecycle() {
26486 futures::executor::block_on(async {
26487 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
26488 .await
26489 .expect("create runtime");
26490
26491 runtime
26492 .eval(
26493 r#"
26494 globalThis.streamA = [];
26495 globalThis.streamB = [];
26496 globalThis.doneA = false;
26497 globalThis.doneB = false;
26498 (async () => {
26499 const stream = pi.exec("cmd-a", [], { stream: true });
26500 for await (const chunk of stream) {
26501 globalThis.streamA.push(chunk);
26502 }
26503 globalThis.doneA = true;
26504 })();
26505 (async () => {
26506 const stream = pi.exec("cmd-b", [], { stream: true });
26507 for await (const chunk of stream) {
26508 globalThis.streamB.push(chunk);
26509 }
26510 globalThis.doneB = true;
26511 })();
26512 "#,
26513 )
26514 .await
26515 .expect("eval");
26516
26517 let requests = runtime.drain_hostcall_requests();
26518 assert_eq!(requests.len(), 2, "expected two streaming exec requests");
26519
26520 let mut call_a: Option<String> = None;
26521 let mut call_b: Option<String> = None;
26522 for request in &requests {
26523 match &request.kind {
26524 HostcallKind::Exec { cmd } if cmd == "cmd-a" => {
26525 call_a = Some(request.call_id.clone());
26526 }
26527 HostcallKind::Exec { cmd } if cmd == "cmd-b" => {
26528 call_b = Some(request.call_id.clone());
26529 }
26530 _ => {}
26531 }
26532 }
26533
26534 let call_a = call_a.expect("call_id for cmd-a");
26535 let call_b = call_b.expect("call_id for cmd-b");
26536 assert_ne!(call_a, call_b, "concurrent calls must have distinct ids");
26537 assert_eq!(runtime.pending_hostcall_count(), 2);
26538
26539 runtime.complete_hostcall(
26540 call_a.clone(),
26541 HostcallOutcome::StreamChunk {
26542 sequence: 0,
26543 chunk: serde_json::json!("a0"),
26544 is_final: false,
26545 },
26546 );
26547 runtime.tick().await.expect("tick a0");
26548
26549 runtime.complete_hostcall(
26550 call_b.clone(),
26551 HostcallOutcome::StreamChunk {
26552 sequence: 0,
26553 chunk: serde_json::json!("b0"),
26554 is_final: false,
26555 },
26556 );
26557 runtime.tick().await.expect("tick b0");
26558 assert_eq!(runtime.pending_hostcall_count(), 2);
26559
26560 runtime.complete_hostcall(
26561 call_b.clone(),
26562 HostcallOutcome::StreamChunk {
26563 sequence: 1,
26564 chunk: serde_json::json!("b1"),
26565 is_final: true,
26566 },
26567 );
26568 runtime.tick().await.expect("tick b1");
26569 assert_eq!(runtime.pending_hostcall_count(), 1);
26570 assert!(runtime.is_hostcall_pending(&call_a));
26571 assert!(!runtime.is_hostcall_pending(&call_b));
26572
26573 runtime.complete_hostcall(
26574 call_a.clone(),
26575 HostcallOutcome::StreamChunk {
26576 sequence: 1,
26577 chunk: serde_json::json!("a1"),
26578 is_final: true,
26579 },
26580 );
26581 runtime.tick().await.expect("tick a1");
26582 assert_eq!(runtime.pending_hostcall_count(), 0);
26583 assert!(!runtime.is_hostcall_pending(&call_a));
26584
26585 runtime.tick().await.expect("tick settle 1");
26586 runtime.tick().await.expect("tick settle 2");
26587
26588 let stream_a = get_global_json(&runtime, "streamA").await;
26589 let stream_b = get_global_json(&runtime, "streamB").await;
26590 assert_eq!(
26591 stream_a.as_array().expect("streamA array"),
26592 &vec![serde_json::json!("a0"), serde_json::json!("a1")]
26593 );
26594 assert_eq!(
26595 stream_b.as_array().expect("streamB array"),
26596 &vec![serde_json::json!("b0"), serde_json::json!("b1")]
26597 );
26598 assert_eq!(
26599 get_global_json(&runtime, "doneA").await,
26600 serde_json::json!(true)
26601 );
26602 assert_eq!(
26603 get_global_json(&runtime, "doneB").await,
26604 serde_json::json!(true)
26605 );
26606 });
26607 }
26608
26609 #[test]
26610 fn pijs_stream_chunk_ignored_after_hostcall_completed() {
26611 futures::executor::block_on(async {
26612 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
26613 .await
26614 .expect("create runtime");
26615
26616 runtime
26617 .eval(
26618 r#"
26619 globalThis.result = null;
26620 pi.tool("read", { path: "test.txt" }).then(r => {
26621 globalThis.result = r;
26622 });
26623 "#,
26624 )
26625 .await
26626 .expect("eval");
26627
26628 let requests = runtime.drain_hostcall_requests();
26629 let call_id = requests[0].call_id.clone();
26630
26631 runtime.complete_hostcall(
26633 call_id.clone(),
26634 HostcallOutcome::Success(serde_json::json!({ "content": "done" })),
26635 );
26636 runtime.tick().await.expect("tick success");
26637
26638 runtime.complete_hostcall(
26640 call_id,
26641 HostcallOutcome::StreamChunk {
26642 sequence: 0,
26643 chunk: serde_json::json!("stale"),
26644 is_final: false,
26645 },
26646 );
26647 let stats = runtime.tick().await.expect("tick stale chunk");
26649 assert!(stats.ran_macrotask, "macrotask should run (and be ignored)");
26650
26651 let result = get_global_json(&runtime, "result").await;
26652 assert_eq!(result["content"], serde_json::json!("done"));
26653 });
26654 }
26655
26656 #[test]
26659 fn pijs_exec_sync_denied_by_default_security_policy() {
26660 futures::executor::block_on(async {
26661 let clock = Arc::new(DeterministicClock::new(0));
26662 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
26663 .await
26664 .expect("create runtime");
26665
26666 runtime
26667 .eval(
26668 r"
26669 globalThis.syncDenied = {};
26670 import('node:child_process').then(({ execSync }) => {
26671 try {
26672 execSync('echo should-not-run');
26673 globalThis.syncDenied.threw = false;
26674 } catch (e) {
26675 globalThis.syncDenied.threw = true;
26676 globalThis.syncDenied.msg = String((e && e.message) || e || '');
26677 }
26678 globalThis.syncDenied.done = true;
26679 });
26680 ",
26681 )
26682 .await
26683 .expect("eval execSync deny");
26684
26685 let r = get_global_json(&runtime, "syncDenied").await;
26686 assert_eq!(r["done"], serde_json::json!(true));
26687 assert_eq!(r["threw"], serde_json::json!(true));
26688 assert!(
26689 r["msg"]
26690 .as_str()
26691 .unwrap_or("")
26692 .contains("disabled by default"),
26693 "unexpected denial message: {}",
26694 r["msg"]
26695 );
26696 });
26697 }
26698
26699 #[test]
26700 fn pijs_exec_sync_enforces_exec_mediation_for_critical_commands() {
26701 futures::executor::block_on(async {
26702 let clock = Arc::new(DeterministicClock::new(0));
26703 let config = PiJsRuntimeConfig {
26704 allow_unsafe_sync_exec: true,
26705 ..PiJsRuntimeConfig::default()
26706 };
26707 let policy = crate::extensions::PolicyProfile::Permissive.to_policy();
26708 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
26709 Arc::clone(&clock),
26710 config,
26711 Some(policy),
26712 )
26713 .await
26714 .expect("create runtime");
26715
26716 runtime
26717 .eval(
26718 r"
26719 globalThis.syncMediation = {};
26720 import('node:child_process').then(({ execSync }) => {
26721 try {
26722 execSync('dd if=/dev/zero of=/dev/null count=1');
26723 globalThis.syncMediation.threw = false;
26724 } catch (e) {
26725 globalThis.syncMediation.threw = true;
26726 globalThis.syncMediation.msg = String((e && e.message) || e || '');
26727 }
26728 globalThis.syncMediation.done = true;
26729 });
26730 ",
26731 )
26732 .await
26733 .expect("eval execSync mediation");
26734
26735 let r = get_global_json(&runtime, "syncMediation").await;
26736 assert_eq!(r["done"], serde_json::json!(true));
26737 assert_eq!(r["threw"], serde_json::json!(true));
26738 assert!(
26739 r["msg"].as_str().unwrap_or("").contains("exec mediation"),
26740 "unexpected mediation denial message: {}",
26741 r["msg"]
26742 );
26743 });
26744 }
26745
26746 #[test]
26747 fn pijs_exec_sync_runs_command_and_returns_stdout() {
26748 futures::executor::block_on(async {
26749 let clock = Arc::new(DeterministicClock::new(0));
26750 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26751
26752 runtime
26753 .eval(
26754 r"
26755 globalThis.syncResult = {};
26756 import('node:child_process').then(({ execSync }) => {
26757 try {
26758 const output = execSync('echo hello-sync');
26759 globalThis.syncResult.stdout = output.trim();
26760 globalThis.syncResult.done = true;
26761 } catch (e) {
26762 globalThis.syncResult.error = String(e);
26763 globalThis.syncResult.stack = e.stack || '';
26764 globalThis.syncResult.done = false;
26765 }
26766 }).catch(e => {
26767 globalThis.syncResult.promiseError = String(e);
26768 });
26769 ",
26770 )
26771 .await
26772 .expect("eval execSync test");
26773
26774 let r = get_global_json(&runtime, "syncResult").await;
26775 assert!(
26776 r["done"] == serde_json::json!(true),
26777 "execSync test failed: error={}, stack={}, promiseError={}",
26778 r["error"],
26779 r["stack"],
26780 r["promiseError"]
26781 );
26782 assert_eq!(r["stdout"], serde_json::json!("hello-sync"));
26783 });
26784 }
26785
26786 #[test]
26787 fn pijs_exec_sync_throws_when_stdout_exceeds_max_buffer() {
26788 futures::executor::block_on(async {
26789 let clock = Arc::new(DeterministicClock::new(0));
26790 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26791
26792 runtime
26793 .eval(
26794 r#"
26795 globalThis.syncMaxBuffer = {};
26796 import('node:child_process').then(({ execSync }) => {
26797 try {
26798 execSync(
26799 "python3 -c 'import sys; sys.stdout.write(\"x\" * 70000)'",
26800 { maxBuffer: 1024 }
26801 );
26802 globalThis.syncMaxBuffer.threw = false;
26803 } catch (e) {
26804 globalThis.syncMaxBuffer.threw = true;
26805 globalThis.syncMaxBuffer.msg = String((e && e.message) || e || '');
26806 globalThis.syncMaxBuffer.stdoutLen =
26807 typeof e.stdout === 'string' ? e.stdout.length : -1;
26808 }
26809 globalThis.syncMaxBuffer.done = true;
26810 });
26811 "#,
26812 )
26813 .await
26814 .expect("eval execSync maxBuffer");
26815
26816 let r = get_global_json(&runtime, "syncMaxBuffer").await;
26817 assert_eq!(r["done"], serde_json::json!(true));
26818 assert_eq!(r["threw"], serde_json::json!(true));
26819 assert!(
26820 r["msg"]
26821 .as_str()
26822 .unwrap_or("")
26823 .contains("maxBuffer length exceeded"),
26824 "unexpected maxBuffer message: {}",
26825 r["msg"]
26826 );
26827 assert_eq!(r["stdoutLen"].as_f64(), Some(1024.0));
26828 });
26829 }
26830
26831 #[test]
26832 fn pijs_exec_sync_throws_on_nonzero_exit() {
26833 futures::executor::block_on(async {
26834 let clock = Arc::new(DeterministicClock::new(0));
26835 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26836
26837 runtime
26838 .eval(
26839 r"
26840 globalThis.syncErr = {};
26841 import('node:child_process').then(({ execSync }) => {
26842 try {
26843 execSync('exit 42');
26844 globalThis.syncErr.threw = false;
26845 } catch (e) {
26846 globalThis.syncErr.threw = true;
26847 globalThis.syncErr.status = e.status;
26848 globalThis.syncErr.hasStderr = typeof e.stderr === 'string';
26849 }
26850 globalThis.syncErr.done = true;
26851 });
26852 ",
26853 )
26854 .await
26855 .expect("eval execSync nonzero");
26856
26857 let r = get_global_json(&runtime, "syncErr").await;
26858 assert_eq!(r["done"], serde_json::json!(true));
26859 assert_eq!(r["threw"], serde_json::json!(true));
26860 assert_eq!(r["status"].as_f64(), Some(42.0));
26862 assert_eq!(r["hasStderr"], serde_json::json!(true));
26863 });
26864 }
26865
26866 #[test]
26867 fn pijs_exec_sync_empty_command_throws() {
26868 futures::executor::block_on(async {
26869 let clock = Arc::new(DeterministicClock::new(0));
26870 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26871
26872 runtime
26873 .eval(
26874 r"
26875 globalThis.emptyResult = {};
26876 import('node:child_process').then(({ execSync }) => {
26877 try {
26878 execSync('');
26879 globalThis.emptyResult.threw = false;
26880 } catch (e) {
26881 globalThis.emptyResult.threw = true;
26882 globalThis.emptyResult.msg = e.message;
26883 }
26884 globalThis.emptyResult.done = true;
26885 });
26886 ",
26887 )
26888 .await
26889 .expect("eval execSync empty");
26890
26891 let r = get_global_json(&runtime, "emptyResult").await;
26892 assert_eq!(r["done"], serde_json::json!(true));
26893 assert_eq!(r["threw"], serde_json::json!(true));
26894 assert!(
26895 r["msg"]
26896 .as_str()
26897 .unwrap_or("")
26898 .contains("command is required")
26899 );
26900 });
26901 }
26902
26903 #[test]
26904 fn pijs_spawn_sync_returns_result_object() {
26905 futures::executor::block_on(async {
26906 let clock = Arc::new(DeterministicClock::new(0));
26907 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26908
26909 runtime
26910 .eval(
26911 r"
26912 globalThis.spawnSyncResult = {};
26913 import('node:child_process').then(({ spawnSync }) => {
26914 const r = spawnSync('echo', ['spawn-test']);
26915 globalThis.spawnSyncResult.stdout = r.stdout.trim();
26916 globalThis.spawnSyncResult.status = r.status;
26917 globalThis.spawnSyncResult.hasOutput = Array.isArray(r.output);
26918 globalThis.spawnSyncResult.noError = r.error === undefined;
26919 globalThis.spawnSyncResult.done = true;
26920 });
26921 ",
26922 )
26923 .await
26924 .expect("eval spawnSync test");
26925
26926 let r = get_global_json(&runtime, "spawnSyncResult").await;
26927 assert_eq!(r["done"], serde_json::json!(true));
26928 assert_eq!(r["stdout"], serde_json::json!("spawn-test"));
26929 assert_eq!(r["status"].as_f64(), Some(0.0));
26930 assert_eq!(r["hasOutput"], serde_json::json!(true));
26931 assert_eq!(r["noError"], serde_json::json!(true));
26932 });
26933 }
26934
26935 #[test]
26936 fn pijs_spawn_sync_captures_nonzero_exit() {
26937 futures::executor::block_on(async {
26938 let clock = Arc::new(DeterministicClock::new(0));
26939 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26940
26941 runtime
26942 .eval(
26943 r"
26944 globalThis.spawnSyncFail = {};
26945 import('node:child_process').then(({ spawnSync }) => {
26946 const r = spawnSync('sh', ['-c', 'exit 7']);
26947 globalThis.spawnSyncFail.status = r.status;
26948 globalThis.spawnSyncFail.signal = r.signal;
26949 globalThis.spawnSyncFail.done = true;
26950 });
26951 ",
26952 )
26953 .await
26954 .expect("eval spawnSync fail");
26955
26956 let r = get_global_json(&runtime, "spawnSyncFail").await;
26957 assert_eq!(r["done"], serde_json::json!(true));
26958 assert_eq!(r["status"].as_f64(), Some(7.0));
26959 assert_eq!(r["signal"], serde_json::json!(null));
26960 });
26961 }
26962
26963 #[test]
26964 fn pijs_spawn_sync_bad_command_returns_error() {
26965 futures::executor::block_on(async {
26966 let clock = Arc::new(DeterministicClock::new(0));
26967 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26968
26969 runtime
26970 .eval(
26971 r"
26972 globalThis.badCmd = {};
26973 import('node:child_process').then(({ spawnSync }) => {
26974 const r = spawnSync('__nonexistent_binary_xyzzy__');
26975 globalThis.badCmd.hasError = r.error !== undefined;
26976 globalThis.badCmd.statusNull = r.status === null;
26977 globalThis.badCmd.done = true;
26978 });
26979 ",
26980 )
26981 .await
26982 .expect("eval spawnSync bad cmd");
26983
26984 let r = get_global_json(&runtime, "badCmd").await;
26985 assert_eq!(r["done"], serde_json::json!(true));
26986 assert_eq!(r["hasError"], serde_json::json!(true));
26987 assert_eq!(r["statusNull"], serde_json::json!(true));
26988 });
26989 }
26990
26991 #[test]
26992 fn pijs_exec_file_sync_runs_binary_directly() {
26993 futures::executor::block_on(async {
26994 let clock = Arc::new(DeterministicClock::new(0));
26995 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26996
26997 runtime
26998 .eval(
26999 r"
27000 globalThis.execFileResult = {};
27001 import('node:child_process').then(({ execFileSync }) => {
27002 const output = execFileSync('echo', ['file-sync-test']);
27003 globalThis.execFileResult.stdout = output.trim();
27004 globalThis.execFileResult.done = true;
27005 });
27006 ",
27007 )
27008 .await
27009 .expect("eval execFileSync test");
27010
27011 let r = get_global_json(&runtime, "execFileResult").await;
27012 assert_eq!(r["done"], serde_json::json!(true));
27013 assert_eq!(r["stdout"], serde_json::json!("file-sync-test"));
27014 });
27015 }
27016
27017 #[test]
27018 fn pijs_exec_file_sync_throws_when_stdout_exceeds_max_buffer() {
27019 futures::executor::block_on(async {
27020 let clock = Arc::new(DeterministicClock::new(0));
27021 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
27022
27023 runtime
27024 .eval(
27025 r#"
27026 globalThis.execFileMaxBuffer = {};
27027 import('node:child_process').then(({ execFileSync }) => {
27028 try {
27029 execFileSync(
27030 'python3',
27031 ['-c', 'import sys; sys.stdout.write("x" * 70000)'],
27032 { maxBuffer: 1024 }
27033 );
27034 globalThis.execFileMaxBuffer.threw = false;
27035 } catch (e) {
27036 globalThis.execFileMaxBuffer.threw = true;
27037 globalThis.execFileMaxBuffer.msg = String((e && e.message) || e || '');
27038 globalThis.execFileMaxBuffer.stdoutLen =
27039 typeof e.stdout === 'string' ? e.stdout.length : -1;
27040 }
27041 globalThis.execFileMaxBuffer.done = true;
27042 });
27043 "#,
27044 )
27045 .await
27046 .expect("eval execFileSync maxBuffer");
27047
27048 let r = get_global_json(&runtime, "execFileMaxBuffer").await;
27049 assert_eq!(r["done"], serde_json::json!(true));
27050 assert_eq!(r["threw"], serde_json::json!(true));
27051 assert!(
27052 r["msg"]
27053 .as_str()
27054 .unwrap_or("")
27055 .contains("maxBuffer length exceeded"),
27056 "unexpected execFileSync maxBuffer message: {}",
27057 r["msg"]
27058 );
27059 assert_eq!(r["stdoutLen"].as_f64(), Some(1024.0));
27060 });
27061 }
27062
27063 #[test]
27064 fn pijs_exec_sync_captures_stderr() {
27065 futures::executor::block_on(async {
27066 let clock = Arc::new(DeterministicClock::new(0));
27067 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
27068
27069 runtime
27070 .eval(
27071 r"
27072 globalThis.stderrResult = {};
27073 import('node:child_process').then(({ execSync }) => {
27074 try {
27075 execSync('echo err-msg >&2 && exit 1');
27076 globalThis.stderrResult.threw = false;
27077 } catch (e) {
27078 globalThis.stderrResult.threw = true;
27079 globalThis.stderrResult.stderr = e.stderr.trim();
27080 }
27081 globalThis.stderrResult.done = true;
27082 });
27083 ",
27084 )
27085 .await
27086 .expect("eval execSync stderr");
27087
27088 let r = get_global_json(&runtime, "stderrResult").await;
27089 assert_eq!(r["done"], serde_json::json!(true));
27090 assert_eq!(r["threw"], serde_json::json!(true));
27091 assert_eq!(r["stderr"], serde_json::json!("err-msg"));
27092 });
27093 }
27094
27095 #[test]
27096 #[cfg(unix)]
27097 fn pijs_exec_sync_with_cwd_option() {
27098 futures::executor::block_on(async {
27099 let clock = Arc::new(DeterministicClock::new(0));
27100 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
27101
27102 runtime
27103 .eval(
27104 r"
27105 globalThis.cwdResult = {};
27106 import('node:child_process').then(({ execSync }) => {
27107 const output = execSync('pwd', { cwd: '/tmp' });
27108 globalThis.cwdResult.dir = output.trim();
27109 globalThis.cwdResult.done = true;
27110 });
27111 ",
27112 )
27113 .await
27114 .expect("eval execSync cwd");
27115
27116 let r = get_global_json(&runtime, "cwdResult").await;
27117 assert_eq!(r["done"], serde_json::json!(true));
27118 let dir = r["dir"].as_str().unwrap_or("");
27120 assert!(
27121 dir == "/tmp" || dir.ends_with("/tmp"),
27122 "expected /tmp, got: {dir}"
27123 );
27124 });
27125 }
27126
27127 #[test]
27128 fn pijs_spawn_sync_empty_command_throws() {
27129 futures::executor::block_on(async {
27130 let clock = Arc::new(DeterministicClock::new(0));
27131 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
27132
27133 runtime
27134 .eval(
27135 r"
27136 globalThis.emptySpawn = {};
27137 import('node:child_process').then(({ spawnSync }) => {
27138 try {
27139 spawnSync('');
27140 globalThis.emptySpawn.threw = false;
27141 } catch (e) {
27142 globalThis.emptySpawn.threw = true;
27143 globalThis.emptySpawn.msg = e.message;
27144 }
27145 globalThis.emptySpawn.done = true;
27146 });
27147 ",
27148 )
27149 .await
27150 .expect("eval spawnSync empty");
27151
27152 let r = get_global_json(&runtime, "emptySpawn").await;
27153 assert_eq!(r["done"], serde_json::json!(true));
27154 assert_eq!(r["threw"], serde_json::json!(true));
27155 assert!(
27156 r["msg"]
27157 .as_str()
27158 .unwrap_or("")
27159 .contains("command is required")
27160 );
27161 });
27162 }
27163
27164 #[test]
27165 #[cfg(unix)]
27166 fn pijs_spawn_sync_options_as_second_arg() {
27167 futures::executor::block_on(async {
27168 let clock = Arc::new(DeterministicClock::new(0));
27169 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
27170
27171 runtime
27173 .eval(
27174 r"
27175 globalThis.optsResult = {};
27176 import('node:child_process').then(({ spawnSync }) => {
27177 const r = spawnSync('pwd', { cwd: '/tmp' });
27178 globalThis.optsResult.stdout = r.stdout.trim();
27179 globalThis.optsResult.done = true;
27180 });
27181 ",
27182 )
27183 .await
27184 .expect("eval spawnSync opts as 2nd arg");
27185
27186 let r = get_global_json(&runtime, "optsResult").await;
27187 assert_eq!(r["done"], serde_json::json!(true));
27188 let stdout = r["stdout"].as_str().unwrap_or("");
27189 assert!(
27190 stdout == "/tmp" || stdout.ends_with("/tmp"),
27191 "expected /tmp, got: {stdout}"
27192 );
27193 });
27194 }
27195
27196 #[test]
27199 fn pijs_os_expanded_apis() {
27200 futures::executor::block_on(async {
27201 let clock = Arc::new(DeterministicClock::new(0));
27202 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
27203 .await
27204 .expect("create runtime");
27205
27206 runtime
27207 .eval(
27208 r"
27209 globalThis.osEx = {};
27210 import('node:os').then((os) => {
27211 const cpuArr = os.cpus();
27212 globalThis.osEx.cpusIsArray = Array.isArray(cpuArr);
27213 globalThis.osEx.cpusLen = cpuArr.length;
27214 globalThis.osEx.cpuHasModel = typeof cpuArr[0].model === 'string';
27215 globalThis.osEx.cpuHasSpeed = typeof cpuArr[0].speed === 'number';
27216 globalThis.osEx.cpuHasTimes = typeof cpuArr[0].times === 'object';
27217
27218 globalThis.osEx.totalmem = os.totalmem();
27219 globalThis.osEx.totalMemPositive = os.totalmem() > 0;
27220 globalThis.osEx.freeMemPositive = os.freemem() > 0;
27221 globalThis.osEx.freeMemLessTotal = os.freemem() <= os.totalmem();
27222
27223 globalThis.osEx.uptimePositive = os.uptime() > 0;
27224
27225 const la = os.loadavg();
27226 globalThis.osEx.loadavgIsArray = Array.isArray(la);
27227 globalThis.osEx.loadavgLen = la.length;
27228
27229 globalThis.osEx.networkInterfacesIsObj = typeof os.networkInterfaces() === 'object';
27230
27231 const ui = os.userInfo();
27232 globalThis.osEx.userInfoHasUid = typeof ui.uid === 'number';
27233 globalThis.osEx.userInfoHasUsername = typeof ui.username === 'string';
27234 globalThis.osEx.userInfoHasHomedir = typeof ui.homedir === 'string';
27235 globalThis.osEx.userInfoHasShell = typeof ui.shell === 'string';
27236
27237 globalThis.osEx.endianness = os.endianness();
27238 globalThis.osEx.eol = os.EOL;
27239 globalThis.osEx.devNull = os.devNull;
27240 globalThis.osEx.hasConstants = typeof os.constants === 'object';
27241
27242 globalThis.osEx.done = true;
27243 });
27244 ",
27245 )
27246 .await
27247 .expect("eval node:os expanded");
27248
27249 let r = get_global_json(&runtime, "osEx").await;
27250 assert_eq!(r["done"], serde_json::json!(true));
27251 assert_eq!(r["cpusIsArray"], serde_json::json!(true));
27253 assert!(r["cpusLen"].as_f64().unwrap_or(0.0) >= 1.0);
27254 assert_eq!(r["cpuHasModel"], serde_json::json!(true));
27255 assert_eq!(r["cpuHasSpeed"], serde_json::json!(true));
27256 assert_eq!(r["cpuHasTimes"], serde_json::json!(true));
27257 assert_eq!(r["totalMemPositive"], serde_json::json!(true));
27259 assert_eq!(r["freeMemPositive"], serde_json::json!(true));
27260 assert_eq!(r["freeMemLessTotal"], serde_json::json!(true));
27261 assert_eq!(r["uptimePositive"], serde_json::json!(true));
27263 assert_eq!(r["loadavgIsArray"], serde_json::json!(true));
27265 assert_eq!(r["loadavgLen"].as_f64(), Some(3.0));
27266 assert_eq!(r["networkInterfacesIsObj"], serde_json::json!(true));
27268 assert_eq!(r["userInfoHasUid"], serde_json::json!(true));
27270 assert_eq!(r["userInfoHasUsername"], serde_json::json!(true));
27271 assert_eq!(r["userInfoHasHomedir"], serde_json::json!(true));
27272 assert_eq!(r["userInfoHasShell"], serde_json::json!(true));
27273 assert_eq!(r["endianness"], serde_json::json!("LE"));
27275 let expected_eol = if cfg!(windows) { "\r\n" } else { "\n" };
27276 assert_eq!(r["eol"], serde_json::json!(expected_eol));
27277 let expected_dev_null = if cfg!(windows) {
27278 "\\\\.\\NUL"
27279 } else {
27280 "/dev/null"
27281 };
27282 assert_eq!(r["devNull"], serde_json::json!(expected_dev_null));
27283 assert_eq!(r["hasConstants"], serde_json::json!(true));
27284 });
27285 }
27286
27287 #[test]
27290 fn pijs_buffer_expanded_apis() {
27291 futures::executor::block_on(async {
27292 let clock = Arc::new(DeterministicClock::new(0));
27293 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
27294 .await
27295 .expect("create runtime");
27296
27297 runtime
27298 .eval(
27299 r"
27300 globalThis.bufResult = {};
27301 (() => {
27302 const B = globalThis.Buffer;
27303
27304 // alloc
27305 const a = B.alloc(4, 0xAB);
27306 globalThis.bufResult.allocFill = Array.from(a);
27307
27308 // from string + hex encoding
27309 const hex = B.from('48656c6c6f', 'hex');
27310 globalThis.bufResult.hexDecode = hex.toString('utf8');
27311
27312 // concat
27313 const c = B.concat([B.from('Hello'), B.from(' World')]);
27314 globalThis.bufResult.concat = c.toString();
27315
27316 // byteLength
27317 globalThis.bufResult.byteLength = B.byteLength('Hello');
27318
27319 // compare
27320 globalThis.bufResult.compareEqual = B.compare(B.from('abc'), B.from('abc'));
27321 globalThis.bufResult.compareLess = B.compare(B.from('abc'), B.from('abd'));
27322 globalThis.bufResult.compareGreater = B.compare(B.from('abd'), B.from('abc'));
27323
27324 // isEncoding
27325 globalThis.bufResult.isEncodingUtf8 = B.isEncoding('utf8');
27326 globalThis.bufResult.isEncodingFake = B.isEncoding('fake');
27327
27328 // isBuffer
27329 globalThis.bufResult.isBufferTrue = B.isBuffer(B.from('x'));
27330 globalThis.bufResult.isBufferFalse = B.isBuffer('x');
27331
27332 // instance methods
27333 const b = B.from('Hello World');
27334 globalThis.bufResult.indexOf = b.indexOf('World');
27335 globalThis.bufResult.includes = b.includes('World');
27336 globalThis.bufResult.notIncludes = b.includes('xyz');
27337 const neg = B.from('abc');
27338 globalThis.bufResult.negativeMiss = neg.indexOf('a', -1);
27339 globalThis.bufResult.negativeHit = neg.indexOf('c', -1);
27340 globalThis.bufResult.negativeIncludes = neg.includes('a', -1);
27341 globalThis.bufResult.indexOfHexNeedle = B.from('hello').indexOf('6c6c', 'hex');
27342
27343 const sliced = b.slice(0, 5);
27344 globalThis.bufResult.slice = sliced.toString();
27345
27346 globalThis.bufResult.toJSON = b.toJSON().type;
27347
27348 const eq1 = B.from('abc');
27349 const eq2 = B.from('abc');
27350 const eq3 = B.from('xyz');
27351 globalThis.bufResult.equalsTrue = eq1.equals(eq2);
27352 globalThis.bufResult.equalsFalse = eq1.equals(eq3);
27353
27354 // copy
27355 const src = B.from('Hello');
27356 const dst = B.alloc(5);
27357 src.copy(dst);
27358 globalThis.bufResult.copy = dst.toString();
27359
27360 // write
27361 const wb = B.alloc(10);
27362 wb.write('Hi');
27363 globalThis.bufResult.write = wb.toString('utf8', 0, 2);
27364
27365 // readUInt / writeUInt
27366 const nb = B.alloc(4);
27367 nb.writeUInt16BE(0x1234, 0);
27368 globalThis.bufResult.readUInt16BE = nb.readUInt16BE(0);
27369 nb.writeUInt32LE(0xDEADBEEF, 0);
27370 globalThis.bufResult.readUInt32LE = nb.readUInt32LE(0);
27371
27372 // hex encoding
27373 const hb = B.from([0xDE, 0xAD]);
27374 globalThis.bufResult.toHex = hb.toString('hex');
27375
27376 // base64 round-trip
27377 const b64 = B.from('Hello').toString('base64');
27378 const roundTrip = B.from(b64, 'base64').toString();
27379 globalThis.bufResult.base64Round = roundTrip;
27380
27381 globalThis.bufResult.done = true;
27382 })();
27383 ",
27384 )
27385 .await
27386 .expect("eval Buffer expanded");
27387
27388 let r = get_global_json(&runtime, "bufResult").await;
27389 assert_eq!(r["done"], serde_json::json!(true));
27390 assert_eq!(r["allocFill"], serde_json::json!([0xAB, 0xAB, 0xAB, 0xAB]));
27392 assert_eq!(r["hexDecode"], serde_json::json!("Hello"));
27394 assert_eq!(r["concat"], serde_json::json!("Hello World"));
27396 assert_eq!(r["byteLength"].as_f64(), Some(5.0));
27398 assert_eq!(r["compareEqual"].as_f64(), Some(0.0));
27400 assert!(r["compareLess"].as_f64().unwrap_or(0.0) < 0.0);
27401 assert!(r["compareGreater"].as_f64().unwrap_or(0.0) > 0.0);
27402 assert_eq!(r["isEncodingUtf8"], serde_json::json!(true));
27404 assert_eq!(r["isEncodingFake"], serde_json::json!(false));
27405 assert_eq!(r["isBufferTrue"], serde_json::json!(true));
27407 assert_eq!(r["isBufferFalse"], serde_json::json!(false));
27408 assert_eq!(r["indexOf"].as_f64(), Some(6.0));
27410 assert_eq!(r["includes"], serde_json::json!(true));
27411 assert_eq!(r["notIncludes"], serde_json::json!(false));
27412 assert_eq!(r["negativeMiss"].as_f64(), Some(-1.0));
27413 assert_eq!(r["negativeHit"].as_f64(), Some(2.0));
27414 assert_eq!(r["negativeIncludes"], serde_json::json!(false));
27415 assert_eq!(r["indexOfHexNeedle"].as_f64(), Some(2.0));
27416 assert_eq!(r["slice"], serde_json::json!("Hello"));
27418 assert_eq!(r["toJSON"], serde_json::json!("Buffer"));
27420 assert_eq!(r["equalsTrue"], serde_json::json!(true));
27422 assert_eq!(r["equalsFalse"], serde_json::json!(false));
27423 assert_eq!(r["copy"], serde_json::json!("Hello"));
27425 assert_eq!(r["write"], serde_json::json!("Hi"));
27427 assert_eq!(r["readUInt16BE"].as_f64(), Some(f64::from(0x1234)));
27429 assert_eq!(r["readUInt32LE"].as_f64(), Some(f64::from(0xDEAD_BEEF_u32)));
27431 assert_eq!(r["toHex"], serde_json::json!("dead"));
27433 assert_eq!(r["base64Round"], serde_json::json!("Hello"));
27435 });
27436 }
27437}