Skip to main content

lucy/
app.rs

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