Skip to content

Commit 4060689

Browse files
committed
fix: address CodeRabbit security and correctness findings
Redact sensitive command data, harden Unicode and timestamp rendering, and clamp popup geometry for small terminals. Make editor handoff and JSON-RPC requests failure-safe, preserve event ordering, and fix state, arithmetic, diagnostics, and protocol robustness issues.
1 parent 335aa17 commit 4060689

35 files changed

Lines changed: 1900 additions & 516 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ similar = "2"
3131
fuzzy-matcher = "0.3"
3232
syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] }
3333
unicode-width = "0.2"
34+
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
3435
toml = "1"
3536
dirs = "6"
3637
fs2 = "0.4"

src/acp/commands/unsupported.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub(super) fn file_index<C: AcpConnection>(ctx: CommandContext<'_, C>) {
1010

1111
pub(super) fn command<C: AcpConnection>(ctx: CommandContext<'_, C>, command: &Command) {
1212
ctx.events.error(format!(
13-
"unsupported in the current ACP subset: {command:?}"
13+
"unsupported in the current ACP subset: {}",
14+
command.label()
1415
));
1516
}

src/acp/configuration.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ fn model_from_option(option: &Value, model_id: &str) -> Option<Model> {
191191
let (provider, model) = model_id
192192
.split_once('/')
193193
.map(|(provider, model)| (provider.to_string(), model.to_string()))
194-
.unwrap_or_else(|| ("unknown".to_string(), label.clone()));
194+
.unwrap_or_else(|| ("unknown".to_string(), model_id.to_string()));
195195
Model {
196196
id: model_id.to_string(),
197197
label,
@@ -236,6 +236,18 @@ mod tests {
236236
assert_eq!(entry["node_id"], "node-1");
237237
}
238238

239+
#[test]
240+
fn model_option_without_provider_uses_identifier_not_friendly_label() {
241+
let option = json!({
242+
"options": [{ "value": "stable-id", "name": "Friendly Label" }]
243+
});
244+
let model = model_from_option(&option, "stable-id").expect("model");
245+
246+
assert_eq!(model.label, "Friendly Label");
247+
assert_eq!(model.provider, "unknown");
248+
assert_eq!(model.model, "stable-id");
249+
}
250+
239251
#[test]
240252
fn option_parser_reads_grouped_models_and_profiles() {
241253
let option = json!({

src/acp/contract_tests.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,34 @@ async fn router_keeps_noop_and_exact_unsupported_semantics() {
161161
let unsupported = Command::ClearApiToken {
162162
provider: "openai".into(),
163163
};
164-
commands::dispatch(context(&connection, &state, &events), unsupported.clone())
164+
commands::dispatch(context(&connection, &state, &events), unsupported)
165165
.await
166166
.expect("api token unsupported");
167167
assert!(matches!(
168168
rx.try_recv().expect("unsupported error"),
169169
ServerChannelMsg::Acp(AcpAppEvent::Error { message })
170-
if message == format!("unsupported in the current ACP subset: {unsupported:?}")
170+
if message == "unsupported in the current ACP subset: ClearApiToken"
171171
));
172+
173+
let api_key = "sentinel-secret-api-key";
174+
let unsupported = Command::SetApiToken {
175+
provider: "openai".into(),
176+
api_key: api_key.into(),
177+
};
178+
assert!(!format!("{unsupported:?}").contains(api_key));
179+
commands::dispatch(context(&connection, &state, &events), unsupported)
180+
.await
181+
.expect("set api token unsupported");
182+
let ServerChannelMsg::Acp(AcpAppEvent::Error { message }) =
183+
rx.try_recv().expect("unsupported error")
184+
else {
185+
panic!("expected unsupported error");
186+
};
187+
assert_eq!(
188+
message,
189+
"unsupported in the current ACP subset: SetApiToken"
190+
);
191+
assert!(!message.contains(api_key));
172192
assert!(connection.messages().is_empty());
173193
}
174194

src/acp/notification.rs

Lines changed: 132 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ pub(super) enum Translation {
1717
message_id: Option<String>,
1818
thinking: bool,
1919
},
20-
ToolStart(AcpSessionUpdate),
20+
ToolBoundary(AcpSessionUpdate),
2121
AgentMode(String),
2222
ConfigOptions(Vec<acp::SessionConfigOption>),
2323
Ignore,
@@ -43,9 +43,11 @@ pub(super) fn translate(notification: acp::SessionNotification) -> (String, Tran
4343
thinking: true,
4444
},
4545
acp::SessionUpdate::ToolCall(tool_call) => {
46-
Translation::ToolStart(tool_start_update(&tool_call))
46+
Translation::ToolBoundary(tool_start_update(&tool_call))
47+
}
48+
acp::SessionUpdate::ToolCallUpdate(update) => {
49+
Translation::ToolBoundary(tool_call_update(update))
4750
}
48-
acp::SessionUpdate::ToolCallUpdate(update) => Translation::Update(tool_call_update(update)),
4951
acp::SessionUpdate::CurrentModeUpdate(update) => {
5052
Translation::AgentMode(update.current_mode_id.to_string())
5153
}
@@ -69,7 +71,7 @@ pub(super) async fn apply(
6971
) {
7072
match translation {
7173
Translation::Update(update) => emit_or_buffer(state, events, &session_id, update).await,
72-
Translation::ToolStart(update) => match state.replay.route(&session_id, update).await {
74+
Translation::ToolBoundary(update) => match state.replay.route(&session_id, update).await {
7375
None => {}
7476
Some(update) => {
7577
flush_assistant(state, events, &session_id).await;
@@ -248,6 +250,8 @@ fn value_to_text(value: &Value) -> String {
248250
#[cfg(test)]
249251
mod tests {
250252
use super::*;
253+
use crate::runtime_events::ServerChannelMsg;
254+
use tokio::sync::mpsc;
251255

252256
fn notification(update: acp::SessionUpdate) -> acp::SessionNotification {
253257
acp::SessionNotification::new("session-1", update)
@@ -322,7 +326,7 @@ mod tests {
322326
)));
323327
assert!(matches!(
324328
started,
325-
Translation::ToolStart(AcpSessionUpdate::ToolCallStart {
329+
Translation::ToolBoundary(AcpSessionUpdate::ToolCallStart {
326330
tool_call_id: Some(id),
327331
name,
328332
arguments: Some(arguments),
@@ -339,7 +343,7 @@ mod tests {
339343
)));
340344
assert!(matches!(
341345
pending,
342-
Translation::Update(AcpSessionUpdate::ToolCallStart {
346+
Translation::ToolBoundary(AcpSessionUpdate::ToolCallStart {
343347
tool_call_id: Some(id),
344348
name,
345349
arguments: Some(arguments),
@@ -365,7 +369,7 @@ mod tests {
365369
)));
366370
assert!(matches!(
367371
completed,
368-
Translation::Update(AcpSessionUpdate::ToolCallEnd {
372+
Translation::ToolBoundary(AcpSessionUpdate::ToolCallEnd {
369373
tool_call_id: Some(id),
370374
name,
371375
is_error: false,
@@ -388,7 +392,7 @@ mod tests {
388392
]),
389393
),
390394
)));
391-
let Translation::Update(AcpSessionUpdate::ToolCallEnd {
395+
let Translation::ToolBoundary(AcpSessionUpdate::ToolCallEnd {
392396
tool_call_id: Some(id),
393397
name,
394398
is_error,
@@ -466,6 +470,126 @@ mod tests {
466470
}
467471
}
468472

473+
#[tokio::test]
474+
async fn live_tool_updates_flush_assistant_content_at_each_boundary() {
475+
let state = Arc::new(RuntimeState::new(None));
476+
let (tx, mut rx) = mpsc::unbounded_channel();
477+
let events = EventSink::new(tx);
478+
479+
apply(
480+
&state,
481+
&events,
482+
"session-1".into(),
483+
Translation::AssistantChunk {
484+
text: "before start".into(),
485+
message_id: Some("a1".into()),
486+
thinking: false,
487+
},
488+
)
489+
.await;
490+
apply(
491+
&state,
492+
&events,
493+
"session-1".into(),
494+
Translation::ToolBoundary(AcpSessionUpdate::ToolCallStart {
495+
tool_call_id: Some("tool-1".into()),
496+
name: "shell".into(),
497+
arguments: None,
498+
}),
499+
)
500+
.await;
501+
apply(
502+
&state,
503+
&events,
504+
"session-1".into(),
505+
Translation::AssistantChunk {
506+
text: "before end".into(),
507+
message_id: Some("a2".into()),
508+
thinking: false,
509+
},
510+
)
511+
.await;
512+
apply(
513+
&state,
514+
&events,
515+
"session-1".into(),
516+
Translation::ToolBoundary(AcpSessionUpdate::ToolCallEnd {
517+
tool_call_id: Some("tool-1".into()),
518+
name: "shell".into(),
519+
is_error: false,
520+
result: None,
521+
}),
522+
)
523+
.await;
524+
525+
let updates = (0..6)
526+
.map(|_| match rx.try_recv().expect("session update") {
527+
ServerChannelMsg::Acp(AcpAppEvent::SessionUpdate { update, .. }) => update,
528+
other => panic!("unexpected event: {other:?}"),
529+
})
530+
.collect::<Vec<_>>();
531+
assert!(matches!(
532+
&updates[..],
533+
[
534+
AcpSessionUpdate::AssistantContentDelta { content: first_delta, .. },
535+
AcpSessionUpdate::AssistantMessage { content: first_final, .. },
536+
AcpSessionUpdate::ToolCallStart { .. },
537+
AcpSessionUpdate::AssistantContentDelta { content: second_delta, .. },
538+
AcpSessionUpdate::AssistantMessage { content: second_final, .. },
539+
AcpSessionUpdate::ToolCallEnd { .. },
540+
] if first_delta == "before start"
541+
&& first_final == "before start"
542+
&& second_delta == "before end"
543+
&& second_final == "before end"
544+
));
545+
}
546+
547+
#[tokio::test]
548+
async fn usage_updates_do_not_flush_assistant_content() {
549+
let state = Arc::new(RuntimeState::new(None));
550+
let (tx, mut rx) = mpsc::unbounded_channel();
551+
let events = EventSink::new(tx);
552+
apply(
553+
&state,
554+
&events,
555+
"session-1".into(),
556+
Translation::AssistantChunk {
557+
text: "buffered".into(),
558+
message_id: None,
559+
thinking: false,
560+
},
561+
)
562+
.await;
563+
apply(
564+
&state,
565+
&events,
566+
"session-1".into(),
567+
Translation::Update(AcpSessionUpdate::UsageUpdate {
568+
used: 1,
569+
size: 2,
570+
cost_usd: None,
571+
}),
572+
)
573+
.await;
574+
575+
assert!(matches!(
576+
rx.try_recv(),
577+
Ok(ServerChannelMsg::Acp(AcpAppEvent::SessionUpdate {
578+
update: AcpSessionUpdate::AssistantContentDelta { .. },
579+
..
580+
}))
581+
));
582+
assert!(matches!(
583+
rx.try_recv(),
584+
Ok(ServerChannelMsg::Acp(AcpAppEvent::SessionUpdate {
585+
update: AcpSessionUpdate::UsageUpdate { .. },
586+
..
587+
}))
588+
));
589+
assert!(rx.try_recv().is_err());
590+
assert!(state.assistants.flush("session-1").await.is_some());
591+
}
592+
469593
#[test]
470594
fn usage_translation_keeps_only_usd_cost() {
471595
for (cost, expected) in [

src/acp/transport/jsonrpc.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::collections::HashMap;
22
use std::sync::Arc;
33
use std::sync::atomic::{AtomicI64, Ordering};
4+
use std::time::Duration;
45

56
use agent_client_protocol as acp_sdk;
67
use serde::{Deserialize, Serialize};
@@ -10,6 +11,8 @@ use tokio_tungstenite::tungstenite::Message;
1011

1112
use super::super::connection::internal_error;
1213

14+
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(60);
15+
1316
#[derive(Debug, Clone, Serialize, Deserialize)]
1417
pub(in crate::acp) struct Envelope {
1518
pub(in crate::acp) jsonrpc: String,
@@ -97,6 +100,16 @@ impl Peer {
97100
&self,
98101
method: &str,
99102
params: Value,
103+
) -> Result<Value, acp_sdk::Error> {
104+
self.request_with_timeout(method, params, RESPONSE_TIMEOUT)
105+
.await
106+
}
107+
108+
async fn request_with_timeout(
109+
&self,
110+
method: &str,
111+
params: Value,
112+
timeout: Duration,
100113
) -> Result<Value, acp_sdk::Error> {
101114
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
102115
let (tx, rx) = oneshot::channel();
@@ -111,8 +124,16 @@ impl Peer {
111124
self.pending.lock().await.remove(&id);
112125
return Err(err);
113126
}
114-
rx.await
115-
.map_err(|_| internal_error(format!("ACP WebSocket request dropped: {method}")))?
127+
match tokio::time::timeout(timeout, rx).await {
128+
Ok(result) => result
129+
.map_err(|_| internal_error(format!("ACP WebSocket request dropped: {method}")))?,
130+
Err(_) => {
131+
self.pending.lock().await.remove(&id);
132+
Err(internal_error(format!(
133+
"ACP WebSocket request timed out: {method}"
134+
)))
135+
}
136+
}
116137
}
117138

118139
pub(in crate::acp) fn notify(&self, method: &str, params: Value) -> Result<(), acp_sdk::Error> {
@@ -339,6 +360,41 @@ mod tests {
339360
assert_eq!(peer.pending_len().await, 0);
340361
}
341362

363+
#[tokio::test]
364+
async fn timeout_removes_pending_request_and_ignores_late_response() {
365+
let (tx, mut rx) = mpsc::unbounded_channel();
366+
let peer = Peer::new(tx);
367+
let request_peer = peer.clone();
368+
let task = tokio::spawn(async move {
369+
request_peer
370+
.request_with_timeout("querymt/slow", json!({}), Duration::from_millis(10))
371+
.await
372+
});
373+
let Message::Text(text) = rx.recv().await.expect("request frame") else {
374+
panic!("expected text frame");
375+
};
376+
let wire: Envelope = serde_json::from_str(&text).expect("request envelope");
377+
378+
let error = task
379+
.await
380+
.expect("request task")
381+
.expect_err("timeout error")
382+
.to_string();
383+
assert!(error.contains("timed out"));
384+
assert!(error.contains("querymt/slow"));
385+
assert_eq!(peer.pending_len().await, 0);
386+
387+
peer.resolve(Envelope {
388+
method: None,
389+
params: Value::Null,
390+
result: Some(json!("late")),
391+
error: None,
392+
..wire
393+
})
394+
.await;
395+
assert_eq!(peer.pending_len().await, 0);
396+
}
397+
342398
#[tokio::test]
343399
async fn fail_all_drains_each_pending_request_once_and_late_responses_are_ignored() {
344400
let (tx, mut rx) = mpsc::unbounded_channel();

0 commit comments

Comments
 (0)