@@ -69,6 +69,80 @@ const WRITE_FRAME_TIMEOUT: Duration = Duration::from_secs(30);
6969/// down and every awaiter observes [`HostError::ConnectionLost`].
7070const READER_BUFFER_CAP : usize = 8 * 1024 * 1024 ;
7171
72+ /// How many trailing stderr lines to retain per plugin for failure diagnostics.
73+ const STDERR_TAIL_CAP : usize = 40 ;
74+
75+ /// Max bytes retained per captured stderr line (bounds memory for a plugin that
76+ /// emits pathologically long lines; the tail is diagnostics, not a full log).
77+ const MAX_STDERR_LINE_LEN : usize = 512 ;
78+
79+ /// Truncate `line` to at most [`MAX_STDERR_LINE_LEN`] bytes on a char boundary,
80+ /// appending an ellipsis when clipped. Keeps the ring buffer bounded.
81+ fn clip_stderr_line ( line : String ) -> String {
82+ if line. len ( ) <= MAX_STDERR_LINE_LEN {
83+ return line;
84+ }
85+ let mut end = MAX_STDERR_LINE_LEN ;
86+ while end > 0 && !line. is_char_boundary ( end) {
87+ end -= 1 ;
88+ }
89+ format ! ( "{}…" , & line[ ..end] )
90+ }
91+
92+ /// Matches a credential label anywhere in a stderr line, tolerating word breaks
93+ /// (`api key`, `api_key`, `api-key`) and any following separator/spacing. Once
94+ /// matched, everything from the label to end-of-line is masked, so the value is
95+ /// redacted regardless of how it is delimited or whether it spans tokens (PEM
96+ /// blob, JSON, `password = hunter2`, `Authorization: Bearer <tok>`).
97+ fn secret_marker_regex ( ) -> & ' static regex:: Regex {
98+ static RE : std:: sync:: OnceLock < regex:: Regex > = std:: sync:: OnceLock :: new ( ) ;
99+ RE . get_or_init ( || {
100+ regex:: Regex :: new (
101+ r"(?i)(pass(?:word|wd)?|pwd|secret|token|api[ _-]?key|access[ _-]?key|client[ _-]?secret|authorization|credential|private[ _-]?key|session[ _-]?key|bearer|basic)" ,
102+ )
103+ . expect ( "valid secret-marker regex" )
104+ } )
105+ }
106+
107+ /// Redact credentials from a stderr line before it is surfaced in a user-visible
108+ /// error, so secrets stay in operator logs and do not leak into RPC/CLI errors.
109+ ///
110+ /// Two passes: (1) mask the password of any `scheme://user:pass@host` connection
111+ /// string (a Postgres plugin echoing its DATABASE_URL on a connect failure), even
112+ /// when no credential *label* is present; (2) if a credential label (`password`,
113+ /// `api key`, `Authorization`, `bearer`, ...) appears, keep the text up to and
114+ /// including the label and mask everything after it. This over-redacts the tail of
115+ /// such a line — the stderr tail is failure diagnostics, not a full log — but
116+ /// never leaks the value regardless of separator, spacing, or token spanning.
117+ fn redact_stderr_line ( line : & str ) -> String {
118+ let url_redacted = redact_url_userinfo ( line) ;
119+ if let Some ( m) = secret_marker_regex ( ) . find ( & url_redacted) {
120+ return format ! ( "{} ***" , url_redacted[ ..m. end( ) ] . trim_end( ) ) ;
121+ }
122+ url_redacted
123+ }
124+
125+ /// Mask the password in any `scheme://user:pass@host` token: `user:***@host`.
126+ /// Uses the LAST `@` so an unescaped `@` inside the password cannot leave a
127+ /// suffix unmasked (`u:p@ss@host` -> `u:***@host`).
128+ fn redact_url_userinfo ( line : & str ) -> String {
129+ line. split ( ' ' )
130+ . map ( |token| {
131+ if let Some ( scheme_end) = token. find ( "://" ) {
132+ let after = & token[ scheme_end + 3 ..] ;
133+ if let Some ( at) = after. rfind ( '@' ) {
134+ let userinfo = & after[ ..at] ;
135+ if let Some ( colon) = userinfo. find ( ':' ) {
136+ return format ! ( "{}{}:***@{}" , & token[ ..scheme_end + 3 ] , & userinfo[ ..colon] , & after[ at + 1 ..] ) ;
137+ }
138+ }
139+ }
140+ token. to_string ( )
141+ } )
142+ . collect :: < Vec < _ > > ( )
143+ . join ( " " )
144+ }
145+
72146/// Deadline the [`PluginHost::shutdown_transport`] flow waits for a transport
73147/// plugin's `transport/shutdown` reply before moving on to the generic
74148/// shutdown. Spec-compliant transports drain in-flight requests during this
@@ -541,6 +615,12 @@ pub struct PluginHostInner {
541615 /// guard and drop it eagerly after the child wait completes, ahead of the
542616 /// last `Arc` drop. In steady state nothing else touches this field.
543617 _process_slot : std:: sync:: Mutex < Option < BoxedProcessSlotGuard > > ,
618+ /// Ring buffer of the plugin's most recent stderr lines (last
619+ /// [`STDERR_TAIL_CAP`]), captured by the stderr reader task. Surfaced in
620+ /// handshake / ConnectionLost errors so a plugin that dies during startup
621+ /// reports WHY (its stderr) instead of an opaque "connection lost". Empty
622+ /// for in-memory test hosts.
623+ stderr_tail : Arc < std:: sync:: Mutex < std:: collections:: VecDeque < String > > > ,
544624}
545625
546626/// Single-process JSON-RPC plugin host.
@@ -722,18 +802,35 @@ impl PluginHost {
722802
723803 let stderr_plugin_name = name. clone ( ) ;
724804 let stderr_sink = options. stderr_sink . clone ( ) ;
805+
806+ let capacity = resolve_broadcast_capacity ( options. notification_capacity , options. notification_buffer_hint ) ;
807+ let host = Self :: launch_with_slot ( name, Box :: new ( stdout) , Box :: new ( stdin) , Some ( child) , capacity, process_slot) ;
808+
809+ // Capture the plugin's stderr into a bounded ring buffer (in addition to
810+ // the standard warn! + optional sink) so a startup/handshake failure can
811+ // report the plugin's own last words instead of a bare "connection lost".
812+ let stderr_tail = host. inner . stderr_tail . clone ( ) ;
725813 tokio:: spawn ( async move {
726814 let mut lines = tokio:: io:: BufReader :: new ( stderr) . lines ( ) ;
727815 while let Ok ( Some ( line) ) = lines. next_line ( ) . await {
728816 warn ! ( plugin = %stderr_plugin_name, "{}" , line) ;
729817 if let Some ( sink) = stderr_sink. as_ref ( ) {
730818 sink ( & stderr_plugin_name, & line) ;
731819 }
820+ if let Ok ( mut buf) = stderr_tail. lock ( ) {
821+ if buf. len ( ) >= STDERR_TAIL_CAP {
822+ buf. pop_front ( ) ;
823+ }
824+ // Redact BEFORE clipping: clipping first could split a
825+ // `scheme://user:pass@host` across the byte cutoff and defeat
826+ // the URL detector, leaking the password prefix. The raw line
827+ // still reaches operator logs above via `warn!`/`sink`.
828+ buf. push_back ( clip_stderr_line ( redact_stderr_line ( & line) ) ) ;
829+ }
732830 }
733831 } ) ;
734832
735- let capacity = resolve_broadcast_capacity ( options. notification_capacity , options. notification_buffer_hint ) ;
736- Ok ( Self :: launch_with_slot ( name, Box :: new ( stdout) , Box :: new ( stdin) , Some ( child) , capacity, process_slot) )
833+ Ok ( host)
737834 }
738835
739836 /// Build a host from caller-supplied in-memory streams. Used by tests
@@ -829,6 +926,7 @@ impl PluginHost {
829926 reader_handle : std:: sync:: Mutex :: new ( None ) ,
830927 alive : AtomicBool :: new ( true ) ,
831928 _process_slot : std:: sync:: Mutex :: new ( process_slot) ,
929+ stderr_tail : Arc :: new ( std:: sync:: Mutex :: new ( std:: collections:: VecDeque :: new ( ) ) ) ,
832930 } ) ;
833931
834932 let reader_inner = inner. clone ( ) ;
@@ -931,6 +1029,23 @@ impl PluginHost {
9311029 ///
9321030 /// Returns the plugin's [`InitializeResult`] on success and rejects on
9331031 /// protocol-version drift via [`check_protocol_compat`].
1032+ /// A short diagnostic suffix built from the plugin's captured stderr tail,
1033+ /// appended to startup/handshake failures so an opaque transport error
1034+ /// ("connection lost") carries the plugin's own last words. When the plugin
1035+ /// died without printing anything, that itself is the signal (killed by a
1036+ /// signal / OOM / exec failure).
1037+ fn stderr_tail_context ( & self ) -> String {
1038+ match self . inner . stderr_tail . lock ( ) {
1039+ Ok ( buf) if !buf. is_empty ( ) => {
1040+ let tail: Vec < String > = buf. iter ( ) . rev ( ) . take ( 15 ) . map ( |line| redact_stderr_line ( line) ) . collect ( ) ;
1041+ let joined = tail. into_iter ( ) . rev ( ) . collect :: < Vec < _ > > ( ) . join ( "\n " ) ;
1042+ format ! ( "; last plugin stderr:\n {joined}" )
1043+ }
1044+ _ => "; plugin emitted no stderr before exit (likely killed by a signal / OOM, or an exec/fork failure)"
1045+ . to_string ( ) ,
1046+ }
1047+ }
1048+
9341049 pub async fn handshake ( & self ) -> Result < InitializeResult > {
9351050 const HANDSHAKE_TIMEOUT : Duration = Duration :: from_secs ( 30 ) ;
9361051
@@ -943,7 +1058,9 @@ impl PluginHost {
9431058 let response = self
9441059 . request_raw_with_timeout ( "initialize" , Some ( serde_json:: to_value ( params) ?) , HANDSHAKE_TIMEOUT )
9451060 . await
946- . map_err ( |error| anyhow ! ( "plugin '{}' initialize failed: {error}" , self . inner. name) ) ?;
1061+ . map_err ( |error| {
1062+ anyhow ! ( "plugin '{}' initialize failed: {error}{}" , self . inner. name, self . stderr_tail_context( ) )
1063+ } ) ?;
9471064
9481065 if let Some ( error) = response. error {
9491066 return Err ( anyhow ! ( "plugin initialize failed ({}): {}" , error. code, error. message) ) ;
@@ -1508,6 +1625,32 @@ mod tests {
15081625
15091626 use super :: * ;
15101627
1628+ #[ test]
1629+ fn redact_stderr_line_masks_credentials_without_leaking_tails ( ) {
1630+ // URL userinfo password, even with no credential label and an '@' in it.
1631+ assert_eq ! (
1632+ redact_stderr_line( "connect failed postgres://user:p@ss@db:5432/x" ) ,
1633+ "connect failed postgres://user:***@db:5432/x"
1634+ ) ;
1635+ // Labelled secrets with assorted separators / spacing — value + rest of
1636+ // line masked, prefix kept.
1637+ assert_eq ! ( redact_stderr_line( "db password = hunter2 for role app" ) , "db password ***" ) ;
1638+ assert_eq ! ( redact_stderr_line( "using api key: sk-abc123 now" ) , "using api key ***" ) ;
1639+ assert_eq ! ( redact_stderr_line( "Authorization: Bearer sk-xyz trailing" ) , "Authorization ***" ) ;
1640+ // Multi-token / PEM value cannot leak its tail.
1641+ assert_eq ! (
1642+ redact_stderr_line( "private_key=-----BEGIN KEY----- abcd -----END KEY-----" ) ,
1643+ "private_key ***"
1644+ ) ;
1645+ // Query-string credential in a URL without userinfo.
1646+ assert_eq ! (
1647+ redact_stderr_line( "cb https://h/x?client_id=a&client_secret=zzz" ) ,
1648+ "cb https://h/x?client_id=a&client_secret ***"
1649+ ) ;
1650+ // A line with no credential material is untouched.
1651+ assert_eq ! ( redact_stderr_line( "could not connect to host db:5432" ) , "could not connect to host db:5432" ) ;
1652+ }
1653+
15111654 fn ok_initialize_response ( id : Option < Value > , protocol_version : & str ) -> RpcResponse {
15121655 RpcResponse :: ok (
15131656 id,
0 commit comments