@@ -377,6 +377,13 @@ const MAX_REQUEST_SIZE: usize = 1024 * 1024;
377377/// Maximum scroll amount to prevent long-running requests.
378378const MAX_SCROLL_AMOUNT : u32 = 1000 ;
379379
380+ /// Maximum delay between keys in a sequence (10 seconds).
381+ /// Allows time for slow TUI animations while preventing DoS.
382+ const MAX_KEY_DELAY_MS : u32 = 10_000 ;
383+
384+ /// Maximum keys in a sequence to prevent long-running requests.
385+ const MAX_KEY_SEQUENCE_LEN : usize = 32 ;
386+
380387/// Read a line with a maximum size limit to prevent memory DoS.
381388///
382389/// Returns the number of bytes read (0 means EOF).
@@ -514,7 +521,11 @@ async fn handle_request(
514521
515522 Command :: Type { text, session } => handle_type ( & request. id , & sessions, text, session) . await ,
516523
517- Command :: Key { key, session } => handle_key ( & request. id , & sessions, key, session) . await ,
524+ Command :: Key {
525+ key,
526+ delay_ms,
527+ session,
528+ } => handle_key ( & request. id , & sessions, key, delay_ms, session) . await ,
518529
519530 Command :: Click { row, col, session } => {
520531 handle_click ( & request. id , & sessions, row, col, session) . await
@@ -818,14 +829,32 @@ async fn handle_type(
818829 }
819830}
820831
821- /// Handle key command - send key or key combo to PTY.
832+ /// Handle key command - send key, key combo, or key sequence to PTY.
833+ ///
834+ /// Supports space-separated key sequences like "Ctrl+X m" for chords.
835+ /// If delay_ms > 0, waits that many milliseconds between each key in a sequence.
822836async fn handle_key (
823837 request_id : & str ,
824838 sessions : & SessionManager ,
825839 key : String ,
840+ delay_ms : u32 ,
826841 session : Option < String > ,
827842) -> Response {
828- use pilotty_core:: input:: { key_to_bytes, parse_key_combo} ;
843+ use pilotty_core:: input:: parse_key_sequence;
844+
845+ // Validate delay_ms to prevent DoS
846+ if delay_ms > MAX_KEY_DELAY_MS {
847+ return Response :: error (
848+ request_id,
849+ ApiError :: invalid_input_with_suggestion (
850+ format ! (
851+ "Key delay {}ms exceeds maximum {}ms" ,
852+ delay_ms, MAX_KEY_DELAY_MS
853+ ) ,
854+ "Use a smaller delay (<= 10000ms). For longer waits, use multiple key commands." ,
855+ ) ,
856+ ) ;
857+ }
829858
830859 // Resolve session
831860 let session_id = match sessions. resolve_session ( session. as_deref ( ) ) . await {
@@ -841,50 +870,61 @@ async fn handle_key(
841870 . await
842871 . unwrap_or ( false ) ;
843872
844- // Try to parse the key
845- // Note: We check for combos only if there's a `+` that's not the entire key
846- // This allows sending literal `+` as a single character
847- let bytes = if key. len ( ) > 1 && key. contains ( '+' ) {
848- // Key combo like Ctrl+C (but not a literal "+")
849- parse_key_combo ( & key, app_cursor)
850- } else {
851- // Named key like Enter, Plus, or single character (including "+")
852- key_to_bytes ( & key, app_cursor) . or_else ( || {
853- // Fall back to single character
854- if key. len ( ) == 1 {
855- Some ( key. as_bytes ( ) . to_vec ( ) )
856- } else {
857- None
858- }
859- } )
873+ // Parse key sequence (handles single keys, combos, and space-separated sequences)
874+ let sequence = match parse_key_sequence ( & key, app_cursor) {
875+ Some ( seq) => seq,
876+ None => {
877+ return Response :: error (
878+ request_id,
879+ ApiError :: invalid_input_with_suggestion (
880+ format ! ( "Invalid key: '{}'" , key) ,
881+ "Use named keys (Enter, Tab, Escape, F1), combos (Ctrl+C, Alt+F), \
882+ or space-separated sequences (\" Ctrl+X m\" ). Run 'pilotty key --help' for examples.",
883+ ) ,
884+ ) ;
885+ }
860886 } ;
861887
862- match bytes {
863- Some ( bytes) => match sessions. write_to_session ( & session_id, & bytes) . await {
864- Ok ( ( ) ) => {
865- debug ! (
866- "Sent key '{}' ({} bytes) to session {}" ,
867- key,
868- bytes. len( ) ,
869- session_id
870- ) ;
871- Response :: success (
872- request_id,
873- ResponseData :: Ok {
874- message : format ! ( "Sent key: {}" , key) ,
875- } ,
876- )
877- }
878- Err ( e) => Response :: error ( request_id, e) ,
879- } ,
880- None => Response :: error (
888+ // Validate sequence length to prevent DoS
889+ if sequence. len ( ) > MAX_KEY_SEQUENCE_LEN {
890+ return Response :: error (
881891 request_id,
882- ApiError :: invalid_input ( format ! (
883- "Unknown key: '{}'. Try named keys like Enter, Tab, Escape, Up, Down, F1, etc. or combos like Ctrl+C" ,
884- key
885- ) ) ,
886- ) ,
892+ ApiError :: invalid_input_with_suggestion (
893+ format ! (
894+ "Key sequence has {} keys, maximum is {}" ,
895+ sequence. len( ) ,
896+ MAX_KEY_SEQUENCE_LEN
897+ ) ,
898+ "Split long sequences into multiple key commands (max 32 keys)." ,
899+ ) ,
900+ ) ;
901+ }
902+
903+ // Send each key in the sequence
904+ let key_count = sequence. len ( ) ;
905+ for ( i, bytes) in sequence. into_iter ( ) . enumerate ( ) {
906+ // Apply inter-key delay (but not before the first key)
907+ if i > 0 && delay_ms > 0 {
908+ tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( u64:: from ( delay_ms) ) ) . await ;
909+ }
910+
911+ if let Err ( e) = sessions. write_to_session ( & session_id, & bytes) . await {
912+ return Response :: error ( request_id, e) ;
913+ }
887914 }
915+
916+ debug ! (
917+ "Sent {} key(s) '{}' to session {}" ,
918+ key_count, key, session_id
919+ ) ;
920+
921+ let message = if key_count == 1 {
922+ format ! ( "Sent key: {}" , key)
923+ } else {
924+ format ! ( "Sent {} keys: {}" , key_count, key)
925+ } ;
926+
927+ Response :: success ( request_id, ResponseData :: Ok { message } )
888928}
889929
890930/// Handle click command - click at a specific row/column coordinate.
@@ -1700,6 +1740,7 @@ mod tests {
17001740 id : "key-1" . to_string ( ) ,
17011741 command : Command :: Key {
17021742 key : "Enter" . to_string ( ) ,
1743+ delay_ms : 0 ,
17031744 session : Some ( "key-test" . to_string ( ) ) ,
17041745 } ,
17051746 } ;
@@ -1723,6 +1764,7 @@ mod tests {
17231764 id : "key-2" . to_string ( ) ,
17241765 command : Command :: Key {
17251766 key : "Ctrl+C" . to_string ( ) ,
1767+ delay_ms : 0 ,
17261768 session : Some ( "key-test" . to_string ( ) ) ,
17271769 } ,
17281770 } ;
@@ -1748,6 +1790,63 @@ mod tests {
17481790 let _ = std:: fs:: remove_file ( & socket_path) ;
17491791 }
17501792
1793+ #[ tokio:: test]
1794+ async fn test_key_rejects_large_delay ( ) {
1795+ let short_id = Uuid :: new_v4 ( ) . simple ( ) . to_string ( ) ;
1796+ let socket_path = std:: path:: PathBuf :: from ( "/tmp" )
1797+ . join ( format ! ( "pilotty-keydelay-{}.sock" , & short_id[ ..8 ] ) ) ;
1798+ let pid_path = socket_path. with_extension ( "pid" ) ;
1799+
1800+ let server = DaemonServer :: bind_to ( socket_path. clone ( ) , pid_path. clone ( ) )
1801+ . await
1802+ . expect ( "Failed to bind server" ) ;
1803+
1804+ let server_handle = tokio:: spawn ( async move {
1805+ let _ = timeout ( Duration :: from_secs ( 2 ) , server. run ( ) ) . await ;
1806+ } ) ;
1807+
1808+ tokio:: time:: sleep ( Duration :: from_millis ( 50 ) ) . await ;
1809+
1810+ let stream = UnixStream :: connect ( & socket_path)
1811+ . await
1812+ . expect ( "Failed to connect" ) ;
1813+ let ( reader, mut writer) = stream. into_split ( ) ;
1814+ let mut reader = BufReader :: new ( reader) ;
1815+
1816+ // Try to send a key with delay exceeding MAX_KEY_DELAY_MS (10000)
1817+ let request = Request {
1818+ id : "key-delay-1" . to_string ( ) ,
1819+ command : Command :: Key {
1820+ key : "a b" . to_string ( ) ,
1821+ delay_ms : MAX_KEY_DELAY_MS + 1 ,
1822+ session : None ,
1823+ } ,
1824+ } ;
1825+ let request_json = serde_json:: to_string ( & request) . unwrap ( ) ;
1826+ writer
1827+ . write_all ( request_json. as_bytes ( ) )
1828+ . await
1829+ . expect ( "write" ) ;
1830+ writer. write_all ( b"\n " ) . await . expect ( "newline" ) ;
1831+ writer. flush ( ) . await . expect ( "flush" ) ;
1832+
1833+ let mut response_line = String :: new ( ) ;
1834+ timeout ( Duration :: from_secs ( 2 ) , reader. read_line ( & mut response_line) )
1835+ . await
1836+ . expect ( "timeout" )
1837+ . expect ( "read" ) ;
1838+
1839+ let response: Response = serde_json:: from_str ( & response_line) . expect ( "parse response" ) ;
1840+ assert ! ( !response. success) ;
1841+ let error = response. error . expect ( "error response" ) ;
1842+ assert_eq ! ( error. code, ErrorCode :: InvalidInput ) ;
1843+ assert ! ( error. message. contains( "exceeds maximum" ) ) ;
1844+
1845+ server_handle. abort ( ) ;
1846+ let _ = std:: fs:: remove_file ( & socket_path) ;
1847+ let _ = std:: fs:: remove_file ( & pid_path) ;
1848+ }
1849+
17511850 #[ tokio:: test]
17521851 async fn test_click_command ( ) {
17531852 let temp_dir = std:: env:: temp_dir ( ) ;
0 commit comments