1use std::io::{self, BufRead, IsTerminal, Write};
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5use serde_json::{Map, Value};
6
7use crate::config::{Config, DEFAULT_API_KEY_ENV};
8use crate::context::resolve_boot_context_with_api_key_env;
9use crate::model::{ChatMessage, ChatToolCall};
10use crate::protocol::{EventSink, ProtocolEvent, ProtocolWriter};
11use crate::provider::{Provider, ProviderTurn};
12use crate::redaction::{
13 conflicts_with_protected_literal, conflicts_with_tui_literal, is_structural_key,
14 redact_secret, redaction_marker,
15};
16use crate::session::Session;
17
18#[derive(Debug)]
19struct CliOptions {
20 session: Option<String>,
21 list_sessions: bool,
22 jsonl: bool,
23 tui: bool,
24}
25
26#[derive(Debug, Deserialize)]
27struct InputRecord {
28 #[serde(rename = "type")]
29 record_type: String,
30 text: Option<String>,
31}
32
33const MAX_TOOL_ROUNDS: usize = 32;
34const MAX_TOOL_CALLS_PER_MESSAGE: usize = 64;
35const USER_CANCEL_REASON: &str = "user_cancelled";
36const PROVIDER_PHASE: &str = "provider_stream";
37const COMMAND_PHASE: &str = "cmd";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum FrontendMode {
41 Jsonl,
42 Tui,
43}
44
45pub fn run_cli<R, W, E>(args: &[String], input: R, output: W, diagnostics: E) -> i32
46where
47 R: BufRead,
48 W: Write,
49 E: Write,
50{
51 let home = match home_directory() {
52 Ok(home) => home,
53 Err(error) => {
54 let mut diagnostics = diagnostics;
55 write_diagnostic(&mut diagnostics, &error);
56 return 1;
57 }
58 };
59 let cwd = match std::env::current_dir() {
60 Ok(cwd) => cwd,
61 Err(_error) => {
62 let mut diagnostics = diagnostics;
63 write_diagnostic(&mut diagnostics, "unable to resolve cwd");
64 return 1;
65 }
66 };
67 run_cli_at_home_with_terminals(
68 args,
69 input,
70 output,
71 diagnostics,
72 &home,
73 &cwd,
74 io::stdin().is_terminal(),
75 io::stdout().is_terminal(),
76 )
77}
78
79pub fn run_cli_at_home<R, W, E>(
80 args: &[String],
81 input: R,
82 output: W,
83 diagnostics: E,
84 home: &Path,
85 cwd: &Path,
86) -> i32
87where
88 R: BufRead,
89 W: Write,
90 E: Write,
91{
92 run_cli_at_home_with_terminals(args, input, output, diagnostics, home, cwd, false, false)
95}
96
97#[allow(clippy::too_many_arguments)]
98fn run_cli_at_home_with_terminals<R, W, E>(
99 args: &[String],
100 input: R,
101 output: W,
102 mut diagnostics: E,
103 home: &Path,
104 cwd: &Path,
105 stdin_is_tty: bool,
106 stdout_is_tty: bool,
107) -> i32
108where
109 R: BufRead,
110 W: Write,
111 E: Write,
112{
113 let options = match parse_args(args) {
114 Ok(options) => options,
115 Err(error) => {
116 let mut diagnostics = diagnostics;
117 write_diagnostic(&mut diagnostics, &error);
118 return 2;
119 }
120 };
121 let mode = match resolve_mode(args, stdin_is_tty, stdout_is_tty) {
122 Ok(mode) => mode,
123 Err(error) => {
124 write_diagnostic(&mut diagnostics, &error);
125 return 2;
126 }
127 };
128
129 if options.list_sessions {
130 let mut protocol = ProtocolWriter::new(output);
131 if let Err(error) = Config::ensure_exists(home) {
132 write_diagnostic(&mut diagnostics, &error.to_string());
133 return 1;
134 }
135 return match Session::list(home) {
136 Ok(sessions) => {
137 for session in sessions {
138 if let Err(error) = protocol.emit_serializable(&session) {
139 write_diagnostic(
140 &mut diagnostics,
141 &format!("unable to write session metadata: {error}"),
142 );
143 return 1;
144 }
145 }
146 0
147 }
148 Err(error) => {
149 write_diagnostic(&mut diagnostics, &error.to_string());
150 1
151 }
152 };
153 }
154
155 let (session, provider, resumed) = if let Some(id) = options.session.as_deref() {
156 let session = match Session::resume(home, id) {
157 Ok(session) => session,
158 Err(error) => {
159 write_diagnostic(&mut diagnostics, &error.to_string());
160 return 1;
161 }
162 };
163 let provider = match Provider::new(&session.llm) {
164 Ok(provider) => provider,
165 Err(error) => {
166 write_diagnostic(&mut diagnostics, &error.to_string());
167 return 1;
168 }
169 };
170 if mode == FrontendMode::Tui && conflicts_with_tui_literal(provider.api_key()) {
171 write_diagnostic_safe(
172 &mut diagnostics,
173 "API key conflicts with terminal UI literals",
174 Some(provider.api_key()),
175 );
176 return 1;
177 }
178 (session, provider, true)
179 } else {
180 let config = match Config::load_or_create(home) {
181 Ok(config) => config,
182 Err(error) => {
183 write_diagnostic(&mut diagnostics, &error.to_string());
184 return 1;
185 }
186 };
187 let configured_secret = configured_api_key(&config);
188 let api_key_env = configured_api_key_env(&config);
189 let llm = match config.resolved_llm() {
190 Ok(llm) => llm,
191 Err(error) => {
192 write_diagnostic_safe(
193 &mut diagnostics,
194 &error.to_string(),
195 configured_secret.as_deref(),
196 );
197 return 1;
198 }
199 };
200 let provider = match Provider::new(&llm) {
201 Ok(provider) => provider,
202 Err(error) => {
203 write_diagnostic_safe(
204 &mut diagnostics,
205 &error.to_string(),
206 configured_secret.as_deref(),
207 );
208 return 1;
209 }
210 };
211 if mode == FrontendMode::Tui && conflicts_with_tui_literal(provider.api_key()) {
212 write_diagnostic_safe(
213 &mut diagnostics,
214 "API key conflicts with terminal UI literals",
215 Some(provider.api_key()),
216 );
217 return 1;
218 }
219 let safe_cwd = match std::fs::canonicalize(cwd) {
220 Ok(cwd) if !cwd.display().to_string().contains(provider.api_key()) => cwd,
221 Ok(_) => {
222 write_diagnostic_safe(
223 &mut diagnostics,
224 "session header rejected",
225 Some(provider.api_key()),
226 );
227 return 1;
228 }
229 Err(_) => {
230 write_diagnostic_safe(
231 &mut diagnostics,
232 "unable to resolve session cwd",
233 Some(provider.api_key()),
234 );
235 return 1;
236 }
237 };
238 let context = match resolve_boot_context_with_api_key_env(
239 home,
240 &safe_cwd,
241 &config.system_prompt,
242 api_key_env.as_deref(),
243 ) {
244 Ok(context) => context,
245 Err(error) => {
246 write_diagnostic_safe(
247 &mut diagnostics,
248 &error.to_string(),
249 configured_secret.as_deref(),
250 );
251 return 1;
252 }
253 };
254 let boot_system_prompt = redact_secret(&context.system_prompt, Some(provider.api_key()));
255 let session = match Session::create_with_secret(
256 home,
257 &safe_cwd,
258 boot_system_prompt,
259 llm,
260 Some(provider.api_key()),
261 ) {
262 Ok(session) => session,
263 Err(error) => {
264 write_diagnostic_safe(
265 &mut diagnostics,
266 &error.to_string(),
267 Some(provider.api_key()),
268 );
269 return 1;
270 }
271 };
272 (session, provider, false)
273 };
274
275 let harness = Harness { session, provider };
276 if mode == FrontendMode::Tui {
277 return match crate::tui::run(harness, resumed, output) {
278 Ok(()) => 0,
279 Err(error) => {
280 write_diagnostic(&mut diagnostics, &error);
281 1
282 }
283 };
284 }
285
286 let mut protocol = ProtocolWriter::new(output);
287 let mut harness = harness;
288 if let Err(error) = protocol.session(&harness.session.id, resumed) {
289 write_diagnostic_safe(
290 &mut diagnostics,
291 &format!("unable to write session event: {error}"),
292 Some(harness.provider.api_key()),
293 );
294 return 1;
295 }
296
297 for line in input.lines() {
298 let line = match line {
299 Ok(line) => line,
300 Err(error) => {
301 write_diagnostic_safe(
302 &mut diagnostics,
303 &format!("unable to read stdin: {error}"),
304 Some(harness.provider.api_key()),
305 );
306 return 1;
307 }
308 };
309 if line.trim().is_empty() {
310 continue;
311 }
312 let text = match parse_input_message(&line) {
313 Ok(text) => text,
314 Err(error) => {
315 let error = redact_secret(&error, Some(harness.provider.api_key()));
316 if let Err(write_error) = protocol.error(&error) {
317 write_diagnostic_safe(
318 &mut diagnostics,
319 &format!("unable to write protocol error: {write_error}"),
320 Some(harness.provider.api_key()),
321 );
322 return 1;
323 }
324 continue;
325 }
326 };
327 if let Err(error) = harness.handle_message(&text, &mut protocol, None) {
328 let error = redact_secret(&error, Some(harness.provider.api_key()));
329 if let Err(write_error) = protocol.error(&error) {
330 write_diagnostic_safe(
331 &mut diagnostics,
332 &format!("unable to write protocol error: {write_error}"),
333 Some(harness.provider.api_key()),
334 );
335 return 1;
336 }
337 }
338 }
339
340 0
341}
342
343pub fn resolve_mode(
344 args: &[String],
345 stdin_is_tty: bool,
346 stdout_is_tty: bool,
347) -> Result<FrontendMode, String> {
348 let options = parse_args(args)?;
349 if options.list_sessions {
350 if options.tui {
351 return Err("--tui cannot be combined with --list-sessions".to_owned());
352 }
353 return Ok(FrontendMode::Jsonl);
354 }
355 if options.tui && !(stdin_is_tty && stdout_is_tty) {
356 return Err("--tui requires a terminal on stdin and stdout".to_owned());
357 }
358 if options.tui {
359 Ok(FrontendMode::Tui)
360 } else if options.jsonl || !(stdin_is_tty && stdout_is_tty) {
361 Ok(FrontendMode::Jsonl)
362 } else {
363 Ok(FrontendMode::Tui)
364 }
365}
366
367pub(crate) struct Harness {
368 pub(crate) session: Session,
369 pub(crate) provider: Provider,
370}
371
372impl Harness {
373 pub(crate) fn handle_message<S: EventSink>(
374 &mut self,
375 text: &str,
376 sink: &mut S,
377 cancellation: Option<&crate::cancellation::CancellationToken>,
378 ) -> Result<(), String> {
379 let secret = self.provider.api_key().to_owned();
380 let user_message = ChatMessage::user(redact_secret(text, Some(&secret)));
381 if let Err(error) = self.session.append_message(user_message) {
382 if cancellation.is_some_and(|token| token.is_cancelled()) {
383 let interruption = self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
384 return interruption
385 .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
386 }
387 return Err(error.to_string());
388 }
389
390 let mut tool_rounds = 0;
391 let mut tool_calls: usize = 0;
392 loop {
393 if cancellation.is_some_and(|token| token.is_cancelled()) {
394 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
395 }
396 if tool_rounds >= MAX_TOOL_ROUNDS {
397 return Err(format!(
398 "tool loop exceeded maximum of {MAX_TOOL_ROUNDS} provider rounds"
399 ));
400 }
401 let messages = self.session.provider_messages();
402 let mut raw_content = String::new();
403 let mut redactor = SecretRedactor::new(&secret);
404 let stream_result = {
405 let mut on_text = |delta: &str| {
406 raw_content.push_str(delta);
407 redactor.push(delta, |safe_delta| {
408 sink.emit_event(&ProtocolEvent::AssistantDelta {
409 text: safe_delta.to_owned(),
410 })
411 })
412 };
413 match cancellation {
414 Some(token) => {
415 self.provider
416 .stream_chat_cancellable(&messages, &mut on_text, token)
417 }
418 None => self.provider.stream_chat(&messages, &mut on_text),
419 }
420 };
421 redactor
422 .finish(|safe_delta| {
423 sink.emit_event(&ProtocolEvent::AssistantDelta {
424 text: safe_delta.to_owned(),
425 })
426 })
427 .map_err(|error| format!("unable to write assistant delta: {error}"))?;
428 let turn = match stream_result {
429 Ok(turn) => turn,
430 Err(error)
431 if cancellation.is_some_and(|token| token.is_cancelled())
432 || error.is_cancelled() =>
433 {
434 let partial = error.partial_turn().cloned().unwrap_or(ProviderTurn {
435 content: raw_content,
436 tool_calls: Vec::new(),
437 reasoning_details: Vec::new(),
438 });
439 return self.interrupt(
440 sink,
441 PROVIDER_PHASE,
442 &partial.content,
443 &partial.tool_calls,
444 Vec::new(),
445 );
446 }
447 Err(error) => return Err(error.to_string()),
448 };
449 let canceled_after_stream = cancellation.is_some_and(|token| token.is_cancelled());
450
451 if turn.tool_calls.iter().any(|call| call.name != "cmd") {
452 if canceled_after_stream {
453 return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
454 }
455 return Err("provider requested an unsupported tool".to_owned());
456 }
457 if tool_calls.saturating_add(turn.tool_calls.len()) > MAX_TOOL_CALLS_PER_MESSAGE {
458 if canceled_after_stream {
459 return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
460 }
461 return Err(format!(
462 "tool call budget exceeded maximum of {MAX_TOOL_CALLS_PER_MESSAGE} calls per input message"
463 ));
464 }
465
466 let safe_tool_calls = turn
467 .tool_calls
468 .iter()
469 .map(|call| ChatToolCall {
470 id: redact_secret(&call.id, Some(&secret)),
471 name: redact_secret(&call.name, Some(&secret)),
472 arguments: redact_tool_arguments(&call.arguments, &secret),
473 })
474 .collect::<Vec<_>>();
475 let assistant_content = redact_secret(&turn.content, Some(&secret));
476 let safe_reasoning_details = redact_reasoning_details(&turn.reasoning_details, &secret);
477 let mut assistant =
478 ChatMessage::assistant(assistant_content.clone(), safe_tool_calls.clone());
479 assistant.reasoning_details = safe_reasoning_details;
480 if let Err(error) = self.session.append_message(assistant) {
481 if cancellation.is_some_and(|token| token.is_cancelled()) {
482 let interruption = self.interrupt(
483 sink,
484 PROVIDER_PHASE,
485 &assistant_content,
486 &turn.tool_calls,
487 Vec::new(),
488 );
489 return interruption
490 .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
491 }
492 return Err(error.to_string());
493 }
494
495 if safe_tool_calls.is_empty() {
496 if canceled_after_stream || cancellation.is_some_and(|token| !token.try_complete())
497 {
498 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
499 }
500 sink.emit_event(&ProtocolEvent::TurnEnd)
501 .map_err(|error| format!("unable to write turn end: {error}"))?;
502 return Ok(());
503 }
504
505 tool_rounds += 1;
506 tool_calls += safe_tool_calls.len();
507 for safe_call in &safe_tool_calls {
508 sink.emit_event(&ProtocolEvent::ToolCall {
509 id: safe_call.id.clone(),
510 name: safe_call.name.clone(),
511 arguments: safe_call.arguments.clone(),
512 })
513 .map_err(|error| format!("unable to write tool call: {error}"))?;
514 }
515 for index in 0..turn.tool_calls.len() {
516 let raw_call = &turn.tool_calls[index];
517 let safe_call = &safe_tool_calls[index];
518 let result = if cancellation.is_some_and(|token| token.is_cancelled()) {
519 crate::command::canceled_result(&safe_call.arguments, &secret)
520 } else {
521 crate::command::execute_with_cancellation(
522 &raw_call.arguments,
523 &self.session.cwd,
524 self.provider.api_key_env(),
525 Some(&secret),
526 cancellation,
527 )
528 };
529 let result = redact_json_value(
530 serde_json::to_value(result)
531 .map_err(|error| format!("unable to encode cmd result: {error}"))?,
532 &secret,
533 );
534 let tool_content = serde_json::to_string(&result)
535 .map_err(|error| format!("unable to encode tool result: {error}"))?;
536 let tool_message = ChatMessage::tool(
537 safe_call.id.clone(),
538 safe_call.name.clone(),
539 redact_secret(&tool_content, Some(&secret)),
540 );
541 let observation = crate::session::SessionToolResult {
542 id: safe_call.id.clone(),
543 name: safe_call.name.clone(),
544 result: result.clone(),
545 };
546 if let Err(error) = self.session.append_message(tool_message) {
547 if cancellation.is_some_and(|token| token.is_cancelled()) {
548 let interruption =
549 self.interrupt(sink, COMMAND_PHASE, "", &[], vec![observation]);
550 return interruption
551 .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
552 }
553 return Err(error.to_string());
554 }
555 sink.emit_event(&ProtocolEvent::ToolResult {
556 id: safe_call.id.clone(),
557 name: safe_call.name.clone(),
558 result: result.clone(),
559 })
560 .map_err(|error| format!("unable to write tool result: {error}"))?;
561 if cancellation.is_some_and(|token| token.is_cancelled()) {
562 for pending_call in safe_tool_calls.iter().skip(index + 1) {
563 let pending_result = redact_json_value(
564 serde_json::to_value(crate::command::canceled_result(
565 &pending_call.arguments,
566 &secret,
567 ))
568 .map_err(|error| format!("unable to encode cmd result: {error}"))?,
569 &secret,
570 );
571 let pending_content = serde_json::to_string(&pending_result)
572 .map_err(|error| format!("unable to encode tool result: {error}"))?;
573 let pending_message = ChatMessage::tool(
574 pending_call.id.clone(),
575 pending_call.name.clone(),
576 redact_secret(&pending_content, Some(&secret)),
577 );
578 let pending_observation = crate::session::SessionToolResult {
579 id: pending_call.id.clone(),
580 name: pending_call.name.clone(),
581 result: pending_result.clone(),
582 };
583 if let Err(error) = self.session.append_message(pending_message) {
584 if cancellation.is_some_and(|token| token.is_cancelled()) {
585 let interruption = self.interrupt(
586 sink,
587 COMMAND_PHASE,
588 "",
589 &[],
590 vec![pending_observation],
591 );
592 return interruption.map_err(|interrupt_error| {
593 format!("{error}; {interrupt_error}")
594 });
595 }
596 return Err(error.to_string());
597 }
598 sink.emit_event(&ProtocolEvent::ToolResult {
599 id: pending_call.id.clone(),
600 name: pending_call.name.clone(),
601 result: pending_result.clone(),
602 })
603 .map_err(|error| format!("unable to write tool result: {error}"))?;
604 }
605 return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
606 }
607 }
608 }
609 }
610
611 fn interrupt<S: EventSink>(
612 &mut self,
613 sink: &mut S,
614 phase: &str,
615 assistant_text: &str,
616 tool_calls: &[ChatToolCall],
617 tool_results: Vec<crate::session::SessionToolResult>,
618 ) -> Result<(), String> {
619 let secret = self.provider.api_key();
620 let safe_tool_calls = tool_calls
621 .iter()
622 .filter(|call| call.name == "cmd")
623 .map(|call| safe_partial_tool_call(call, secret))
624 .collect::<Vec<_>>();
625 let safe_tool_results = tool_results.clone();
626 let interruption = crate::session::InterruptionRecord {
627 timestamp: 0,
628 reason: USER_CANCEL_REASON.to_owned(),
629 phase: phase.to_owned(),
630 assistant_text: redact_secret(assistant_text, Some(secret)),
631 tool_calls: safe_tool_calls.clone(),
632 tool_results,
633 };
634 let persistence_error = self.session.append_interruption(interruption).err();
635 let mut event_error = None;
636 for call in &safe_tool_calls {
637 if let Err(error) = sink.emit_event(&ProtocolEvent::ToolCall {
638 id: call.id.clone(),
639 name: call.name.clone(),
640 arguments: call.arguments.clone(),
641 }) {
642 event_error.get_or_insert(error);
643 }
644 }
645 for observation in &safe_tool_results {
646 if let Err(error) = sink.emit_event(&ProtocolEvent::ToolResult {
647 id: observation.id.clone(),
648 name: observation.name.clone(),
649 result: observation.result.clone(),
650 }) {
651 event_error.get_or_insert(error);
652 }
653 }
654 if let Err(error) = sink.emit_event(&ProtocolEvent::TurnInterrupted {
655 reason: USER_CANCEL_REASON.to_owned(),
656 phase: phase.to_owned(),
657 }) {
658 event_error.get_or_insert(error);
659 }
660 match (persistence_error, event_error) {
661 (None, None) => Ok(()),
662 (Some(error), None) => Err(format!("unable to persist interruption: {error}")),
663 (None, Some(error)) => Err(format!("unable to write interruption event: {error}")),
664 (Some(persistence), Some(event)) => Err(format!(
665 "unable to persist interruption: {persistence}; unable to write interruption event: {event}"
666 )),
667 }
668 }
669}
670
671struct SecretRedactor {
672 secret_text: String,
673 secret: Vec<char>,
674 marker: String,
675 pending: String,
676}
677
678impl SecretRedactor {
679 fn new(secret: &str) -> Self {
680 Self {
681 secret_text: secret.to_owned(),
682 secret: secret.chars().collect(),
683 marker: redaction_marker(secret).unwrap_or_default(),
684 pending: String::new(),
685 }
686 }
687
688 fn push<F>(&mut self, text: &str, mut emit: F) -> io::Result<()>
689 where
690 F: FnMut(&str) -> io::Result<()>,
691 {
692 if self.secret.is_empty() {
693 return emit(text);
694 }
695
696 let mut output = String::new();
697 for character in text.chars() {
698 self.pending.push(character);
699 if self.pending.chars().eq(self.secret.iter().copied()) {
700 self.pending.clear();
701 output.push_str(&self.marker);
702 continue;
703 }
704 if self.pending_is_secret_prefix() {
705 continue;
706 }
707
708 let pending = self.pending.chars().collect::<Vec<_>>();
709 let suffix_len = (1..pending.len())
710 .rev()
711 .find(|length| {
712 pending[pending.len() - length..].iter().copied().eq(self
713 .secret
714 .iter()
715 .copied()
716 .take(*length))
717 })
718 .unwrap_or(0);
719 let safe_len = pending.len() - suffix_len;
720 output.extend(pending[..safe_len].iter());
721 self.pending = pending[safe_len..].iter().collect();
722 }
723
724 if output.is_empty() {
725 Ok(())
726 } else {
727 let safe_output = redact_secret(&output, Some(&self.secret_text));
728 emit(&safe_output)
729 }
730 }
731
732 fn finish<F>(&mut self, mut emit: F) -> io::Result<()>
733 where
734 F: FnMut(&str) -> io::Result<()>,
735 {
736 let pending = std::mem::take(&mut self.pending);
737 if pending.is_empty() {
738 return Ok(());
739 }
740 let safe_pending = redact_secret(&pending, Some(&self.secret_text));
741 emit(&safe_pending)
742 }
743
744 fn pending_is_secret_prefix(&self) -> bool {
745 let length = self.pending.chars().count();
746 length < self.secret.len()
747 && self
748 .pending
749 .chars()
750 .zip(self.secret.iter().copied())
751 .all(|(pending, secret)| pending == secret)
752 }
753}
754
755fn redact_tool_arguments(arguments: &str, secret: &str) -> String {
756 let fallback = || "{}".to_owned();
757 let Ok(value) = serde_json::from_str::<Value>(arguments) else {
758 return fallback();
759 };
760 let Some(object) = value.as_object() else {
761 return fallback();
762 };
763 if object.len() != 1 || !object.get("command").is_some_and(Value::is_string) {
764 return fallback();
765 }
766 let redacted = redact_json_value(value, secret);
767 serde_json::to_string(&redacted).unwrap_or_else(|_| fallback())
768}
769
770fn safe_partial_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
771 let arguments = if serde_json::from_str::<Value>(&call.arguments)
772 .ok()
773 .and_then(|value| value.as_object().cloned())
774 .is_some_and(|object| object.len() == 1 && object.contains_key("command"))
775 {
776 redact_tool_arguments(&call.arguments, secret)
777 } else {
778 "{}".to_owned()
782 };
783 ChatToolCall {
784 id: redact_secret(&call.id, Some(secret)),
785 name: redact_secret(&call.name, Some(secret)),
786 arguments,
787 }
788}
789
790fn redact_json_value(value: Value, secret: &str) -> Value {
791 match value {
792 Value::String(text) => Value::String(redact_secret(&text, Some(secret))),
793 Value::Array(values) => Value::Array(
794 values
795 .into_iter()
796 .map(|value| redact_json_value(value, secret))
797 .collect(),
798 ),
799 Value::Object(object) => {
800 let marker = redaction_marker(secret).unwrap_or_default();
801 let mut redacted = Map::new();
802 for (key, value) in object {
803 let mut safe_key = if is_structural_key(&key) {
804 key
805 } else {
806 redact_secret(&key, Some(secret))
807 };
808 if redacted.contains_key(&safe_key) {
809 if marker.is_empty() {
810 continue;
811 }
812 while redacted.contains_key(&safe_key) {
813 safe_key.push_str(&marker);
814 }
815 }
816 redacted.insert(safe_key, redact_json_value(value, secret));
817 }
818 Value::Object(redacted)
819 }
820 value => value,
821 }
822}
823
824fn redact_reasoning_details(details: &[Value], secret: &str) -> Option<Vec<Value>> {
825 if details.is_empty() {
826 return None;
827 }
828 match redact_json_value(Value::Array(details.to_vec()), secret) {
829 Value::Array(details) => Some(details),
830 _ => None,
831 }
832}
833
834fn parse_args(args: &[String]) -> Result<CliOptions, String> {
835 let mut options = CliOptions {
836 session: None,
837 list_sessions: false,
838 jsonl: false,
839 tui: false,
840 };
841 let mut index = 0;
842 while index < args.len() {
843 match args[index].as_str() {
844 "--session" => {
845 if options.list_sessions || options.session.is_some() {
846 return Err("--session cannot be combined or repeated".to_owned());
847 }
848 index += 1;
849 let Some(id) = args.get(index) else {
850 return Err("--session requires an id".to_owned());
851 };
852 options.session = Some(id.clone());
853 }
854 "--list-sessions" => {
855 if options.session.is_some() || options.list_sessions {
856 return Err("--list-sessions cannot be combined or repeated".to_owned());
857 }
858 options.list_sessions = true;
859 }
860 "--jsonl" => {
861 if options.jsonl || options.tui {
862 return Err("--jsonl cannot be combined or repeated".to_owned());
863 }
864 options.jsonl = true;
865 }
866 "--tui" => {
867 if options.tui || options.jsonl {
868 return Err("--tui cannot be combined or repeated".to_owned());
869 }
870 options.tui = true;
871 }
872 "--help" | "-h" => {
873 return Err(
874 "usage: lucy [--jsonl|--tui] [--session <id>] [--list-sessions]".to_owned(),
875 );
876 }
877 _ => return Err("unknown argument".to_owned()),
878 }
879 index += 1;
880 }
881 Ok(options)
882}
883
884fn parse_input_message(line: &str) -> Result<String, String> {
885 let record: InputRecord = serde_json::from_str(line)
886 .map_err(|_| "input must be a JSONL message record".to_owned())?;
887 if record.record_type != "message" {
888 return Err("input record type must be message".to_owned());
889 }
890 record
891 .text
892 .ok_or_else(|| "message record requires a text string".to_owned())
893}
894
895fn home_directory() -> Result<PathBuf, String> {
896 std::env::var_os("HOME")
897 .map(PathBuf::from)
898 .ok_or_else(|| "HOME is not set; Lucy needs a user home directory".to_owned())
899}
900
901fn configured_api_key_env(config: &Config) -> Option<String> {
902 let api_key_env = config
903 .llm
904 .api_key_env
905 .as_deref()
906 .unwrap_or(DEFAULT_API_KEY_ENV)
907 .trim();
908 (!api_key_env.is_empty()).then(|| api_key_env.to_owned())
909}
910
911fn configured_api_key(config: &Config) -> Option<String> {
912 configured_api_key_env(config)
913 .and_then(|api_key_env| std::env::var(api_key_env).ok())
914 .filter(|secret| !secret.is_empty())
915}
916
917fn write_diagnostic_safe<W: Write>(diagnostics: &mut W, message: &str, secret: Option<&str>) {
918 write_diagnostic_safe_with_environment(
919 diagnostics,
920 message,
921 secret,
922 std::env::vars().map(|(_, value)| value),
923 );
924}
925
926fn write_diagnostic_safe_with_environment<W, I>(
927 diagnostics: &mut W,
928 message: &str,
929 secret: Option<&str>,
930 environment_values: I,
931) where
932 W: Write,
933 I: IntoIterator<Item = String>,
934{
935 let mut safe_line = format!("!: {message}");
936 safe_line = redact_secret(&safe_line, secret);
937 let mut environment_secrets = environment_values
938 .into_iter()
939 .filter(|value| !value.is_empty() && !conflicts_with_protected_literal(value))
940 .collect::<Vec<_>>();
941 environment_secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
942 for environment_secret in environment_secrets {
943 safe_line = redact_secret(&safe_line, Some(&environment_secret));
944 }
945 let _ = writeln!(diagnostics, "{safe_line}");
946}
947
948fn write_diagnostic<W: Write>(diagnostics: &mut W, message: &str) {
949 write_diagnostic_safe(diagnostics, message, None);
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955 use std::io::Cursor;
956
957 #[test]
958 fn parses_only_message_records() {
959 assert_eq!(
960 parse_input_message(r#"{"type":"message","text":"hello"}"#).expect("message"),
961 "hello"
962 );
963 assert!(parse_input_message(r#"{"type":"event","text":"hello"}"#).is_err());
964 assert_eq!(
965 parse_input_message(r#"{"type":"message","text":""}"#).expect("empty message"),
966 ""
967 );
968 }
969
970 #[test]
971 fn resolves_terminal_and_forced_modes() {
972 assert_eq!(
973 resolve_mode(&[], true, true).expect("default TUI"),
974 FrontendMode::Tui
975 );
976 assert_eq!(
977 resolve_mode(&[], true, false).expect("automatic JSONL"),
978 FrontendMode::Jsonl
979 );
980 assert_eq!(
981 resolve_mode(&["--jsonl".to_owned()], true, true).expect("forced JSONL"),
982 FrontendMode::Jsonl
983 );
984 assert!(resolve_mode(&["--tui".to_owned()], true, false).is_err());
985 }
986
987 #[test]
988 fn redactor_does_not_leak_a_secret_across_deltas() {
989 let mut redactor = SecretRedactor::new("secret");
990 let mut output = Vec::new();
991 redactor
992 .push("prefix sec", |text| {
993 output.push(text.to_owned());
994 Ok(())
995 })
996 .expect("push");
997 redactor
998 .push("ret suffix", |text| {
999 output.push(text.to_owned());
1000 Ok(())
1001 })
1002 .expect("push");
1003 redactor
1004 .finish(|text| {
1005 output.push(text.to_owned());
1006 Ok(())
1007 })
1008 .expect("finish");
1009 let output = output.join("");
1010 assert_eq!(
1011 output,
1012 format!("prefix {} suffix", redaction_marker("secret").unwrap())
1013 );
1014 assert!(!output.contains("secret"));
1015 }
1016
1017 #[test]
1018 fn redactor_handles_secrets_introduced_by_protocol_json_escaping() {
1019 let mut redactor = SecretRedactor::new("n0");
1020 let mut output = String::new();
1021 redactor
1022 .push("\n0", |text| {
1023 output.push_str(text);
1024 Ok(())
1025 })
1026 .expect("push");
1027 redactor
1028 .finish(|text| {
1029 output.push_str(text);
1030 Ok(())
1031 })
1032 .expect("finish");
1033 assert!(!output.contains("n0"));
1034 assert_eq!(output, redaction_marker("n0").unwrap());
1035 }
1036
1037 #[test]
1038 fn redactor_does_not_emit_a_secret_when_it_completes_at_a_delta_boundary() {
1039 let mut redactor = SecretRedactor::new("secret");
1040 let mut output = Vec::new();
1041 redactor
1042 .push("xsecre", |text| {
1043 output.push(text.to_owned());
1044 Ok(())
1045 })
1046 .expect("first delta");
1047 redactor
1048 .push("t", |text| {
1049 output.push(text.to_owned());
1050 Ok(())
1051 })
1052 .expect("second delta");
1053 redactor
1054 .finish(|text| {
1055 output.push(text.to_owned());
1056 Ok(())
1057 })
1058 .expect("finish");
1059 let output = output.join("");
1060 assert_eq!(output, format!("x{}", redaction_marker("secret").unwrap()));
1061 assert!(!output.contains("secret"));
1062 }
1063
1064 #[test]
1065 fn streaming_redaction_handles_marker_collision_keys_at_delta_boundaries() {
1066 for secret in ["REDACTED", "[REDACTED]"] {
1067 let mut redactor = SecretRedactor::new(secret);
1068 let split = secret.len() / 2;
1069 let (first, second) = secret.split_at(split);
1070 let mut output = String::new();
1071 redactor
1072 .push(first, |text| {
1073 output.push_str(text);
1074 Ok(())
1075 })
1076 .expect("first delta");
1077 redactor
1078 .push(second, |text| {
1079 output.push_str(text);
1080 Ok(())
1081 })
1082 .expect("second delta");
1083 redactor
1084 .finish(|text| {
1085 output.push_str(text);
1086 Ok(())
1087 })
1088 .expect("finish");
1089 assert!(!output.contains(secret));
1090 assert!(output.len() <= secret.len());
1091 }
1092 }
1093
1094 #[test]
1095 fn malformed_tool_arguments_use_a_safe_copy() {
1096 let secret = "provider-secret";
1097 let escaped = secret
1098 .chars()
1099 .map(|character| format!(r#"\u{:04x}"#, character as u32))
1100 .collect::<String>();
1101 let arguments = format!(r#"{{"command":"{escaped}""#);
1102 let safe = redact_tool_arguments(&arguments, secret);
1103 assert_eq!(safe, "{}");
1104 serde_json::from_str::<Value>(&safe).expect("safe arguments JSON");
1105 assert!(!safe.contains(secret));
1106 assert!(!safe.contains(&escaped));
1107 for invalid in ["[]", "{\"command\":1}", "{\"other\":\"value\"}"] {
1108 assert_eq!(redact_tool_arguments(invalid, secret), "{}");
1109 }
1110 }
1111
1112 #[test]
1113 fn structured_redaction_preserves_tool_and_result_schema_keys() {
1114 let secret = "provider-secret";
1115 let value = serde_json::json!({
1116 "command": "printf provider-secret",
1117 "stdout": "provider-secret",
1118 "stderr": "ordinary",
1119 "exit_code": 0,
1120 "timed_out": false,
1121 "stdout_truncated": false,
1122 "stderr_truncated": false,
1123 "unknown-provider-secret": "provider-secret"
1124 });
1125 let redacted = redact_json_value(value, secret);
1126 for key in [
1127 "command",
1128 "stdout",
1129 "stderr",
1130 "exit_code",
1131 "timed_out",
1132 "stdout_truncated",
1133 "stderr_truncated",
1134 ] {
1135 assert!(redacted.get(key).is_some(), "missing schema key: {key}");
1136 }
1137 let encoded = serde_json::to_string(&redacted).expect("redacted JSON");
1138 assert!(!encoded.contains(secret));
1139 assert!(redacted.get("unknown-provider-secret").is_none());
1140 }
1141
1142 #[test]
1143 fn structured_redaction_preserves_typed_values_even_for_a_pathological_key() {
1144 let value = serde_json::json!({
1145 "exit_code": 0,
1146 "timed_out": false,
1147 "stdout_truncated": true,
1148 "error": null,
1149 });
1150 let redacted = redact_json_value(value, "0");
1151 assert!(redacted["exit_code"].is_number());
1152 assert!(redacted["timed_out"].is_boolean());
1153 assert!(redacted["stdout_truncated"].is_boolean());
1154 assert!(redacted["error"].is_null());
1155 }
1156
1157 #[test]
1158 fn reasoning_details_are_recursively_redacted_before_persistence() {
1159 let details = vec![serde_json::json!({
1160 "type": "reasoning.text",
1161 "text": "provider-secret",
1162 "nested": [{"value": "provider-secret"}],
1163 "provider-secret": "provider-secret"
1164 })];
1165 let redacted = redact_reasoning_details(&details, "provider-secret")
1166 .expect("non-empty reasoning details");
1167 let redacted = Value::Array(redacted);
1168 let encoded = serde_json::to_string(&redacted).expect("reasoning details JSON");
1169 assert!(!encoded.contains("provider-secret"));
1170 assert_eq!(redacted[0]["type"], "reasoning.text");
1171 assert_eq!(redacted[0]["text"], "[REDACTED]");
1172 assert_eq!(redacted[0]["nested"][0]["value"], "[REDACTED]");
1173 assert!(redacted[0].get("provider-secret").is_none());
1174 }
1175
1176 #[test]
1177 fn malformed_input_error_does_not_echo_secret_bearing_input() {
1178 let error =
1179 parse_input_message(r#"{"type":"message","text":"provider-secret","unexpected":}"#)
1180 .expect_err("invalid input");
1181 assert!(!error.contains("provider-secret"));
1182 }
1183
1184 #[test]
1185 fn malformed_input_is_an_error_event_and_not_diagnostic_json() {
1186 let mut output = Vec::new();
1187 let error = parse_input_message("not json").expect_err("invalid input");
1188 let mut protocol = ProtocolWriter::new(&mut output);
1189 protocol.error(&error).expect("error event");
1190 assert_eq!(String::from_utf8_lossy(&output).lines().count(), 1);
1191 let _ = Cursor::new("");
1192 }
1193
1194 #[test]
1195 fn early_diagnostic_scrubbing_removes_short_values_from_the_complete_line() {
1196 let secret = "lucy";
1197 let mut diagnostics = Vec::new();
1198 write_diagnostic_safe_with_environment(
1199 &mut diagnostics,
1200 secret,
1201 None,
1202 vec![secret.to_owned()],
1203 );
1204 let diagnostics = String::from_utf8(diagnostics).expect("diagnostic UTF-8");
1205 assert!(!diagnostics.contains(secret));
1206 }
1207}