1use std::io::{self, BufRead, IsTerminal, Write};
2use std::path::{Path, PathBuf};
3use std::sync::mpsc;
4
5use serde::Deserialize;
6use serde_json::{Map, Value};
7
8use crate::cancellation::CancellationToken;
9use crate::config::{AuthProvider, Config, LlmSettings};
10use crate::context::{resolve_boot_context_with_api_key_env, InstructionSource, SkillEntry};
11use crate::model::{estimate_context_tokens, estimate_message_tokens, ChatMessage, ChatToolCall};
12use crate::protocol::{EventSink, ProtocolEvent, ProtocolWriter};
13use crate::provider::{Provider, ProviderStreamEvent, ProviderTurn};
14use crate::redaction::{
15 conflicts_with_protected_literal, conflicts_with_tui_literal, is_structural_key, redact_secret,
16 redaction_marker,
17};
18use crate::session::Session;
19
20#[derive(Debug)]
21struct CliOptions {
22 session: Option<String>,
23 list_sessions: bool,
24 jsonl: bool,
25 tui: bool,
26 version: bool,
27 command: Option<CliCommand>,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum CliCommand {
32 CodexLogin,
33 CodexLogout,
34}
35
36#[derive(Debug, Deserialize)]
37struct InputRecord {
38 #[serde(rename = "type")]
39 record_type: String,
40 text: Option<String>,
41}
42
43const USER_CANCEL_REASON: &str = "user_cancelled";
44const PROVIDER_PHASE: &str = "provider_stream";
45const COMMAND_PHASE: &str = "cmd";
46const AUTO_COMPACTION_THRESHOLD_PERCENT: usize = 95;
47const COMPACTION_KEEP_RECENT_TOKENS: usize = 20_000;
48const COMPACTION_SYSTEM_PROMPT: &str = "You are compacting a coding-agent conversation. Produce a concise, factual continuation summary. Preserve the user's goals, explicit decisions, constraints, files and code changes, commands and results, current implementation state, unresolved work, and exact identifiers that future turns need. Do not invent facts. Return only the summary text; do not call tools.";
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum FrontendMode {
52 Jsonl,
53 Tui,
54}
55
56pub fn run_cli<R, W, E>(args: &[String], input: R, output: W, diagnostics: E) -> i32
57where
58 R: BufRead + Send + 'static,
59 W: Write,
60 E: Write,
61{
62 let options = match parse_args(args) {
63 Ok(options) => options,
64 Err(error) => {
65 let mut diagnostics = diagnostics;
66 write_diagnostic(&mut diagnostics, &error);
67 return 2;
68 }
69 };
70 if options.version {
71 if let Err(error) = write_version(output) {
72 let mut diagnostics = diagnostics;
73 write_diagnostic(
74 &mut diagnostics,
75 &format!("unable to write version: {error}"),
76 );
77 return 1;
78 }
79 return 0;
80 }
81
82 let home = match home_directory() {
83 Ok(home) => home,
84 Err(error) => {
85 let mut diagnostics = diagnostics;
86 write_diagnostic(&mut diagnostics, &error);
87 return 1;
88 }
89 };
90 let cwd = match std::env::current_dir() {
91 Ok(cwd) => cwd,
92 Err(_error) => {
93 let mut diagnostics = diagnostics;
94 write_diagnostic(&mut diagnostics, "unable to resolve cwd");
95 return 1;
96 }
97 };
98 run_cli_at_home_with_terminals(
99 args,
100 input,
101 output,
102 diagnostics,
103 &home,
104 &cwd,
105 io::stdin().is_terminal(),
106 io::stdout().is_terminal(),
107 )
108}
109
110pub fn run_cli_at_home<R, W, E>(
111 args: &[String],
112 input: R,
113 output: W,
114 diagnostics: E,
115 home: &Path,
116 cwd: &Path,
117) -> i32
118where
119 R: BufRead + Send + 'static,
120 W: Write,
121 E: Write,
122{
123 run_cli_at_home_with_terminals(args, input, output, diagnostics, home, cwd, false, false)
126}
127
128#[allow(clippy::too_many_arguments)]
129fn run_cli_at_home_with_terminals<R, W, E>(
130 args: &[String],
131 input: R,
132 output: W,
133 mut diagnostics: E,
134 home: &Path,
135 cwd: &Path,
136 stdin_is_tty: bool,
137 stdout_is_tty: bool,
138) -> i32
139where
140 R: BufRead + Send + 'static,
141 W: Write,
142 E: Write,
143{
144 let options = match parse_args(args) {
145 Ok(options) => options,
146 Err(error) => {
147 let mut diagnostics = diagnostics;
148 write_diagnostic(&mut diagnostics, &error);
149 return 2;
150 }
151 };
152 if options.version {
153 if let Err(error) = write_version(output) {
154 write_diagnostic(
155 &mut diagnostics,
156 &format!("unable to write version: {error}"),
157 );
158 return 1;
159 }
160 return 0;
161 }
162 if let Some(command) = options.command {
163 return run_codex_command(command, home, output, &mut diagnostics);
164 }
165 let mode = match resolve_mode(args, stdin_is_tty, stdout_is_tty) {
166 Ok(mode) => mode,
167 Err(error) => {
168 write_diagnostic(&mut diagnostics, &error);
169 return 2;
170 }
171 };
172
173 if options.list_sessions {
174 let mut protocol = ProtocolWriter::new(output);
175 if let Err(error) = Config::ensure_exists(home) {
176 write_diagnostic(&mut diagnostics, &error.to_string());
177 return 1;
178 }
179 let codex_secret = Config::load_or_create(home)
180 .ok()
181 .and_then(|config| config.resolved_auth().ok())
182 .and_then(|auth| configured_codex_secret(home, auth.provider));
183 return match Session::list_with_secret(home, codex_secret.as_deref()) {
184 Ok(sessions) => {
185 for session in sessions {
186 if let Err(error) = protocol.emit_serializable(&session) {
187 write_diagnostic(
188 &mut diagnostics,
189 &format!("unable to write session metadata: {error}"),
190 );
191 return 1;
192 }
193 }
194 0
195 }
196 Err(error) => {
197 write_diagnostic(&mut diagnostics, &error.to_string());
198 1
199 }
200 };
201 }
202
203 let (session, provider, resumed, attached_agents) = if let Some(id) = options.session.as_deref()
204 {
205 let mut session = match Session::resume(home, id) {
206 Ok(session) => session,
207 Err(error) => {
208 write_diagnostic(&mut diagnostics, &error.to_string());
209 return 1;
210 }
211 };
212 let config = match Config::load_or_create(home) {
213 Ok(config) => config,
214 Err(error) => {
215 write_diagnostic(&mut diagnostics, &error.to_string());
216 return 1;
217 }
218 };
219 let auth = match config.resolved_auth() {
220 Ok(auth) => auth,
221 Err(error) => {
222 write_diagnostic(&mut diagnostics, &error.to_string());
223 return 1;
224 }
225 };
226 if let Some(secret) = configured_codex_secret(home, auth.provider) {
227 session = match Session::resume_with_secret(home, id, Some(&secret)) {
228 Ok(session) => session,
229 Err(error) => {
230 write_diagnostic_safe(&mut diagnostics, &error.to_string(), Some(&secret));
231 return 1;
232 }
233 };
234 }
235 let mut selected = match config.resolved_llm() {
236 Ok(settings) => settings,
237 Err(error) => {
238 write_diagnostic_safe(
239 &mut diagnostics,
240 &error.to_string(),
241 configured_api_key(&config).as_deref(),
242 );
243 return 1;
244 }
245 };
246 apply_auth_to_settings(&mut selected, auth.provider);
247 session.llm.model = selected.model;
248 session.llm.effort = selected.effort;
249 session.llm.api_key_env = selected.api_key_env;
250 let provider = match provider_for_settings(home, &session.llm) {
251 Ok(provider) => provider,
252 Err(error) => {
253 write_diagnostic(&mut diagnostics, &error.to_string());
254 return 1;
255 }
256 };
257 if let Err(error) =
258 session.append_provider_settings(session.llm.model.clone(), session.llm.effort.clone())
259 {
260 write_diagnostic_safe(
261 &mut diagnostics,
262 &error.to_string(),
263 Some(&provider.api_key()),
264 );
265 return 1;
266 }
267 if mode == FrontendMode::Tui && conflicts_with_tui_literal(&provider.api_key()) {
268 write_diagnostic_safe(
269 &mut diagnostics,
270 "API key conflicts with terminal UI literals",
271 Some(&provider.api_key()),
272 );
273 return 1;
274 }
275 (session, provider, true, Vec::new())
276 } else {
277 let config = match Config::load_or_create(home) {
278 Ok(config) => config,
279 Err(error) => {
280 write_diagnostic(&mut diagnostics, &error.to_string());
281 return 1;
282 }
283 };
284 let auth = match config.resolved_auth() {
285 Ok(auth) => auth,
286 Err(error) => {
287 write_diagnostic(&mut diagnostics, &error.to_string());
288 return 1;
289 }
290 };
291 let configured_secret = configured_api_key(&config);
292 let api_key_env = auth.api_key_env.clone();
293 let mut llm = match config.resolved_llm() {
294 Ok(llm) => llm,
295 Err(error) => {
296 write_diagnostic_safe(
297 &mut diagnostics,
298 &error.to_string(),
299 configured_secret.as_deref(),
300 );
301 return 1;
302 }
303 };
304 apply_auth_to_settings(&mut llm, auth.provider);
305 let provider = match provider_for_settings(home, &llm) {
306 Ok(provider) => provider,
307 Err(error) => {
308 write_diagnostic_safe(
309 &mut diagnostics,
310 &error.to_string(),
311 configured_secret.as_deref(),
312 );
313 return 1;
314 }
315 };
316 if mode == FrontendMode::Tui && conflicts_with_tui_literal(&provider.api_key()) {
317 write_diagnostic_safe(
318 &mut diagnostics,
319 "API key conflicts with terminal UI literals",
320 Some(&provider.api_key()),
321 );
322 return 1;
323 }
324 let safe_cwd = match std::fs::canonicalize(cwd) {
325 Ok(cwd) if !cwd.display().to_string().contains(&provider.api_key()) => cwd,
326 Ok(_) => {
327 write_diagnostic_safe(
328 &mut diagnostics,
329 "session header rejected",
330 Some(&provider.api_key()),
331 );
332 return 1;
333 }
334 Err(_) => {
335 write_diagnostic_safe(
336 &mut diagnostics,
337 "unable to resolve session cwd",
338 Some(&provider.api_key()),
339 );
340 return 1;
341 }
342 };
343 let context = match resolve_boot_context_with_api_key_env(
344 home,
345 &safe_cwd,
346 &config.system_prompt,
347 api_key_env.as_deref(),
348 ) {
349 Ok(context) => context,
350 Err(error) => {
351 write_diagnostic_safe(
352 &mut diagnostics,
353 &error.to_string(),
354 configured_secret.as_deref(),
355 );
356 return 1;
357 }
358 };
359 let boot_system_prompt = redact_secret(&context.system_prompt, Some(&provider.api_key()));
360 let attached_agents = attached_agents(context.instruction_files, &provider.api_key());
361 let skills = redact_skills(context.skills, &provider.api_key());
362 let session = match Session::create_with_skills_and_secret(
363 home,
364 &safe_cwd,
365 boot_system_prompt,
366 llm,
367 skills,
368 Some(&provider.api_key()),
369 ) {
370 Ok(session) => session,
371 Err(error) => {
372 write_diagnostic_safe(
373 &mut diagnostics,
374 &error.to_string(),
375 Some(&provider.api_key()),
376 );
377 return 1;
378 }
379 };
380 (session, provider, false, attached_agents)
381 };
382
383 let harness = Harness {
384 home: home.to_path_buf(),
385 session,
386 provider,
387 context_window: None,
388 attached_agents,
389 };
390 if mode == FrontendMode::Tui {
391 return match crate::tui::run(harness, resumed, output) {
392 Ok(()) => 0,
393 Err(error) => {
394 write_diagnostic(&mut diagnostics, &error);
395 1
396 }
397 };
398 }
399
400 let mut protocol = ProtocolWriter::new(output);
401 let mut harness = harness;
402 if let Err(error) = protocol.session(&harness.session.id, resumed) {
403 write_diagnostic_safe(
404 &mut diagnostics,
405 &format!("unable to write session event: {error}"),
406 Some(harness.provider.api_key().as_str()),
407 );
408 return 1;
409 }
410
411 let (input_tx, input_rx) = mpsc::channel();
412 std::thread::spawn(move || {
413 for line in input.lines() {
414 if input_tx.send(line).is_err() {
415 break;
416 }
417 }
418 });
419 let mut input_closed = false;
420 loop {
421 if input_closed {
422 break;
423 }
424 let line = match input_rx.recv_timeout(std::time::Duration::from_millis(25)) {
425 Ok(Ok(line)) => line,
426 Ok(Err(error)) => {
427 write_diagnostic_safe(
428 &mut diagnostics,
429 &format!("unable to read stdin: {error}"),
430 Some(harness.provider.api_key().as_str()),
431 );
432 return 1;
433 }
434 Err(mpsc::RecvTimeoutError::Timeout) => continue,
435 Err(mpsc::RecvTimeoutError::Disconnected) => {
436 input_closed = true;
437 continue;
438 }
439 };
440 if line.trim().is_empty() {
441 continue;
442 }
443 let text = match parse_input_message(&line) {
444 Ok(text) => text,
445 Err(error) => {
446 let error = redact_secret(&error, Some(harness.provider.api_key().as_str()));
447 if let Err(write_error) = protocol.error(&error) {
448 write_diagnostic_safe(
449 &mut diagnostics,
450 &format!("unable to write protocol error: {write_error}"),
451 Some(harness.provider.api_key().as_str()),
452 );
453 return 1;
454 }
455 continue;
456 }
457 };
458 if let Err(error) = harness.handle_message(&text, &mut protocol, None) {
459 let error = redact_secret(&error, Some(harness.provider.api_key().as_str()));
460 if let Err(write_error) = protocol.error(&error) {
461 write_diagnostic_safe(
462 &mut diagnostics,
463 &format!("unable to write protocol error: {write_error}"),
464 Some(harness.provider.api_key().as_str()),
465 );
466 return 1;
467 }
468 }
469 }
470 0
471}
472
473pub fn resolve_mode(
474 args: &[String],
475 stdin_is_tty: bool,
476 stdout_is_tty: bool,
477) -> Result<FrontendMode, String> {
478 let options = parse_args(args)?;
479 if options.list_sessions {
480 if options.tui {
481 return Err("--tui cannot be combined with --list-sessions".to_owned());
482 }
483 return Ok(FrontendMode::Jsonl);
484 }
485 if options.tui && !(stdin_is_tty && stdout_is_tty) {
486 return Err("--tui requires a terminal on stdin and stdout".to_owned());
487 }
488 if options.tui {
489 Ok(FrontendMode::Tui)
490 } else if options.jsonl || !(stdin_is_tty && stdout_is_tty) {
491 Ok(FrontendMode::Jsonl)
492 } else {
493 Ok(FrontendMode::Tui)
494 }
495}
496
497pub(crate) struct Harness {
498 pub(crate) home: PathBuf,
499 pub(crate) session: Session,
500 pub(crate) provider: Provider,
501 pub(crate) context_window: Option<usize>,
505 pub(crate) attached_agents: Vec<String>,
508}
509
510fn should_compact_context(context_tokens: usize, context_window: usize) -> bool {
511 context_window > 0
512 && context_tokens as u128 * 100
513 >= context_window as u128 * AUTO_COMPACTION_THRESHOLD_PERCENT as u128
514}
515
516fn find_compaction_boundary(
517 messages: &[ChatMessage],
518 previous_boundary: Option<usize>,
519) -> Option<usize> {
520 let user_starts = messages
521 .iter()
522 .enumerate()
523 .filter_map(|(index, message)| (message.role == "user").then_some(index))
524 .collect::<Vec<_>>();
525 let mut start = *user_starts.last()?;
526 let end = messages.len();
527 let mut kept_tokens = messages[start..end]
528 .iter()
529 .map(estimate_message_tokens)
530 .sum::<usize>();
531
532 while kept_tokens < COMPACTION_KEEP_RECENT_TOKENS {
533 let Some(previous_start) = user_starts
534 .iter()
535 .copied()
536 .rev()
537 .find(|candidate| *candidate < start)
538 else {
539 break;
540 };
541 start = previous_start;
542 kept_tokens = messages[start..end]
543 .iter()
544 .map(estimate_message_tokens)
545 .sum::<usize>();
546 }
547
548 (start > 0 && previous_boundary.is_none_or(|previous| start > previous)).then_some(start)
549}
550
551impl Harness {
552 pub(crate) fn apply_settings(
553 &mut self,
554 home: &Path,
555 model: String,
556 effort: Option<String>,
557 ) -> Result<(), String> {
558 let config = Config::load_or_create(home).map_err(|error| error.to_string())?;
559 let mut settings = config.resolved_llm().map_err(|error| error.to_string())?;
560 settings.model = model.trim().to_owned();
561 settings.effort = effort
562 .map(|value| value.trim().to_owned())
563 .filter(|value| !value.is_empty());
564 settings.base_url = self.session.llm.base_url.clone();
566 settings.api_key_env = self.session.llm.api_key_env.clone();
567 apply_auth_to_settings(&mut settings, auth_provider_for_settings(&self.session.llm));
568 let provider = provider_for_settings(home, &settings).map_err(|error| error.to_string())?;
569 Config::save_selection(home, &settings.model, settings.effort.as_deref())
571 .map_err(|error| error.to_string())?;
572 self.session
573 .append_provider_settings(settings.model.clone(), settings.effort.clone())
574 .map_err(|error| error.to_string())?;
575 self.session.llm = settings;
576 self.provider = provider;
577 self.context_window = self.provider.context_window();
578 Ok(())
579 }
580
581 fn should_compact(&self, messages: &[ChatMessage]) -> bool {
582 self.context_window
583 .is_some_and(|window| should_compact_context(estimate_context_tokens(messages), window))
584 }
585
586 fn compaction_boundary(&self) -> Option<usize> {
587 let latest_boundary = self
588 .session
589 .history
590 .iter()
591 .rev()
592 .find_map(|record| match record {
593 crate::session::SessionHistoryRecord::Compaction(compaction) => {
594 Some(compaction.first_kept_message)
595 }
596 _ => None,
597 });
598 find_compaction_boundary(&self.session.messages, latest_boundary)
599 }
600
601 fn compact_context<S: EventSink>(
602 &mut self,
603 sink: &mut S,
604 cancellation: Option<&crate::cancellation::CancellationToken>,
605 tokens_before: usize,
606 ) -> Result<(), String> {
607 let Some(boundary) = self.compaction_boundary() else {
608 return Err("context cannot be compacted without an earlier complete turn".to_owned());
609 };
610 let Some(cancellation) = cancellation else {
611 return Err("context compaction requires a cancellable turn".to_owned());
612 };
613 sink.compaction_started()
614 .map_err(|error| format!("unable to emit compaction state: {error}"))?;
615 let context_messages = self.session.provider_messages();
616 let mut summary_messages = Vec::with_capacity(context_messages.len() + 1);
617 summary_messages.push(ChatMessage::system(self.session.boot_system_prompt.clone()));
618 summary_messages.push(ChatMessage::system(COMPACTION_SYSTEM_PROMPT.to_owned()));
619 summary_messages.extend(context_messages.into_iter().skip(1));
620 let summary = match self.provider.summarize(&summary_messages, cancellation) {
621 Ok(summary) => redact_secret(&summary, Some(self.provider.api_key().as_str())),
622 Err(error) if cancellation.is_cancelled() || error.is_cancelled() => {
623 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
624 }
625 Err(error) => return Err(format!("unable to compact context: {error}")),
626 };
627 self.session
628 .append_compaction(summary, boundary, tokens_before)
629 .map_err(|error| format!("unable to persist context compaction: {error}"))?;
630 let tokens_after = estimate_context_tokens(&self.session.provider_messages());
631 sink.compaction_finished(tokens_before, tokens_after)
632 .map_err(|error| format!("unable to emit compaction state: {error}"))?;
633 Ok(())
634 }
635
636 pub(crate) fn handle_message<S: EventSink>(
637 &mut self,
638 text: &str,
639 sink: &mut S,
640 cancellation: Option<&crate::cancellation::CancellationToken>,
641 ) -> Result<(), String> {
642 if cancellation.is_some_and(CancellationToken::is_cancelled) {
643 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
644 }
645 let secret = self.provider.api_key();
646 let expanded = expand_skill_invocation(text, &self.session.skills)?;
647 let user_message = ChatMessage::user(redact_secret(&expanded.text, Some(&secret)));
648 if let Err(error) = self.session.append_message(user_message) {
649 if cancellation.is_some_and(|token| token.is_cancelled()) {
650 let interruption = self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
651 return interruption
652 .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
653 }
654 return Err(error.to_string());
655 }
656 if let Some(name) = expanded.attached_skill.as_deref() {
657 sink.skill_instruction_attached(name)
658 .map_err(|error| format!("unable to emit skill attachment state: {error}"))?;
659 }
660
661 let mut compacted_for_turn = false;
662 loop {
663 if cancellation.is_some_and(CancellationToken::is_cancelled) {
664 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
665 }
666 let mut messages = self.session.provider_messages();
667 let tokens_before = estimate_context_tokens(&messages);
668 if !compacted_for_turn && self.should_compact(&messages) {
669 self.compact_context(sink, cancellation, tokens_before)?;
670 compacted_for_turn = true;
671 messages = self.session.provider_messages();
672 }
673 sink.context_usage(estimate_context_tokens(&messages))
674 .map_err(|error| format!("unable to emit context usage: {error}"))?;
675 let mut raw_content = String::new();
676 let mut redactor = SecretRedactor::new(&secret);
677 let mut reasoning_active = false;
678 let stream_result = {
679 let mut on_event = |event: ProviderStreamEvent| -> io::Result<()> {
680 match event {
681 ProviderStreamEvent::ReasoningStarted => {
682 if !reasoning_active {
683 reasoning_active = true;
684 sink.reasoning_started()?;
685 }
686 Ok(())
687 }
688 ProviderStreamEvent::Text(delta) => {
689 if reasoning_active {
690 reasoning_active = false;
691 sink.reasoning_completed()?;
692 }
693 raw_content.push_str(&delta);
694 redactor.push(&delta, |safe_delta| {
695 sink.emit_event(&ProtocolEvent::AssistantDelta {
696 text: safe_delta.to_owned(),
697 })
698 })
699 }
700 }
701 };
702 match cancellation {
703 Some(token) => self
704 .provider
705 .stream_chat_cancellable_with_options_and_events(
706 &messages,
707 &mut on_event,
708 token,
709 true,
710 ),
711 None => self.provider.stream_chat(&messages, &mut |delta| {
712 raw_content.push_str(delta);
713 redactor.push(delta, |safe_delta| {
714 sink.emit_event(&ProtocolEvent::AssistantDelta {
715 text: safe_delta.to_owned(),
716 })
717 })
718 }),
719 }
720 };
721 redactor
722 .finish(|safe_delta| {
723 sink.emit_event(&ProtocolEvent::AssistantDelta {
724 text: safe_delta.to_owned(),
725 })
726 })
727 .map_err(|error| format!("unable to write assistant delta: {error}"))?;
728 let turn = match stream_result {
729 Ok(turn) => {
730 if reasoning_active {
731 sink.reasoning_completed()
732 .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
733 }
734 turn
735 }
736 Err(error)
737 if cancellation.is_some_and(|token| token.is_cancelled())
738 || error.is_cancelled() =>
739 {
740 if reasoning_active {
741 sink.reasoning_completed()
742 .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
743 }
744 let partial = error.partial_turn().cloned().unwrap_or(ProviderTurn {
745 content: raw_content,
746 tool_calls: Vec::new(),
747 reasoning_details: Vec::new(),
748 });
749 return self.interrupt(
750 sink,
751 PROVIDER_PHASE,
752 &partial.content,
753 &partial.tool_calls,
754 Vec::new(),
755 );
756 }
757 Err(error) => {
758 if reasoning_active {
759 sink.reasoning_completed()
760 .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
761 }
762 return Err(error.to_string());
763 }
764 };
765 let canceled_after_stream = cancellation.is_some_and(|token| token.is_cancelled());
766
767 if turn
768 .tool_calls
769 .iter()
770 .any(|call| !matches!(call.name.as_str(), "cmd"))
771 {
772 if canceled_after_stream {
773 return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
774 }
775 return Err("provider requested an unsupported tool".to_owned());
776 }
777 let safe_tool_calls = turn
778 .tool_calls
779 .iter()
780 .map(|call| safe_tool_call(call, &secret))
781 .collect::<Vec<_>>();
782 let assistant_content = redact_secret(&turn.content, Some(&secret));
783 let safe_reasoning_details = redact_reasoning_details(&turn.reasoning_details, &secret);
784 let mut assistant =
785 ChatMessage::assistant(assistant_content.clone(), safe_tool_calls.clone());
786 assistant.reasoning_details = safe_reasoning_details;
787 if let Err(error) = self.session.append_message(assistant) {
788 if cancellation.is_some_and(|token| token.is_cancelled()) {
789 let interruption = self.interrupt(
790 sink,
791 PROVIDER_PHASE,
792 &assistant_content,
793 &turn.tool_calls,
794 Vec::new(),
795 );
796 return interruption
797 .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
798 }
799 return Err(error.to_string());
800 }
801
802 if safe_tool_calls.is_empty() {
803 if canceled_after_stream
804 || cancellation.is_some_and(CancellationToken::is_cancelled)
805 {
806 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
807 }
808 if cancellation.is_some_and(|token| !token.try_complete()) {
809 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
810 }
811 sink.context_usage(estimate_context_tokens(&self.session.provider_messages()))
812 .map_err(|error| format!("unable to emit context usage: {error}"))?;
813 sink.emit_event(&ProtocolEvent::TurnEnd)
814 .map_err(|error| format!("unable to write turn end: {error}"))?;
815 return Ok(());
816 }
817
818 for safe_call in &safe_tool_calls {
819 sink.emit_event(&ProtocolEvent::ToolCall {
820 id: safe_call.id.clone(),
821 name: safe_call.name.clone(),
822 arguments: safe_call.arguments.clone(),
823 })
824 .map_err(|error| format!("unable to write tool call: {error}"))?;
825 }
826 for (index, raw_call) in turn.tool_calls.iter().enumerate() {
827 let safe_call = &safe_tool_calls[index];
828 let result = if cancellation.is_some_and(|token| token.is_cancelled()) {
829 serde_json::to_value(crate::command::canceled_result(
830 &safe_call.arguments,
831 &secret,
832 ))
833 .map_err(|error| format!("unable to encode cmd result: {error}"))?
834 } else {
835 serde_json::to_value(crate::command::execute_with_cancellation(
836 &raw_call.arguments,
837 &self.session.cwd,
838 self.provider.api_key_env(),
839 Some(&secret),
840 cancellation,
841 ))
842 .map_err(|error| format!("unable to encode cmd result: {error}"))?
843 };
844 let result = redact_json_value(result, &secret);
845 let tool_content = serde_json::to_string(&result)
846 .map_err(|error| format!("unable to encode tool result: {error}"))?;
847 let tool_message = ChatMessage::tool(
848 safe_call.id.clone(),
849 safe_call.name.clone(),
850 redact_secret(&tool_content, Some(&secret)),
851 );
852 let observation = crate::session::SessionToolResult {
853 id: safe_call.id.clone(),
854 name: safe_call.name.clone(),
855 result: result.clone(),
856 };
857 if let Err(error) = self.session.append_message(tool_message) {
858 if cancellation.is_some_and(|token| token.is_cancelled()) {
859 let interruption =
860 self.interrupt(sink, COMMAND_PHASE, "", &[], vec![observation]);
861 return interruption
862 .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
863 }
864 return Err(error.to_string());
865 }
866 sink.emit_event(&ProtocolEvent::ToolResult {
867 id: safe_call.id.clone(),
868 name: safe_call.name.clone(),
869 result: result.clone(),
870 })
871 .map_err(|error| format!("unable to write tool result: {error}"))?;
872 if cancellation.is_some_and(|token| token.is_cancelled()) {
873 for pending_call in safe_tool_calls.iter().skip(index + 1) {
874 let pending_result = redact_json_value(
875 serde_json::to_value(crate::command::canceled_result(
876 &pending_call.arguments,
877 &secret,
878 ))
879 .map_err(|error| format!("unable to encode cmd result: {error}"))?,
880 &secret,
881 );
882 let pending_content = serde_json::to_string(&pending_result)
883 .map_err(|error| format!("unable to encode tool result: {error}"))?;
884 let pending_message = ChatMessage::tool(
885 pending_call.id.clone(),
886 pending_call.name.clone(),
887 redact_secret(&pending_content, Some(&secret)),
888 );
889 let pending_observation = crate::session::SessionToolResult {
890 id: pending_call.id.clone(),
891 name: pending_call.name.clone(),
892 result: pending_result.clone(),
893 };
894 if let Err(error) = self.session.append_message(pending_message) {
895 if cancellation.is_some_and(|token| token.is_cancelled()) {
896 let interruption = self.interrupt(
897 sink,
898 COMMAND_PHASE,
899 "",
900 &[],
901 vec![pending_observation],
902 );
903 return interruption.map_err(|interrupt_error| {
904 format!("{error}; {interrupt_error}")
905 });
906 }
907 return Err(error.to_string());
908 }
909 sink.emit_event(&ProtocolEvent::ToolResult {
910 id: pending_call.id.clone(),
911 name: pending_call.name.clone(),
912 result: pending_result.clone(),
913 })
914 .map_err(|error| format!("unable to write tool result: {error}"))?;
915 }
916 return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
917 }
918 }
919 if cancellation.is_some_and(CancellationToken::is_cancelled) {
920 return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
921 }
922 }
923 }
924
925 fn interrupt<S: EventSink>(
926 &mut self,
927 sink: &mut S,
928 phase: &str,
929 assistant_text: &str,
930 tool_calls: &[ChatToolCall],
931 tool_results: Vec<crate::session::SessionToolResult>,
932 ) -> Result<(), String> {
933 let secret = self.provider.api_key();
934 let safe_tool_calls = tool_calls
935 .iter()
936 .filter(|call| call.name == "cmd")
937 .map(|call| safe_partial_tool_call(call, &secret))
938 .collect::<Vec<_>>();
939 let safe_tool_results = tool_results.clone();
940 let interruption = crate::session::InterruptionRecord {
941 timestamp: 0,
942 reason: USER_CANCEL_REASON.to_owned(),
943 phase: phase.to_owned(),
944 assistant_text: redact_secret(assistant_text, Some(&secret)),
945 tool_calls: safe_tool_calls.clone(),
946 tool_results,
947 };
948 let persistence_error = self.session.append_interruption(interruption).err();
949 let mut event_error = None;
950 for call in &safe_tool_calls {
951 if let Err(error) = sink.emit_event(&ProtocolEvent::ToolCall {
952 id: call.id.clone(),
953 name: call.name.clone(),
954 arguments: call.arguments.clone(),
955 }) {
956 event_error.get_or_insert(error);
957 }
958 }
959 for observation in &safe_tool_results {
960 if let Err(error) = sink.emit_event(&ProtocolEvent::ToolResult {
961 id: observation.id.clone(),
962 name: observation.name.clone(),
963 result: observation.result.clone(),
964 }) {
965 event_error.get_or_insert(error);
966 }
967 }
968 if let Err(error) = sink.emit_event(&ProtocolEvent::TurnInterrupted {
969 reason: USER_CANCEL_REASON.to_owned(),
970 phase: phase.to_owned(),
971 }) {
972 event_error.get_or_insert(error);
973 }
974 match (persistence_error, event_error) {
975 (None, None) => Ok(()),
976 (Some(error), None) => Err(format!("unable to persist interruption: {error}")),
977 (None, Some(error)) => Err(format!("unable to write interruption event: {error}")),
978 (Some(persistence), Some(event)) => Err(format!(
979 "unable to persist interruption: {persistence}; unable to write interruption event: {event}"
980 )),
981 }
982 }
983}
984
985struct SecretRedactor {
986 secret_text: String,
987 secret: Vec<char>,
988 marker: String,
989 pending: String,
990}
991
992impl SecretRedactor {
993 fn new(secret: &str) -> Self {
994 Self {
995 secret_text: secret.to_owned(),
996 secret: secret.chars().collect(),
997 marker: redaction_marker(secret).unwrap_or_default(),
998 pending: String::new(),
999 }
1000 }
1001
1002 fn push<F>(&mut self, text: &str, mut emit: F) -> io::Result<()>
1003 where
1004 F: FnMut(&str) -> io::Result<()>,
1005 {
1006 if self.secret.is_empty() {
1007 return emit(text);
1008 }
1009
1010 let mut output = String::new();
1011 for character in text.chars() {
1012 self.pending.push(character);
1013 if self.pending.chars().eq(self.secret.iter().copied()) {
1014 self.pending.clear();
1015 output.push_str(&self.marker);
1016 continue;
1017 }
1018 if self.pending_is_secret_prefix() {
1019 continue;
1020 }
1021
1022 let pending = self.pending.chars().collect::<Vec<_>>();
1023 let suffix_len = (1..pending.len())
1024 .rev()
1025 .find(|length| {
1026 pending[pending.len() - length..].iter().copied().eq(self
1027 .secret
1028 .iter()
1029 .copied()
1030 .take(*length))
1031 })
1032 .unwrap_or(0);
1033 let safe_len = pending.len() - suffix_len;
1034 output.extend(pending[..safe_len].iter());
1035 self.pending = pending[safe_len..].iter().collect();
1036 }
1037
1038 if output.is_empty() {
1039 Ok(())
1040 } else {
1041 let safe_output = redact_secret(&output, Some(&self.secret_text));
1042 emit(&safe_output)
1043 }
1044 }
1045
1046 fn finish<F>(&mut self, mut emit: F) -> io::Result<()>
1047 where
1048 F: FnMut(&str) -> io::Result<()>,
1049 {
1050 let pending = std::mem::take(&mut self.pending);
1051 if pending.is_empty() {
1052 return Ok(());
1053 }
1054 let safe_pending = redact_secret(&pending, Some(&self.secret_text));
1055 emit(&safe_pending)
1056 }
1057
1058 fn pending_is_secret_prefix(&self) -> bool {
1059 let length = self.pending.chars().count();
1060 length < self.secret.len()
1061 && self
1062 .pending
1063 .chars()
1064 .zip(self.secret.iter().copied())
1065 .all(|(pending, secret)| pending == secret)
1066 }
1067}
1068
1069fn attached_agents(instruction_files: Vec<InstructionSource>, secret: &str) -> Vec<String> {
1072 instruction_files
1073 .into_iter()
1074 .filter(|source| {
1075 source
1076 .path
1077 .file_name()
1078 .is_some_and(|name| name == "AGENTS.md")
1079 })
1080 .map(|source| redact_secret(&source.path.display().to_string(), Some(secret)))
1081 .collect()
1082}
1083
1084fn escape_xml_attribute(text: &str) -> String {
1087 text.replace('&', "&")
1088 .replace('<', "<")
1089 .replace('>', ">")
1090 .replace('\"', """)
1091 .replace('\'', "'")
1092}
1093
1094fn redact_skills(skills: Vec<SkillEntry>, secret: &str) -> Vec<SkillEntry> {
1095 skills
1096 .into_iter()
1097 .map(|skill| SkillEntry {
1098 name: redact_secret(&skill.name, Some(secret)),
1099 description: redact_secret(&skill.description, Some(secret)),
1100 path: std::path::PathBuf::from(redact_secret(
1101 &skill.path.display().to_string(),
1102 Some(secret),
1103 )),
1104 contents: redact_secret(&skill.contents, Some(secret)),
1105 model_invocable: skill.model_invocable,
1106 })
1107 .collect()
1108}
1109
1110#[derive(Debug)]
1113struct ExpandedSkillInvocation {
1114 text: String,
1115 attached_skill: Option<String>,
1116}
1117
1118fn expand_skill_invocation(
1122 text: &str,
1123 skills: &[SkillEntry],
1124) -> Result<ExpandedSkillInvocation, String> {
1125 let Some(invocation) = text.strip_prefix('/') else {
1126 return Ok(ExpandedSkillInvocation {
1127 text: text.to_owned(),
1128 attached_skill: None,
1129 });
1130 };
1131 let mut pieces = invocation.splitn(2, char::is_whitespace);
1132 let name = pieces.next().unwrap_or_default();
1133 if name.is_empty() {
1134 return Err("skill command requires a skill name: /<name> [args]".to_owned());
1135 }
1136 let Some(skill) = skills.iter().find(|skill| skill.name == name) else {
1137 return Err(format!("unknown skill: {name}"));
1138 };
1139 let arguments = pieces.next().unwrap_or_default().trim();
1140 let mut message = format!(
1141 "<skill name=\"{}\" location=\"{}\">\n{}\n</skill>",
1142 escape_xml_attribute(&skill.name),
1143 escape_xml_attribute(&skill.path.display().to_string()),
1144 skill.contents.trim()
1145 );
1146 if !arguments.is_empty() {
1147 message.push_str("\n\nUser: ");
1148 message.push_str(arguments);
1149 }
1150 Ok(ExpandedSkillInvocation {
1151 text: message,
1152 attached_skill: Some(skill.name.clone()),
1153 })
1154}
1155
1156#[cfg(test)]
1157fn redact_tool_arguments(arguments: &str, secret: &str) -> String {
1158 safe_tool_call(
1159 &ChatToolCall {
1160 id: String::new(),
1161 name: "cmd".to_owned(),
1162 arguments: arguments.to_owned(),
1163 },
1164 secret,
1165 )
1166 .arguments
1167}
1168
1169fn safe_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
1170 let valid = match call.name.as_str() {
1171 "cmd" => serde_json::from_str::<Value>(&call.arguments)
1172 .ok()
1173 .and_then(|value| value.as_object().cloned())
1174 .is_some_and(|object| {
1175 object.len() == 1 && object.get("command").is_some_and(Value::is_string)
1176 }),
1177 _ => false,
1178 };
1179 let arguments = if valid {
1180 serde_json::to_string(&redact_json_value(
1181 serde_json::from_str(&call.arguments).unwrap_or(Value::Null),
1182 secret,
1183 ))
1184 .unwrap_or_else(|_| "{}".to_owned())
1185 } else {
1186 "{}".to_owned()
1187 };
1188 ChatToolCall {
1189 id: redact_secret(&call.id, Some(secret)),
1190 name: redact_secret(&call.name, Some(secret)),
1191 arguments,
1192 }
1193}
1194
1195fn safe_partial_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
1196 let arguments = if serde_json::from_str::<Value>(&call.arguments)
1197 .ok()
1198 .and_then(|value| value.as_object().cloned())
1199 .is_some_and(|object| object.len() == 1 && object.contains_key("command"))
1200 {
1201 safe_tool_call(call, secret).arguments
1202 } else {
1203 "{}".to_owned()
1207 };
1208 ChatToolCall {
1209 id: redact_secret(&call.id, Some(secret)),
1210 name: redact_secret(&call.name, Some(secret)),
1211 arguments,
1212 }
1213}
1214
1215fn redact_json_value(value: Value, secret: &str) -> Value {
1216 match value {
1217 Value::String(text) => Value::String(redact_secret(&text, Some(secret))),
1218 Value::Array(values) => Value::Array(
1219 values
1220 .into_iter()
1221 .map(|value| redact_json_value(value, secret))
1222 .collect(),
1223 ),
1224 Value::Object(object) => {
1225 let marker = redaction_marker(secret).unwrap_or_default();
1226 let mut redacted = Map::new();
1227 for (key, value) in object {
1228 let mut safe_key = if is_structural_key(&key) {
1229 key
1230 } else {
1231 redact_secret(&key, Some(secret))
1232 };
1233 if redacted.contains_key(&safe_key) {
1234 if marker.is_empty() {
1235 continue;
1236 }
1237 while redacted.contains_key(&safe_key) {
1238 safe_key.push_str(&marker);
1239 }
1240 }
1241 redacted.insert(safe_key, redact_json_value(value, secret));
1242 }
1243 Value::Object(redacted)
1244 }
1245 value => value,
1246 }
1247}
1248
1249fn redact_reasoning_details(details: &[Value], secret: &str) -> Option<Vec<Value>> {
1250 if details.is_empty() {
1251 return None;
1252 }
1253 match redact_json_value(Value::Array(details.to_vec()), secret) {
1254 Value::Array(details) => Some(details),
1255 _ => None,
1256 }
1257}
1258
1259fn write_version<W: Write>(mut output: W) -> io::Result<()> {
1260 writeln!(output, "lucy {}", env!("CARGO_PKG_VERSION"))
1261}
1262
1263fn parse_args(args: &[String]) -> Result<CliOptions, String> {
1264 let mut options = CliOptions {
1265 session: None,
1266 list_sessions: false,
1267 jsonl: false,
1268 tui: false,
1269 version: false,
1270 command: None,
1271 };
1272 if args.len() == 2 && args[0] == "codex" {
1273 options.command = Some(match args[1].as_str() {
1274 "login" => CliCommand::CodexLogin,
1275 "logout" => CliCommand::CodexLogout,
1276 _ => return Err("usage: lucy codex <login|logout>".to_owned()),
1277 });
1278 return Ok(options);
1279 }
1280 if args.first().is_some_and(|arg| arg == "codex") {
1281 return Err("usage: lucy codex <login|logout>".to_owned());
1282 }
1283 let mut index = 0;
1284 while index < args.len() {
1285 match args[index].as_str() {
1286 "--session" => {
1287 if options.list_sessions || options.session.is_some() {
1288 return Err("--session cannot be combined or repeated".to_owned());
1289 }
1290 index += 1;
1291 let Some(id) = args.get(index) else {
1292 return Err("--session requires an id".to_owned());
1293 };
1294 options.session = Some(id.clone());
1295 }
1296 "--list-sessions" => {
1297 if options.session.is_some() || options.list_sessions {
1298 return Err("--list-sessions cannot be combined or repeated".to_owned());
1299 }
1300 options.list_sessions = true;
1301 }
1302 "--jsonl" => {
1303 if options.jsonl || options.tui {
1304 return Err("--jsonl cannot be combined or repeated".to_owned());
1305 }
1306 options.jsonl = true;
1307 }
1308 "--tui" => {
1309 if options.tui || options.jsonl {
1310 return Err("--tui cannot be combined or repeated".to_owned());
1311 }
1312 options.tui = true;
1313 }
1314 "--version" => {
1315 if options.version {
1316 return Err("--version cannot be repeated".to_owned());
1317 }
1318 options.version = true;
1319 }
1320 "--help" | "-h" => {
1321 return Err(
1322 "usage: lucy [--version] [--jsonl|--tui] [--session <id>] [--list-sessions] | lucy codex <login|logout>"
1323 .to_owned(),
1324 );
1325 }
1326 _ => return Err("unknown argument".to_owned()),
1327 }
1328 index += 1;
1329 }
1330 Ok(options)
1331}
1332
1333fn parse_input_message(line: &str) -> Result<String, String> {
1334 let record: InputRecord = serde_json::from_str(line)
1335 .map_err(|_| "input must be a JSONL message record".to_owned())?;
1336 if record.record_type != "message" {
1337 return Err("input record type must be message".to_owned());
1338 }
1339 record
1340 .text
1341 .ok_or_else(|| "message record requires a text string".to_owned())
1342}
1343
1344fn home_directory() -> Result<PathBuf, String> {
1345 std::env::var_os("HOME")
1346 .map(PathBuf::from)
1347 .ok_or_else(|| "HOME is not set; Lucy needs a user home directory".to_owned())
1348}
1349
1350fn configured_api_key_env(config: &Config) -> Option<String> {
1351 config.resolved_auth().ok()?.api_key_env
1352}
1353
1354fn configured_api_key(config: &Config) -> Option<String> {
1355 configured_api_key_env(config)
1356 .and_then(|api_key_env| std::env::var(api_key_env).ok())
1357 .filter(|secret| !secret.is_empty())
1358}
1359
1360fn run_codex_command<W: Write, E: Write>(
1361 command: CliCommand,
1362 home: &Path,
1363 mut output: W,
1364 diagnostics: &mut E,
1365) -> i32 {
1366 match command {
1367 CliCommand::CodexLogin => match crate::auth::login(home) {
1368 Ok(_) => {
1369 let _ = writeln!(output, "Codex login successful");
1370 0
1371 }
1372 Err(error) => {
1373 write_diagnostic(diagnostics, &error.to_string());
1374 1
1375 }
1376 },
1377 CliCommand::CodexLogout => match crate::auth::AuthStore::for_home(home).logout() {
1378 Ok(true) => {
1379 let _ = writeln!(output, "Codex logout successful");
1380 0
1381 }
1382 Ok(false) => {
1383 let _ = writeln!(output, "Codex was not logged in");
1384 0
1385 }
1386 Err(error) => {
1387 write_diagnostic(diagnostics, &error.to_string());
1388 1
1389 }
1390 },
1391 }
1392}
1393
1394fn apply_auth_to_settings(settings: &mut LlmSettings, provider: AuthProvider) {
1395 if provider == AuthProvider::CodexSubscription {
1396 settings.api_key_env = crate::codex_provider::CODEX_ENV_SENTINEL.to_owned();
1397 }
1398}
1399
1400fn auth_provider_for_settings(settings: &LlmSettings) -> AuthProvider {
1401 if settings.api_key_env == crate::codex_provider::CODEX_ENV_SENTINEL {
1402 AuthProvider::CodexSubscription
1403 } else {
1404 AuthProvider::Openrouter
1405 }
1406}
1407
1408fn provider_for_settings(
1409 home: &Path,
1410 settings: &LlmSettings,
1411) -> Result<Provider, crate::provider::ProviderError> {
1412 match auth_provider_for_settings(settings) {
1413 AuthProvider::CodexSubscription => Provider::new_codex(home, settings),
1414 AuthProvider::Openrouter => Provider::new(settings),
1415 }
1416}
1417
1418fn configured_codex_secret(home: &Path, provider: AuthProvider) -> Option<String> {
1419 if provider != AuthProvider::CodexSubscription {
1420 return None;
1421 }
1422 crate::auth::AuthStore::for_home(home)
1423 .load()
1424 .ok()
1425 .flatten()
1426 .map(|credentials| credentials.access)
1427 .filter(|secret| !secret.is_empty())
1428}
1429
1430fn write_diagnostic_safe<W: Write>(diagnostics: &mut W, message: &str, secret: Option<&str>) {
1431 write_diagnostic_safe_with_environment(
1432 diagnostics,
1433 message,
1434 secret,
1435 std::env::vars().map(|(_, value)| value),
1436 );
1437}
1438
1439fn write_diagnostic_safe_with_environment<W, I>(
1440 diagnostics: &mut W,
1441 message: &str,
1442 secret: Option<&str>,
1443 environment_values: I,
1444) where
1445 W: Write,
1446 I: IntoIterator<Item = String>,
1447{
1448 let mut safe_line = format!("!: {message}");
1449 safe_line = redact_secret(&safe_line, secret);
1450 let mut environment_secrets = environment_values
1451 .into_iter()
1452 .filter(|value| !value.is_empty() && !conflicts_with_protected_literal(value))
1453 .collect::<Vec<_>>();
1454 environment_secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
1455 for environment_secret in environment_secrets {
1456 safe_line = redact_secret(&safe_line, Some(&environment_secret));
1457 }
1458 let _ = writeln!(diagnostics, "{safe_line}");
1459}
1460
1461fn write_diagnostic<W: Write>(diagnostics: &mut W, message: &str) {
1462 write_diagnostic_safe(diagnostics, message, None);
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467 use super::*;
1468 use crate::cancellation::CancellationToken;
1469 use std::io::{Cursor, Read, Write};
1470 use std::net::TcpListener;
1471 use std::thread;
1472
1473 #[test]
1474 fn codex_subcommands_parse_without_entering_a_session() {
1475 assert_eq!(
1476 parse_args(&["codex".to_owned(), "login".to_owned()])
1477 .expect("codex login")
1478 .command,
1479 Some(CliCommand::CodexLogin)
1480 );
1481 assert_eq!(
1482 parse_args(&["codex".to_owned(), "logout".to_owned()])
1483 .expect("codex logout")
1484 .command,
1485 Some(CliCommand::CodexLogout)
1486 );
1487 assert_eq!(
1488 parse_args(&["codex".to_owned(), "status".to_owned()])
1489 .expect_err("unknown codex command"),
1490 "usage: lucy codex <login|logout>"
1491 );
1492 }
1493
1494 #[test]
1495 fn codex_logout_is_idempotent_and_does_not_bootstrap_a_session() {
1496 let home = std::env::temp_dir().join(format!("lucy-codex-logout-{}", std::process::id()));
1497 let _ = std::fs::remove_dir_all(&home);
1498 let cwd = std::env::current_dir().expect("cwd");
1499 let mut output = Vec::new();
1500 let mut diagnostics = Vec::new();
1501 let exit = run_cli_at_home(
1502 &["codex".to_owned(), "logout".to_owned()],
1503 Cursor::new(Vec::<u8>::new()),
1504 &mut output,
1505 &mut diagnostics,
1506 &home,
1507 &cwd,
1508 );
1509 assert_eq!(exit, 0);
1510 assert!(String::from_utf8_lossy(&output).contains("not logged in"));
1511 assert!(diagnostics.is_empty());
1512 assert!(!home.exists());
1513 }
1514
1515 #[test]
1516 fn auto_compaction_triggers_at_or_above_ninety_five_percent_only() {
1517 assert!(!should_compact_context(94, 100));
1518 assert!(should_compact_context(95, 100));
1519 assert!(should_compact_context(96, 100));
1520 assert!(!should_compact_context(100, 0));
1521 }
1522
1523 #[test]
1524 fn compaction_boundary_keeps_complete_recent_turns() {
1525 let messages = [
1526 ChatMessage::user("old request".to_owned()),
1527 ChatMessage::assistant("old answer".to_owned(), Vec::new()),
1528 ChatMessage::user("recent request".to_owned()),
1529 ChatMessage::assistant("recent answer ".repeat(8_000), Vec::new()),
1530 ];
1531
1532 assert_eq!(find_compaction_boundary(&messages, None), Some(2));
1533 assert_eq!(find_compaction_boundary(&messages, Some(2)), None);
1534 }
1535
1536 #[test]
1537 fn mid_turn_compaction_summarizes_without_tools_then_continues_original_request() {
1538 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("compaction listener");
1539 let address = listener.local_addr().expect("compaction address");
1540 let responses = ["summary", "continued"];
1541 let server = thread::spawn(move || {
1542 let mut requests = Vec::new();
1543 for response_text in responses {
1544 let (mut stream, _) = listener.accept().expect("compaction request");
1545 let mut request = String::new();
1546 let mut reader = std::io::BufReader::new(stream.try_clone().expect("clone"));
1547 let mut content_length = 0usize;
1548 loop {
1549 let mut line = String::new();
1550 reader.read_line(&mut line).expect("request header");
1551 if line == "\r\n" {
1552 break;
1553 }
1554 if let Some((name, value)) = line.split_once(':') {
1555 if name.eq_ignore_ascii_case("content-length") {
1556 content_length = value.trim().parse().expect("content length");
1557 }
1558 }
1559 }
1560 let mut body = vec![0u8; content_length];
1561 reader.read_exact(&mut body).expect("request body");
1562 request.push_str(std::str::from_utf8(&body).expect("request JSON"));
1563 requests.push(serde_json::from_str::<Value>(&request).expect("request value"));
1564 let payload = serde_json::json!({
1565 "choices": [{
1566 "delta": {"content": response_text},
1567 "finish_reason": null
1568 }]
1569 });
1570 let body = format!("data: {payload}\n\ndata: [DONE]\n\n");
1571 let header = format!(
1572 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1573 body.len()
1574 );
1575 stream
1576 .write_all(header.as_bytes())
1577 .expect("response header");
1578 stream.write_all(body.as_bytes()).expect("response body");
1579 stream.flush().expect("response flush");
1580 }
1581 requests
1582 });
1583
1584 let key_env = format!("LUCY_COMPACTION_APP_KEY_{}", std::process::id());
1585 std::env::set_var(&key_env, "provider-secret");
1586 let settings = crate::config::LlmSettings {
1587 base_url: format!("http://{address}/v1"),
1588 model: "model".to_owned(),
1589 api_key_env: key_env.clone(),
1590 effort: None,
1591 };
1592 let provider = Provider::new(&settings).expect("provider");
1593 let home = std::env::temp_dir().join(format!("lucy-app-compaction-{}", std::process::id()));
1594 let _ = std::fs::remove_dir_all(&home);
1595 std::fs::create_dir(&home).expect("temp home");
1596 let cwd = std::env::current_dir().expect("cwd");
1597 let mut session = Session::create_with_secret(
1598 &home,
1599 &cwd,
1600 "prompt".to_owned(),
1601 settings,
1602 Some("provider-secret"),
1603 )
1604 .expect("session");
1605 session
1606 .append_message(ChatMessage::user("old request".to_owned()))
1607 .expect("old user");
1608 session
1609 .append_message(ChatMessage::assistant("old answer".to_owned(), Vec::new()))
1610 .expect("old answer");
1611 session
1612 .append_message(ChatMessage::user("recent request".to_owned()))
1613 .expect("recent user");
1614 session
1615 .append_message(ChatMessage::assistant(
1616 "recent answer ".repeat(8_000),
1617 Vec::new(),
1618 ))
1619 .expect("recent answer");
1620
1621 struct Sink {
1622 events: Vec<ProtocolEvent>,
1623 compaction_started: bool,
1624 compaction_finished: bool,
1625 }
1626 impl EventSink for Sink {
1627 fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
1628 self.events.push(event.clone());
1629 Ok(())
1630 }
1631 fn compaction_started(&mut self) -> io::Result<()> {
1632 self.compaction_started = true;
1633 Ok(())
1634 }
1635 fn compaction_finished(&mut self, _: usize, _: usize) -> io::Result<()> {
1636 self.compaction_finished = true;
1637 Ok(())
1638 }
1639 }
1640
1641 let mut harness = Harness {
1642 home: std::env::temp_dir(),
1643 session,
1644 provider,
1645 context_window: Some(1),
1646 attached_agents: Vec::new(),
1647 };
1648 let cancellation = CancellationToken::new();
1649 let mut sink = Sink {
1650 events: Vec::new(),
1651 compaction_started: false,
1652 compaction_finished: false,
1653 };
1654 harness
1655 .handle_message("continue", &mut sink, Some(&cancellation))
1656 .expect("continued turn");
1657
1658 let requests = server.join().expect("server");
1659 assert_eq!(requests.len(), 2);
1660 assert!(requests[0].get("tools").is_none());
1661 assert!(requests[1].get("tools").is_some());
1662 assert!(sink.compaction_started);
1663 assert!(sink.compaction_finished);
1664 assert!(sink.events.iter().any(
1665 |event| matches!(event, ProtocolEvent::AssistantDelta { text } if text == "continued")
1666 ));
1667 assert!(harness
1668 .session
1669 .history
1670 .iter()
1671 .any(|record| matches!(record, crate::session::SessionHistoryRecord::Compaction(_))));
1672 let provider_text = harness
1673 .session
1674 .provider_messages()
1675 .iter()
1676 .filter_map(|message| message.content.as_deref())
1677 .collect::<Vec<_>>()
1678 .join("\n");
1679 assert!(!provider_text.contains("old request"));
1680 assert!(provider_text.contains("continue"));
1681
1682 std::env::remove_var(key_env);
1683 std::fs::remove_dir_all(home).expect("cleanup");
1684 }
1685
1686 #[test]
1687 fn parses_only_message_records() {
1688 assert_eq!(
1689 parse_input_message(r#"{"type":"message","text":"hello"}"#).expect("message"),
1690 "hello"
1691 );
1692 assert!(parse_input_message(r#"{"type":"event","text":"hello"}"#).is_err());
1693 assert_eq!(
1694 parse_input_message(r#"{"type":"message","text":""}"#).expect("empty message"),
1695 ""
1696 );
1697 }
1698
1699 #[test]
1700 fn resolves_terminal_and_forced_modes() {
1701 assert_eq!(
1702 resolve_mode(&[], true, true).expect("default TUI"),
1703 FrontendMode::Tui
1704 );
1705 assert_eq!(
1706 resolve_mode(&[], true, false).expect("automatic JSONL"),
1707 FrontendMode::Jsonl
1708 );
1709 assert_eq!(
1710 resolve_mode(&["--jsonl".to_owned()], true, true).expect("forced JSONL"),
1711 FrontendMode::Jsonl
1712 );
1713 assert!(resolve_mode(&["--tui".to_owned()], true, false).is_err());
1714 }
1715
1716 #[test]
1717 fn redactor_does_not_leak_a_secret_across_deltas() {
1718 let mut redactor = SecretRedactor::new("secret");
1719 let mut output = Vec::new();
1720 redactor
1721 .push("prefix sec", |text| {
1722 output.push(text.to_owned());
1723 Ok(())
1724 })
1725 .expect("push");
1726 redactor
1727 .push("ret suffix", |text| {
1728 output.push(text.to_owned());
1729 Ok(())
1730 })
1731 .expect("push");
1732 redactor
1733 .finish(|text| {
1734 output.push(text.to_owned());
1735 Ok(())
1736 })
1737 .expect("finish");
1738 let output = output.join("");
1739 assert_eq!(
1740 output,
1741 format!("prefix {} suffix", redaction_marker("secret").unwrap())
1742 );
1743 assert!(!output.contains("secret"));
1744 }
1745
1746 #[test]
1747 fn redactor_handles_secrets_introduced_by_protocol_json_escaping() {
1748 let mut redactor = SecretRedactor::new("n0");
1749 let mut output = String::new();
1750 redactor
1751 .push("\n0", |text| {
1752 output.push_str(text);
1753 Ok(())
1754 })
1755 .expect("push");
1756 redactor
1757 .finish(|text| {
1758 output.push_str(text);
1759 Ok(())
1760 })
1761 .expect("finish");
1762 assert!(!output.contains("n0"));
1763 assert_eq!(output, redaction_marker("n0").unwrap());
1764 }
1765
1766 #[test]
1767 fn redactor_does_not_emit_a_secret_when_it_completes_at_a_delta_boundary() {
1768 let mut redactor = SecretRedactor::new("secret");
1769 let mut output = Vec::new();
1770 redactor
1771 .push("xsecre", |text| {
1772 output.push(text.to_owned());
1773 Ok(())
1774 })
1775 .expect("first delta");
1776 redactor
1777 .push("t", |text| {
1778 output.push(text.to_owned());
1779 Ok(())
1780 })
1781 .expect("second delta");
1782 redactor
1783 .finish(|text| {
1784 output.push(text.to_owned());
1785 Ok(())
1786 })
1787 .expect("finish");
1788 let output = output.join("");
1789 assert_eq!(output, format!("x{}", redaction_marker("secret").unwrap()));
1790 assert!(!output.contains("secret"));
1791 }
1792
1793 #[test]
1794 fn streaming_redaction_handles_marker_collision_keys_at_delta_boundaries() {
1795 for secret in ["REDACTED", "[REDACTED]"] {
1796 let mut redactor = SecretRedactor::new(secret);
1797 let split = secret.len() / 2;
1798 let (first, second) = secret.split_at(split);
1799 let mut output = String::new();
1800 redactor
1801 .push(first, |text| {
1802 output.push_str(text);
1803 Ok(())
1804 })
1805 .expect("first delta");
1806 redactor
1807 .push(second, |text| {
1808 output.push_str(text);
1809 Ok(())
1810 })
1811 .expect("second delta");
1812 redactor
1813 .finish(|text| {
1814 output.push_str(text);
1815 Ok(())
1816 })
1817 .expect("finish");
1818 assert!(!output.contains(secret));
1819 assert!(output.len() <= secret.len());
1820 }
1821 }
1822
1823 #[test]
1824 fn malformed_tool_arguments_use_a_safe_copy() {
1825 let secret = "provider-secret";
1826 let escaped = secret
1827 .chars()
1828 .map(|character| format!(r#"\u{:04x}"#, character as u32))
1829 .collect::<String>();
1830 let arguments = format!(r#"{{"command":"{escaped}""#);
1831 let safe = redact_tool_arguments(&arguments, secret);
1832 assert_eq!(safe, "{}");
1833 serde_json::from_str::<Value>(&safe).expect("safe arguments JSON");
1834 assert!(!safe.contains(secret));
1835 assert!(!safe.contains(&escaped));
1836 for invalid in ["[]", "{\"command\":1}", "{\"other\":\"value\"}"] {
1837 assert_eq!(redact_tool_arguments(invalid, secret), "{}");
1838 }
1839 }
1840
1841 #[test]
1842 fn structured_redaction_preserves_tool_and_result_schema_keys() {
1843 let secret = "provider-secret";
1844 let value = serde_json::json!({
1845 "command": "printf provider-secret",
1846 "stdout": "provider-secret",
1847 "stderr": "ordinary",
1848 "exit_code": 0,
1849 "timed_out": false,
1850 "stdout_truncated": false,
1851 "stderr_truncated": false,
1852 "unknown-provider-secret": "provider-secret"
1853 });
1854 let redacted = redact_json_value(value, secret);
1855 for key in [
1856 "command",
1857 "stdout",
1858 "stderr",
1859 "exit_code",
1860 "timed_out",
1861 "stdout_truncated",
1862 "stderr_truncated",
1863 ] {
1864 assert!(redacted.get(key).is_some(), "missing schema key: {key}");
1865 }
1866 let encoded = serde_json::to_string(&redacted).expect("redacted JSON");
1867 assert!(!encoded.contains(secret));
1868 assert!(redacted.get("unknown-provider-secret").is_none());
1869 }
1870
1871 #[test]
1872 fn structured_redaction_preserves_typed_values_even_for_a_pathological_key() {
1873 let value = serde_json::json!({
1874 "exit_code": 0,
1875 "timed_out": false,
1876 "stdout_truncated": true,
1877 "error": null,
1878 });
1879 let redacted = redact_json_value(value, "0");
1880 assert!(redacted["exit_code"].is_number());
1881 assert!(redacted["timed_out"].is_boolean());
1882 assert!(redacted["stdout_truncated"].is_boolean());
1883 assert!(redacted["error"].is_null());
1884 }
1885
1886 #[test]
1887 fn reasoning_details_are_recursively_redacted_before_persistence() {
1888 let details = vec![serde_json::json!({
1889 "type": "reasoning.text",
1890 "text": "provider-secret",
1891 "nested": [{"value": "provider-secret"}],
1892 "provider-secret": "provider-secret"
1893 })];
1894 let redacted = redact_reasoning_details(&details, "provider-secret")
1895 .expect("non-empty reasoning details");
1896 let redacted = Value::Array(redacted);
1897 let encoded = serde_json::to_string(&redacted).expect("reasoning details JSON");
1898 assert!(!encoded.contains("provider-secret"));
1899 assert_eq!(redacted[0]["type"], "reasoning.text");
1900 assert_eq!(redacted[0]["text"], "[REDACTED]");
1901 assert_eq!(redacted[0]["nested"][0]["value"], "[REDACTED]");
1902 assert!(redacted[0].get("provider-secret").is_none());
1903 }
1904
1905 #[test]
1906 fn malformed_input_error_does_not_echo_secret_bearing_input() {
1907 let error =
1908 parse_input_message(r#"{"type":"message","text":"provider-secret","unexpected":}"#)
1909 .expect_err("invalid input");
1910 assert!(!error.contains("provider-secret"));
1911 }
1912
1913 #[test]
1914 fn malformed_input_is_an_error_event_and_not_diagnostic_json() {
1915 let mut output = Vec::new();
1916 let error = parse_input_message("not json").expect_err("invalid input");
1917 let mut protocol = ProtocolWriter::new(&mut output);
1918 protocol.error(&error).expect("error event");
1919 assert_eq!(String::from_utf8_lossy(&output).lines().count(), 1);
1920 let _ = Cursor::new("");
1921 }
1922
1923 #[test]
1924 fn early_diagnostic_scrubbing_removes_short_values_from_the_complete_line() {
1925 let secret = "lucy";
1926 let mut diagnostics = Vec::new();
1927 write_diagnostic_safe_with_environment(
1928 &mut diagnostics,
1929 secret,
1930 None,
1931 vec![secret.to_owned()],
1932 );
1933 let diagnostics = String::from_utf8(diagnostics).expect("diagnostic UTF-8");
1934 assert!(!diagnostics.contains(secret));
1935 }
1936 #[test]
1937 fn attached_agents_keeps_only_agents_files_and_redacts_their_paths() {
1938 let sources = vec![
1939 InstructionSource {
1940 path: std::path::PathBuf::from("/project/AGENTS.md"),
1941 contents: "agents".to_owned(),
1942 },
1943 InstructionSource {
1944 path: std::path::PathBuf::from("/project/CLAUDE.md"),
1945 contents: "claude".to_owned(),
1946 },
1947 InstructionSource {
1948 path: std::path::PathBuf::from("/private-secret/AGENTS.md"),
1949 contents: "agents".to_owned(),
1950 },
1951 ];
1952
1953 assert_eq!(
1954 attached_agents(sources, "secret"),
1955 vec!["/project/AGENTS.md", "/private-!/AGENTS.md"]
1956 );
1957 }
1958
1959 #[test]
1960 fn expands_slash_prefixed_skill_names_and_keeps_ordinary_messages() {
1961 let skill = SkillEntry {
1962 name: "release-notes".to_owned(),
1963 description: "Writes release notes".to_owned(),
1964 path: std::path::PathBuf::from("/skills/release-notes/SKILL.md"),
1965 contents: "# Release notes\nUse the template.".to_owned(),
1966 model_invocable: true,
1967 };
1968 let expanded = expand_skill_invocation("/release-notes v1.2", std::slice::from_ref(&skill))
1969 .expect("skill command");
1970 assert!(expanded.text.contains("# Release notes"));
1971 assert!(expanded.text.contains("User: v1.2"));
1972 assert_eq!(expanded.attached_skill.as_deref(), Some("release-notes"));
1973 let ordinary = expand_skill_invocation("ordinary message", &[]).expect("ordinary message");
1974 assert_eq!(ordinary.text, "ordinary message");
1975 assert_eq!(ordinary.attached_skill, None);
1976 assert_eq!(
1977 expand_skill_invocation("/missing", &[]).unwrap_err(),
1978 "unknown skill: missing"
1979 );
1980 assert_eq!(
1981 expand_skill_invocation("/skill:release-notes", &[skill]).unwrap_err(),
1982 "unknown skill: skill:release-notes"
1983 );
1984 }
1985}