codex_mobile_bridge/
config.rs1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3
4#[cfg(unix)]
5use std::os::unix::fs::PermissionsExt;
6
7use anyhow::{Context, Result, bail};
8use clap::Parser;
9
10#[derive(Debug, Clone, Parser)]
11#[command(author, version, about = "Codex Mobile App Server bridge")]
12pub struct Config {
13 #[arg(long, env = "CODEX_MOBILE_LISTEN_ADDR", default_value = "0.0.0.0:8787")]
14 pub listen_addr: String,
15
16 #[arg(long, env = "CODEX_MOBILE_TOKEN")]
17 pub token: String,
18
19 #[arg(long, env = "CODEX_MOBILE_RUNTIME_LIMIT", default_value_t = 4)]
20 pub runtime_limit: usize,
21
22 #[arg(
23 long,
24 env = "CODEX_MOBILE_DB_PATH",
25 default_value = "~/.local/state/codex-mobile/bridge.db"
26 )]
27 pub db_path: PathBuf,
28
29 #[arg(long, env = "CODEX_HOME")]
30 pub codex_home: Option<PathBuf>,
31
32 #[arg(long, env = "CODEX_BINARY", default_value = "codex")]
33 pub codex_binary: String,
34
35 #[arg(long = "workspace-root")]
36 pub workspace_roots: Vec<PathBuf>,
37}
38
39impl Config {
40 pub fn validated(mut self) -> Result<Self> {
41 if self.token.trim().is_empty() {
42 bail!("bridge token 不能为空");
43 }
44
45 self.db_path = expand_path(&self.db_path)?;
46 self.codex_home = self
47 .codex_home
48 .as_ref()
49 .map(|path| expand_path(path))
50 .transpose()?;
51 self.codex_binary = resolve_codex_binary(
52 &self.codex_binary,
53 std::env::var_os("PATH").as_deref(),
54 std::env::var_os("HOME").as_deref().map(Path::new),
55 )?;
56
57 let normalized_roots = self
58 .workspace_roots
59 .iter()
60 .map(|path| expand_path(path))
61 .collect::<Result<Vec<_>>>()?;
62 self.workspace_roots = normalized_roots;
63
64 Ok(self)
65 }
66}
67
68pub fn expand_path(path: &Path) -> Result<PathBuf> {
69 let raw = path.to_string_lossy();
70 if raw == "~" {
71 return home_dir();
72 }
73
74 if let Some(stripped) = raw.strip_prefix("~/") {
75 return Ok(home_dir()?.join(stripped));
76 }
77
78 if path.is_absolute() {
79 return Ok(path.to_path_buf());
80 }
81
82 let cwd = std::env::current_dir().context("读取当前工作目录失败")?;
83 Ok(cwd.join(path))
84}
85
86fn home_dir() -> Result<PathBuf> {
87 std::env::var_os("HOME")
88 .map(PathBuf::from)
89 .context("未找到 HOME 环境变量")
90}
91
92pub fn resolve_codex_binary(
93 raw: &str,
94 path_env: Option<&OsStr>,
95 home_env: Option<&Path>,
96) -> Result<String> {
97 let trimmed = raw.trim();
98 if trimmed.is_empty() {
99 bail!("CODEX_BINARY 不能为空");
100 }
101
102 if trimmed.contains('/') || trimmed.starts_with('~') {
103 return Ok(expand_path(Path::new(trimmed))?
104 .to_string_lossy()
105 .to_string());
106 }
107
108 if let Some(resolved) = find_in_path(trimmed, path_env) {
109 return Ok(resolved.to_string_lossy().to_string());
110 }
111
112 if let Some(resolved) = find_in_home_bins(trimmed, home_env) {
113 return Ok(resolved.to_string_lossy().to_string());
114 }
115
116 Ok(trimmed.to_string())
117}
118
119fn find_in_path(binary: &str, path_env: Option<&OsStr>) -> Option<PathBuf> {
120 let path_env = path_env?;
121 std::env::split_paths(path_env)
122 .map(|dir| dir.join(binary))
123 .find(|candidate| is_executable(candidate))
124}
125
126fn find_in_home_bins(binary: &str, home_env: Option<&Path>) -> Option<PathBuf> {
127 let home = home_env?;
128 [
129 home.join(".npm-global/bin").join(binary),
130 home.join(".local/bin").join(binary),
131 home.join("bin").join(binary),
132 home.join(".cargo/bin").join(binary),
133 ]
134 .into_iter()
135 .find(|candidate| is_executable(candidate))
136}
137
138fn is_executable(path: &Path) -> bool {
139 let Ok(metadata) = std::fs::metadata(path) else {
140 return false;
141 };
142 if !metadata.is_file() {
143 return false;
144 }
145
146 #[cfg(unix)]
147 {
148 metadata.permissions().mode() & 0o111 != 0
149 }
150
151 #[cfg(not(unix))]
152 {
153 true
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use std::env;
160 use std::ffi::OsString;
161 use std::fs;
162 #[cfg(unix)]
163 use std::os::unix::fs::PermissionsExt;
164
165 use super::resolve_codex_binary;
166
167 #[test]
168 fn resolve_codex_binary_uses_path_entry_when_available() {
169 let base_dir =
170 env::temp_dir().join(format!("codex-mobile-config-test-{}", std::process::id()));
171 fs::create_dir_all(&base_dir).expect("创建测试目录失败");
172 let binary_path = base_dir.join("codex");
173 fs::write(&binary_path, "#!/bin/sh\n").expect("写入可执行文件失败");
174 #[cfg(unix)]
175 {
176 fs::set_permissions(&binary_path, fs::Permissions::from_mode(0o755))
177 .expect("设置权限失败");
178 }
179
180 let path_env = OsString::from(&base_dir);
181 let resolved = resolve_codex_binary(
182 "codex",
183 Some(path_env.as_os_str()),
184 Some(base_dir.as_path()),
185 )
186 .expect("解析 codex 失败");
187
188 assert_eq!(resolved, binary_path.to_string_lossy());
189 }
190
191 #[test]
192 fn resolve_codex_binary_falls_back_to_common_home_bin() {
193 let home_dir =
194 env::temp_dir().join(format!("codex-mobile-home-test-{}", std::process::id()));
195 let npm_dir = home_dir.join(".npm-global/bin");
196 fs::create_dir_all(&npm_dir).expect("创建 npm bin 目录失败");
197 let binary_path = npm_dir.join("codex");
198 fs::write(&binary_path, "#!/bin/sh\n").expect("写入可执行文件失败");
199 #[cfg(unix)]
200 {
201 fs::set_permissions(&binary_path, fs::Permissions::from_mode(0o755))
202 .expect("设置权限失败");
203 }
204
205 let path_env = OsString::from("/usr/bin");
206 let resolved = resolve_codex_binary("codex", Some(path_env.as_os_str()), Some(&home_dir))
207 .expect("应回退到 HOME bin");
208
209 assert_eq!(resolved, binary_path.to_string_lossy());
210 }
211}