Skip to content

Commit df9235e

Browse files
committed
Tighten audit follow-up: name magic constants, share reap helper, dedupe tool-block filter
1 parent 767b1b8 commit df9235e

8 files changed

Lines changed: 70 additions & 80 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,12 @@ sofos --resume
201201
- **Enter** submits the current message.
202202
- **Shift+Enter** inserts a newline when the terminal supports it.
203203
- **Alt+Enter** or **Ctrl+Enter** can be used as newline fallbacks.
204-
- Typing a leading `/` opens an inline command suggestion list. Use **Up / Down** to highlight an entry, **Enter** to run the highlighted command, **Tab** to insert it into the input, and **Esc** to dismiss the list.
204+
- **Ctrl+U** deletes from the cursor to the start of the line; **Ctrl+W** deletes the previous word; **Ctrl+K** deletes to the end of the line — matching readline / bash / zsh / fish.
205+
- **Alt+Up** / **Alt+Down** walk through previously submitted prompts. The in-progress draft is preserved and restored when you walk past the newest entry.
206+
- Typing a leading `/` opens an inline command suggestion list. Use **Up / Down** to highlight an entry, **Enter** to run the highlighted command, **Tab** to insert it into the input, and **Esc** (or **Ctrl+C**) to dismiss the list.
205207
- You can keep typing while the model is working. New messages are queued and processed in order.
206208
- If the model is inside a tool loop, a queued message is delivered at the next tool-result boundary so it can steer the current turn without interrupting it.
207-
- The status line shows the model, mode, reasoning setting, and running token totals.
209+
- The status line shows the model, mode, reasoning setting, running token totals, and (when present) the cumulative cache-read and cache-creation tokens.
208210

209211
### One-shot prompts
210212

src/api/types.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,22 @@ pub enum ImageSource {
494494
}
495495

