codex_cli.rs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856
  1. //! Codex app-server transport.
  2. //!
  3. //! QMAI owns the agent loop, permissions, and tools. Codex is hosted as a
  4. //! long-lived JSON-RPC app-server and receives only QMAI dynamic tools. The
  5. //! native Codex workspace is an empty temporary directory and the sandbox is
  6. //! always read-only.
  7. use std::path::{Path, PathBuf};
  8. use std::process::Stdio;
  9. use std::sync::{
  10. atomic::{AtomicU64, Ordering},
  11. Arc,
  12. };
  13. use std::time::Duration;
  14. use serde::Serialize;
  15. use serde_json::{json, Value};
  16. use tauri::{AppHandle, Emitter, State};
  17. use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
  18. use tokio::process::{Child, ChildStdin, Command};
  19. use tokio::sync::Mutex;
  20. use super::cli_resolver::{child_path_env, find_cli_command};
  21. use super::local_cli_config::{
  22. apply_local_cli_environment, read_codex_local_config, resolve_home_dir,
  23. };
  24. const APP_SERVER_EVENT: &str = "codex-app-server:event";
  25. const APP_SERVER_EXIT_EVENT: &str = "codex-app-server:exit";
  26. const DETECT_TIMEOUT: Duration = Duration::from_secs(8);
  27. static NEXT_PROBE_GENERATION: AtomicU64 = AtomicU64::new(1);
  28. #[derive(Default)]
  29. struct AppServerProcess {
  30. child: Option<Child>,
  31. stdin: Option<ChildStdin>,
  32. generation: u64,
  33. cwd: Option<PathBuf>,
  34. root: Option<PathBuf>,
  35. }
  36. #[derive(Default)]
  37. pub struct CodexAppServerState {
  38. process: Arc<Mutex<AppServerProcess>>,
  39. next_generation: AtomicU64,
  40. }
  41. #[derive(Debug, Clone, Serialize)]
  42. #[serde(rename_all = "camelCase")]
  43. pub struct AppServerStartResult {
  44. generation: u64,
  45. cwd: String,
  46. }
  47. #[derive(Debug, Clone, Serialize)]
  48. #[serde(rename_all = "camelCase")]
  49. pub struct DetectResult {
  50. installed: bool,
  51. version: Option<String>,
  52. path: Option<String>,
  53. model: Option<String>,
  54. app_server_ready: bool,
  55. dynamic_tools_ready: bool,
  56. models: Vec<String>,
  57. error: Option<String>,
  58. }
  59. fn suppress_windows_console(_cmd: &mut Command) {
  60. #[cfg(windows)]
  61. {
  62. #[allow(unused_imports)]
  63. use std::os::windows::process::CommandExt;
  64. const CREATE_NO_WINDOW: u32 = 0x08000000;
  65. _cmd.creation_flags(CREATE_NO_WINDOW);
  66. }
  67. }
  68. async fn find_codex_command() -> Result<PathBuf, String> {
  69. find_cli_command("codex", &["codex.cmd", "codex.exe"]).await
  70. }
  71. async fn configure_codex_command(cmd: &mut Command) {
  72. suppress_windows_console(cmd);
  73. apply_local_cli_environment(cmd);
  74. if let Some(path_env) = child_path_env().await {
  75. cmd.env("PATH", path_env);
  76. }
  77. }
  78. fn controlled_temp_dir(label: &str, generation: u64) -> PathBuf {
  79. std::env::temp_dir().join(format!(
  80. "qmai-codex-{label}-{}-{generation}",
  81. std::process::id()
  82. ))
  83. }
  84. fn next_probe_generation() -> u64 {
  85. NEXT_PROBE_GENERATION.fetch_add(1, Ordering::SeqCst)
  86. }
  87. const CODEX_CONFIG_PASSTHROUGH_KEYS: &[&str] = &[
  88. "model_provider",
  89. "model_providers",
  90. "openai_base_url",
  91. "chatgpt_base_url",
  92. "cli_auth_credentials_store",
  93. "forced_login_method",
  94. "forced_chatgpt_workspace_id",
  95. ];
  96. fn isolated_codex_config(home_dir: Option<&Path>) -> toml::Table {
  97. let mut isolated = toml::Table::new();
  98. let source = home_dir
  99. .and_then(|home| std::fs::read_to_string(home.join(".codex").join("config.toml")).ok())
  100. .and_then(|content| content.parse::<toml::Table>().ok())
  101. .unwrap_or_default();
  102. for key in CODEX_CONFIG_PASSTHROUGH_KEYS {
  103. if let Some(value) = source.get(*key) {
  104. isolated.insert((*key).to_string(), value.clone());
  105. }
  106. }
  107. let features = [
  108. "apps",
  109. "browser_use",
  110. "browser_use_external",
  111. "browser_use_full_cdp_access",
  112. "computer_use",
  113. "goals",
  114. "hooks",
  115. "image_generation",
  116. "in_app_browser",
  117. "memories",
  118. "multi_agent",
  119. "multi_agent_v2",
  120. "plugins",
  121. "remote_plugin",
  122. "shell_snapshot",
  123. "shell_tool",
  124. "skill_mcp_dependency_install",
  125. "skill_search",
  126. ]
  127. .into_iter()
  128. .map(|key| (key.to_string(), toml::Value::Boolean(false)))
  129. .collect::<toml::Table>();
  130. isolated.insert("features".to_string(), toml::Value::Table(features));
  131. isolated.insert(
  132. "web_search".to_string(),
  133. toml::Value::String("disabled".to_string()),
  134. );
  135. isolated.insert("project_doc_max_bytes".to_string(), toml::Value::Integer(0));
  136. isolated.insert(
  137. "project_doc_fallback_filenames".to_string(),
  138. toml::Value::Array(Vec::new()),
  139. );
  140. isolated.insert(
  141. "project_root_markers".to_string(),
  142. toml::Value::Array(Vec::new()),
  143. );
  144. isolated.insert(
  145. "tools".to_string(),
  146. toml::Value::Table(
  147. [
  148. ("view_image".to_string(), toml::Value::Boolean(false)),
  149. ("web_search".to_string(), toml::Value::Boolean(false)),
  150. ]
  151. .into_iter()
  152. .collect(),
  153. ),
  154. );
  155. isolated
  156. }
  157. fn serialize_isolated_codex_config(home_dir: Option<&Path>) -> Result<String, String> {
  158. toml::to_string(&isolated_codex_config(home_dir))
  159. .map_err(|error| format!("无法序列化 Codex 隔离配置:{error}"))
  160. }
  161. fn prepare_isolated_runtime(
  162. label: &str,
  163. generation: u64,
  164. ) -> Result<(PathBuf, PathBuf, PathBuf), String> {
  165. let root = controlled_temp_dir(label, generation);
  166. if root.exists() {
  167. std::fs::remove_dir_all(&root)
  168. .map_err(|error| format!("无法清理 Codex 隔离目录:{error}"))?;
  169. }
  170. let cwd = root.join("workspace");
  171. let codex_home = root.join("codex-home");
  172. std::fs::create_dir_all(&cwd)
  173. .and_then(|_| std::fs::create_dir_all(&codex_home))
  174. .map_err(|error| format!("无法创建 Codex 隔离目录:{error}"))?;
  175. let user_home = resolve_home_dir();
  176. if let Some(auth_path) = user_home
  177. .as_deref()
  178. .map(|home| home.join(".codex").join("auth.json"))
  179. .filter(|path| path.is_file())
  180. {
  181. std::fs::copy(auth_path, codex_home.join("auth.json"))
  182. .map_err(|error| format!("无法复制 Codex 登录凭据:{error}"))?;
  183. }
  184. let config = serialize_isolated_codex_config(user_home.as_deref())?;
  185. std::fs::write(codex_home.join("config.toml"), config)
  186. .map_err(|error| format!("无法写入 Codex 隔离配置:{error}"))?;
  187. Ok((root, cwd, codex_home))
  188. }
  189. async fn spawn_app_server_process(
  190. codex: &Path,
  191. cwd: &Path,
  192. codex_home: &Path,
  193. ) -> Result<
  194. (
  195. Child,
  196. ChildStdin,
  197. tokio::process::ChildStdout,
  198. tokio::process::ChildStderr,
  199. ),
  200. String,
  201. > {
  202. let mut cmd = Command::new(codex);
  203. configure_codex_command(&mut cmd).await;
  204. cmd.args(["app-server", "--stdio"])
  205. .current_dir(cwd)
  206. .env("CODEX_HOME", codex_home)
  207. .stdin(Stdio::piped())
  208. .stdout(Stdio::piped())
  209. .stderr(Stdio::piped())
  210. .kill_on_drop(true);
  211. let mut child = cmd
  212. .spawn()
  213. .map_err(|error| format!("无法启动 Codex app-server:{error}"))?;
  214. let stdin = child
  215. .stdin
  216. .take()
  217. .ok_or("无法打开 Codex app-server stdin")?;
  218. let stdout = child
  219. .stdout
  220. .take()
  221. .ok_or("无法打开 Codex app-server stdout")?;
  222. let stderr = child
  223. .stderr
  224. .take()
  225. .ok_or("无法打开 Codex app-server stderr")?;
  226. Ok((child, stdin, stdout, stderr))
  227. }
  228. fn initialize_request(id: u64) -> Value {
  229. json!({
  230. "jsonrpc": "2.0",
  231. "id": id,
  232. "method": "initialize",
  233. "params": {
  234. "clientInfo": { "name": "QMaiWrite", "title": "QMaiWrite", "version": env!("CARGO_PKG_VERSION") },
  235. "capabilities": { "experimentalApi": true, "requestAttestation": false }
  236. }
  237. })
  238. }
  239. fn probe_thread_request(id: u64, cwd: &Path) -> Value {
  240. json!({
  241. "jsonrpc": "2.0",
  242. "id": id,
  243. "method": "thread/start",
  244. "params": {
  245. "cwd": cwd.to_string_lossy(),
  246. "approvalPolicy": "never",
  247. "sandbox": "read-only",
  248. "ephemeral": true,
  249. "baseInstructions": "QMAI capability probe. Do not use native tools.",
  250. "developerInstructions": "Use only client-provided dynamic tools.",
  251. "dynamicTools": [{
  252. "type": "function",
  253. "name": "qmai_capability_probe",
  254. "description": "QMAI capability probe; never call it.",
  255. "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false }
  256. }],
  257. "config": restricted_feature_config()
  258. }
  259. })
  260. }
  261. fn restricted_feature_config() -> Value {
  262. json!({
  263. "features": {
  264. "apps": false,
  265. "browser_use": false,
  266. "browser_use_external": false,
  267. "browser_use_full_cdp_access": false,
  268. "computer_use": false,
  269. "image_generation": false,
  270. "in_app_browser": false,
  271. "multi_agent": false,
  272. "multi_agent_v2": false,
  273. "plugins": false,
  274. "remote_plugin": false,
  275. "shell_snapshot": false,
  276. "shell_tool": false,
  277. "skill_mcp_dependency_install": false,
  278. "skill_search": false
  279. },
  280. "web_search": "disabled",
  281. "project_doc_max_bytes": 0,
  282. "project_doc_fallback_filenames": [],
  283. "project_root_markers": [],
  284. "tools": { "view_image": false, "web_search": false }
  285. })
  286. }
  287. async fn write_json_line(stdin: &mut ChildStdin, value: &Value) -> Result<(), String> {
  288. let mut encoded = serde_json::to_vec(value)
  289. .map_err(|error| format!("Codex app-server 请求序列化失败:{error}"))?;
  290. encoded.push(b'\n');
  291. stdin
  292. .write_all(&encoded)
  293. .await
  294. .map_err(|error| format!("Codex app-server 写入失败:{error}"))?;
  295. stdin
  296. .flush()
  297. .await
  298. .map_err(|error| format!("Codex app-server 刷新失败:{error}"))
  299. }
  300. async fn read_response_for_id(
  301. reader: &mut BufReader<tokio::process::ChildStdout>,
  302. expected_id: u64,
  303. ) -> Result<Value, String> {
  304. let read = async {
  305. loop {
  306. let mut line = String::new();
  307. let count = reader
  308. .read_line(&mut line)
  309. .await
  310. .map_err(|error| format!("Codex app-server 读取失败:{error}"))?;
  311. if count == 0 {
  312. return Err("Codex app-server 在握手期间退出".to_string());
  313. }
  314. let Ok(value) = serde_json::from_str::<Value>(line.trim()) else {
  315. continue;
  316. };
  317. if value.get("id").and_then(Value::as_u64) == Some(expected_id) {
  318. return Ok(value);
  319. }
  320. }
  321. };
  322. tokio::time::timeout(DETECT_TIMEOUT, read)
  323. .await
  324. .map_err(|_| "Codex app-server 握手超时".to_string())?
  325. }
  326. fn response_error(value: &Value) -> Option<String> {
  327. value
  328. .get("error")
  329. .and_then(|error| error.get("message").or(Some(error)))
  330. .and_then(|message| message.as_str().map(ToOwned::to_owned))
  331. }
  332. fn models_from_response(value: &Value) -> Vec<String> {
  333. let mut models = value
  334. .pointer("/result/data")
  335. .and_then(Value::as_array)
  336. .into_iter()
  337. .flatten()
  338. .filter_map(|item| item.get("model").and_then(Value::as_str))
  339. .map(ToOwned::to_owned)
  340. .collect::<Vec<_>>();
  341. models.sort();
  342. models.dedup();
  343. models
  344. }
  345. fn stderr_summary(stderr: &str) -> Option<String> {
  346. let lines = stderr
  347. .lines()
  348. .map(str::trim)
  349. .filter(|line| !line.is_empty())
  350. .take(8)
  351. .map(|line| {
  352. let lower = line.to_ascii_lowercase();
  353. if [
  354. "authorization",
  355. "bearer",
  356. "api_key",
  357. "api-key",
  358. "apikey",
  359. "token",
  360. "sk-",
  361. ]
  362. .iter()
  363. .any(|secret| lower.contains(secret))
  364. {
  365. "[敏感内容已隐藏]".to_string()
  366. } else {
  367. line.chars().take(500).collect::<String>()
  368. }
  369. })
  370. .collect::<Vec<_>>();
  371. (!lines.is_empty()).then(|| lines.join(" | "))
  372. }
  373. async fn probe_app_server(codex: &Path) -> Result<Vec<String>, (bool, String)> {
  374. let probe_generation = next_probe_generation();
  375. let (root, cwd, codex_home) =
  376. prepare_isolated_runtime("probe", probe_generation).map_err(|error| (false, error))?;
  377. let (mut child, mut stdin, stdout, stderr) =
  378. match spawn_app_server_process(codex, &cwd, &codex_home).await {
  379. Ok(spawned) => spawned,
  380. Err(error) => {
  381. let _ = std::fs::remove_dir_all(&root);
  382. return Err((false, error));
  383. }
  384. };
  385. let stderr_task = tokio::spawn(async move {
  386. let mut reader = BufReader::new(stderr);
  387. let mut output = String::new();
  388. let _ = reader.read_to_string(&mut output).await;
  389. output
  390. });
  391. let mut reader = BufReader::new(stdout);
  392. let mut app_server_ready = false;
  393. let result = async {
  394. write_json_line(&mut stdin, &initialize_request(1)).await?;
  395. let initialized = read_response_for_id(&mut reader, 1).await?;
  396. if let Some(error) = response_error(&initialized) {
  397. return Err(format!("Codex app-server initialize 失败:{error}"));
  398. }
  399. app_server_ready = true;
  400. write_json_line(
  401. &mut stdin,
  402. &json!({ "jsonrpc": "2.0", "method": "initialized" }),
  403. )
  404. .await?;
  405. write_json_line(
  406. &mut stdin,
  407. &json!({
  408. "jsonrpc": "2.0", "id": 2, "method": "model/list",
  409. "params": { "limit": 200, "includeHidden": false }
  410. }),
  411. )
  412. .await?;
  413. let model_response = read_response_for_id(&mut reader, 2).await?;
  414. if let Some(error) = response_error(&model_response) {
  415. return Err(format!("Codex app-server model/list 失败:{error}"));
  416. }
  417. write_json_line(&mut stdin, &probe_thread_request(3, &cwd)).await?;
  418. let thread_response = read_response_for_id(&mut reader, 3).await?;
  419. if let Some(error) = response_error(&thread_response) {
  420. return Err(format!("Codex app-server 不支持 dynamicTools:{error}"));
  421. }
  422. let instruction_sources = thread_response
  423. .pointer("/result/instructionSources")
  424. .and_then(Value::as_array)
  425. .map(Vec::as_slice)
  426. .unwrap_or_default();
  427. if !instruction_sources.is_empty() {
  428. return Err("Codex app-server 仍加载了本机或项目规则".to_string());
  429. }
  430. Ok(models_from_response(&model_response))
  431. }
  432. .await;
  433. let _ = child.kill().await;
  434. let _ = child.wait().await;
  435. let stderr = tokio::time::timeout(Duration::from_secs(1), stderr_task)
  436. .await
  437. .ok()
  438. .and_then(Result::ok)
  439. .unwrap_or_default();
  440. let _ = std::fs::remove_dir_all(&root);
  441. result.map_err(|error| {
  442. let detail = stderr_summary(&stderr)
  443. .map(|summary| format!(";Codex stderr:{summary}"))
  444. .unwrap_or_default();
  445. (app_server_ready, format!("{error}{detail}"))
  446. })
  447. }
  448. pub async fn do_codex_cli_detect() -> Result<DetectResult, String> {
  449. let configured_model = read_codex_local_config(resolve_home_dir().as_deref()).model;
  450. let codex = match find_codex_command().await {
  451. Ok(path) => path,
  452. Err(error) => {
  453. return Ok(DetectResult {
  454. installed: false,
  455. version: None,
  456. path: None,
  457. model: configured_model,
  458. app_server_ready: false,
  459. dynamic_tools_ready: false,
  460. models: Vec::new(),
  461. error: Some(error),
  462. });
  463. }
  464. };
  465. let path = codex.to_string_lossy().to_string();
  466. let mut version_cmd = Command::new(&codex);
  467. configure_codex_command(&mut version_cmd).await;
  468. let version = match tokio::time::timeout(
  469. Duration::from_secs(3),
  470. version_cmd.arg("--version").output(),
  471. )
  472. .await
  473. {
  474. Ok(Ok(output)) if output.status.success() => {
  475. Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
  476. }
  477. Ok(Ok(output)) => {
  478. let error = String::from_utf8_lossy(&output.stderr).trim().to_string();
  479. return Ok(DetectResult {
  480. installed: false,
  481. version: None,
  482. path: Some(path),
  483. model: configured_model,
  484. app_server_ready: false,
  485. dynamic_tools_ready: false,
  486. models: Vec::new(),
  487. error: Some(if error.is_empty() {
  488. format!("`codex --version` exited with {}", output.status)
  489. } else {
  490. error
  491. }),
  492. });
  493. }
  494. Ok(Err(error)) => {
  495. return Ok(DetectResult {
  496. installed: false,
  497. version: None,
  498. path: Some(path),
  499. model: configured_model,
  500. app_server_ready: false,
  501. dynamic_tools_ready: false,
  502. models: Vec::new(),
  503. error: Some(format!("Failed to spawn `codex`: {error}")),
  504. });
  505. }
  506. Err(_) => {
  507. return Ok(DetectResult {
  508. installed: false,
  509. version: None,
  510. path: Some(path),
  511. model: configured_model,
  512. app_server_ready: false,
  513. dynamic_tools_ready: false,
  514. models: Vec::new(),
  515. error: Some("`codex --version` timed out after 3s".to_string()),
  516. });
  517. }
  518. };
  519. match probe_app_server(&codex).await {
  520. Ok(models) => Ok(DetectResult {
  521. installed: true,
  522. version,
  523. path: Some(path),
  524. model: configured_model,
  525. app_server_ready: true,
  526. dynamic_tools_ready: true,
  527. models,
  528. error: None,
  529. }),
  530. Err((app_server_ready, error)) => Ok(DetectResult {
  531. installed: true,
  532. version,
  533. path: Some(path),
  534. model: configured_model,
  535. app_server_ready,
  536. dynamic_tools_ready: false,
  537. models: Vec::new(),
  538. error: Some(format!(
  539. "当前 Codex CLI 不支持 QMAI 主 Agent,请升级 Codex CLI。{error}"
  540. )),
  541. }),
  542. }
  543. }
  544. #[tauri::command]
  545. pub async fn codex_cli_detect() -> Result<DetectResult, String> {
  546. do_codex_cli_detect().await
  547. }
  548. #[tauri::command]
  549. pub async fn codex_app_server_start(
  550. app: AppHandle,
  551. state: State<'_, CodexAppServerState>,
  552. ) -> Result<AppServerStartResult, String> {
  553. let mut process = state.process.lock().await;
  554. if let Some(child) = process.child.as_mut() {
  555. match child.try_wait() {
  556. Ok(None) => {
  557. let cwd = process
  558. .cwd
  559. .as_ref()
  560. .ok_or("Codex app-server 隔离目录丢失")?;
  561. return Ok(AppServerStartResult {
  562. generation: process.generation,
  563. cwd: cwd.to_string_lossy().to_string(),
  564. });
  565. }
  566. Ok(Some(_)) | Err(_) => {
  567. process.child = None;
  568. process.stdin = None;
  569. if let Some(root) = process.root.take() {
  570. let _ = std::fs::remove_dir_all(root);
  571. }
  572. process.cwd = None;
  573. }
  574. }
  575. }
  576. let generation = state.next_generation.fetch_add(1, Ordering::SeqCst) + 1;
  577. let codex = find_codex_command().await?;
  578. let (root, cwd, codex_home) = prepare_isolated_runtime("runtime", generation)?;
  579. let (child, stdin, stdout, stderr) =
  580. match spawn_app_server_process(&codex, &cwd, &codex_home).await {
  581. Ok(spawned) => spawned,
  582. Err(error) => {
  583. let _ = std::fs::remove_dir_all(&root);
  584. return Err(error);
  585. }
  586. };
  587. process.child = Some(child);
  588. process.stdin = Some(stdin);
  589. process.generation = generation;
  590. process.cwd = Some(cwd.clone());
  591. process.root = Some(root);
  592. drop(process);
  593. let event_app = app.clone();
  594. let process_state = state.process.clone();
  595. tokio::spawn(async move {
  596. let mut reader = BufReader::new(stdout).lines();
  597. while let Ok(Some(line)) = reader.next_line().await {
  598. let _ = event_app.emit(
  599. APP_SERVER_EVENT,
  600. json!({ "generation": generation, "line": line }),
  601. );
  602. }
  603. let (mut child, root) = {
  604. let mut process = process_state.lock().await;
  605. if process.generation != generation {
  606. (None, None)
  607. } else {
  608. process.stdin = None;
  609. process.cwd = None;
  610. (process.child.take(), process.root.take())
  611. }
  612. };
  613. if let Some(child) = child.as_mut() {
  614. let _ = child.kill().await;
  615. }
  616. if let Some(root) = root {
  617. let _ = std::fs::remove_dir_all(root);
  618. }
  619. let _ = event_app.emit(APP_SERVER_EXIT_EVENT, json!({ "generation": generation }));
  620. });
  621. tokio::spawn(async move {
  622. let mut reader = BufReader::new(stderr).lines();
  623. while let Ok(Some(line)) = reader.next_line().await {
  624. eprintln!("[codex-app-server stderr] {line}");
  625. }
  626. });
  627. Ok(AppServerStartResult {
  628. generation,
  629. cwd: cwd.to_string_lossy().to_string(),
  630. })
  631. }
  632. #[tauri::command]
  633. pub async fn codex_app_server_write(
  634. state: State<'_, CodexAppServerState>,
  635. generation: u64,
  636. data: String,
  637. ) -> Result<(), String> {
  638. let mut process = state.process.lock().await;
  639. if process.generation != generation {
  640. return Err("Codex app-server 已重启,本次请求不能重放".to_string());
  641. }
  642. let stdin = process.stdin.as_mut().ok_or("Codex app-server 未运行")?;
  643. stdin
  644. .write_all(data.as_bytes())
  645. .await
  646. .map_err(|error| format!("Codex app-server 写入失败:{error}"))?;
  647. if !data.ends_with('\n') {
  648. stdin
  649. .write_all(b"\n")
  650. .await
  651. .map_err(|error| format!("Codex app-server 写入失败:{error}"))?;
  652. }
  653. stdin
  654. .flush()
  655. .await
  656. .map_err(|error| format!("Codex app-server 刷新失败:{error}"))
  657. }
  658. #[tauri::command]
  659. pub async fn codex_app_server_stop(state: State<'_, CodexAppServerState>) -> Result<(), String> {
  660. let (mut child, root) = {
  661. let mut process = state.process.lock().await;
  662. process.stdin = None;
  663. process.cwd = None;
  664. (process.child.take(), process.root.take())
  665. };
  666. if let Some(child) = child.as_mut() {
  667. let _ = child.kill().await;
  668. }
  669. if let Some(root) = root {
  670. let _ = std::fs::remove_dir_all(root);
  671. }
  672. Ok(())
  673. }
  674. #[cfg(test)]
  675. mod tests {
  676. use super::*;
  677. #[test]
  678. fn probe_uses_dynamic_tools_and_read_only_sandbox() {
  679. let cwd = Path::new("/tmp/qmai-probe");
  680. let request = probe_thread_request(3, cwd);
  681. assert_eq!(
  682. request.pointer("/params/sandbox").and_then(Value::as_str),
  683. Some("read-only")
  684. );
  685. assert_eq!(
  686. request
  687. .pointer("/params/approvalPolicy")
  688. .and_then(Value::as_str),
  689. Some("never")
  690. );
  691. assert_eq!(
  692. request
  693. .pointer("/params/ephemeral")
  694. .and_then(Value::as_bool),
  695. Some(true)
  696. );
  697. assert_eq!(
  698. request
  699. .pointer("/params/dynamicTools/0/name")
  700. .and_then(Value::as_str),
  701. Some("qmai_capability_probe")
  702. );
  703. }
  704. #[test]
  705. fn model_response_is_sorted_and_deduplicated() {
  706. let response = json!({ "result": { "data": [
  707. { "model": "gpt-z" }, { "model": "gpt-a" }, { "model": "gpt-z" }
  708. ] } });
  709. assert_eq!(models_from_response(&response), vec!["gpt-a", "gpt-z"]);
  710. }
  711. #[test]
  712. fn concurrent_probes_receive_distinct_temp_directories() {
  713. let first = controlled_temp_dir("probe", next_probe_generation());
  714. let second = controlled_temp_dir("probe", next_probe_generation());
  715. assert_ne!(first, second);
  716. }
  717. #[test]
  718. fn stderr_summary_redacts_likely_credentials() {
  719. let summary = stderr_summary(
  720. "configuration error\nAuthorization: Bearer secret\napi_key=secret\nretry failed",
  721. )
  722. .unwrap();
  723. assert!(summary.contains("configuration error"));
  724. assert!(summary.contains("[敏感内容已隐藏]"));
  725. assert!(!summary.contains("secret"));
  726. }
  727. #[test]
  728. fn restricted_config_disables_native_extensions() {
  729. let config = restricted_feature_config();
  730. assert_eq!(
  731. config.pointer("/features/plugins").and_then(Value::as_bool),
  732. Some(false)
  733. );
  734. assert_eq!(
  735. config.get("web_search").and_then(Value::as_str),
  736. Some("disabled")
  737. );
  738. assert_eq!(
  739. config
  740. .pointer("/features/multi_agent")
  741. .and_then(Value::as_bool),
  742. Some(false)
  743. );
  744. assert_eq!(
  745. config
  746. .pointer("/features/browser_use")
  747. .and_then(Value::as_bool),
  748. Some(false)
  749. );
  750. }
  751. #[test]
  752. fn isolated_config_keeps_only_login_and_model_provider_settings() {
  753. let dir = controlled_temp_dir("config-test", 42);
  754. let source = dir.join(".codex");
  755. std::fs::create_dir_all(&source).unwrap();
  756. std::fs::write(
  757. source.join("config.toml"),
  758. r#"model = "do-not-copy"
  759. model_provider = "proxy"
  760. developer_instructions = "do-not-copy"
  761. [model_providers.proxy]
  762. name = "Proxy"
  763. base_url = "https://example.test/v1"
  764. [mcp_servers.local]
  765. command = "danger"
  766. "#,
  767. )
  768. .unwrap();
  769. let config = isolated_codex_config(Some(&dir));
  770. assert_eq!(
  771. config.get("model_provider").and_then(toml::Value::as_str),
  772. Some("proxy")
  773. );
  774. assert!(config.contains_key("model_providers"));
  775. assert!(!config.contains_key("model"));
  776. assert!(!config.contains_key("developer_instructions"));
  777. assert!(!config.contains_key("mcp_servers"));
  778. assert_eq!(
  779. config
  780. .get("features")
  781. .and_then(toml::Value::as_table)
  782. .and_then(|features| features.get("hooks"))
  783. .and_then(toml::Value::as_bool),
  784. Some(false)
  785. );
  786. assert_eq!(
  787. config.get("web_search").and_then(toml::Value::as_str),
  788. Some("disabled")
  789. );
  790. let serialized = serialize_isolated_codex_config(Some(&dir)).unwrap();
  791. let reparsed = serialized.parse::<toml::Table>().unwrap();
  792. assert_eq!(
  793. reparsed.get("model_provider").and_then(toml::Value::as_str),
  794. Some("proxy")
  795. );
  796. assert_eq!(
  797. reparsed
  798. .get("features")
  799. .and_then(toml::Value::as_table)
  800. .and_then(|features| features.get("shell_tool"))
  801. .and_then(toml::Value::as_bool),
  802. Some(false)
  803. );
  804. let _ = std::fs::remove_dir_all(dir);
  805. }
  806. }