1#![forbid(unsafe_code)]
2#![doc = include_str!("../Documentation.md")]
3#[cfg(not(unix))]
4compile_error!("kcode-k1-codex-websearch requires Unix process groups");
5
6use nix::{
7 sys::signal::{Signal, killpg},
8 unistd::Pid,
9};
10use serde_json::Value;
11use std::{ffi::OsString, io, path::PathBuf, time::Instant};
12use tokio::{
13 io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
14 process::Command,
15 sync::oneshot,
16};
17
18const ARG_ERROR: &str =
19 "WebSearch failed: model or reasoning_effort is not representable as a process argument";
20const PREFLIGHT: &str =
21 "WebSearch failed: Codex compatibility check failed in kcode-k1-codex-websearch preflight";
22const EXEC_IO_ERROR: &str = "WebSearch failed: kcode-k1-codex-websearch could not start or communicate with the Codex execution subprocess";
23const EXEC_CANCELLED: &str =
24 "WebSearch failed: kcode-k1-codex-websearch Codex execution subprocess was cancelled";
25const EXEC_EXIT_ERROR: &str =
26 "WebSearch failed: kcode-k1-codex-websearch Codex execution subprocess exited unsuccessfully";
27const EXEC_ERROR: &str = "WebSearch failed: Codex execution failed";
28const BAD_JSON: &str = "WebSearch failed: invalid Codex JSONL output";
29const INCOMPLETE_ERROR: &str = "WebSearch failed: Codex response was incomplete";
30const MESSAGE_ERROR: &str = "WebSearch failed: Codex returned no agent message";
31
32const CONFIG: &str = r#"web_search="live"
33tools.web_search=true
34tools.view_image=false
35apps._default.enabled=false
36agents.enabled=false
37features.apps=false
38features.code_mode.enabled=false
39features.goals=false
40features.hooks=false
41features.memories=false
42features.multi_agent=false
43features.remote_plugin=false
44features.shell_snapshot=false
45features.shell_tool=false
46features.skill_mcp_dependency_install=false
47features.unified_exec=false
48memories.generate_memories=false
49memories.use_memories=false
50history.persistence="none"
51check_for_update_on_startup=false
52feedback.enabled=false
53analytics.enabled=false
54allow_login_shell=false
55skills.config=[]
56mcp_servers={}
57plugins={}
58marketplaces={}
59hooks={}"#;
60
61const EXEC_PREFIX: &str = r#"exec
62--search
63--ephemeral
64--ignore-user-config
65--ignore-rules
66--json
67--sandbox
68read-only
69--ask-for-approval
70never
71--skip-git-repo-check
72--no-daemon
73--strict-config
74--model"#;
75
76#[derive(Clone)]
77pub struct Runner {
78 executable: PathBuf,
79}
80
81pub struct Request {
82 pub query: String,
83 pub model: String,
84 pub reasoning_effort: String,
85 pub deadline: Instant,
86}
87
88impl Runner {
89 pub fn new(executable: PathBuf) -> Self {
90 Self { executable }
91 }
92
93 pub async fn run(&self, request: Request) -> Result<String, String> {
94 if request.model.contains('\0') || request.reasoning_effort.contains('\0') {
95 return Err(ARG_ERROR.to_owned());
96 }
97 let deadline = request.deadline;
98 if Instant::now() >= deadline {
99 return Err(deadline_error("before compatibility preflight"));
100 }
101
102 let mut help = self
103 .check_help(&["--help"], "codex --help", deadline)
104 .await?;
105 help.extend(
106 self.check_help(&["exec", "--help"], "codex exec --help", deadline)
107 .await?,
108 );
109 let help = String::from_utf8_lossy(&help);
110 let missing = required_flags()
111 .into_iter()
112 .filter(|flag| !help.contains(flag.as_str()))
113 .collect::<Vec<_>>();
114 if !missing.is_empty() {
115 return Err(format!(
116 "{PREFLIGHT}: combined required-flag validation omitted {}",
117 missing.join(", ")
118 ));
119 }
120 if Instant::now() >= deadline {
121 return Err(deadline_error("after compatibility preflight"));
122 }
123
124 let output = run_process(
125 self.executable.clone(),
126 exec_args(&request.model, &request.reasoning_effort),
127 request.query.into_bytes(),
128 deadline,
129 )
130 .await
131 .map_err(|error| match error {
132 ProcError::Timeout => deadline_error("during Codex execution"),
133 ProcError::Io => EXEC_IO_ERROR.to_owned(),
134 ProcError::Cancelled => EXEC_CANCELLED.to_owned(),
135 })?;
136 if !output.status.success() {
137 return Err(EXEC_EXIT_ERROR.to_owned());
138 }
139 parse_jsonl(&output.stdout)
140 }
141
142 async fn check_help(
143 &self,
144 args: &[&str],
145 phase: &str,
146 deadline: Instant,
147 ) -> Result<Vec<u8>, String> {
148 let output = run_process(
149 self.executable.clone(),
150 args.iter().map(OsString::from).collect(),
151 Vec::new(),
152 deadline,
153 )
154 .await
155 .map_err(|error| match error {
156 ProcError::Timeout => {
157 deadline_error(&format!("during compatibility preflight ({phase})"))
158 }
159 ProcError::Io => preflight_error(phase, "subprocess setup, spawn, or I/O failed"),
160 ProcError::Cancelled => preflight_error(phase, "subprocess was cancelled"),
161 })?;
162 if !output.status.success() {
163 return Err(preflight_error(phase, "subprocess exited unsuccessfully"));
164 }
165 let mut text = output.stdout;
166 text.extend(output.stderr);
167 Ok(text)
168 }
169}
170
171fn deadline_error(phase: &str) -> String {
172 format!("WebSearch failed: absolute operation deadline exceeded {phase}")
173}
174
175fn preflight_error(phase: &str, cause: &str) -> String {
176 format!("{PREFLIGHT}: {phase} {cause}")
177}
178
179fn exec_args(model: &str, effort: &str) -> Vec<OsString> {
180 let mut args = EXEC_PREFIX.lines().map(OsString::from).collect::<Vec<_>>();
181 args.push(model.into());
182 args.push("-c".into());
183 args.push(format!("model_reasoning_effort={}", toml_quote(effort)).into());
184 for config in CONFIG.lines() {
185 args.push("-c".into());
186 args.push(config.into());
187 }
188 args.push("-".into());
189 args
190}
191
192fn required_flags() -> Vec<String> {
193 let mut flags = exec_args("", "")
194 .into_iter()
195 .filter_map(|arg| arg.into_string().ok())
196 .filter(|arg| arg.starts_with("--"))
197 .collect::<Vec<_>>();
198 flags.push("--config".into());
199 flags
200}
201
202fn toml_quote(value: &str) -> String {
203 let mut output = String::from("\"");
204 for character in value.chars() {
205 match character {
206 '"' => output.push_str("\\\""),
207 '\\' => output.push_str("\\\\"),
208 '\u{8}' => output.push_str("\\b"),
209 '\t' => output.push_str("\\t"),
210 '\n' => output.push_str("\\n"),
211 '\u{c}' => output.push_str("\\f"),
212 '\r' => output.push_str("\\r"),
213 value if value.is_control() => output.push_str(&format!("\\u{:04X}", value as u32)),
214 value => output.push(value),
215 }
216 }
217 output.push('"');
218 output
219}
220
221fn parse_jsonl(bytes: &[u8]) -> Result<String, String> {
222 let mut turn_completed = false;
223 let mut turn_failed = false;
224 let mut message = None;
225 for raw in bytes.split(|byte| *byte == b'\n') {
226 let line = raw.trim_ascii();
227 if line.is_empty() {
228 continue;
229 }
230 let value: Value = serde_json::from_slice(line).map_err(|_| BAD_JSON.to_owned())?;
231 match value.get("type").and_then(Value::as_str) {
232 Some("turn.completed") => turn_completed = true,
233 Some("turn.failed") => turn_failed = true,
234 Some("item.completed")
235 if value.pointer("/item/type").and_then(Value::as_str) == Some("agent_message") =>
236 {
237 let text = value
238 .pointer("/item/text")
239 .and_then(Value::as_str)
240 .ok_or_else(|| BAD_JSON.to_owned())?;
241 message = Some(text.to_owned());
242 }
243 _ => {}
244 }
245 }
246 if turn_failed {
247 return Err(EXEC_ERROR.to_owned());
248 }
249 if !turn_completed {
250 return Err(INCOMPLETE_ERROR.to_owned());
251 }
252 message.ok_or_else(|| MESSAGE_ERROR.to_owned())
253}
254
255enum ProcError {
256 Io,
257 Timeout,
258 Cancelled,
259}
260
261async fn drain<R: AsyncRead + Unpin>(mut reader: R) -> io::Result<Vec<u8>> {
262 let mut bytes = Vec::new();
263 reader.read_to_end(&mut bytes).await?;
264 Ok(bytes)
265}
266
267async fn run_process(
268 executable: PathBuf,
269 args: Vec<OsString>,
270 input: Vec<u8>,
271 until: Instant,
272) -> Result<std::process::Output, ProcError> {
273 if Instant::now() >= until {
274 return Err(ProcError::Timeout);
275 }
276 let directory = tempfile::tempdir().map_err(|_| ProcError::Io)?;
277 let mut command = Command::new(executable);
278 command
279 .args(args)
280 .current_dir(directory.path())
281 .stdin(std::process::Stdio::piped())
282 .stdout(std::process::Stdio::piped())
283 .stderr(std::process::Stdio::piped())
284 .kill_on_drop(true)
285 .process_group(0);
286 let mut child = command.spawn().map_err(|_| ProcError::Io)?;
287 let pid = Pid::from_raw(child.id().ok_or(ProcError::Io)? as i32);
288 let mut stdin = child.stdin.take().ok_or(ProcError::Io)?;
289 let stdout = child.stdout.take().ok_or(ProcError::Io)?;
290 let stderr = child.stderr.take().ok_or(ProcError::Io)?;
291 let (cancel_tx, mut cancel_rx) = oneshot::channel::<()>();
292
293 let worker = tokio::spawn(async move {
294 let _directory = directory;
295 let complete = async move {
296 let write = async move {
297 if input.is_empty() {
298 return Ok(());
299 }
300 stdin.write_all(&input).await?;
301 stdin.shutdown().await
302 };
303 let (status, written, stdout, stderr) =
304 tokio::join!(child.wait(), write, drain(stdout), drain(stderr));
305 written.map_err(|_| ProcError::Io)?;
306 Ok::<_, ProcError>(std::process::Output {
307 status: status.map_err(|_| ProcError::Io)?,
308 stdout: stdout.map_err(|_| ProcError::Io)?,
309 stderr: stderr.map_err(|_| ProcError::Io)?,
310 })
311 };
312 tokio::pin!(complete);
313 let timer = tokio::time::sleep_until(tokio::time::Instant::from_std(until));
314 tokio::pin!(timer);
315 let error = tokio::select! {
316 result = &mut complete => return result,
317 _ = &mut timer => ProcError::Timeout,
318 _ = &mut cancel_rx => ProcError::Cancelled,
319 };
320 let _ = killpg(pid, Signal::SIGKILL);
321 let _ = (&mut complete).await;
322 Err(error)
323 });
324 let result = worker.await.map_err(|_| ProcError::Io)?;
325 drop(cancel_tx);
326 result
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use std::{fs, os::unix::fs::PermissionsExt, path::Path, time::Duration};
333
334 struct Fake(tempfile::TempDir);
335
336 impl Fake {
337 fn file(&self, name: &str) -> PathBuf {
338 self.0.path().join(name)
339 }
340
341 async fn run(&self, duration: Duration) -> Result<String, String> {
342 Runner::new(self.file("codex"))
343 .run(request("q", duration))
344 .await
345 }
346 }
347
348 fn fake(body: &str, exit: i32, help: Option<String>) -> Fake {
349 let directory = tempfile::tempdir().unwrap();
350 let executable = directory.path().join("codex");
351 let flags = required_flags().join(" ");
352 let help = help.unwrap_or_else(|| format!("printf '%s\\n' '{flags}'"));
353 let script = format!(
354 "#!/bin/sh\nbase=${{0%/*}}\nif [ \"$1\" = \"--help\" ] || {{ [ \"$1\" = exec ] && [ \"$2\" = \"--help\" ]; }}; then\n{}\nexit 0\nfi\nprintf '%s\\n' \"$@\" > \"$base/args\"\ncat > \"$base/input\"\n{}\nexit {}\n",
355 help, body, exit,
356 );
357 fs::write(&executable, script).unwrap();
358 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
359 Fake(directory)
360 }
361
362 fn request(query: &str, duration: Duration) -> Request {
363 Request {
364 query: query.into(),
365 model: "model-x".into(),
366 reasoning_effort: "high".into(),
367 deadline: Instant::now() + duration,
368 }
369 }
370
371 const SUCCESS: &str = r#"printf '%s\n' \
372'{"type":"item.completed","item":{"type":"agent_message","text":"answer"}}' \
373'{"type":"turn.completed"}'"#;
374 const DESCENDANTS: &str = r#"sleep 30 & echo "$$ $!" > "$base/pids"; wait"#;
375
376 #[tokio::test]
377 async fn invocation_stdin_and_last_message_are_exact() {
378 let fake = fake(
379 r#"printf '%s\n' \
380'{"type":"future.event","x":1}' \
381'{"type":"item.completed","item":{"type":"agent_message","text":"old"}}' \
382'{"type":"item.completed","item":{"type":"agent_message","text":"最後\nline"}}' \
383'{"type":"turn.completed","extra":true}'"#,
384 0,
385 None,
386 );
387 let query = "héllo\n世界\0tail";
388 let answer = Runner::new(fake.file("codex"))
389 .run(request(query, Duration::from_secs(5)))
390 .await
391 .unwrap();
392 assert_eq!(answer, "最後\nline");
393 assert_eq!(fs::read(fake.file("input")).unwrap(), query.as_bytes());
394 let text = fs::read_to_string(fake.file("args")).unwrap();
395 let got = text.lines().collect::<Vec<_>>();
396 assert_eq!(&got[..3], ["exec", "--search", "--ephemeral"]);
397 assert_eq!(
398 &got[3..6],
399 ["--ignore-user-config", "--ignore-rules", "--json"]
400 );
401 assert_eq!(
402 &got[6..10],
403 ["--sandbox", "read-only", "--ask-for-approval", "never"]
404 );
405 assert_eq!(
406 &got[10..13],
407 ["--skip-git-repo-check", "--no-daemon", "--strict-config"]
408 );
409 assert_eq!(&got[13..15], ["--model", "model-x"]);
410 assert_eq!(&got[15..17], ["-c", r#"model_reasoning_effort="high""#]);
411 for (index, config) in CONFIG.lines().enumerate() {
412 assert_eq!(&got[17 + index * 2..19 + index * 2], ["-c", config]);
413 }
414 assert_eq!(got.last(), Some(&"-"));
415 assert_eq!(toml_quote("a\"\n\u{7f}"), "\"a\\\"\\n\\u007F\"");
416 }
417
418 #[tokio::test]
419 async fn compatibility_diagnostic_names_missing_flag_without_help() {
420 let advertised = required_flags().join(" ").replace("--no-daemon", "");
421 let help = format!("printf '%s\\n' '{advertised} conspicuous-private-help-text'");
422 let fake = fake("", 0, Some(help));
423 let error = fake.run(Duration::from_secs(5)).await.unwrap_err();
424 assert_eq!(
425 error,
426 preflight_error("combined required-flag validation", "omitted --no-daemon")
427 );
428 assert!(!error.contains("conspicuous-private-help-text"));
429 }
430
431 #[tokio::test]
432 async fn help_and_execution_use_only_the_absolute_deadline() {
433 let help = format!(
434 "if [ \"$1\" = \"--help\" ]; then sleep 3; fi\nprintf '%s\\n' '{}'",
435 required_flags().join(" ")
436 );
437 let slow_help = fake(SUCCESS, 0, Some(help));
438 assert_eq!(
439 slow_help.run(Duration::from_secs(7)).await.unwrap(),
440 "answer"
441 );
442 let body = format!("sleep 2\n{SUCCESS}");
443 let slow_execution = fake(&body, 0, None);
444 assert_eq!(
445 slow_execution
446 .run(Duration::from_millis(2800))
447 .await
448 .unwrap(),
449 "answer"
450 );
451 }
452
453 #[tokio::test]
454 async fn rejects_failures_and_nul_arguments() {
455 let cases = [
456 ("{", 0, BAD_JSON),
457 (r#"{"type":"turn.failed"}"#, 0, EXEC_ERROR),
458 (r#"{"type":"turn.completed"}"#, 7, EXEC_EXIT_ERROR),
459 (r#"{"type":"turn.completed"}"#, 0, MESSAGE_ERROR),
460 (
461 r#"{"type":"item.completed","item":{"type":"agent_message","text":"x"}}"#,
462 0,
463 INCOMPLETE_ERROR,
464 ),
465 ];
466 for (json, status, error) in cases {
467 let body = format!("printf '%s\\n' '{json}'");
468 let fake = fake(&body, status, None);
469 assert_eq!(fake.run(Duration::from_secs(5)).await.unwrap_err(), error);
470 }
471
472 let runner = Runner::new("/not/executed".into());
473 for (model, effort) in [("bad\0", "high"), ("model-x", "bad\0")] {
474 let mut bad = request("q", Duration::from_secs(2));
475 bad.model = model.into();
476 bad.reasoning_effort = effort.into();
477 assert_eq!(runner.run(bad).await.unwrap_err(), ARG_ERROR);
478 }
479 }
480
481 async fn wait_until(mut condition: impl FnMut() -> bool) {
482 for _ in 0..200 {
483 if condition() {
484 return;
485 }
486 tokio::time::sleep(Duration::from_millis(10)).await;
487 }
488 panic!("condition was not reached");
489 }
490
491 fn process_is_live(pid: i32) -> bool {
492 fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| {
493 let state = stat
494 .rsplit_once(") ")
495 .and_then(|(_, tail)| tail.bytes().next());
496 !matches!(state, Some(b'Z' | b'X'))
497 })
498 }
499
500 async fn wait_for_processes_to_stop(path: &Path) {
501 let pids = fs::read_to_string(path)
502 .unwrap()
503 .split_whitespace()
504 .map(|value| value.parse::<i32>().unwrap())
505 .collect::<Vec<_>>();
506 assert!(pids.len() >= 2);
507 wait_until(|| pids.iter().all(|pid| !process_is_live(*pid))).await;
508 }
509
510 #[tokio::test]
511 async fn deadline_and_cancellation_reap_descendants() {
512 let deadline = fake(DESCENDANTS, 0, None);
513 let error = deadline.run(Duration::from_millis(1600)).await.unwrap_err();
514 assert_eq!(error, deadline_error("during Codex execution"));
515 wait_for_processes_to_stop(&deadline.file("pids")).await;
516
517 let cancelled = fake(DESCENDANTS, 0, None);
518 let runner = Runner::new(cancelled.file("codex"));
519 let task =
520 tokio::spawn(async move { runner.run(request("q", Duration::from_secs(10))).await });
521 wait_until(|| cancelled.file("pids").exists()).await;
522 task.abort();
523 let _ = task.await;
524 wait_for_processes_to_stop(&cancelled.file("pids")).await;
525 }
526}