1use std::{collections::HashSet, path::PathBuf, process::Stdio, sync::Arc, time::Duration};
2
3use serde_json::{Value, json};
4use tokio::{
5 io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader},
6 process::{Child, Command},
7 sync::{mpsc, watch},
8};
9
10use crate::{
11 AgentEvent, AgentRequest, CompletedTurn, DynamicToolCall, Error, ErrorKind, Result, TokenUsage,
12 ToolResult,
13};
14
15pub const DEFAULT_CODEX_EXECUTABLE: &str = "codex-safe";
17
18const MAX_INPUT_CHARACTERS: usize = 1_048_576;
19const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
20
21#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct CodexConfig {
24 pub executable: String,
26 pub working_directory: PathBuf,
28 pub base_instruction: String,
30 pub model_catalog: Option<PathBuf>,
32}
33
34impl Default for CodexConfig {
35 fn default() -> Self {
36 Self {
37 executable: DEFAULT_CODEX_EXECUTABLE.into(),
38 working_directory: std::env::temp_dir(),
39 base_instruction: String::new(),
40 model_catalog: None,
41 }
42 }
43}
44
45#[derive(Clone, Debug)]
47pub struct Codex {
48 config: Arc<CodexConfig>,
49}
50
51impl Codex {
52 pub async fn open(config: CodexConfig) -> Result<Self> {
54 validate_config(&config)?;
55 validate_chatgpt_login(&config.executable).await?;
56 Ok(Self {
57 config: Arc::new(config),
58 })
59 }
60
61 pub async fn start_turn(&self, request: AgentRequest) -> Result<AgentTurn> {
63 validate_request(&request)?;
64 let child = spawn_app_server(&self.config)?;
65 let (event_sender, events) = mpsc::channel(32);
66 let (tool_results, result_receiver) = mpsc::channel(8);
67 let (cancel, cancelled) = watch::channel(false);
68 let config = self.config.clone();
69 tokio::spawn(async move {
70 let result = tokio::select! {
71 _ = cancellation(cancelled) => Err(Error::new(ErrorKind::Cancelled, "Codex turn was cancelled")),
72 result = tokio::time::timeout(request.timeout, run_protocol(child, &config, request, &event_sender, result_receiver)) => {
73 match result {
74 Ok(result) => result,
75 Err(_) => Err(Error::new(ErrorKind::Timeout, "Codex turn timed out")),
76 }
77 }
78 };
79 if let Err(error) = result {
80 let _ = event_sender.send(Err(error)).await;
81 }
82 });
83 Ok(AgentTurn {
84 events,
85 tool_results,
86 cancel,
87 })
88 }
89}
90
91pub struct AgentTurn {
93 events: mpsc::Receiver<Result<AgentEvent>>,
94 tool_results: mpsc::Sender<(String, ToolResult)>,
95 cancel: watch::Sender<bool>,
96}
97
98impl AgentTurn {
99 pub async fn next_event(&mut self) -> Option<Result<AgentEvent>> {
101 self.events.recv().await
102 }
103
104 pub async fn respond(&self, call_id: impl Into<String>, result: ToolResult) -> Result<()> {
106 self.tool_results
107 .send((call_id.into(), result))
108 .await
109 .map_err(|_| Error::new(ErrorKind::Cancelled, "Codex turn is no longer running"))
110 }
111
112 pub fn cancel(&self) {
114 let _ = self.cancel.send(true);
115 }
116}
117
118impl Drop for AgentTurn {
119 fn drop(&mut self) {
120 let _ = self.cancel.send(true);
121 }
122}
123
124async fn cancellation(mut cancelled: watch::Receiver<bool>) {
125 if *cancelled.borrow() {
126 return;
127 }
128 while cancelled.changed().await.is_ok() {
129 if *cancelled.borrow() {
130 return;
131 }
132 }
133}
134
135fn validate_config(config: &CodexConfig) -> Result<()> {
136 if config.executable.trim().is_empty() {
137 return Err(Error::new(
138 ErrorKind::InvalidInput,
139 "Codex executable must not be empty",
140 ));
141 }
142 if config.base_instruction.chars().count() > MAX_INPUT_CHARACTERS {
143 return Err(Error::new(
144 ErrorKind::InvalidInput,
145 "Codex base instruction is too large",
146 ));
147 }
148 Ok(())
149}
150
151fn validate_request(request: &AgentRequest) -> Result<()> {
152 if request.input.trim().is_empty() {
153 return Err(Error::new(
154 ErrorKind::InvalidInput,
155 "Codex turn input must not be empty",
156 ));
157 }
158 if request.input.chars().count() > MAX_INPUT_CHARACTERS {
159 return Err(Error::new(
160 ErrorKind::InvalidInput,
161 format!("Codex turn input exceeds {MAX_INPUT_CHARACTERS} characters"),
162 ));
163 }
164 if request.model.trim().is_empty() {
165 return Err(Error::new(
166 ErrorKind::InvalidInput,
167 "Codex model must not be empty",
168 ));
169 }
170 if request.timeout.is_zero() {
171 return Err(Error::new(
172 ErrorKind::InvalidInput,
173 "Codex timeout must be greater than zero",
174 ));
175 }
176 let mut names = HashSet::new();
177 for tool in &request.tools {
178 if tool.name.trim().is_empty() || tool.description.trim().is_empty() {
179 return Err(Error::new(
180 ErrorKind::InvalidInput,
181 "dynamic tool names and descriptions must not be empty",
182 ));
183 }
184 if !tool.input_schema.is_object() {
185 return Err(Error::new(
186 ErrorKind::InvalidInput,
187 format!(
188 "dynamic tool '{}' must use an object JSON Schema",
189 tool.name
190 ),
191 ));
192 }
193 if !names.insert(tool.name.as_str()) {
194 return Err(Error::new(
195 ErrorKind::InvalidInput,
196 format!("dynamic tool '{}' is duplicated", tool.name),
197 ));
198 }
199 }
200 Ok(())
201}
202
203async fn validate_chatgpt_login(executable: &str) -> Result<()> {
204 let output = tokio::time::timeout(
205 STARTUP_TIMEOUT,
206 Command::new(executable)
207 .args(["login", "status"])
208 .env_remove("OPENAI_API_KEY")
209 .env_remove("CODEX_API_KEY")
210 .output(),
211 )
212 .await
213 .map_err(|_| Error::new(ErrorKind::Unavailable, "Codex login check timed out"))?
214 .map_err(|_| {
215 Error::new(
216 ErrorKind::Unavailable,
217 format!("Codex sandbox launcher '{executable}' could not be started"),
218 )
219 })?;
220 let status = format!(
221 "{}\n{}",
222 String::from_utf8_lossy(&output.stdout),
223 String::from_utf8_lossy(&output.stderr)
224 );
225 if !output.status.success() || !status.to_ascii_lowercase().contains("chatgpt") {
226 return Err(Error::new(
227 ErrorKind::Authentication,
228 format!("'{executable}' must be logged in with ChatGPT"),
229 ));
230 }
231 Ok(())
232}
233
234fn spawn_app_server(config: &CodexConfig) -> Result<Child> {
235 let mut command = Command::new(&config.executable);
236 command
237 .arg("-c")
238 .arg("web_search=\"disabled\"")
239 .arg("-c")
240 .arg("mcp_servers={}")
241 .arg("-c")
242 .arg("features.shell_tool=false")
243 .arg("-c")
244 .arg("features.apps=false")
245 .arg("-c")
246 .arg("features.browser_use=false")
247 .arg("-c")
248 .arg("features.computer_use=false")
249 .arg("-c")
250 .arg("features.goals=false")
251 .arg("-c")
252 .arg("features.hooks=false")
253 .arg("-c")
254 .arg("features.image_generation=false")
255 .arg("-c")
256 .arg("features.multi_agent=false")
257 .arg("-c")
258 .arg("features.plugins=false")
259 .arg("-c")
260 .arg("features.tool_suggest=false")
261 .arg("-c")
262 .arg("features.remote_plugin=false")
263 .arg("-c")
264 .arg("model_auto_compact_token_limit=9223372036854775807");
265 if let Some(path) = &config.model_catalog {
266 command.arg("-c").arg(format!(
267 "model_catalog_json={}",
268 serde_json::to_string(path.to_string_lossy().as_ref())
269 .expect("serializing a path string cannot fail")
270 ));
271 }
272 command
273 .arg("app-server")
274 .arg("--stdio")
275 .current_dir(&config.working_directory)
276 .stdin(Stdio::piped())
277 .stdout(Stdio::piped())
278 .stderr(Stdio::null())
279 .env_remove("OPENAI_API_KEY")
280 .env_remove("CODEX_API_KEY")
281 .kill_on_drop(true);
282 command.spawn().map_err(|_| {
283 Error::new(
284 ErrorKind::Unavailable,
285 format!(
286 "Codex sandbox launcher '{}' could not start app-server",
287 config.executable
288 ),
289 )
290 })
291}
292
293async fn run_protocol(
294 mut child: Child,
295 config: &CodexConfig,
296 request: AgentRequest,
297 events: &mpsc::Sender<Result<AgentEvent>>,
298 mut tool_results: mpsc::Receiver<(String, ToolResult)>,
299) -> Result<()> {
300 let stdin = child.stdin.take().ok_or_else(|| {
301 Error::new(
302 ErrorKind::Unavailable,
303 "Codex app-server standard input is unavailable",
304 )
305 })?;
306 let stdout = child.stdout.take().ok_or_else(|| {
307 Error::new(
308 ErrorKind::Unavailable,
309 "Codex app-server standard output is unavailable",
310 )
311 })?;
312 let mut writer = stdin;
313 let mut lines = BufReader::new(stdout).lines();
314
315 write_record(
316 &mut writer,
317 events,
318 json!({
319 "id":1,
320 "method":"initialize",
321 "params":{
322 "clientInfo":{"name":"kcode-codex-runtime-v2","version":env!("CARGO_PKG_VERSION")},
323 "capabilities":{"experimentalApi":true}
324 }
325 }),
326 )
327 .await?;
328 let initialized = read_response(&mut lines, 1).await?;
329 require_result(&initialized, "initialize")?;
330 write_record(
331 &mut writer,
332 events,
333 json!({"method":"initialized","params":{}}),
334 )
335 .await?;
336
337 let thread_method;
338 let thread_params;
339 if let Some(thread_id) = request.previous_thread_id.as_deref() {
340 thread_method = "thread/resume";
341 thread_params = json!({
342 "threadId":thread_id,
343 "model":request.model,
344 "cwd":config.working_directory,
345 "approvalPolicy":"never",
346 "sandbox":"read-only",
347 "baseInstructions":config.base_instruction,
348 });
349 } else {
350 thread_method = "thread/start";
351 let tools = request
352 .tools
353 .iter()
354 .map(|tool| {
355 json!({
356 "type":"function",
357 "name":tool.name,
358 "description":tool.description,
359 "inputSchema":tool.input_schema,
360 })
361 })
362 .collect::<Vec<_>>();
363 thread_params = json!({
364 "model":request.model,
365 "cwd":config.working_directory,
366 "approvalPolicy":"never",
367 "sandbox":"read-only",
368 "baseInstructions":config.base_instruction,
369 "developerInstructions":"",
370 "dynamicTools":tools,
371 "ephemeral":request.ephemeral,
372 "environments":[],
373 });
374 }
375 write_record(
376 &mut writer,
377 events,
378 json!({"id":2,"method":thread_method,"params":thread_params}),
379 )
380 .await?;
381 let thread_response = read_response(&mut lines, 2).await?;
382 let thread_result = require_result(&thread_response, thread_method)?;
383 let thread_id = thread_result
384 .pointer("/thread/id")
385 .and_then(Value::as_str)
386 .ok_or_else(|| Error::new(ErrorKind::Protocol, "Codex omitted its thread ID"))?
387 .to_owned();
388
389 write_record(
390 &mut writer,
391 events,
392 json!({
393 "id":3,
394 "method":"turn/start",
395 "params":{
396 "threadId":thread_id,
397 "input":[{"type":"text","text":request.input}],
398 "effort":request.reasoning_effort.as_str(),
399 "approvalPolicy":"never",
400 }
401 }),
402 )
403 .await?;
404 let turn_response = read_response(&mut lines, 3).await?;
405 let turn_result = require_result(&turn_response, "turn/start")?;
406 let turn_id = turn_result
407 .pointer("/turn/id")
408 .and_then(Value::as_str)
409 .ok_or_else(|| Error::new(ErrorKind::Protocol, "Codex omitted its turn ID"))?
410 .to_owned();
411
412 let mut answer = String::new();
413 let mut usage = None;
414 while let Some(line) = lines.next_line().await.map_err(|_| {
415 Error::new(
416 ErrorKind::Unavailable,
417 "Codex app-server output could not be read",
418 )
419 })? {
420 let message: Value = serde_json::from_str(&line)
421 .map_err(|_| Error::new(ErrorKind::Protocol, "Codex emitted invalid JSONL"))?;
422 match message.get("method").and_then(Value::as_str) {
423 Some("item/tool/call") => {
424 let id = message.get("id").cloned().ok_or_else(|| {
425 Error::new(
426 ErrorKind::Protocol,
427 "Codex tool request omitted its JSON-RPC ID",
428 )
429 })?;
430 let params = message.get("params").ok_or_else(|| {
431 Error::new(ErrorKind::Protocol, "Codex tool request omitted params")
432 })?;
433 let call_id = required_string(params, "callId", "Codex tool request")?;
434 let call = DynamicToolCall {
435 call_id: call_id.clone(),
436 tool: required_string(params, "tool", "Codex tool request")?,
437 arguments: params.get("arguments").cloned().unwrap_or(Value::Null),
438 };
439 events
440 .send(Ok(AgentEvent::ToolCall(call)))
441 .await
442 .map_err(|_| Error::new(ErrorKind::Cancelled, "turn event receiver closed"))?;
443 let (response_call_id, result) = tool_results.recv().await.ok_or_else(|| {
444 Error::new(ErrorKind::Cancelled, "tool result channel closed")
445 })?;
446 if response_call_id != call_id {
447 return Err(Error::new(
448 ErrorKind::InvalidInput,
449 format!(
450 "tool result call ID '{response_call_id}' does not match pending call '{call_id}'"
451 ),
452 ));
453 }
454 write_record(
455 &mut writer,
456 events,
457 json!({
458 "id":id,
459 "result":{
460 "success":result.success,
461 "contentItems":[{"type":"inputText","text":result.text}],
462 }
463 }),
464 )
465 .await?;
466 }
467 Some("item/completed") => {
468 if let Some(item) = message.pointer("/params/item")
469 && item.get("type").and_then(Value::as_str) == Some("agentMessage")
470 && item.get("phase").and_then(Value::as_str) != Some("commentary")
471 && let Some(text) = item.get("text").and_then(Value::as_str)
472 {
473 answer = text.to_owned();
474 }
475 }
476 Some("thread/tokenUsage/updated") => {
477 usage = parse_usage(message.pointer("/params/tokenUsage"));
478 }
479 Some("turn/completed") => {
480 if message.pointer("/params/turn/id").and_then(Value::as_str)
481 != Some(turn_id.as_str())
482 {
483 continue;
484 }
485 let status = message
486 .pointer("/params/turn/status")
487 .and_then(Value::as_str)
488 .unwrap_or("failed");
489 if status != "completed" {
490 let detail = message
491 .pointer("/params/turn/error/message")
492 .and_then(Value::as_str)
493 .unwrap_or("Codex turn did not complete");
494 return Err(Error::new(ErrorKind::Protocol, detail));
495 }
496 events
497 .send(Ok(AgentEvent::Completed(CompletedTurn {
498 thread_id,
499 turn_id,
500 answer,
501 usage,
502 })))
503 .await
504 .map_err(|_| Error::new(ErrorKind::Cancelled, "turn event receiver closed"))?;
505 let _ = child.kill().await;
506 return Ok(());
507 }
508 _ => {
509 if message.get("id").is_some() && message.get("method").is_some() {
510 let id = message.get("id").cloned().unwrap_or(Value::Null);
511 write_record(
512 &mut writer,
513 events,
514 json!({"id":id,"error":{"code":-32601,"message":"Method is not supported by this client"}}),
515 )
516 .await?;
517 }
518 }
519 }
520 }
521 Err(Error::new(
522 ErrorKind::Unavailable,
523 "Codex app-server closed before completing the turn",
524 ))
525}
526
527async fn write_record<W: AsyncWrite + Unpin>(
528 writer: &mut W,
529 events: &mpsc::Sender<Result<AgentEvent>>,
530 value: Value,
531) -> Result<()> {
532 let mut exact = serde_json::to_string(&value).map_err(|_| {
533 Error::new(
534 ErrorKind::InvalidInput,
535 "Codex request could not be encoded",
536 )
537 })?;
538 exact.push('\n');
539 writer.write_all(exact.as_bytes()).await.map_err(|_| {
540 Error::new(
541 ErrorKind::Unavailable,
542 "Codex app-server closed its standard input",
543 )
544 })?;
545 writer.flush().await.map_err(|_| {
546 Error::new(
547 ErrorKind::Unavailable,
548 "Codex app-server input could not be flushed",
549 )
550 })?;
551 events
552 .send(Ok(AgentEvent::ProviderInput(exact)))
553 .await
554 .map_err(|_| Error::new(ErrorKind::Cancelled, "turn event receiver closed"))
555}
556
557async fn read_response<R: tokio::io::AsyncBufRead + Unpin>(
558 lines: &mut tokio::io::Lines<R>,
559 expected_id: u64,
560) -> Result<Value> {
561 while let Some(line) = lines.next_line().await.map_err(|_| {
562 Error::new(
563 ErrorKind::Unavailable,
564 "Codex app-server output could not be read",
565 )
566 })? {
567 let message: Value = serde_json::from_str(&line)
568 .map_err(|_| Error::new(ErrorKind::Protocol, "Codex emitted invalid JSONL"))?;
569 if message.get("id").and_then(Value::as_u64) == Some(expected_id) {
570 return Ok(message);
571 }
572 }
573 Err(Error::new(
574 ErrorKind::Unavailable,
575 "Codex app-server closed during startup",
576 ))
577}
578
579fn require_result<'a>(message: &'a Value, method: &str) -> Result<&'a Value> {
580 if let Some(error) = message.get("error") {
581 let detail = error
582 .get("message")
583 .and_then(Value::as_str)
584 .unwrap_or("unknown protocol error");
585 return Err(Error::new(
586 ErrorKind::Protocol,
587 format!("Codex {method} failed: {detail}"),
588 ));
589 }
590 message.get("result").ok_or_else(|| {
591 Error::new(
592 ErrorKind::Protocol,
593 format!("Codex {method} response omitted result"),
594 )
595 })
596}
597
598fn required_string(value: &Value, key: &str, label: &str) -> Result<String> {
599 value
600 .get(key)
601 .and_then(Value::as_str)
602 .map(str::to_owned)
603 .ok_or_else(|| Error::new(ErrorKind::Protocol, format!("{label} omitted {key}")))
604}
605
606fn parse_usage(value: Option<&Value>) -> Option<TokenUsage> {
607 let value = value?;
608 let total = value.get("total")?;
609 let last = value.get("last");
610 Some(TokenUsage {
611 input_tokens: nonnegative(total.get("inputTokens")),
612 output_tokens: nonnegative(total.get("outputTokens")),
613 cached_input_tokens: nonnegative(total.get("cachedInputTokens")),
614 reasoning_output_tokens: nonnegative(total.get("reasoningOutputTokens")),
615 last_input_tokens: last.map(|value| nonnegative(value.get("inputTokens"))),
616 last_output_tokens: last.map(|value| nonnegative(value.get("outputTokens"))),
617 })
618}
619
620fn nonnegative(value: Option<&Value>) -> u64 {
621 value.and_then(Value::as_i64).unwrap_or_default().max(0) as u64
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627 use crate::DynamicTool;
628
629 #[test]
630 fn validates_tool_names_and_schema() {
631 let mut request = AgentRequest::new("hello", "model");
632 request.tools = vec![
633 DynamicTool::new("call", "Call it", json!({"type":"object"})),
634 DynamicTool::new("call", "Call it again", json!({"type":"object"})),
635 ];
636 assert_eq!(
637 validate_request(&request).unwrap_err().kind(),
638 ErrorKind::InvalidInput
639 );
640 }
641
642 #[test]
643 fn parses_cumulative_and_last_usage() {
644 let usage = parse_usage(Some(&json!({
645 "total":{"inputTokens":20,"outputTokens":7,"cachedInputTokens":4,"reasoningOutputTokens":2},
646 "last":{"inputTokens":8,"outputTokens":3,"cachedInputTokens":1,"reasoningOutputTokens":1}
647 })))
648 .unwrap();
649 assert_eq!(usage.input_tokens, 20);
650 assert_eq!(usage.last_output_tokens, Some(3));
651 }
652
653 #[cfg(unix)]
654 #[tokio::test]
655 async fn completes_a_native_dynamic_tool_turn() {
656 use std::os::unix::fs::PermissionsExt;
657
658 let path = std::env::temp_dir().join(format!(
659 "kcode-codex-runtime-v2-test-{}",
660 std::process::id()
661 ));
662 std::fs::write(
663 &path,
664 r##"#!/bin/sh
665if [ "$1" = "login" ]; then
666 echo "Logged in using ChatGPT"
667 exit 0
668fi
669read initialize
670echo '{"id":1,"result":{"userAgent":"test","platformFamily":"unix","platformOs":"linux","codexHome":"/tmp"}}'
671read initialized
672read thread_start
673echo '{"id":2,"result":{"thread":{"id":"thread-1"}}}'
674read turn_start
675echo '{"id":3,"result":{"turn":{"id":"turn-1"}}}'
676echo '{"id":77,"method":"item/tool/call","params":{"threadId":"thread-1","turnId":"turn-1","callId":"call-1","tool":"call_ktool","arguments":{"name":"LoadNode","arguments":{"identifier":3}}}}'
677read tool_result
678echo '{"method":"item/completed","params":{"threadId":"thread-1","turnId":"turn-1","completedAtMs":1,"item":{"id":"message-1","type":"agentMessage","phase":"final_answer","text":"Finished."}}}'
679echo '{"method":"thread/tokenUsage/updated","params":{"threadId":"thread-1","turnId":"turn-1","tokenUsage":{"total":{"inputTokens":12,"outputTokens":4,"cachedInputTokens":2,"reasoningOutputTokens":1,"totalTokens":16},"last":{"inputTokens":12,"outputTokens":4,"cachedInputTokens":2,"reasoningOutputTokens":1,"totalTokens":16}}}}'
680echo '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"completed"}}}'
681"##,
682 )
683 .unwrap();
684 let mut permissions = std::fs::metadata(&path).unwrap().permissions();
685 permissions.set_mode(0o700);
686 std::fs::set_permissions(&path, permissions).unwrap();
687
688 let config = CodexConfig {
689 executable: path.to_string_lossy().into_owned(),
690 ..CodexConfig::default()
691 };
692 let codex = Codex::open(config).await.unwrap();
693 let mut request = AgentRequest::new("Exact input", "test-model");
694 request.tools.push(DynamicTool::new(
695 "call_ktool",
696 "Call one tool",
697 json!({"type":"object"}),
698 ));
699 let mut turn = codex.start_turn(request).await.unwrap();
700 let mut provider_inputs = String::new();
701 let completed = loop {
702 match turn.next_event().await.unwrap().unwrap() {
703 AgentEvent::ProviderInput(exact) => provider_inputs.push_str(&exact),
704 AgentEvent::ToolCall(call) => {
705 assert_eq!(call.arguments["name"], "LoadNode");
706 turn.respond(call.call_id, ToolResult::success("loaded"))
707 .await
708 .unwrap();
709 }
710 AgentEvent::Completed(completed) => break completed,
711 }
712 };
713 assert_eq!(completed.answer, "Finished.");
714 assert_eq!(completed.usage.unwrap().input_tokens, 12);
715 assert!(provider_inputs.contains("\"method\":\"turn/start\""));
716 assert!(provider_inputs.contains("\"success\":true"));
717 std::fs::remove_file(path).unwrap();
718 }
719}