11use aes_gcm:: {
2- AesGcm , Nonce ,
3- aead:: { Aead , KeyInit , OsRng , generic_array:: typenum:: U16 , rand_core:: RngCore } ,
2+ aead:: { generic_array:: typenum:: U16 , rand_core:: RngCore , Aead , KeyInit , OsRng } ,
43 aes:: Aes256 ,
4+ AesGcm , Nonce ,
55} ;
6- use base64:: { Engine as _ , engine:: general_purpose:: STANDARD as BASE64_STANDARD } ;
6+ use base64:: { engine:: general_purpose:: STANDARD as BASE64_STANDARD , Engine as _ } ;
77use rquickjs:: { Ctx , Exception , Function , Object } ;
88use sha2:: { Digest , Sha256 } ;
99use std:: collections:: { HashMap , HashSet } ;
1010use std:: ffi:: { OsStr , OsString } ;
1111use std:: path:: { Path , PathBuf } ;
1212use std:: process:: Command ;
1313use std:: sync:: { Mutex , OnceLock } ;
14+ use std:: time:: { Duration , Instant } ;
1415
1516const WHITELISTED_ENV_VARS : [ & str ; 16 ] = [
1617 "CODEX_HOME" ,
@@ -31,6 +32,45 @@ const WHITELISTED_ENV_VARS: [&str; 16] = [
3132 "PI_CODING_AGENT_DIR" ,
3233] ;
3334
35+ #[ derive( Clone , Copy , Debug ) ]
36+ pub ( crate ) struct ProbeDeadline {
37+ expires_at : Option < Instant > ,
38+ }
39+
40+ impl ProbeDeadline {
41+ #[ cfg( test) ]
42+ pub ( crate ) fn none ( ) -> Self {
43+ Self { expires_at : None }
44+ }
45+
46+ pub ( crate ) fn at ( expires_at : Instant ) -> Self {
47+ Self {
48+ expires_at : Some ( expires_at) ,
49+ }
50+ }
51+
52+ pub ( crate ) fn has_elapsed ( self ) -> bool {
53+ self . expires_at
54+ . map ( |expires_at| Instant :: now ( ) >= expires_at)
55+ . unwrap_or ( false )
56+ }
57+
58+ fn clamp_duration ( self , requested : Duration ) -> Duration {
59+ let Some ( expires_at) = self . expires_at else {
60+ return requested;
61+ } ;
62+ let remaining = expires_at
63+ . checked_duration_since ( Instant :: now ( ) )
64+ . unwrap_or_default ( ) ;
65+ let clamped = requested. min ( remaining) ;
66+ if clamped. is_zero ( ) {
67+ Duration :: from_millis ( 1 )
68+ } else {
69+ clamped
70+ }
71+ }
72+ }
73+
3474fn last_non_empty_trimmed_line ( text : & str ) -> Option < String > {
3575 text. lines ( )
3676 . map ( |line| line. trim ( ) )
@@ -511,11 +551,28 @@ fn encrypt_aes_256_gcm_envelope(plaintext: &str, key_b64: &str) -> Result<String
511551 ) )
512552}
513553
514- pub fn inject_host_api < ' js > (
554+ #[ cfg( test) ]
555+ pub ( crate ) fn inject_host_api < ' js > (
556+ ctx : & Ctx < ' js > ,
557+ plugin_id : & str ,
558+ app_data_dir : & PathBuf ,
559+ app_version : & str ,
560+ ) -> rquickjs:: Result < ( ) > {
561+ inject_host_api_with_deadline (
562+ ctx,
563+ plugin_id,
564+ app_data_dir,
565+ app_version,
566+ ProbeDeadline :: none ( ) ,
567+ )
568+ }
569+
570+ pub ( crate ) fn inject_host_api_with_deadline < ' js > (
515571 ctx : & Ctx < ' js > ,
516572 plugin_id : & str ,
517573 app_data_dir : & PathBuf ,
518574 app_version : & str ,
575+ deadline : ProbeDeadline ,
519576) -> rquickjs:: Result < ( ) > {
520577 let globals = ctx. globals ( ) ;
521578 let probe_ctx = Object :: new ( ctx. clone ( ) ) ?;
@@ -545,11 +602,11 @@ pub fn inject_host_api<'js>(
545602 inject_fs ( ctx, & host) ?;
546603 inject_crypto ( ctx, & host) ?;
547604 inject_env ( ctx, & host, plugin_id) ?;
548- inject_http ( ctx, & host, plugin_id) ?;
605+ inject_http ( ctx, & host, plugin_id, deadline ) ?;
549606 inject_keychain ( ctx, & host, plugin_id) ?;
550607 inject_sqlite ( ctx, & host) ?;
551608 inject_ls ( ctx, & host, plugin_id) ?;
552- inject_ccusage ( ctx, & host, plugin_id) ?;
609+ inject_ccusage ( ctx, & host, plugin_id, deadline ) ?;
553610
554611 probe_ctx. set ( "host" , host) ?;
555612 globals. set ( "__openusage_ctx" , probe_ctx) ?;
@@ -720,7 +777,12 @@ fn inject_env<'js>(ctx: &Ctx<'js>, host: &Object<'js>, _plugin_id: &str) -> rqui
720777 Ok ( ( ) )
721778}
722779
723- fn inject_http < ' js > ( ctx : & Ctx < ' js > , host : & Object < ' js > , plugin_id : & str ) -> rquickjs:: Result < ( ) > {
780+ fn inject_http < ' js > (
781+ ctx : & Ctx < ' js > ,
782+ host : & Object < ' js > ,
783+ plugin_id : & str ,
784+ deadline : ProbeDeadline ,
785+ ) -> rquickjs:: Result < ( ) > {
724786 let http_obj = Object :: new ( ctx. clone ( ) ) ?;
725787 let pid = plugin_id. to_string ( ) ;
726788
@@ -733,6 +795,10 @@ fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rqui
733795 Exception :: throw_message ( & ctx_inner, & format ! ( "invalid request: {}" , e) )
734796 } ) ?;
735797
798+ if deadline. has_elapsed ( ) {
799+ return Err ( Exception :: throw_message ( & ctx_inner, "probe timed out" ) ) ;
800+ }
801+
736802 let method_str = req. method . as_deref ( ) . unwrap_or ( "GET" ) ;
737803 let redacted_url = redact_url ( & req. url ) ;
738804 log:: info!( "[plugin:{}] HTTP {} {}" , pid, method_str, redacted_url) ;
@@ -758,9 +824,10 @@ fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rqui
758824 }
759825
760826 let timeout_ms = req. timeout_ms . unwrap_or ( 10_000 ) ;
827+ let timeout = deadline. clamp_duration ( Duration :: from_millis ( timeout_ms) ) ;
761828 let mut builder = reqwest:: blocking:: Client :: builder ( )
762- . timeout ( std :: time :: Duration :: from_millis ( timeout_ms ) )
763- . connect_timeout ( std :: time :: Duration :: from_millis ( timeout_ms ) )
829+ . timeout ( timeout )
830+ . connect_timeout ( timeout )
764831 . redirect ( reqwest:: redirect:: Policy :: none ( ) ) ;
765832
766833 // Apply pre-resolved proxy (localhost bypass already configured)
@@ -1972,31 +2039,56 @@ fn format_ccusage_timeout(timeout: std::time::Duration) -> String {
19722039 format ! ( "{:.3}s" , timeout. as_secs_f64( ) )
19732040}
19742041
2042+ #[ cfg( test) ]
19752043fn run_ccusage_with_runner (
19762044 kind : CcusageRunnerKind ,
19772045 program : & str ,
19782046 opts : & CcusageQueryOpts ,
19792047 provider : CcusageProvider ,
19802048 plugin_id : & str ,
19812049) -> CcusageRunnerResult {
2050+ run_ccusage_with_runner_deadline (
2051+ kind,
2052+ program,
2053+ opts,
2054+ provider,
2055+ plugin_id,
2056+ ProbeDeadline :: none ( ) ,
2057+ )
2058+ }
2059+
2060+ fn run_ccusage_with_runner_deadline (
2061+ kind : CcusageRunnerKind ,
2062+ program : & str ,
2063+ opts : & CcusageQueryOpts ,
2064+ provider : CcusageProvider ,
2065+ plugin_id : & str ,
2066+ deadline : ProbeDeadline ,
2067+ ) -> CcusageRunnerResult {
2068+ if deadline. has_elapsed ( ) {
2069+ log:: warn!( "[plugin:{}] ccusage skipped: probe timed out" , plugin_id) ;
2070+ return CcusageRunnerResult :: TimedOut ;
2071+ }
2072+
19822073 let current = run_ccusage_with_runner_timeout (
19832074 kind,
19842075 program,
19852076 opts,
19862077 provider,
19872078 plugin_id,
19882079 CcusageCommandFlavor :: Current ,
1989- std :: time :: Duration :: from_secs ( CCUSAGE_TIMEOUT_SECS ) ,
2080+ deadline . clamp_duration ( Duration :: from_secs ( CCUSAGE_TIMEOUT_SECS ) ) ,
19902081 ) ;
19912082 match current {
2083+ CcusageRunnerResult :: Failed if deadline. has_elapsed ( ) => CcusageRunnerResult :: TimedOut ,
19922084 CcusageRunnerResult :: Failed => run_ccusage_with_runner_timeout (
19932085 kind,
19942086 program,
19952087 opts,
19962088 provider,
19972089 plugin_id,
19982090 CcusageCommandFlavor :: Legacy ,
1999- std :: time :: Duration :: from_secs ( CCUSAGE_TIMEOUT_SECS ) ,
2091+ deadline . clamp_duration ( Duration :: from_secs ( CCUSAGE_TIMEOUT_SECS ) ) ,
20002092 ) ,
20012093 other => other,
20022094 }
@@ -2195,6 +2287,7 @@ fn inject_ccusage<'js>(
21952287 ctx : & Ctx < ' js > ,
21962288 host : & Object < ' js > ,
21972289 plugin_id : & str ,
2290+ deadline : ProbeDeadline ,
21982291) -> rquickjs:: Result < ( ) > {
21992292 let ccusage_obj = Object :: new ( ctx. clone ( ) ) ?;
22002293 let pid = plugin_id. to_string ( ) ;
@@ -2222,7 +2315,11 @@ fn inject_ccusage<'js>(
22222315 & opts,
22232316 provider,
22242317 & pid,
2225- run_ccusage_with_runner,
2318+ |kind, program, opts, provider, plugin_id| {
2319+ run_ccusage_with_runner_deadline (
2320+ kind, program, opts, provider, plugin_id, deadline,
2321+ )
2322+ } ,
22262323 ) )
22272324 } ,
22282325 ) ?,
@@ -4087,6 +4184,21 @@ esac
40874184 ) ;
40884185 }
40894186
4187+ #[ test]
4188+ fn probe_deadline_clamps_host_timeout_to_remaining_budget ( ) {
4189+ let deadline = ProbeDeadline :: at ( Instant :: now ( ) + Duration :: from_millis ( 25 ) ) ;
4190+ let clamped = deadline. clamp_duration ( Duration :: from_secs ( 10 ) ) ;
4191+
4192+ assert ! (
4193+ clamped <= Duration :: from_millis( 25 ) ,
4194+ "host timeout should not exceed remaining probe budget"
4195+ ) ;
4196+ assert ! (
4197+ clamped >= Duration :: from_millis( 1 ) ,
4198+ "host timeout should stay non-zero for blocking clients"
4199+ ) ;
4200+ }
4201+
40904202 #[ cfg( unix) ]
40914203 #[ test]
40924204 fn ccusage_timeout_kills_descendant_and_closes_pipes ( ) {
0 commit comments