496496
impl MessageContentBlock {
497+
/// True for the three block types that initiate a tool call — the
498+
/// blocks that MUST be paired with a matching `tool_result` on the
499+
/// next user turn or the provider rejects the request. Sofos
500+
/// filters these out from truncated responses, max-iter recovery
501+
/// turns, and tail-orphan resume cleanup, so the predicate lives
502+
/// next to the enum it discriminates rather than being repeated
503+
/// at every call site.
504+
pub fn is_tool_call_initiator(&self) -> bool {
505+
matches!(
506+
self,
507+
MessageContentBlock::ToolUse { .. }
508+
| MessageContentBlock::ServerToolUse { .. }
509+
| MessageContentBlock::WebSearchToolResult { .. }
510+
)
511+
}
512+
497513
pub fn from_content_block_for_api(block: &ContentBlock) -> Self {
498514
match block {
499515
ContentBlock::Text { text } => MessageContentBlock::Text {

src/api/utils.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,15 @@ pub const MAX_SSE_BUFFER_BYTES: usize = 16 * 1024 * 1024;
373373
/// expected to apply [`truncate_at_char_boundary`] separately if the
374374
/// body needs a length cap.
375375
pub fn redact_api_secrets(body: &str) -> String {
376+
/// Minimum byte count for a `sk-…` run we treat as a real key.
377+
/// Below this, the prefix is just an unrelated `sk-` substring
378+
/// (a CSS class, an error code, a stray identifier).
379+
const SK_KEY_MIN_LEN: usize = 11;
380+
/// Same idea on the bearer side, sized against the random tail
381+
/// that follows the `Bearer ` prefix.
382+
const BEARER_TAIL_MIN_LEN: usize = 8;
383+
const BEARER_PREFIX_LEN: usize = "Bearer ".len();
384+
376385
fn is_key_byte(b: u8) -> bool {
377386
b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
378387
}
@@ -386,20 +395,19 @@ pub fn redact_api_secrets(body: &str) -> String {
386395
while end < bytes.len() && is_key_byte(bytes[end]) {
387396
end += 1;
388397
}
389-
if end - i >= 11 {
398+
if end - i >= SK_KEY_MIN_LEN {
390399
out.push_str("sk-[redacted]");
391400
i = end;
392401
continue;
393402
}
394403
}
395404
if bytes[i..].starts_with(b"Bearer ") || bytes[i..].starts_with(b"bearer ") {
396-
let prefix_len = 7;
397-
let mut end = i + prefix_len;
405+
let mut end = i + BEARER_PREFIX_LEN;
398406
while end < bytes.len() && is_key_byte(bytes[end]) {
399407
end += 1;
400408
}
401-
if end - i >= prefix_len + 8 {
402-
out.push_str(&body[i..i + prefix_len]);
409+
if end - i >= BEARER_PREFIX_LEN + BEARER_TAIL_MIN_LEN {
410+
out.push_str(&body[i..i + BEARER_PREFIX_LEN]);
403411
out.push_str("[redacted]");
404412
i = end;
405413
continue;

src/mcp/transport/stdio.rs

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,24 @@ pub struct StdioClient {
308308
next_id: Arc<AtomicU64>,
309309
}
310310

311+
/// Send SIGKILL / `TerminateProcess` then poll `try_wait` for up to
312+
/// [`STDIO_DROP_WAIT_TOTAL`] before giving up. Shared by `Drop` and
313+
/// the timeout-recovery `kill_child_detached` path so the same
314+
/// bounded shutdown shape applies whether the reap runs inline or on
315+
/// a blocking task. Returns once the child has been reaped or the
316+
/// budget expires; the OS reaps any survivor when sofos exits.
317+
fn kill_and_reap_bounded(child: &mut Child) {
318+
let _ = child.kill();
319+
let start = std::time::Instant::now();
320+
while start.elapsed() < STDIO_DROP_WAIT_TOTAL {
321+
match child.try_wait() {
322+
Ok(Some(_)) => return,
323+
Ok(None) => std::thread::sleep(STDIO_DROP_POLL_INTERVAL),
324+
Err(_) => return,
325+
}
326+
}
327+
}
328+
311329
impl Drop for StdioClient {
312330
fn drop(&mut self) {
313331
// `Child::drop` does NOT wait on the subprocess, so without
@@ -317,22 +335,9 @@ impl Drop for StdioClient {
317335
// them, and the kill/wait pair is idempotent: a second `kill`
318336
// after the child already exited returns `InvalidInput`, and
319337
// a second `wait` after the child was already reaped returns
320-
// a harmless error. Both are discarded.
321-
//
322-
// The wait is bounded with `try_wait` so a child stuck in
323-
// uninterruptible IO cannot freeze session shutdown — after
324-
// the budget we leave the child as a zombie and let the OS
325-
// reap it when sofos itself exits.
338+
// a harmless error.
326339
if let Ok(mut child) = self.process.lock() {
327-
let _ = child.kill();
328-
let start = std::time::Instant::now();
329-
while start.elapsed() < STDIO_DROP_WAIT_TOTAL {
330-
match child.try_wait() {
331-
Ok(Some(_)) => return,
332-
Ok(None) => std::thread::sleep(STDIO_DROP_POLL_INTERVAL),
333-
Err(_) => return,
334-
}
335-
}
340+
kill_and_reap_bounded(&mut child);
336341
}
337342
}
338343
}
@@ -437,15 +442,7 @@ impl StdioClient {
437442
let process = Arc::clone(&self.process);
438443
tokio::task::spawn_blocking(move || {
439444
if let Ok(mut child) = process.lock() {
440-
let _ = child.kill();
441-
let start = std::time::Instant::now();
442-
while start.elapsed() < STDIO_DROP_WAIT_TOTAL {
443-
match child.try_wait() {
444-
Ok(Some(_)) => return,
445-
Ok(None) => std::thread::sleep(STDIO_DROP_POLL_INTERVAL),
446-
Err(_) => return,
447-
}
448-
}
445+
kill_and_reap_bounded(&mut child);
449446
}
450447
});
451448
}

src/repl/conversation/lifecycle.rs

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -230,25 +230,10 @@ impl ConversationHistory {
230230
let crate::api::MessageContent::Blocks { content } = &mut last.content else {
231231
return false;
232232
};
233-
let had_initiator = content.iter().any(|b| {
234-
matches!(
235-
b,
236-
crate::api::MessageContentBlock::ToolUse { .. }
237-
| crate::api::MessageContentBlock::ServerToolUse { .. }
238-
| crate::api::MessageContentBlock::WebSearchToolResult { .. }
239-
)
240-
});
241-
if !had_initiator {
233+
if !content.iter().any(|b| b.is_tool_call_initiator()) {
242234
return false;
243235
}
244-
content.retain(|b| {
245-
!matches!(
246-
b,
247-
crate::api::MessageContentBlock::ToolUse { .. }
248-
| crate::api::MessageContentBlock::ServerToolUse { .. }
249-
| crate::api::MessageContentBlock::WebSearchToolResult { .. }
250-
)
251-
});
236+
content.retain(|b| !b.is_tool_call_initiator());
252237
if content.is_empty() {
253238
content.push(crate::api::MessageContentBlock::Text {
254239
text: "[Tool call interrupted before execution]".to_string(),

src/repl/response_handler.rs

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,7 @@ impl ResponseHandler {
156156
// matching `tool_result` (or a server tool result
157157
// without its `server_tool_use`) puts the next
158158
// request in a shape the provider will reject.
159-
message_blocks.retain(|block| {
160-
!matches!(
161-
block,
162-
crate::api::MessageContentBlock::ToolUse { .. }
163-
| crate::api::MessageContentBlock::ServerToolUse { .. }
164-
| crate::api::MessageContentBlock::WebSearchToolResult { .. }
165-
)
166-
});
159+
message_blocks.retain(|block| !block.is_tool_call_initiator());
167160
if message_blocks.is_empty() {
168161
// The truncated response was tool-use only. Record
169162
// a short placeholder so the conversation keeps
@@ -697,14 +690,7 @@ impl ResponseHandler {
697690
.content
698691
.iter()
699692
.map(crate::api::MessageContentBlock::from_content_block_for_api)
700-
.filter(|block| {
701-
!matches!(
702-
block,
703-
crate::api::MessageContentBlock::ToolUse { .. }
704-
| crate::api::MessageContentBlock::ServerToolUse { .. }
705-
| crate::api::MessageContentBlock::WebSearchToolResult { .. }
706-
)
707-
})
693+
.filter(|block| !block.is_tool_call_initiator())
708694
.collect();
709695
if !message_blocks.is_empty() {
710696
self.conversation.add_assistant_with_blocks(message_blocks);

src/tools/filesystem.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ fn write_atomic(path: &Path, content: &str) -> std::io::Result<()> {
9999
Ok(())
100100
}
101101

102+
/// Windows error code returned by `MoveFile` when source and
103+
/// destination are on different volumes. The `winapi` crate exposes
104+
/// this as `ERROR_NOT_SAME_DEVICE`; the literal is used directly so
105+
/// the Unix build doesn't pull in a Windows-only dependency for one
106+
/// integer.
107+
#[cfg(windows)]
108+
const ERROR_NOT_SAME_DEVICE: i32 = 17;
109+
102110
/// True when `e` describes a rename that crossed a filesystem
103111
/// boundary. Uses the stable `ErrorKind::CrossesDevices` mapping
104112
/// first; falls back to the platform-specific raw code so a future
@@ -113,8 +121,7 @@ fn is_cross_device_error(e: &std::io::Error) -> bool {
113121
}
114122
#[cfg(windows)]
115123
{
116-
// ERROR_NOT_SAME_DEVICE
117-
e.raw_os_error() == Some(17)
124+
e.raw_os_error() == Some(ERROR_NOT_SAME_DEVICE)
118125
}
119126
#[cfg(not(any(unix, windows)))]
120127
{

src/ui/cost.rs

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,9 @@ use colored::Colorize;
88
/// record.
99
const CACHE_READ_RATE: f64 = 0.10;
1010
/// Multiplier applied to the base input price for tokens written to a
11-
/// 5-minute Anthropic cache breakpoint. OpenAI has no separate
12-
/// creation charge (the wire format never reports cache-creation
13-
/// tokens for OpenAI requests), so the multiplier only fires on
14-
/// Anthropic responses.
15-
///
16-
/// NOTE: Anthropic also exposes a 1-hour TTL on the *last* tool
17-
/// definition breakpoint, billed at 2× the input rate rather than
18-
/// 1.25×. Sofos stamps that breakpoint with `ephemeral_one_hour`
19-
/// today but uses the same constant here, so cost summaries
20-
/// under-report the cache-creation premium on the 1-hour anchor by
21-
/// a roughly 40% margin. Weighting by an empirical 5m/1h mix factor
22-
/// would close the gap, but the absolute amount on a typical session
23-
/// is small enough that documenting the under-report is more useful
24-
/// than a model-specific lookup.
11+
/// 5-minute Anthropic cache breakpoint. OpenAI has no creation charge.
12+
/// The 1-hour breakpoint Anthropic exposes for the last tool definition
13+
/// bills at 2×, not 1.25× — the cost summary under-reports that anchor.
2514
const CACHE_CREATION_RATE: f64 = 1.25;
2615

2716
/// True for models hosted by OpenAI. Used by the cost and

0 commit comments

Comments
 (0)