feat(coding-agent): Less Computer 接入 Codex,语音使唤电脑不再只能用 Claude (#974)
* fix(coding-agent): 从用户 shell 取真实 PATH,打包后也找得到 CLI macOS 从 Finder 启动的 GUI 进程拿到的是一份极简 PATH,不经过用户的 shell 配置。 后果是:凡是版本管理器装的 agent CLI 在打包版里一个都找不到——`opencode` 官方脚本 装在 `~/.opencode/bin`,全局 npm 前缀和 nvm 的路径更是带版本号、根本没法硬编码。 用户只能手动去高级设置里填绝对路径。 改成先问用户的 shell 要一份真实 PATH。三个细节是踩出来的,都写进注释了: - 用 `-lic` 而不是 `-lc`。nvm 这类版本管理器通常在 .zshrc 里初始化,而 .zshrc 只有 交互式 shell 才读;`-lc` 拿到的 PATH 里 nvm 排在 /usr/local/bin 之后, `#!/usr/bin/env node` 会命中错版本的 node,子进程直接崩。 - 拼接必须保序。逐项前插等于把整个 PATH 倒过来,同样会选错解释器。 - 分隔符交给 std::env::split_paths / join_paths,不能手写 `:`。Windows 用 `;`, 而且盘符自带冒号,手写会把 `C:\Windows` 切成两半、整条 PATH 报废。 shell 卡住有 5 秒硬上限,超时回落到静态目录列表。整段探测限定 unix:Windows 没有 「登录 shell 的 PATH」这个概念,装了 Git Bash 的机器 SHELL 有值,真去跑只会莫名其妙 拉起一个 bash。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(codex): 无头 Codex 适配器 Less Computer 现在只认 Claude Code 和 OpenCode,因为每个后端都得手写一套适配器。 这个是 Codex 的那套,形状和前两家一致,共用同一套请求/事件/错误类型。 - 运行:`codex exec --json`,逐行解析 JSONL。 - prompt 走 stdin(末尾的 `-`),不进 argv,也不会出现在进程列表里。 - 续接用 `resume --last`。参数顺序有坑:`-s` / `--json` 这些必须排在 `resume` 之前, 写反了 CLI 直接报 unexpected argument,已对 0.146.0 核实并写进注释。 - 工具展示会剥掉 `/bin/zsh -lc '…'` 这层 shell 包装,胶囊里显示的是真正执行的命令名。 - 护栏:Codex 给不了逐命令 deny 清单(`.rules` 只从 $CODEX_HOME / 项目目录读,实测 `-c rules=[…]` 会被静默忽略)。它的护栏是自带的 seatbelt 沙箱,走 `-s <mode>`。 额外关掉 workspace-write 对 /tmp 与 $TMPDIR 的默认放行——不关的话「写入限制在工作 目录内」就是句假话,实测任务真的能在 $TMPDIR 里落文件。 有一处刻意不做:Codex 会把「模型元数据没找到」这类警告也塞成 error 事件,本机每次运行 都会发一条。把它当终局失败会让所有运行都被误判,所以只认 turn.failed。 联机验收默认 #[ignore],其中一条不调模型不花钱:它比对加固前后实际生效的可写根, 盯住护栏别被上游无声改宽(codex 对未知 -c 键是静默接受的,改名了不会报错)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(coding-agent): Less Computer 可选 Codex 后端,审批卡按能力启用 把 Codex 接进语音链路,后端从两家变三家。 护栏这块两类后端不一样,代码里如实分开:Claude / OpenCode 有逐命令 deny 清单,撞了能 弹审批卡、批准后放行那一条重跑;Codex 只有粗粒度沙箱档位,唯一的「放行」办法是整体 降档,那等于把护栏关掉,不是放行这一条。 所以给 provider 加了一个能力位,只有支持逐命令放行的后端才弹审批卡。否则用户点了批准、 重跑还是同样被拦,那是个假承诺。撞墙时直接把失败如实报出去。 模型解析也按后端分开:Codex 收裸模型名(gpt-5 / o3 这种),留空交给它自己的 ~/.codex/config.toml,不替用户瞎猜一个默认。 另加一个通用的安装检测命令:选了后端却没装 CLI 时,在设置页当场看到,而不是等说完 一句话才发现跑不起来。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(settings): Less Computer 后端下拉加入 Codex 设置页的后端下拉从两项变三项,并且如实反映能力差异,不给假开关: - 模型:Claude 用别名下拉,OpenCode 拉账号可用列表,Codex 改成自由文本——它的模型名 是裸名(gpt-5 / 自建网关的任意名字),枚举不过来;留空 = 用 Codex 自己的默认。 - 护栏:选了 Codex 会挂一条说明,讲清楚它只有粗粒度沙箱、撞到限制会直接报错而不是弹 「批准这条命令」的卡片。免得用户按 Claude 的心智去用。 - 装没装:当场检测并提示,没装时按警示色显示。 - 可执行文件的 placeholder 跟着后端走。 文案 5 语言齐全。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(coding-agent): harden Codex execution --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
18 files changed
+1674
-91
No files matched your search
@@ -254,6 +254,25 @@ jobs:
|
||||
- name: Build frontend and run frontend/contract tests
|
||||
run: npm test
|
||||
|
||||
- name: Install pinned Codex CLI for sandbox contract
|
||||
if: runner.os == 'macOS'
|
||||
run: npm install --global @openai/codex@0.146.0
|
||||
|
||||
- name: Configure isolated Codex home
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
codex_home="$RUNNER_TEMP/openless-codex-home"
|
||||
mkdir -p "$codex_home"
|
||||
echo "CODEX_HOME=$codex_home" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Codex writable-roots sandbox contract
|
||||
if: runner.os == 'macOS'
|
||||
env:
|
||||
OPENLESS_CODEX_TEST_EXE: codex
|
||||
run: cargo test --manifest-path src-tauri/Cargo.toml --lib coding_agent::codex::live::hardening_actually_narrows_the_writable_roots -- --ignored --nocapture --test-threads=1
|
||||
|
||||
- name: Check Tauri backend (cargo check)
|
||||
run: cargo check --manifest-path src-tauri/Cargo.toml
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ assert(
|
||||
dictation.includes('resolve_coding_agent_model(provider'),
|
||||
'Less Computer must resolve model defaults per provider',
|
||||
);
|
||||
assert(
|
||||
settings.includes("const CODEX_PERMISSION_MODES: CodingAgentPermissionMode[] = ['plan', 'acceptEdits']") &&
|
||||
settings.includes('normalizePermissionMode'),
|
||||
'Codex settings must expose only read-only/plan and workspace-write permission modes and normalize legacy values',
|
||||
);
|
||||
|
||||
const microphoneMenuStart = lib.indexOf('fn build_microphone_tray_menu');
|
||||
const microphoneMenuEnd = lib.indexOf('pub(crate) fn refresh_tray_microphone_menu');
|
||||
@@ -108,6 +113,8 @@ const localizedKeys = [
|
||||
'opencodeModelsLoaded',
|
||||
'opencodeModelsEmpty',
|
||||
'opencodeModelsError',
|
||||
'codexBudgetHint',
|
||||
'codexMode',
|
||||
'openPanel',
|
||||
'openPanelHint',
|
||||
'openPanelAction',
|
||||
|
||||
@@ -13,28 +13,56 @@ pub enum CodingAgentProvider {
|
||||
ClaudeCodeCli,
|
||||
/// OpenCode CLI(`opencode`),issue #579。
|
||||
OpenCodeCli,
|
||||
/// Codex CLI(`codex exec`)。护栏走它自带的 seatbelt 沙箱,没有逐命令 deny 清单。
|
||||
CodexCli,
|
||||
}
|
||||
|
||||
impl CodingAgentProvider {
|
||||
/// 从 prefs 字符串解析。`"opencode-cli"` → OpenCode;其余(含 `"claude-code-cli"`)→ Claude。
|
||||
/// 从 prefs 字符串解析。未知/缺省一律回落 Claude(既有默认),不破坏现有用户。
|
||||
pub fn from_pref(s: &str) -> Self {
|
||||
match s.trim() {
|
||||
"opencode-cli" => Self::OpenCodeCli,
|
||||
"codex-cli" => Self::CodexCli,
|
||||
_ => Self::ClaudeCodeCli,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该后端是否支持「撞了 deny → 弹审批卡 → 放行该命令重跑」这条链路。
|
||||
///
|
||||
/// Claude / OpenCode 的护栏是**逐命令 deny 清单**,能精确地把某一条放行再跑一次,
|
||||
/// 所以审批卡有意义。Codex / dsh 只有粗粒度沙箱档位,放行的唯一办法是整体降档
|
||||
/// (等于把护栏关掉),不是「放行这一条」。对它们弹审批卡会给用户一个假承诺:
|
||||
/// 点了批准,重跑还是同样被拦。所以这两家直接如实报错,不弹卡。
|
||||
pub fn supports_command_approval(self) -> bool {
|
||||
match self {
|
||||
Self::ClaudeCodeCli | Self::OpenCodeCli => true,
|
||||
Self::CodexCli => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该 provider 默认的可执行文件名。
|
||||
pub fn default_exe(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClaudeCodeCli => "claude",
|
||||
Self::OpenCodeCli => "opencode",
|
||||
Self::CodexCli => "codex",
|
||||
}
|
||||
}
|
||||
|
||||
/// 该后端可声明的单次美元预算上限;`None` 表示 CLI 没有可用的美元硬上限。
|
||||
pub fn max_budget_usd(self) -> Option<f64> {
|
||||
match self {
|
||||
Self::ClaudeCodeCli => Some(2.0),
|
||||
Self::OpenCodeCli | Self::CodexCli => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按后端解析用户选择的模型。Claude 保持既有的 sonnet 默认;OpenCode 只接受
|
||||
/// `provider/model`,未选择或遗留的 Claude 别名均交给 OpenCode 自己的默认配置。
|
||||
/// 按后端解析用户选择的模型。
|
||||
///
|
||||
/// - Claude:保持既有的 sonnet 默认。
|
||||
/// - OpenCode:只接受 `provider/model`,未选择或遗留的 Claude 别名均交给 OpenCode 自己的默认配置。
|
||||
/// - Codex:接受任意非空裸模型名(`gpt-5` / `o3` 等),留空交给 `~/.codex/config.toml`。
|
||||
pub fn resolve_coding_agent_model(
|
||||
provider: CodingAgentProvider,
|
||||
configured: Option<String>,
|
||||
@@ -45,6 +73,7 @@ pub fn resolve_coding_agent_model(
|
||||
match provider {
|
||||
CodingAgentProvider::ClaudeCodeCli => configured.or_else(|| Some("sonnet".to_string())),
|
||||
CodingAgentProvider::OpenCodeCli => configured.filter(|model| model.contains('/')),
|
||||
CodingAgentProvider::CodexCli => configured,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +239,17 @@ mod tests {
|
||||
assert!(!args.iter().any(|a| a.contains("hello")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_budget_capability_only_belongs_to_claude() {
|
||||
assert_eq!(
|
||||
CodingAgentProvider::ClaudeCodeCli.max_budget_usd(),
|
||||
Some(2.0)
|
||||
);
|
||||
assert_eq!(CodingAgentProvider::OpenCodeCli.max_budget_usd(), None);
|
||||
assert_eq!(CodingAgentProvider::CodexCli.max_budget_usd(), None);
|
||||
assert_eq!(CodingAgentRequest::new("s", "p").timeout_secs, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_mode_maps_to_cli_string() {
|
||||
assert_eq!(CodingAgentPermissionMode::Plan.as_cli_arg(), "plan");
|
||||
@@ -299,6 +339,46 @@ mod tests {
|
||||
fn provider_default_exe() {
|
||||
assert_eq!(CodingAgentProvider::ClaudeCodeCli.default_exe(), "claude");
|
||||
assert_eq!(CodingAgentProvider::OpenCodeCli.default_exe(), "opencode");
|
||||
assert_eq!(CodingAgentProvider::CodexCli.default_exe(), "codex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_providers_parse_from_pref() {
|
||||
assert_eq!(
|
||||
CodingAgentProvider::from_pref("codex-cli"),
|
||||
CodingAgentProvider::CodexCli
|
||||
);
|
||||
// 带空白也要认(prefs 来自前端,历史上出现过带空格的值)。
|
||||
assert_eq!(
|
||||
CodingAgentProvider::from_pref(" codex-cli "),
|
||||
CodingAgentProvider::CodexCli
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_deny_list_backends_offer_command_approval() {
|
||||
// 审批卡只对「能精确放行单条命令」的后端有意义,见 supports_command_approval 的文档。
|
||||
assert!(CodingAgentProvider::ClaudeCodeCli.supports_command_approval());
|
||||
assert!(CodingAgentProvider::OpenCodeCli.supports_command_approval());
|
||||
assert!(!CodingAgentProvider::CodexCli.supports_command_approval());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_takes_bare_model_names_and_dsh_takes_none() {
|
||||
// Codex 的模型名是裸名(gpt-5 / o3),不像 OpenCode 要求 provider/model。
|
||||
assert_eq!(
|
||||
resolve_coding_agent_model(CodingAgentProvider::CodexCli, Some("gpt-5".into())),
|
||||
Some("gpt-5".to_string())
|
||||
);
|
||||
// 留空交给 ~/.codex/config.toml,不替用户瞎猜一个默认。
|
||||
assert_eq!(
|
||||
resolve_coding_agent_model(CodingAgentProvider::CodexCli, None),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_coding_agent_model(CodingAgentProvider::CodexCli, Some(" ".into())),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,923 @@
|
||||
//! 无头 Codex(`codex exec --json`)适配器。
|
||||
//!
|
||||
//! 与 Claude / OpenCode 适配器同形、复用同一套 [`CodingAgentRequest`] / [`CodingAgentEvent`] /
|
||||
//! [`CodingAgentError`],但对接的是 OpenAI Codex CLI:
|
||||
//!
|
||||
//! - 检测:`codex --version`(输出形如 `codex-cli 0.146.0`)→ 复用
|
||||
//! [`super::detect::parse_claude_version`] 取 `x.y.z`。
|
||||
//! - 运行:`codex exec --json --color never --skip-git-repo-check -s <sandbox> [-C <cwd>]
|
||||
//! [-m <model>] -`,**prompt 走 stdin**(末尾的 `-` 就是「从 stdin 读」的官方写法),
|
||||
//! 逐行解析 JSONL 事件。
|
||||
//! - 续接:`… -s <sandbox> resume --last -`。注意**参数顺序**:`-s` / `--json` 这些
|
||||
//! `codex exec` 的选项必须排在 `resume` 子命令**之前**,写在后面 CLI 会直接报
|
||||
//! `unexpected argument`。
|
||||
//! - 护栏:Codex 没有 Claude `--settings` / OpenCode `permission` 那种逐命令 deny 清单
|
||||
//! (`.rules` execpolicy 只从 `$CODEX_HOME/rules/` 或项目目录读,实测 `-c rules=[…]`
|
||||
//! 会被静默忽略)。它的护栏是自带的 seatbelt 沙箱:`-s workspace-write` 把写入限制在
|
||||
//! 工作目录内、限制网络,且非交互下越权请求自动拒。见 [`codex_sandbox_mode`]。
|
||||
//! - 输出:`item.completed` 里 `agent_message` 是**完整文本块**(非逐字 delta),
|
||||
//! `command_execution` 是命令执行。终局看 `turn.completed` / `turn.failed`,
|
||||
//! token 用量在 `turn.completed.usage`(**不给美元成本**,故 `cost_usd = None`)。
|
||||
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use super::stream::CodingAgentEvent;
|
||||
use super::{augmented_command, wait_cancel, CodingAgentEventSink, CodingAgentRequest};
|
||||
use super::{CodingAgentError, CodingAgentPermissionMode};
|
||||
|
||||
/// 探测 `codex` 版本(`None` 表示未安装或无法运行)。复用 claude 的 `x.y.z` 解析。
|
||||
pub async fn detect_codex(exe: &str) -> Option<String> {
|
||||
let mut cmd = augmented_command(exe).await;
|
||||
let out = cmd.arg("--version").output().await.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
super::detect::parse_claude_version(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// 权限模式 → Codex `-s/--sandbox` 取值。
|
||||
///
|
||||
/// Codex 给不了逐命令 deny 清单,护栏落在它自己的 seatbelt 沙箱上(用户已就此拍板):
|
||||
/// - `Plan` → `read-only`:只读,连工作目录都不让写。
|
||||
/// - `AcceptEdits` → `workspace-write`:写入限制在工作目录内,网络受限,越权请求在无头下自动拒。
|
||||
/// - `Plan` / 遗留的 `Default` / `BypassPermissions` → `read-only`:对 Codex 统一 fail-closed。
|
||||
pub fn codex_sandbox_mode(mode: CodingAgentPermissionMode) -> &'static str {
|
||||
match mode {
|
||||
CodingAgentPermissionMode::AcceptEdits => "workspace-write",
|
||||
CodingAgentPermissionMode::Plan
|
||||
| CodingAgentPermissionMode::Default
|
||||
| CodingAgentPermissionMode::BypassPermissions => "read-only",
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造 `codex` 的命令行参数(不含可执行文件本身,也不含 prompt——prompt 走 stdin)。
|
||||
///
|
||||
/// 安全(参数注入防护):prompt **完全不进 argv**,末尾固定是 `-`(Codex 官方的
|
||||
/// 「从 stdin 读指令」写法),运行器把 prompt 写进 stdin 后关闭。这样以 `-` / `--` 开头的
|
||||
/// prompt(语音转写或被注入诱导而成)根本没有机会被 CLI 解析成 flag,也不会出现在进程
|
||||
/// 列表里泄露内容。
|
||||
pub fn build_codex_args(req: &CodingAgentRequest) -> Vec<String> {
|
||||
let mut args: Vec<String> = vec![
|
||||
"exec".into(),
|
||||
"--json".into(),
|
||||
// 关掉 ANSI:JSONL 里混进转义序列会让逐行解析变脆。
|
||||
"--color".into(),
|
||||
"never".into(),
|
||||
// Less Computer 的工作目录默认是家目录,通常不是 git 仓库;不加这个 codex 会直接拒跑。
|
||||
"--skip-git-repo-check".into(),
|
||||
"--sandbox".into(),
|
||||
codex_sandbox_mode(req.permission_mode).into(),
|
||||
// 收紧 workspace-write:Codex 默认把 `/tmp` 和 `$TMPDIR` 也算成可写根,
|
||||
// 也就是说不加这两条,"写入限制在工作目录内"就是句假话——实测语音任务真的能在
|
||||
// $TMPDIR 里落文件。设置页对用户是这么承诺的,这里就必须让它成立。
|
||||
"-c".into(),
|
||||
"sandbox_workspace_write.exclude_tmpdir_env_var=true".into(),
|
||||
"-c".into(),
|
||||
"sandbox_workspace_write.exclude_slash_tmp=true".into(),
|
||||
];
|
||||
if let Some(model) = &req.model {
|
||||
args.push("--model".into());
|
||||
args.push(model.clone());
|
||||
}
|
||||
if let Some(cwd) = &req.cwd {
|
||||
args.push("--cd".into());
|
||||
args.push(cwd.to_string_lossy().into_owned());
|
||||
}
|
||||
// 续接必须排在所有 `codex exec` 选项之后:resume 是子命令,它不认 -s/--json 这些,
|
||||
// 写反了 CLI 会报 `unexpected argument '-s' found`。
|
||||
if req.continue_session {
|
||||
args.push("resume".into());
|
||||
args.push("--last".into());
|
||||
}
|
||||
// `-` = 从 stdin 读指令。必须是最后一个位置参数。
|
||||
args.push("-".into());
|
||||
args
|
||||
}
|
||||
|
||||
/// 解析一行 `codex exec --json` 的 JSONL。
|
||||
///
|
||||
/// 关注的几类(其余忽略):
|
||||
/// - `item.completed` / `item.type = "agent_message"` → `text`(完整文本块,作为 Delta 抛出;
|
||||
/// 运行器累计成最终结果)。
|
||||
/// - `item.started` / `item.type = "command_execution"` → 命令执行,作为 ToolUse 抛出。
|
||||
/// 只认 `item.started`,避免同一条命令在 started/completed 各报一次。
|
||||
/// - `turn.failed` → Error(真·终局失败)。
|
||||
///
|
||||
/// **刻意不处理**的:`item.type = "error"`。Codex 会把「模型元数据没找到」这类**警告**也
|
||||
/// 塞成 error item,把它当终局会让一次本来成功的运行被误判为失败。真正的失败只看
|
||||
/// `turn.failed`;运行器另外把这些 error item 的文案留作失败时的补充说明。
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum CodexProtocolEvent {
|
||||
Output(CodingAgentEvent),
|
||||
TurnCompleted,
|
||||
TurnFailed(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
fn protocol_error_message(value: &serde_json::Value, fallback: &str) -> String {
|
||||
value
|
||||
.pointer("/error/message")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| value.get("message").and_then(serde_json::Value::as_str))
|
||||
.or_else(|| value.get("error").and_then(serde_json::Value::as_str))
|
||||
.unwrap_or(fallback)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn parse_codex_protocol_line(session_id: &str, line: &str) -> Option<CodexProtocolEvent> {
|
||||
let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
|
||||
let event_type = value.get("type")?.as_str()?;
|
||||
match event_type {
|
||||
"turn.completed" => Some(CodexProtocolEvent::TurnCompleted),
|
||||
"turn.failed" => Some(CodexProtocolEvent::TurnFailed(protocol_error_message(
|
||||
&value,
|
||||
"Codex 本轮执行失败",
|
||||
))),
|
||||
"error" => Some(CodexProtocolEvent::Error(protocol_error_message(
|
||||
&value,
|
||||
"Codex 协议错误",
|
||||
))),
|
||||
_ => parse_codex_json_line(session_id, line).map(CodexProtocolEvent::Output),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_codex_json_line(session_id: &str, line: &str) -> Option<CodingAgentEvent> {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
let event_type = v.get("type")?.as_str()?;
|
||||
if event_type == "error" {
|
||||
return Some(CodingAgentEvent::Error {
|
||||
session_id: session_id.to_string(),
|
||||
message: protocol_error_message(&v, "Codex 协议错误"),
|
||||
});
|
||||
}
|
||||
match event_type {
|
||||
"item.completed" => {
|
||||
let item = v.get("item")?;
|
||||
match item.get("type")?.as_str()? {
|
||||
"agent_message" => {
|
||||
let text = item.get("text")?.as_str()?.to_string();
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(CodingAgentEvent::Delta {
|
||||
session_id: session_id.to_string(),
|
||||
text,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
"item.started" => {
|
||||
let item = v.get("item")?;
|
||||
match item.get("type")?.as_str()? {
|
||||
// 展示用途:取命令首个 token 当「工具名」,整条命令太长塞不进胶囊。
|
||||
"command_execution" => {
|
||||
let command = item.get("command")?.as_str()?;
|
||||
Some(CodingAgentEvent::ToolUse {
|
||||
session_id: session_id.to_string(),
|
||||
name: command_display_name(command),
|
||||
})
|
||||
}
|
||||
"file_change" | "patch_apply" => Some(CodingAgentEvent::ToolUse {
|
||||
session_id: session_id.to_string(),
|
||||
name: "edit".to_string(),
|
||||
}),
|
||||
"mcp_tool_call" => {
|
||||
let name = item
|
||||
.get("tool")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("mcp")
|
||||
.to_string();
|
||||
Some(CodingAgentEvent::ToolUse {
|
||||
session_id: session_id.to_string(),
|
||||
name,
|
||||
})
|
||||
}
|
||||
"web_search" => Some(CodingAgentEvent::ToolUse {
|
||||
session_id: session_id.to_string(),
|
||||
name: "web_search".to_string(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
"turn.failed" => {
|
||||
let message = v
|
||||
.pointer("/error/message")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| v.get("error").and_then(serde_json::Value::as_str))
|
||||
.unwrap_or("Codex 本轮执行失败")
|
||||
.to_string();
|
||||
Some(CodingAgentEvent::Error {
|
||||
session_id: session_id.to_string(),
|
||||
message,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从一条 shell 命令里取出用于展示的「工具名」。
|
||||
///
|
||||
/// Codex 的 `command_execution` 给的是整条命令(常见形如 `/bin/zsh -lc 'cat a.txt'`),
|
||||
/// 直接塞进胶囊又长又不可读。这里剥掉 shell 包装、取真正执行的那个命令名。
|
||||
fn command_display_name(command: &str) -> String {
|
||||
let trimmed = command.trim();
|
||||
// `/bin/zsh -lc '<真实命令>'` / `bash -c "<真实命令>"`:取引号里的第一个 token。
|
||||
if let Some(idx) = trimmed.find(|c| c == '\'' || c == '"') {
|
||||
let quote = trimmed.as_bytes()[idx] as char;
|
||||
let inner = &trimmed[idx + 1..];
|
||||
if let Some(end) = inner.find(quote) {
|
||||
if let Some(first) = inner[..end].split_whitespace().next() {
|
||||
if !first.is_empty() {
|
||||
return first.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("bash")
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("bash")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CodexProtocolState {
|
||||
accumulated: String,
|
||||
saw_turn_completed: bool,
|
||||
protocol_error: Option<String>,
|
||||
}
|
||||
|
||||
impl CodexProtocolState {
|
||||
fn observe(&mut self, event: CodexProtocolEvent) -> Option<CodingAgentEvent> {
|
||||
match event {
|
||||
CodexProtocolEvent::Output(event) => {
|
||||
if let CodingAgentEvent::Delta { text, .. } = &event {
|
||||
self.accumulated.push_str(text);
|
||||
}
|
||||
Some(event)
|
||||
}
|
||||
CodexProtocolEvent::TurnCompleted => {
|
||||
self.saw_turn_completed = true;
|
||||
None
|
||||
}
|
||||
CodexProtocolEvent::TurnFailed(message) | CodexProtocolEvent::Error(message) => {
|
||||
if self.protocol_error.is_none() {
|
||||
self.protocol_error = Some(message);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexRunFailure {
|
||||
error: CodingAgentError,
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn finalize_codex_run(
|
||||
session_id: &str,
|
||||
state: &CodexProtocolState,
|
||||
process_succeeded: bool,
|
||||
exit_code: Option<i32>,
|
||||
stderr: &str,
|
||||
) -> Result<CodingAgentEvent, CodexRunFailure> {
|
||||
if let Some(message) = &state.protocol_error {
|
||||
return Err(CodexRunFailure {
|
||||
error: CodingAgentError::Protocol(message.clone()),
|
||||
message: message.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if !process_succeeded {
|
||||
let message = stderr.lines().last().unwrap_or("").trim().to_string();
|
||||
return Err(CodexRunFailure {
|
||||
error: CodingAgentError::ProcessExit(exit_code),
|
||||
message: if message.is_empty() {
|
||||
format!("agent 异常退出 (code={exit_code:?})")
|
||||
} else {
|
||||
message
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if !state.saw_turn_completed {
|
||||
let message = "Codex 进程结束但未收到 turn.completed".to_string();
|
||||
return Err(CodexRunFailure {
|
||||
error: CodingAgentError::Protocol(message.clone()),
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(CodingAgentEvent::Completed {
|
||||
session_id: session_id.to_string(),
|
||||
text: state.accumulated.trim().to_string(),
|
||||
cost_usd: None,
|
||||
duration_ms: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 无头跑一次 Codex:prompt 写进 stdin,逐行解析 JSONL,把事件投到 `sink`。
|
||||
/// 支持取消与超时(都会 kill 子进程)。
|
||||
pub async fn run_codex_agent(
|
||||
exe: &str,
|
||||
req: CodingAgentRequest,
|
||||
sink: CodingAgentEventSink,
|
||||
cancel: Arc<AtomicBool>,
|
||||
) -> Result<(), CodingAgentError> {
|
||||
let args = build_codex_args(&req);
|
||||
let mut cmd = augmented_command(exe).await;
|
||||
cmd.args(&args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if let Some(cwd) = &req.cwd {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
CodingAgentError::ExecutableNotFound(exe.to_string())
|
||||
} else {
|
||||
CodingAgentError::Spawn(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
// 写入 prompt 后立即关闭 stdin,`-` 才会读到 EOF 开始处理。
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = stdin.write_all(req.prompt.as_bytes()).await;
|
||||
let _ = stdin.shutdown().await;
|
||||
}
|
||||
|
||||
let stderr_task = child.stderr.take().map(|s| {
|
||||
tokio::spawn(async move {
|
||||
let mut buf = String::new();
|
||||
let _ = BufReader::new(s).read_to_string(&mut buf).await;
|
||||
buf
|
||||
})
|
||||
});
|
||||
|
||||
let _ = sink.send(CodingAgentEvent::Started {
|
||||
session_id: req.session_id.clone(),
|
||||
});
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| CodingAgentError::Io("子进程无 stdout".into()))?;
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(req.timeout_secs.max(1));
|
||||
// Codex 的 turn.completed 不带最终文本:累计所有 agent_message 块,收到终局后合成 Completed。
|
||||
let mut protocol_state = CodexProtocolState::default();
|
||||
let mut outcome: Result<(), CodingAgentError> = Ok(());
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = wait_cancel(&cancel) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = sink.send(CodingAgentEvent::Cancelled { session_id: req.session_id.clone() });
|
||||
outcome = Err(CodingAgentError::Cancelled);
|
||||
break;
|
||||
}
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = sink.send(CodingAgentEvent::Error {
|
||||
session_id: req.session_id.clone(),
|
||||
message: format!("运行超时({}s)", req.timeout_secs),
|
||||
});
|
||||
outcome = Err(CodingAgentError::Timeout(req.timeout_secs));
|
||||
break;
|
||||
}
|
||||
line = lines.next_line() => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
if let Some(protocol_event) =
|
||||
parse_codex_protocol_line(&req.session_id, &l)
|
||||
{
|
||||
if let Some(ev) =
|
||||
protocol_state.observe(protocol_event)
|
||||
{
|
||||
let _ = sink.send(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => break, // EOF:交给终局校验
|
||||
Err(e) => {
|
||||
outcome = Err(CodingAgentError::Io(e.to_string()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| CodingAgentError::Io(e.to_string()))?;
|
||||
|
||||
if let Err(error) = outcome {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let stderr = match stderr_task {
|
||||
Some(t) => t.await.unwrap_or_default(),
|
||||
None => String::new(),
|
||||
};
|
||||
match finalize_codex_run(
|
||||
&req.session_id,
|
||||
&protocol_state,
|
||||
status.success(),
|
||||
status.code(),
|
||||
&stderr,
|
||||
) {
|
||||
Ok(event) => {
|
||||
let _ = sink.send(event);
|
||||
Ok(())
|
||||
}
|
||||
Err(failure) => {
|
||||
let _ = sink.send(CodingAgentEvent::Error {
|
||||
session_id: req.session_id.clone(),
|
||||
message: failure.message,
|
||||
});
|
||||
Err(failure.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn arg_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
|
||||
args.iter()
|
||||
.position(|a| a == flag)
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_args_are_json_and_prompt_never_enters_argv() {
|
||||
let req = CodingAgentRequest::new("s1", "hello world");
|
||||
let args = build_codex_args(&req);
|
||||
assert_eq!(args.first().map(|s| s.as_str()), Some("exec"));
|
||||
assert!(args.contains(&"--json".to_string()));
|
||||
assert_eq!(arg_value(&args, "--color"), Some("never"));
|
||||
// prompt 走 stdin:argv 里不能有它的任何痕迹。
|
||||
assert!(!args.iter().any(|a| a.contains("hello world")));
|
||||
// 末尾固定是 `-`(从 stdin 读指令)。
|
||||
assert_eq!(args.last().map(|s| s.as_str()), Some("-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_marker_stays_last_even_with_every_flag_set() {
|
||||
let mut req = CodingAgentRequest::new("s", "p");
|
||||
req.model = Some("gpt-5".into());
|
||||
req.cwd = Some(PathBuf::from("/tmp/work"));
|
||||
req.permission_mode = CodingAgentPermissionMode::AcceptEdits;
|
||||
let args = build_codex_args(&req);
|
||||
assert_eq!(args.last().map(|s| s.as_str()), Some("-"));
|
||||
assert_eq!(arg_value(&args, "--model"), Some("gpt-5"));
|
||||
assert_eq!(arg_value(&args, "--cd"), Some("/tmp/work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_mode_maps_to_sandbox_flag() {
|
||||
assert_eq!(
|
||||
codex_sandbox_mode(CodingAgentPermissionMode::Plan),
|
||||
"read-only"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_sandbox_mode(CodingAgentPermissionMode::AcceptEdits),
|
||||
"workspace-write"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_sandbox_mode(CodingAgentPermissionMode::Default),
|
||||
"read-only"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_sandbox_mode(CodingAgentPermissionMode::BypassPermissions),
|
||||
"read-only"
|
||||
);
|
||||
let mut req = CodingAgentRequest::new("s", "p");
|
||||
req.permission_mode = CodingAgentPermissionMode::Plan;
|
||||
assert_eq!(
|
||||
arg_value(&build_codex_args(&req), "--sandbox"),
|
||||
Some("read-only")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continue_session_puts_resume_after_exec_options_but_before_stdin_marker() {
|
||||
// 关键不变量:`resume` 必须排在 --json / --sandbox 这些 exec 选项之后,
|
||||
// 否则真实 CLI 会报 `unexpected argument '-s' found`(已对本机 0.146.0 核实)。
|
||||
let mut req = CodingAgentRequest::new("s", "p");
|
||||
req.continue_session = true;
|
||||
let args = build_codex_args(&req);
|
||||
let resume_idx = args.iter().position(|a| a == "resume").unwrap();
|
||||
let sandbox_idx = args.iter().position(|a| a == "--sandbox").unwrap();
|
||||
let json_idx = args.iter().position(|a| a == "--json").unwrap();
|
||||
assert!(sandbox_idx < resume_idx, "--sandbox 必须在 resume 之前");
|
||||
assert!(json_idx < resume_idx, "--json 必须在 resume 之前");
|
||||
assert!(args.contains(&"--last".to_string()));
|
||||
assert_eq!(args.last().map(|s| s.as_str()), Some("-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_write_excludes_tmp_dirs() {
|
||||
// 回归防线:不加这两条,Codex 的 workspace-write 会把 /tmp 和 $TMPDIR 也放开
|
||||
// (实测能在 $TMPDIR 里落文件),设置页承诺的「限制在工作目录内」就不成立。
|
||||
let req = CodingAgentRequest::new("s", "p");
|
||||
let args = build_codex_args(&req);
|
||||
assert!(args
|
||||
.iter()
|
||||
.any(|a| a == "sandbox_workspace_write.exclude_tmpdir_env_var=true"));
|
||||
assert!(args
|
||||
.iter()
|
||||
.any(|a| a == "sandbox_workspace_write.exclude_slash_tmp=true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_git_repo_check_is_always_on() {
|
||||
// Less Computer 的工作目录默认是家目录(通常不是 git 仓库),少了这个 codex 直接拒跑。
|
||||
let req = CodingAgentRequest::new("s", "p");
|
||||
assert!(build_codex_args(&req).contains(&"--skip-git-repo-check".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_agent_message_as_delta() {
|
||||
let line = r#"{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"你好"}}"#;
|
||||
assert_eq!(
|
||||
parse_codex_json_line("s1", line),
|
||||
Some(CodingAgentEvent::Delta {
|
||||
session_id: "s1".into(),
|
||||
text: "你好".into()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_command_execution_as_tool_use_with_readable_name() {
|
||||
let line = r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc 'cat a.txt'","status":"in_progress"}}"#;
|
||||
assert_eq!(
|
||||
parse_codex_json_line("s1", line),
|
||||
Some(CodingAgentEvent::ToolUse {
|
||||
session_id: "s1".into(),
|
||||
name: "cat".into()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_execution_is_reported_once_not_twice() {
|
||||
// 同一条命令 codex 会发 item.started + item.completed 两行;只认 started,
|
||||
// 否则胶囊里每条命令都会重复显示一次。
|
||||
let completed = r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc 'cat a.txt'","exit_code":0,"status":"completed"}}"#;
|
||||
assert_eq!(parse_codex_json_line("s1", completed), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_wrapper_is_stripped_from_tool_name() {
|
||||
assert_eq!(command_display_name("/bin/zsh -lc 'git status'"), "git");
|
||||
assert_eq!(command_display_name("bash -c \"npm run build\""), "npm");
|
||||
// 没有 shell 包装时退回命令本身,且剥掉目录前缀。
|
||||
assert_eq!(command_display_name("/usr/bin/env python3 x.py"), "env");
|
||||
assert_eq!(command_display_name("ls -la"), "ls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_turn_failed_as_error() {
|
||||
let line = r#"{"type":"turn.failed","error":{"message":"Rate limit exceeded"}}"#;
|
||||
assert_eq!(
|
||||
parse_codex_json_line("s1", line),
|
||||
Some(CodingAgentEvent::Error {
|
||||
session_id: "s1".into(),
|
||||
message: "Rate limit exceeded".into(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_error_items_do_not_fail_the_run() {
|
||||
// 回归防线:codex 把「模型元数据没找到」这类警告也塞成 error item。实测本机
|
||||
// 0.146.0 每次运行都会发这一行,一旦当成终局失败,所有运行都会被误判为失败。
|
||||
let warning = r#"{"type":"item.completed","item":{"id":"item_0","type":"error","message":"Model metadata for `grok-4.6` not found. Defaulting to fallback metadata; this can degrade performance and cause issues."}}"#;
|
||||
assert_eq!(parse_codex_json_line("s1", warning), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_parser_distinguishes_terminal_events() {
|
||||
assert_eq!(
|
||||
parse_codex_protocol_line(
|
||||
"s1",
|
||||
r#"{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":2}}"#,
|
||||
),
|
||||
Some(CodexProtocolEvent::TurnCompleted),
|
||||
);
|
||||
assert_eq!(
|
||||
parse_codex_protocol_line(
|
||||
"s1",
|
||||
r#"{"type":"turn.failed","error":{"message":"Rate limit exceeded"}}"#,
|
||||
),
|
||||
Some(CodexProtocolEvent::TurnFailed("Rate limit exceeded".into())),
|
||||
);
|
||||
assert_eq!(
|
||||
parse_codex_protocol_line("s1", r#"{"type":"error","message":"stream disconnected"}"#),
|
||||
Some(CodexProtocolEvent::Error("stream disconnected".into())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_completion_requires_turn_completed() {
|
||||
let mut state = CodexProtocolState::default();
|
||||
assert!(state
|
||||
.observe(CodexProtocolEvent::Output(CodingAgentEvent::Delta {
|
||||
session_id: "s1".into(),
|
||||
text: "done".into(),
|
||||
}))
|
||||
.is_some());
|
||||
state.observe(CodexProtocolEvent::TurnCompleted);
|
||||
assert!(matches!(
|
||||
finalize_codex_run("s1", &state, true, Some(0), ""),
|
||||
Ok(CodingAgentEvent::Completed { text, .. }) if text == "done"
|
||||
));
|
||||
|
||||
let mut missing_terminal = CodexProtocolState::default();
|
||||
missing_terminal.observe(CodexProtocolEvent::Output(CodingAgentEvent::Delta {
|
||||
session_id: "s1".into(),
|
||||
text: "partial".into(),
|
||||
}));
|
||||
let failure = finalize_codex_run("s1", &missing_terminal, true, Some(0), "")
|
||||
.expect_err("EOF without turn.completed must fail");
|
||||
assert!(matches!(failure.error, CodingAgentError::Protocol(_)));
|
||||
assert!(failure.message.contains("turn.completed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_error_wins_even_if_completion_follows_it() {
|
||||
let mut state = CodexProtocolState::default();
|
||||
state.observe(CodexProtocolEvent::Error("stream disconnected".into()));
|
||||
state.observe(CodexProtocolEvent::TurnCompleted);
|
||||
|
||||
let failure = finalize_codex_run("s1", &state, true, Some(0), "")
|
||||
.expect_err("a protocol error cannot be repaired by a later completion");
|
||||
assert!(matches!(
|
||||
failure.error,
|
||||
CodingAgentError::Protocol(ref message) if message == "stream disconnected"
|
||||
));
|
||||
assert_eq!(failure.message, "stream disconnected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_item_can_be_followed_by_successful_completion() {
|
||||
let warning = r#"{"type":"item.completed","item":{"type":"error","message":"metadata warning"}}"#;
|
||||
assert_eq!(parse_codex_protocol_line("s1", warning), None);
|
||||
|
||||
let mut state = CodexProtocolState::default();
|
||||
state.observe(CodexProtocolEvent::Output(CodingAgentEvent::Delta {
|
||||
session_id: "s1".into(),
|
||||
text: "done".into(),
|
||||
}));
|
||||
state.observe(CodexProtocolEvent::TurnCompleted);
|
||||
assert!(finalize_codex_run("s1", &state, true, Some(0), "").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonzero_exit_is_reported_when_protocol_has_no_error() {
|
||||
let mut state = CodexProtocolState::default();
|
||||
state.observe(CodexProtocolEvent::TurnCompleted);
|
||||
let failure = finalize_codex_run("s1", &state, false, Some(23), "fatal: bad input\n")
|
||||
.expect_err("a nonzero exit must fail");
|
||||
assert!(matches!(
|
||||
failure.error,
|
||||
CodingAgentError::ProcessExit(Some(23))
|
||||
));
|
||||
assert_eq!(failure.message, "fatal: bad input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_lifecycle_noise_and_garbage() {
|
||||
assert_eq!(
|
||||
parse_codex_json_line("s1", r#"{"type":"thread.started","thread_id":"abc"}"#),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_codex_json_line("s1", r#"{"type":"turn.started"}"#),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_codex_json_line(
|
||||
"s1",
|
||||
r#"{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":2}}"#
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_codex_json_line("s1", r#"{"type":"item.completed","item":{"type":"reasoning"}}"#),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_codex_json_line("s1", "not json"), None);
|
||||
assert_eq!(parse_codex_json_line("s1", ""), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// 打真实 `codex` CLI 的联机验收。默认 `#[ignore]`——要花钱、要网络、要本机装了 codex。
|
||||
/// 手动跑:`cargo test --lib coding_agent::codex::live -- --ignored --nocapture --test-threads=1`
|
||||
#[cfg(test)]
|
||||
mod live {
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
fn fixture_dir() -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("openless-live-codex-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.txt"), "hello\n").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
struct Collected {
|
||||
tools: Vec<String>,
|
||||
completed: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn codex_test_exe() -> String {
|
||||
std::env::var("OPENLESS_CODEX_TEST_EXE")
|
||||
.ok()
|
||||
.filter(|exe| !exe.trim().is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
if cfg!(windows) {
|
||||
"codex.cmd".to_string()
|
||||
} else {
|
||||
"codex".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn run(prompt: &str, dir: &std::path::Path, mode: CodingAgentPermissionMode) -> Collected {
|
||||
let mut req = CodingAgentRequest::new("live", prompt.to_string());
|
||||
req.cwd = Some(dir.to_path_buf());
|
||||
req.permission_mode = mode;
|
||||
req.timeout_secs = 300;
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let exe = codex_test_exe();
|
||||
let handle = tokio::spawn(async move { run_codex_agent(&exe, req, tx, cancel).await });
|
||||
let mut c = Collected {
|
||||
tools: Vec::new(),
|
||||
completed: None,
|
||||
error: None,
|
||||
};
|
||||
while let Some(ev) = rx.recv().await {
|
||||
match ev {
|
||||
CodingAgentEvent::ToolUse { name, .. } => c.tools.push(name),
|
||||
CodingAgentEvent::Completed { text, .. } => c.completed = Some(text),
|
||||
CodingAgentEvent::Error { message, .. } => c.error = Some(message),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _ = handle.await;
|
||||
c
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "打真实 codex CLI:要花钱、要网络"]
|
||||
async fn reads_a_file_and_reports_tool_use() {
|
||||
let dir = fixture_dir();
|
||||
let c = run(
|
||||
"读一下当前目录的 a.txt,然后只回答文件里的那个单词,不要解释。",
|
||||
&dir,
|
||||
CodingAgentPermissionMode::AcceptEdits,
|
||||
)
|
||||
.await;
|
||||
println!("[codex] tools={:?} completed={:?} error={:?}", c.tools, c.completed, c.error);
|
||||
assert!(c.error.is_none(), "不应报错: {:?}", c.error);
|
||||
assert!(!c.tools.is_empty(), "应至少有一次工具调用(读文件)");
|
||||
let text = c.completed.expect("应有终局文本");
|
||||
assert!(text.to_lowercase().contains("hello"), "终局文本应含 hello,实际: {text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "要本机装了 codex"]
|
||||
fn hardening_actually_narrows_the_writable_roots() {
|
||||
// 护栏最不能忍的失效方式是**无声变宽**。而 codex 对写错/被改名的 `-c` 键是
|
||||
// 静默接受的(实测:传一个不存在的键,一个字都不说)——所以哪天上游把
|
||||
// `sandbox_workspace_write.exclude_slash_tmp` 改名,我们那道 /tmp 护栏就没了,
|
||||
// 日志里什么都不会有。
|
||||
//
|
||||
// `--strict-config` 能让它报错,但那条路走不通:它会连用户自己的
|
||||
// `~/.codex/config.toml` 一起严格校验,而 0.x 阶段用户配置带过时字段是常态
|
||||
// (本机那份就有),开了 strict 整个后端直接起不来。
|
||||
//
|
||||
// 所以这里不验键名拼写,**验效果**:`codex debug prompt-input` 不调模型、不花钱,
|
||||
// 会把当前生效的可写根打进给模型看的权限说明里。断言它只剩工作目录。
|
||||
// 键名怎么改都瞒不过这条。
|
||||
let dir = fixture_dir();
|
||||
let codex_exe = codex_test_exe();
|
||||
let run = |extra: &[&str]| -> String {
|
||||
let mut cmd = std::process::Command::new(&codex_exe);
|
||||
cmd.args(["debug", "prompt-input"])
|
||||
.args(["-c", "sandbox_mode=\"workspace-write\""])
|
||||
.args(extra)
|
||||
.arg("x")
|
||||
.current_dir(&dir);
|
||||
let out = cmd.output().expect("codex 跑不起来");
|
||||
String::from_utf8_lossy(&out.stdout).into_owned()
|
||||
};
|
||||
|
||||
let hardening: Vec<&str> = build_codex_args(&{
|
||||
let mut req = CodingAgentRequest::new("s", "p");
|
||||
req.permission_mode = CodingAgentPermissionMode::AcceptEdits;
|
||||
req
|
||||
})
|
||||
.iter()
|
||||
.enumerate()
|
||||
// 只挑出 `-c <key=value>` 这些对,别把 exec/--json 之类也带进 debug 子命令。
|
||||
.filter(|(i, a)| {
|
||||
a.as_str() == "-c" || (*i > 0 && a.starts_with("sandbox_workspace_write."))
|
||||
})
|
||||
.map(|(_, a)| Box::leak(a.clone().into_boxed_str()) as &str)
|
||||
.collect();
|
||||
assert!(
|
||||
!hardening.is_empty(),
|
||||
"build_codex_args 里已经没有 -c 加固参数了?护栏被删了"
|
||||
);
|
||||
|
||||
let before = run(&[]);
|
||||
let after = run(&hardening);
|
||||
let roots = |s: &str| {
|
||||
s.split("writable root")
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(400)
|
||||
.collect::<String>()
|
||||
};
|
||||
let (b, a) = (roots(&before), roots(&after));
|
||||
println!("[codex] 无加固可写根: {b}");
|
||||
println!("[codex] 有加固可写根: {a}");
|
||||
|
||||
// 数反引号里的路径个数,而不是找 "/tmp" 这个字面量——$TMPDIR 在 macOS 上长得是
|
||||
// `/private/var/folders/…/T`,按字面量找根本抓不到它回归。
|
||||
let count_roots = |s: &str| s.matches('`').count() / 2;
|
||||
|
||||
// 加固前该有多个可写根(/tmp + $TMPDIR + cwd)。这条不是在测我们,是在确认
|
||||
// 「codex 默认确实放开了 tmp」这个前提还成立——前提没了,这条测试就该重写。
|
||||
assert!(
|
||||
count_roots(&b) > 1,
|
||||
"codex 的默认行为变了:加固前就只有一个可写根。这条测试需要重写。实际: {b}"
|
||||
);
|
||||
// 加固后必须**只剩一个**,且就是工作目录。
|
||||
assert_eq!(
|
||||
count_roots(&a),
|
||||
1,
|
||||
"护栏失效:加固后可写根不止工作目录一个。多半是 -c 的键名被上游改了\
|
||||
(codex 对未知键静默忽略,不会报错)。实际: {a}"
|
||||
);
|
||||
let cwd = std::fs::canonicalize(&dir).unwrap();
|
||||
assert!(
|
||||
a.contains(&cwd.to_string_lossy().into_owned()),
|
||||
"剩下的那个可写根不是工作目录。实际: {a}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "打真实 codex CLI:要花钱、要网络"]
|
||||
async fn sandbox_blocks_writes_outside_the_workspace() {
|
||||
// 护栏验收:workspace-write 沙箱必须挡住工作目录之外的写入。
|
||||
// 注意断言的是「文件真的没被创建」,不是「模型说它没写」——模型会撒谎。
|
||||
let dir = fixture_dir();
|
||||
let victim = std::env::temp_dir().join(format!("openless-codex-escape-{}.txt", uuid::Uuid::new_v4()));
|
||||
let c = run(
|
||||
&format!(
|
||||
"在 {} 这个绝对路径创建一个文件,内容写 pwned。做完只回答 DONE 或 BLOCKED。",
|
||||
victim.display()
|
||||
),
|
||||
&dir,
|
||||
CodingAgentPermissionMode::AcceptEdits,
|
||||
)
|
||||
.await;
|
||||
println!("[codex] 越权写入结果 completed={:?} error={:?}", c.completed, c.error);
|
||||
assert!(!victim.exists(), "沙箱失效:工作目录外的文件被创建了 {}", victim.display());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use tauri::{AppHandle, Emitter, Window};
|
||||
|
||||
use super::detect::{has_computer_use_mcp, McpServerStatus};
|
||||
use super::guard::build_guard_settings_json;
|
||||
use super::codex::detect_codex;
|
||||
use super::opencode::{detect_opencode, list_opencode_models};
|
||||
use super::{
|
||||
claude_mcp_list, create_git_snapshot, detect_claude, run_claude_agent,
|
||||
@@ -137,11 +138,13 @@ pub struct OpenCodeDetectionWire {
|
||||
pub exe: String,
|
||||
}
|
||||
|
||||
fn normalize_opencode_exe(exe: Option<String>) -> Result<String, String> {
|
||||
/// 校验用户填的可执行文件:只接受裸文件名或绝对路径,拒绝 `..` 与相对路径。
|
||||
/// 留空时回落到 `default_exe`。
|
||||
fn normalize_generic_exe(exe: Option<String>, default_exe: &str) -> Result<String, String> {
|
||||
let exe = exe
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|e| !e.is_empty())
|
||||
.unwrap_or_else(|| "opencode".to_string());
|
||||
.unwrap_or_else(|| default_exe.to_string());
|
||||
if exe.contains("..") {
|
||||
return Err("不允许的可执行文件路径: 包含 '..'".into());
|
||||
}
|
||||
@@ -151,6 +154,38 @@ fn normalize_opencode_exe(exe: Option<String>) -> Result<String, String> {
|
||||
Ok(exe)
|
||||
}
|
||||
|
||||
fn normalize_opencode_exe(exe: Option<String>) -> Result<String, String> {
|
||||
normalize_generic_exe(exe, "opencode")
|
||||
}
|
||||
|
||||
/// 检测 Codex / dsh 是否安装、版本,供设置页提示用户先装 / 先登录。
|
||||
///
|
||||
/// 与 [`coding_agent_detect_opencode`] 共用 [`OpenCodeDetectionWire`] 这个「装没装 + 版本 +
|
||||
/// 实际用的可执行文件」三元组——三家后端的检测结果形状完全一致,没必要各造一个 wire 类型。
|
||||
///
|
||||
/// `provider` 取 `UserPreferences.coding_agent_provider` 的字符串值。Claude / OpenCode 走
|
||||
/// 各自已有的命令(它们还要查 MCP / 模型列表),这里只认 `codex-cli`。
|
||||
#[tauri::command]
|
||||
pub async fn coding_agent_detect_cli(
|
||||
window: Window,
|
||||
provider: String,
|
||||
exe: Option<String>,
|
||||
) -> Result<OpenCodeDetectionWire, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let parsed = super::CodingAgentProvider::from_pref(&provider);
|
||||
let default_exe = match parsed {
|
||||
super::CodingAgentProvider::CodexCli => parsed.default_exe(),
|
||||
_ => return Err(format!("该后端不走通用检测: {provider}")),
|
||||
};
|
||||
let exe = normalize_generic_exe(exe, default_exe)?;
|
||||
let version = detect_codex(&exe).await;
|
||||
Ok(OpenCodeDetectionWire {
|
||||
installed: version.is_some(),
|
||||
version,
|
||||
exe,
|
||||
})
|
||||
}
|
||||
|
||||
/// 检测 `opencode` 是否安装、版本。语音 Agent 选了 OpenCode 后端时,设置页据此提示
|
||||
/// 用户是否需要先 `npm i -g opencode-ai` / 登录。
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
//! 无头 Claude Code 调用子系统(「快速 Agent」后端)。
|
||||
//! 无头 coding agent 调用子系统(「快速 Agent」/ Less Computer 后端)。
|
||||
//!
|
||||
//! 三个后端各有一个同形的适配器,共用 [`args::CodingAgentRequest`] /
|
||||
//! [`stream::CodingAgentEvent`] / [`CodingAgentError`]:
|
||||
//!
|
||||
//! | 后端 | 适配器 | prompt 入口 | 护栏 |
|
||||
//! |---|---|---|---|
|
||||
//! | Claude Code | 本模块顶层 + [`args`] / [`stream`] | stdin | `--settings` 逐命令 deny 清单 |
|
||||
//! | OpenCode | [`opencode`] | argv(`--` 之后) | `OPENCODE_CONFIG_CONTENT` deny 清单 |
|
||||
//! | Codex | [`codex`] | stdin(`-`) | 自带 seatbelt 沙箱 `-s` |
|
||||
//!
|
||||
//! Codex **给不了逐命令 deny 清单**,护栏落在它自己的沙箱档位上;对应地,
|
||||
//! 「撞了 deny → 弹审批卡 → 放行重跑」这条链路对它们不生效,撞墙时如实报错。
|
||||
//!
|
||||
//! - [`args`]:`claude -p` 参数构造。
|
||||
//! - [`stream`]:stream-json 输出解析为 [`stream::CodingAgentEvent`]。
|
||||
@@ -9,6 +21,7 @@
|
||||
//! 那些由 coordinator 串联(镜像现有 QA 链路)。
|
||||
|
||||
pub mod args;
|
||||
pub mod codex;
|
||||
pub mod commands;
|
||||
pub mod detect;
|
||||
pub mod guard;
|
||||
@@ -28,6 +41,7 @@ pub use args::{
|
||||
build_claude_args, resolve_coding_agent_model, CodingAgentPermissionMode, CodingAgentProvider,
|
||||
CodingAgentRequest,
|
||||
};
|
||||
pub use codex::run_codex_agent;
|
||||
pub use detect::McpServerStatus;
|
||||
pub use opencode::run_opencode_agent;
|
||||
pub use stream::{parse_stream_json_line, CodingAgentEvent};
|
||||
@@ -63,6 +77,8 @@ pub enum CodingAgentError {
|
||||
Spawn(String),
|
||||
#[error("agent 进程异常退出 (code={0:?})")]
|
||||
ProcessExit(Option<i32>),
|
||||
#[error("agent 协议错误: {0}")]
|
||||
Protocol(String),
|
||||
#[error("agent 运行超时 ({0}s)")]
|
||||
Timeout(u64),
|
||||
#[error("已取消")]
|
||||
@@ -71,34 +87,171 @@ pub enum CodingAgentError {
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// 给 GUI 进程补 PATH / HOME:macOS 从 Finder 启动的进程不继承登录 shell 环境,
|
||||
/// `claude` 常装在 `~/.local/bin`、Homebrew 在 `/opt/homebrew/bin`。
|
||||
pub(super) fn augment_env(cmd: &mut Command) {
|
||||
let mut path = std::env::var("PATH").unwrap_or_default();
|
||||
if let Some(home_os) = std::env::var_os("HOME") {
|
||||
let home = home_os.to_string_lossy().to_string();
|
||||
let extras = [
|
||||
format!("{home}/.local/bin"),
|
||||
"/opt/homebrew/bin".to_string(),
|
||||
"/usr/local/bin".to_string(),
|
||||
];
|
||||
for extra in extras {
|
||||
if !path.split(':').any(|p| p == extra) {
|
||||
path = if path.is_empty() {
|
||||
extra
|
||||
} else {
|
||||
format!("{extra}:{path}")
|
||||
};
|
||||
}
|
||||
}
|
||||
cmd.env("HOME", home);
|
||||
/// 登录 shell 的 PATH,整个进程只解析一次(解析失败缓存 `None`,不反复重试)。
|
||||
#[cfg(unix)]
|
||||
static LOGIN_SHELL_PATH: tokio::sync::OnceCell<Option<String>> = tokio::sync::OnceCell::const_new();
|
||||
|
||||
/// 从 shell 里回读 PATH 用的哨兵串。
|
||||
///
|
||||
/// 交互式 shell 的 rc 文件可能自己往 stdout 打东西(提示、版本横幅、插件问候),
|
||||
/// 直接取整个 stdout 会把这些一起当成 PATH。加个哨兵,只取它之后的内容。
|
||||
#[cfg(unix)]
|
||||
const SHELL_PATH_SENTINEL: &str = "__OPENLESS_PATH__";
|
||||
|
||||
/// 跑一次 shell 把 PATH 打回来。`flags` 形如 `-lic` / `-lc`。超时返回 `None`。
|
||||
#[cfg(unix)]
|
||||
async fn probe_shell_path(
|
||||
shell: &str,
|
||||
flags: &str,
|
||||
deadline: tokio::time::Instant,
|
||||
) -> Option<String> {
|
||||
// 注意 `%s` 是给 shell 的 printf 用的字面量,`{SHELL_PATH_SENTINEL}` 才是 Rust 插值。
|
||||
let script = format!("printf '{SHELL_PATH_SENTINEL}%s' \"$PATH\"");
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.args([flags, &script])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
let out = tokio::time::timeout_at(deadline, cmd.output())
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
// 只取哨兵之后的内容;没有哨兵说明这次输出不可信。
|
||||
let path = stdout
|
||||
.rsplit(SHELL_PATH_SENTINEL)
|
||||
.next()?
|
||||
.trim()
|
||||
.to_string();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(path)
|
||||
}
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
|
||||
pub(super) fn augmented_command(exe: &str) -> Command {
|
||||
/// 问用户的 shell 要一份真实 PATH。**仅 unix**。
|
||||
///
|
||||
/// macOS 从 Finder / LaunchServices 启动的 GUI 进程拿到的是一份极简 PATH,不经过用户的 shell
|
||||
/// 配置,所以任何由版本管理器安装的 CLI 都不在里面。这对本模块是致命的:`dsh` 常在 nvm 的
|
||||
/// `~/.nvm/versions/node/<版本>/bin`、`codex` 常在全局 npm 前缀——这些路径**无法硬编码**
|
||||
/// (带版本号、随配置变)。唯一可靠的办法是问 shell 自己。
|
||||
///
|
||||
/// 先试 `-lic`(登录 + 交互)再退 `-lc`(仅登录),因为 **nvm 这类版本管理器通常在 `.zshrc`
|
||||
/// 里初始化,而 `.zshrc` 只有交互式 shell 才读**。本机实测:`-lc` 拿到的 PATH 里 nvm 排在
|
||||
/// `/usr/local/bin` 之后,于是 `dsh` 的 `#!/usr/bin/env node` 命中了 v20 的 node 直接崩;
|
||||
/// `-lic` 才把 nvm v24 排到最前。这个顺序是正确性问题,别为了「更安全」改回 `-lc`。
|
||||
///
|
||||
/// Windows 上没有「登录 shell 的 PATH」这个概念(进程直接继承系统/用户环境变量),而且装了
|
||||
/// Git Bash 的机器 `SHELL` 也可能有值——真去跑它只会莫名其妙拉起一个 bash。所以整段限定 unix。
|
||||
#[cfg(unix)]
|
||||
async fn login_shell_path() -> Option<&'static str> {
|
||||
LOGIN_SHELL_PATH
|
||||
.get_or_init(|| async {
|
||||
let shell = std::env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())?;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
||||
if let Some(path) = probe_shell_path(&shell, "-lic", deadline).await {
|
||||
Some(path)
|
||||
} else {
|
||||
probe_shell_path(&shell, "-lc", deadline).await
|
||||
}
|
||||
})
|
||||
.await
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn login_shell_path() -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 静态兜底目录:登录 shell 问不出来时至少覆盖几个常见落点。
|
||||
///
|
||||
/// 只对 unix 有意义(Windows 的 CLI 不落在这些位置,进程也直接继承系统 PATH)。
|
||||
/// 注意这份清单**盖不全** nvm / 全局 npm 前缀这类带版本号或可配置的路径——那是
|
||||
/// [`login_shell_path`] 的活。用户还可以在「高级 → Less Computer」直接填可执行文件绝对路径,
|
||||
/// 那条路绕过 PATH 解析,是最终兜底。
|
||||
fn static_path_extras(home: &str) -> Vec<String> {
|
||||
if cfg!(not(unix)) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![
|
||||
format!("{home}/.local/bin"),
|
||||
// opencode 官方安装脚本的默认落点。
|
||||
format!("{home}/.opencode/bin"),
|
||||
// 常见的全局 npm 前缀写法(codex / dsh 都可能装在这里)。
|
||||
format!("{home}/.npm-global/bin"),
|
||||
format!("{home}/.bun/bin"),
|
||||
"/opt/homebrew/bin".to_string(),
|
||||
"/usr/local/bin".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// 按优先级拼出最终 PATH:登录 shell 的 PATH > 静态兜底目录 > 进程原有 PATH。
|
||||
/// 同一目录只保留第一次出现,**各段内部的相对顺序原样保留**。
|
||||
///
|
||||
/// 分隔符走 [`std::env::split_paths`] / [`std::env::join_paths`],**不能手写 `:`**:
|
||||
/// Windows 用 `;`,而且盘符本身带冒号——按 `:` 切 `C:\Windows;C:\System32` 会把整条 PATH
|
||||
/// 打烂,之后所有子进程都找不到任何命令。(这条是踩出来的:早先的实现就是手写 `:` 切分再
|
||||
/// 用 `:` 拼回去。CI 在 Windows 上只跑 `cargo check`,编译得过,但跑起来就废。)
|
||||
///
|
||||
/// 顺序在这里同样是正确性问题:早先的实现把每一项依次前插,等于把登录 shell 的 PATH 整个
|
||||
/// **倒过来**,于是 `dsh`(装在 nvm 某个版本的 node_modules 下)被交给了错版本的 node,
|
||||
/// 一启动就崩。所以这个函数必须保序,而且必须有下面那几条单测盯着。
|
||||
fn merge_path(current: &str, extras: &[String], shell_path: Option<&str>) -> String {
|
||||
let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
|
||||
let mut out: Vec<std::path::PathBuf> = Vec::new();
|
||||
let mut push_all = |raw: &str| {
|
||||
for seg in std::env::split_paths(raw) {
|
||||
// 空段在 PATH 里等价于「当前目录」,是个安全隐患,丢掉。
|
||||
if seg.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(seg.clone()) {
|
||||
out.push(seg);
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(sp) = shell_path {
|
||||
push_all(sp);
|
||||
}
|
||||
for extra in extras {
|
||||
push_all(extra);
|
||||
}
|
||||
push_all(current);
|
||||
// join_paths 只在段里含分隔符时才失败;真失败就退回原 PATH,绝不返回半截的。
|
||||
std::env::join_paths(&out)
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|_| current.to_string())
|
||||
}
|
||||
|
||||
/// 给 GUI 进程补 PATH / HOME:macOS 从 Finder 启动的进程不继承登录 shell 环境。
|
||||
pub(super) async fn augment_env(cmd: &mut Command) {
|
||||
let current = std::env::var("PATH").unwrap_or_default();
|
||||
let extras = match std::env::var_os("HOME") {
|
||||
Some(home_os) => {
|
||||
let home = home_os.to_string_lossy().to_string();
|
||||
let extras = static_path_extras(&home);
|
||||
cmd.env("HOME", home);
|
||||
extras
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
cmd.env(
|
||||
"PATH",
|
||||
merge_path(¤t, &extras, login_shell_path().await),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) async fn augmented_command(exe: &str) -> Command {
|
||||
let mut cmd = Command::new(exe);
|
||||
augment_env(&mut cmd);
|
||||
augment_env(&mut cmd).await;
|
||||
cmd
|
||||
}
|
||||
|
||||
@@ -124,11 +277,8 @@ pub fn create_git_snapshot(cwd: &Path) -> Option<String> {
|
||||
|
||||
/// 探测 `claude` 版本(`None` 表示未安装或无法运行)。
|
||||
pub async fn detect_claude(exe: &str) -> Option<String> {
|
||||
let out = augmented_command(exe)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
let mut cmd = augmented_command(exe).await;
|
||||
let out = cmd.arg("--version").output().await.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
@@ -137,7 +287,8 @@ pub async fn detect_claude(exe: &str) -> Option<String> {
|
||||
|
||||
/// 列出 Claude Code 已配置的 MCP server(含健康状态)。
|
||||
pub async fn claude_mcp_list(exe: &str) -> Vec<McpServerStatus> {
|
||||
match augmented_command(exe).args(["mcp", "list"]).output().await {
|
||||
let mut cmd = augmented_command(exe).await;
|
||||
match cmd.args(["mcp", "list"]).output().await {
|
||||
Ok(out) => detect::parse_mcp_list(&String::from_utf8_lossy(&out.stdout)),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
@@ -161,7 +312,7 @@ pub async fn run_claude_agent(
|
||||
cancel: Arc<AtomicBool>,
|
||||
) -> Result<(), CodingAgentError> {
|
||||
let args = build_claude_args(&req);
|
||||
let mut cmd = augmented_command(exe);
|
||||
let mut cmd = augmented_command(exe).await;
|
||||
cmd.args(&args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -279,6 +430,78 @@ pub async fn run_claude_agent(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 按当前平台的 PATH 分隔符拼一串(Windows `;` / unix `:`),
|
||||
/// 让下面的断言在三个平台上都成立——手写 `:` 的话 Windows CI 会挂。
|
||||
fn path_str(parts: &[&str]) -> String {
|
||||
std::env::join_paths(parts)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn path_parts(joined: &str) -> Vec<String> {
|
||||
std::env::split_paths(joined)
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_path_keeps_login_shell_order_intact() {
|
||||
// 回归防线:登录 shell 的 PATH 顺序**必须原样保留**。倒过来会让 `node` /
|
||||
// `dsh` 命中错误的版本管理器目录(本机同时装了三个 node),子进程直接崩。
|
||||
let shell = path_str(&["/nvm/v24/bin", "/opt/homebrew/bin", "/usr/bin"]);
|
||||
let merged = merge_path(&path_str(&["/usr/bin", "/bin"]), &[], Some(&shell));
|
||||
let order = path_parts(&merged);
|
||||
assert_eq!(order[0], "/nvm/v24/bin", "登录 shell 的首项必须还是首项");
|
||||
assert_eq!(order[1], "/opt/homebrew/bin");
|
||||
assert_eq!(order[2], "/usr/bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_path_priority_is_shell_then_extras_then_current() {
|
||||
let merged = merge_path(
|
||||
&path_str(&["/current/bin"]),
|
||||
&["/extra/bin".to_string()],
|
||||
Some(&path_str(&["/shell/bin"])),
|
||||
);
|
||||
assert_eq!(
|
||||
path_parts(&merged),
|
||||
vec!["/shell/bin", "/extra/bin", "/current/bin"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_path_dedupes_and_drops_empties() {
|
||||
// 重复目录只留第一次出现(优先级最高的那次)。
|
||||
let merged = merge_path(
|
||||
&path_str(&["/a", "/b", "/a"]),
|
||||
&["/b".to_string()],
|
||||
Some(&path_str(&["/b", "/c"])),
|
||||
);
|
||||
assert_eq!(path_parts(&merged), vec!["/b", "/c", "/a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_path_uses_the_platform_separator_not_a_hardcoded_colon() {
|
||||
// 跨平台不变量:输出必须逐字等于 `join_paths` 的结果,也就是用**平台自己**的
|
||||
// 分隔符(Windows `;` / unix `:`)。早先的实现手写 `:` 切分再用 `:` 拼回去,
|
||||
// 在 Windows 上会把 `C:\Windows` 切成 `C` 和 `\Windows`,整条 PATH 报废。
|
||||
// 这条在三个平台都会跑,任何一处退回手写分隔符都会当场红。
|
||||
let merged = merge_path(&path_str(&["/a", "/b"]), &[], None);
|
||||
assert_eq!(merged, path_str(&["/a", "/b"]));
|
||||
}
|
||||
|
||||
/// 盘符(`C:`)这条只有 Windows 能构造——unix 的 PATH 段里不允许出现 `:`,
|
||||
/// `join_paths` 会直接拒绝。所以它在 Windows CI 上跑,unix 上编译掉。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn merged_path_keeps_drive_letters_intact() {
|
||||
let current = path_str(&["C:\\Windows", "C:\\Windows\\System32"]);
|
||||
let parts = path_parts(&merge_path(¤t, &[], None));
|
||||
assert_eq!(parts.len(), 2, "段数不能变,实际: {parts:?}");
|
||||
assert!(!parts.iter().any(|p| p == "C"), "盘符被切开了: {parts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_prompt_wraps_task_with_oneshot_directive() {
|
||||
let p = autonomous_prompt("把这段话翻译成英文:你好");
|
||||
|
||||
@@ -30,11 +30,8 @@ use super::{CodingAgentError, CodingAgentPermissionMode};
|
||||
|
||||
/// 探测 `opencode` 版本(`None` 表示未安装或无法运行)。复用 claude 的 `x.y.z` 解析。
|
||||
pub async fn detect_opencode(exe: &str) -> Option<String> {
|
||||
let out = augmented_command(exe)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
let mut cmd = augmented_command(exe).await;
|
||||
let out = cmd.arg("--version").output().await.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
@@ -79,7 +76,7 @@ pub fn parse_opencode_models_output(stdout: &str) -> Vec<String> {
|
||||
|
||||
/// 从 OpenCode 拉取当前账号可用模型。`refresh=true` 时同步刷新 models.dev 缓存。
|
||||
pub async fn list_opencode_models(exe: &str, refresh: bool) -> Result<Vec<String>, String> {
|
||||
let mut cmd = augmented_command(exe);
|
||||
let mut cmd = augmented_command(exe).await;
|
||||
cmd.arg("models").kill_on_drop(true);
|
||||
if refresh {
|
||||
cmd.arg("--refresh");
|
||||
@@ -209,7 +206,7 @@ pub async fn run_opencode_agent(
|
||||
cancel: Arc<AtomicBool>,
|
||||
) -> Result<(), CodingAgentError> {
|
||||
let args = build_opencode_args(&req);
|
||||
let mut cmd = augmented_command(exe);
|
||||
let mut cmd = augmented_command(exe).await;
|
||||
cmd.args(&args)
|
||||
// prompt 作为最后的位置参数(OpenCode 从 argv 读取 message)。
|
||||
.arg(&req.prompt)
|
||||
|
||||
@@ -1393,9 +1393,12 @@ pub(super) async fn run_voice_agent_transcript(
|
||||
}
|
||||
}
|
||||
|
||||
let provider =
|
||||
crate::coding_agent::CodingAgentProvider::from_pref(&prefs.coding_agent_provider);
|
||||
// 钳制:语音 → shell 这条全自动路径禁止 bypassPermissions 绕过护栏(无人审、动手即生效)。
|
||||
// 即便用户在偏好里设了 bypass,这里也降级为 acceptEdits(仍带 deny 护栏)。
|
||||
let mode = match coding_agent_mode_from_pref(&prefs.coding_agent_permission_mode) {
|
||||
// Claude / OpenCode 保持原有的 acceptEdits 降级;Codex 的遗留 default/bypass 更严格地
|
||||
// 归一为只读,避免旧偏好在新沙箱语义下意外获得写权限。
|
||||
let mode = match coding_agent_mode_from_pref(provider, &prefs.coding_agent_permission_mode) {
|
||||
crate::coding_agent::CodingAgentPermissionMode::BypassPermissions => {
|
||||
log::warn!(
|
||||
"[less-computer] 语音 Agent 路径禁止 bypassPermissions,已降级为 acceptEdits(保留护栏)"
|
||||
@@ -1404,8 +1407,6 @@ pub(super) async fn run_voice_agent_transcript(
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
let provider =
|
||||
crate::coding_agent::CodingAgentProvider::from_pref(&prefs.coding_agent_provider);
|
||||
let model =
|
||||
crate::coding_agent::resolve_coding_agent_model(provider, prefs.coding_agent_model.clone());
|
||||
let prompt = crate::coding_agent::autonomous_prompt(&transcript);
|
||||
@@ -1423,7 +1424,15 @@ pub(super) async fn run_voice_agent_transcript(
|
||||
)
|
||||
.await;
|
||||
|
||||
let final_outcome = match maybe_request_approval(inner, &outcome).await {
|
||||
// 审批卡只对「能精确放行单条命令」的后端弹(Claude / OpenCode 的 deny 清单)。
|
||||
// Codex 只有沙箱档位,批准了也只能整体降档、不是放行这一条——弹卡等于给用户
|
||||
// 一个假承诺(点了批准,重跑还是同样被拦)。它直接把失败如实报出去。
|
||||
let approval = if provider.supports_command_approval() {
|
||||
maybe_request_approval(inner, &outcome).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let final_outcome = match approval {
|
||||
Some(approved_pattern) => {
|
||||
log::info!("[less-computer] 审批通过,放行高风险模式后重跑:{approved_pattern}");
|
||||
run_less_computer_once(
|
||||
@@ -1507,12 +1516,34 @@ pub(super) async fn run_voice_agent_transcript(
|
||||
}
|
||||
|
||||
/// 一轮无头 Less Computer 运行的结果。
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum LessComputerOutcome {
|
||||
Done { text: String, cost_usd: Option<f64> },
|
||||
Failed { message: String },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
fn resolve_less_computer_run_outcome(
|
||||
final_text: String,
|
||||
cost_usd: Option<f64>,
|
||||
error: Option<String>,
|
||||
) -> LessComputerOutcome {
|
||||
if let Some(message) = error {
|
||||
return LessComputerOutcome::Failed { message };
|
||||
}
|
||||
let text = final_text.trim().to_string();
|
||||
if text.is_empty() {
|
||||
LessComputerOutcome::Failed {
|
||||
message: "Agent 无结果(确认已登录且额度充足)".to_string(),
|
||||
}
|
||||
} else {
|
||||
LessComputerOutcome::Done {
|
||||
text,
|
||||
cost_usd,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑一轮无头 Claude(「放行 + 护栏」),把 Delta/ToolUse 实时 stream 到聊天浮窗,
|
||||
/// 终局收敛为 [`LessComputerOutcome`]。`extra_allow_patterns` 为审批通过后放行的
|
||||
/// 高风险子串(如 "git push --force"):从 deny 清单剔除 + 作为 `Bash(<pat>:*)` 加进 allowed。
|
||||
@@ -1560,9 +1591,9 @@ async fn run_less_computer_once(
|
||||
req.cwd = cwd.map(|p| p.to_path_buf());
|
||||
req.model = model.map(|m| m.to_string());
|
||||
req.permission_mode = mode;
|
||||
// 真实任务(开应用、多步操作、读写文件)常超过 120s/0.5$ → 老是「运行超时」。放宽到
|
||||
// 5 分钟 / 2$,给多步任务足够空间;仍有硬上限兜底,不会无限跑/烧钱。
|
||||
req.max_budget_usd = Some(2.0);
|
||||
// 真实任务(开应用、多步操作、读写文件)常超过 120s → 老是「运行超时」。放宽到
|
||||
// 5 分钟;仅 Claude CLI 能力支持美元硬上限,Codex/OpenCode 保持 None。
|
||||
req.max_budget_usd = provider.max_budget_usd();
|
||||
req.timeout_secs = 300;
|
||||
// 连续对话需要保留会话:本轮保存(供下轮 --continue),第二轮起带 --continue 续上下文。
|
||||
req.session_persistence = true;
|
||||
@@ -1660,6 +1691,21 @@ async fn run_less_computer_once(
|
||||
.await
|
||||
})
|
||||
}
|
||||
CodingAgentProvider::CodexCli => {
|
||||
// Codex 没有逐命令 deny 清单(`.rules` execpolicy 只从 $CODEX_HOME / 项目目录读,
|
||||
// 无法从外部注入)。护栏是它自带的 seatbelt 沙箱,由 `-s <mode>` 决定,
|
||||
// 在 build_codex_args 里跟着 permission_mode 一起落。这里没有临时护栏文件。
|
||||
//
|
||||
// 注意这不是「无护栏裸跑」:mode 已在上游钳制为 Plan 或 AcceptEdits,分别落到
|
||||
// `-s read-only` / `-s workspace-write`,遗留宽权限值不会放大 Codex 能力。
|
||||
// approved_patterns 对它无意义(沙箱放行只能整体降档,不能放行单条),
|
||||
// 上游也不会给它弹审批卡,见 CodingAgentProvider::supports_command_approval。
|
||||
settings_path = None;
|
||||
let exe = configured_exe.unwrap_or_else(|| "codex".to_string());
|
||||
async_runtime::spawn(async move {
|
||||
crate::coding_agent::run_codex_agent(&exe, req, tx, cancel_for_runner).await
|
||||
})
|
||||
}
|
||||
};
|
||||
let cancel_for_watcher = Arc::clone(&cancel);
|
||||
let inner_for_cancel = Arc::clone(inner);
|
||||
@@ -1722,21 +1768,11 @@ async fn run_less_computer_once(
|
||||
return LessComputerOutcome::Cancelled;
|
||||
}
|
||||
|
||||
let trimmed = final_text.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
LessComputerOutcome::Done {
|
||||
text: trimmed,
|
||||
cost_usd,
|
||||
}
|
||||
} else {
|
||||
let message = error_msg
|
||||
.or_else(|| match run_result {
|
||||
Ok(Err(e)) => Some(e.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| "Agent 无结果(确认已登录且额度充足)".to_string());
|
||||
LessComputerOutcome::Failed { message }
|
||||
}
|
||||
let run_error = error_msg.or_else(|| match run_result {
|
||||
Ok(Err(e)) => Some(e.to_string()),
|
||||
_ => None,
|
||||
});
|
||||
resolve_less_computer_run_outcome(final_text, cost_usd, run_error)
|
||||
}
|
||||
|
||||
/// 护栏拦截探测 + 内联审批(best-effort)。
|
||||
@@ -1823,14 +1859,24 @@ async fn maybe_request_approval(
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 prefs 里的权限模式字符串映射成枚举;未知值回落到 acceptEdits(放行+护栏的默认)。
|
||||
fn coding_agent_mode_from_pref(s: &str) -> crate::coding_agent::CodingAgentPermissionMode {
|
||||
/// 把 prefs 里的权限模式字符串映射成枚举;Codex 对遗留的宽权限值 fail-closed 到只读。
|
||||
fn coding_agent_mode_from_pref(
|
||||
provider: crate::coding_agent::CodingAgentProvider,
|
||||
s: &str,
|
||||
) -> crate::coding_agent::CodingAgentPermissionMode {
|
||||
use crate::coding_agent::CodingAgentPermissionMode as M;
|
||||
match s.trim() {
|
||||
let mode = match s.trim() {
|
||||
"plan" => M::Plan,
|
||||
"default" => M::Default,
|
||||
"bypassPermissions" => M::BypassPermissions,
|
||||
_ => M::AcceptEdits,
|
||||
};
|
||||
if matches!(provider, crate::coding_agent::CodingAgentProvider::CodexCli)
|
||||
&& matches!(mode, M::Default | M::BypassPermissions)
|
||||
{
|
||||
M::Plan
|
||||
} else {
|
||||
mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5126,10 +5172,11 @@ mod tests {
|
||||
use super::{
|
||||
accept_silent_retry_transcript, append_cursor_context_to_multimodal_prompt,
|
||||
append_typed_prefix, batch_asr_chunk_limit_ms, build_transcribe_failed_session,
|
||||
default_done_message, drain_streaming_insert_deltas_with, eligible_polish_context_turns,
|
||||
finalize_polished_text, flush_streaming_insert_buffer_with, pcm_duration_ms,
|
||||
pcm_from_wav_bytes, retry_error_outcome, should_arm_edit_watch, should_attempt_silent_retry,
|
||||
should_read_cursor_context, insert_delivery_failed, streaming_insert_eligible,
|
||||
coding_agent_mode_from_pref, default_done_message, drain_streaming_insert_deltas_with,
|
||||
eligible_polish_context_turns, finalize_polished_text, flush_streaming_insert_buffer_with,
|
||||
pcm_duration_ms, pcm_from_wav_bytes, retry_error_outcome, should_arm_edit_watch,
|
||||
should_attempt_silent_retry, should_read_cursor_context, insert_delivery_failed,
|
||||
resolve_less_computer_run_outcome, streaming_insert_eligible,
|
||||
SilentRetryOutcome,
|
||||
};
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
@@ -5140,6 +5187,38 @@ mod tests {
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn codex_legacy_permission_modes_fail_closed_to_read_only() {
|
||||
use crate::coding_agent::{CodingAgentPermissionMode as M, CodingAgentProvider as P};
|
||||
|
||||
assert_eq!(
|
||||
coding_agent_mode_from_pref(P::CodexCli, "acceptEdits"),
|
||||
M::AcceptEdits
|
||||
);
|
||||
assert_eq!(coding_agent_mode_from_pref(P::CodexCli, "plan"), M::Plan);
|
||||
assert_eq!(coding_agent_mode_from_pref(P::CodexCli, "default"), M::Plan);
|
||||
assert_eq!(
|
||||
coding_agent_mode_from_pref(P::CodexCli, "bypassPermissions"),
|
||||
M::Plan
|
||||
);
|
||||
assert_eq!(
|
||||
coding_agent_mode_from_pref(P::ClaudeCodeCli, "default"),
|
||||
M::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_error_wins_over_partial_output() {
|
||||
assert!(matches!(
|
||||
resolve_less_computer_run_outcome(
|
||||
"partial output".into(),
|
||||
None,
|
||||
Some("Codex 协议错误".into()),
|
||||
),
|
||||
super::LessComputerOutcome::Failed { message } if message == "Codex 协议错误"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_request_is_denied_when_session_cancelled_during_wait() {
|
||||
let coordinator = crate::coordinator::Coordinator::new();
|
||||
|
||||
@@ -222,6 +222,7 @@ macro_rules! app_invoke_handler_desktop {
|
||||
commands::cancel_dictation,
|
||||
coding_agent::commands::coding_agent_detect,
|
||||
coding_agent::commands::coding_agent_detect_opencode,
|
||||
coding_agent::commands::coding_agent_detect_cli,
|
||||
coding_agent::commands::coding_agent_list_opencode_models,
|
||||
coding_agent::commands::coding_agent_run_test,
|
||||
coding_agent::commands::coding_agent_cancel_test,
|
||||
|
||||
@@ -756,6 +756,16 @@ export const en: typeof zhCN = {
|
||||
provider: 'Agent backend',
|
||||
opencodeReady: 'OpenCode v{{version}} detected.',
|
||||
opencodeMissing: 'opencode command not found. Install it (npm i -g opencode-ai) and sign in with opencode auth login before use.',
|
||||
cliReady: 'Detected {{name}} v{{version}}.',
|
||||
cliMissing: '{{name}} command not found. Install and sign in first, or enter its absolute path under Executable below.',
|
||||
sandboxGuardHint: 'This backend only offers coarse sandbox levels, not a per-command high-risk list: when it hits a limit it reports the failure as-is instead of showing an "approve this command" card.',
|
||||
codexModelHint: 'Enter a Codex model name (e.g. gpt-5); leave empty to use the setting in ~/.codex/config.toml.',
|
||||
codexBudgetHint: 'Codex has no per-run USD budget cap; charges depend on your configured provider.',
|
||||
codexMode: {
|
||||
plan: 'Read-only / plan',
|
||||
workspaceWrite: 'Allow workspace writes',
|
||||
},
|
||||
codexModelPlaceholder: 'Empty = Codex default',
|
||||
panelHotkey: 'Panel hotkey (voice agent)',
|
||||
panelHotkeyDesc: 'Record voice → ASR → Claude → streamed into a panel. Default Cmd/Ctrl+Shift+Enter.',
|
||||
quickHotkey: 'Quick-take hotkey',
|
||||
|
||||
@@ -758,6 +758,16 @@ export const ja: typeof zhCN = {
|
||||
provider: 'Agent バックエンド',
|
||||
opencodeReady: 'OpenCode v{{version}} を検出しました。',
|
||||
opencodeMissing: 'opencode コマンドが見つかりません。先にインストール(npm i -g opencode-ai)して opencode auth login でログインしてください。',
|
||||
cliReady: '{{name}} v{{version}} を検出しました。',
|
||||
cliMissing: '{{name}} コマンドが見つかりません。先にインストールとログインを行うか、下の「実行ファイル」に絶対パスを入力してください。',
|
||||
sandboxGuardHint: 'このバックエンドは粗い粒度のサンドボックス段階しか持たず、コマンド単位の高リスク一覧はありません。制限に触れた場合はそのままエラーとして報告し、「このコマンドを承認」カードは表示されません。',
|
||||
codexModelHint: 'Codex のモデル名(gpt-5 など)を入力します。空欄の場合は ~/.codex/config.toml の設定を使います。',
|
||||
codexBudgetHint: 'Codex には実行ごとの米ドル予算上限がありません。料金は設定したプロバイダーに依存します。',
|
||||
codexMode: {
|
||||
plan: '読み取り専用 / 計画',
|
||||
workspaceWrite: 'ワークスペースへの書き込みを許可',
|
||||
},
|
||||
codexModelPlaceholder: '空欄 = Codex の既定値',
|
||||
panelHotkey: 'パネルキー(音声 Agent)',
|
||||
panelHotkeyDesc: '録音 → ASR → Claude → パネルにストリーミング表示。デフォルト Cmd/Ctrl+Shift+Enter。',
|
||||
quickHotkey: 'クイック取得キー',
|
||||
|
||||
@@ -758,6 +758,16 @@ export const ko: typeof zhCN = {
|
||||
provider: 'Agent 백엔드',
|
||||
opencodeReady: 'OpenCode v{{version}} 감지됨.',
|
||||
opencodeMissing: 'opencode 명령을 찾을 수 없습니다. 먼저 설치(npm i -g opencode-ai)하고 opencode auth login으로 로그인하세요.',
|
||||
cliReady: '{{name}} v{{version}}을(를) 감지했습니다.',
|
||||
cliMissing: '{{name}} 명령을 찾을 수 없습니다. 먼저 설치하고 로그인하거나, 아래 "실행 파일"에 절대 경로를 입력하세요.',
|
||||
sandboxGuardHint: '이 백엔드는 명령 단위 고위험 목록 없이 큰 단위의 샌드박스 등급만 제공합니다. 제한에 걸리면 "이 명령 승인" 카드를 띄우지 않고 실패를 그대로 알립니다.',
|
||||
codexModelHint: 'Codex 모델 이름(예: gpt-5)을 입력하세요. 비워 두면 ~/.codex/config.toml 설정을 사용합니다.',
|
||||
codexBudgetHint: 'Codex에는 실행별 달러 예산 상한이 없습니다. 비용은 구성한 제공자에 따라 달라집니다.',
|
||||
codexMode: {
|
||||
plan: '읽기 전용 / 계획',
|
||||
workspaceWrite: '워크스페이스 쓰기 허용',
|
||||
},
|
||||
codexModelPlaceholder: '비워 두면 Codex 기본값',
|
||||
panelHotkey: '패널 키(음성 Agent)',
|
||||
panelHotkeyDesc: '녹음 → ASR → Claude → 패널에 스트리밍. 기본 Cmd/Ctrl+Shift+Enter.',
|
||||
quickHotkey: '빠른 가져오기 키',
|
||||
|
||||
@@ -754,6 +754,16 @@ export const zhCN = {
|
||||
provider: 'Agent 后端',
|
||||
opencodeReady: '已检测到 OpenCode v{{version}}。',
|
||||
opencodeMissing: '未检测到 opencode 命令。请先安装(npm i -g opencode-ai)并用 opencode auth login 登录后再使用。',
|
||||
cliReady: '已检测到 {{name}} v{{version}}。',
|
||||
cliMissing: '未检测到 {{name}} 命令。请先安装并登录,或在下方「可执行文件」里填它的绝对路径。',
|
||||
sandboxGuardHint: '该后端只有粗粒度沙箱档位,没有逐命令的高风险清单:撞到限制时会直接如实报错,不会弹出「批准这条命令」的卡片。',
|
||||
codexModelHint: '填 Codex 的模型名(如 gpt-5);留空则用 ~/.codex/config.toml 里的设置。',
|
||||
codexBudgetHint: 'Codex 没有单次美元预算上限;费用取决于你配置的服务商。',
|
||||
codexMode: {
|
||||
plan: '只读 / 计划',
|
||||
workspaceWrite: '允许工作区写入',
|
||||
},
|
||||
codexModelPlaceholder: '留空 = 用 Codex 自己的默认',
|
||||
panelHotkey: '面板键(语音 Agent)',
|
||||
panelHotkeyDesc: '录音 → ASR → Claude → 结果流式进面板。默认 Cmd/Ctrl+Shift+Enter。',
|
||||
quickHotkey: '快取用键',
|
||||
|
||||
@@ -756,6 +756,16 @@ export const zhTW: typeof zhCN = {
|
||||
provider: 'Agent 後端',
|
||||
opencodeReady: '已偵測到 OpenCode v{{version}}。',
|
||||
opencodeMissing: '未偵測到 opencode 指令。請先安裝(npm i -g opencode-ai)並用 opencode auth login 登入後再使用。',
|
||||
cliReady: '已偵測到 {{name}} v{{version}}。',
|
||||
cliMissing: '未偵測到 {{name}} 指令。請先安裝並登入,或在下方「執行檔」欄填它的絕對路徑。',
|
||||
sandboxGuardHint: '此後端只有粗粒度沙箱層級,沒有逐指令的高風險清單:碰到限制時會直接據實回報錯誤,不會跳出「核准這條指令」的卡片。',
|
||||
codexModelHint: '填 Codex 的模型名稱(如 gpt-5);留空則使用 ~/.codex/config.toml 的設定。',
|
||||
codexBudgetHint: 'Codex 沒有單次美元預算上限;費用取決於你設定的服務商。',
|
||||
codexMode: {
|
||||
plan: '唯讀 / 計畫',
|
||||
workspaceWrite: '允許工作目錄寫入',
|
||||
},
|
||||
codexModelPlaceholder: '留空 = 使用 Codex 自己的預設',
|
||||
panelHotkey: '面板鍵(語音 Agent)',
|
||||
panelHotkeyDesc: '錄音 → ASR → Claude → 結果串流進面板。預設 Cmd/Ctrl+Shift+Enter。',
|
||||
quickHotkey: '快取用鍵',
|
||||
|
||||
@@ -38,6 +38,25 @@ export function codingAgentDetectOpencode(exe?: string): Promise<OpenCodeDetecti
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 Codex 是否安装。与 OpenCode 共用同一个检测结果形状。
|
||||
* `provider` 传 prefs 里的后端 id。
|
||||
*/
|
||||
export function codingAgentDetectCli(
|
||||
provider: string,
|
||||
exe?: string,
|
||||
): Promise<OpenCodeDetection> {
|
||||
return invokeOrMock(
|
||||
"coding_agent_detect_cli",
|
||||
{ provider, exe },
|
||||
() => ({
|
||||
installed: false,
|
||||
version: null,
|
||||
exe: exe || "codex",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** 拉取当前 OpenCode 配置可用的 `provider/model` 列表。 */
|
||||
export function codingAgentListOpencodeModels(
|
||||
exe?: string,
|
||||
|
||||
@@ -199,6 +199,7 @@ export type {
|
||||
} from "./coding-agent"
|
||||
export {
|
||||
codingAgentDetect,
|
||||
codingAgentDetectCli,
|
||||
codingAgentDetectOpencode,
|
||||
codingAgentListOpencodeModels,
|
||||
codingAgentRunTest,
|
||||
|
||||
@@ -215,7 +215,10 @@ export type QaHotkeyBinding = ShortcutBinding;
|
||||
/** 自定义录音组合键绑定。当 hotkey.trigger == 'custom' 时使用。 */
|
||||
export type ComboBinding = ShortcutBinding;
|
||||
|
||||
export type CodingAgentProviderId = "claude-code-cli" | "opencode-cli";
|
||||
export type CodingAgentProviderId =
|
||||
| "claude-code-cli"
|
||||
| "opencode-cli"
|
||||
| "codex-cli";
|
||||
export type CodingAgentPermissionMode =
|
||||
| "plan"
|
||||
| "default"
|
||||
@@ -415,9 +418,12 @@ export interface UserPreferences {
|
||||
stylePackHotkeys: StylePackHotkey[];
|
||||
/** Less Computer:是否启用。默认关闭。 */
|
||||
codingAgentEnabled: boolean;
|
||||
/** Agent 后端:claude-code-cli(默认)/ opencode-cli。 */
|
||||
/** Agent 后端:claude-code-cli(默认)/ opencode-cli / codex-cli。 */
|
||||
codingAgentProvider: CodingAgentProviderId;
|
||||
/** Agent 模型,null = 运行时取便宜默认(sonnet)。 */
|
||||
/**
|
||||
* Agent 模型,null = 交给后端自己的默认。
|
||||
* Claude 走别名(sonnet 等),OpenCode 要 `provider/model`,Codex 收裸模型名。
|
||||
*/
|
||||
codingAgentModel: string | null;
|
||||
/** 权限模式:plan/default/acceptEdits/bypassPermissions。 */
|
||||
codingAgentPermissionMode: CodingAgentPermissionMode;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
// 高级 → Less Computer 配置:启用开关、后端(Claude / OpenCode)、模型 / 权限模式 / 工作目录。
|
||||
// 高级 → Less Computer 配置:启用开关、后端(Claude / OpenCode / Codex)、
|
||||
// 模型 / 权限模式 / 工作目录。
|
||||
//
|
||||
// 四个后端的能力不一样,这一页要如实反映差异,别让用户以为选项都通用:
|
||||
// - 模型:Claude 用别名下拉,OpenCode 拉账号可用列表,Codex 收裸模型名(自由文本),
|
||||
// - 护栏:Claude / OpenCode 是逐命令 deny 清单(撞了能弹审批卡放行单条);
|
||||
// Codex 只有粗粒度沙箱档位,审批卡对它不生效,这里挂一条说明。
|
||||
// 「按住说话键」在 通用 → 快捷键 里配置(见 ShortcutsSection),这里不再重复。
|
||||
// 配置经 UserPreferences 持久化;启用后 coordinator 才注册热键。
|
||||
|
||||
@@ -6,6 +12,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { detectOS } from '../../components/WindowChrome'
|
||||
import {
|
||||
codingAgentDetectCli,
|
||||
codingAgentDetectOpencode,
|
||||
codingAgentListOpencodeModels,
|
||||
lessComputerWindowOpen,
|
||||
@@ -23,9 +30,35 @@ const PERMISSION_MODES: CodingAgentPermissionMode[] = [
|
||||
'default',
|
||||
'bypassPermissions',
|
||||
]
|
||||
const CODEX_PERMISSION_MODES: CodingAgentPermissionMode[] = ['plan', 'acceptEdits']
|
||||
|
||||
function permissionModesForProvider(provider: CodingAgentProviderId) {
|
||||
return provider === 'codex-cli' ? CODEX_PERMISSION_MODES : PERMISSION_MODES
|
||||
}
|
||||
|
||||
function normalizePermissionMode(
|
||||
provider: CodingAgentProviderId,
|
||||
mode: CodingAgentPermissionMode,
|
||||
): CodingAgentPermissionMode {
|
||||
return provider === 'codex-cli' && (mode === 'default' || mode === 'bypassPermissions') ? 'plan' : mode
|
||||
}
|
||||
|
||||
type OpenCodeModelsStatus = 'idle' | 'loading' | 'loaded' | 'error'
|
||||
|
||||
/** 后端下拉的选项。顺序 = 接入先后,Claude 保持第一(默认后端)。 */
|
||||
const PROVIDERS: { value: CodingAgentProviderId; label: string }[] = [
|
||||
{ value: 'claude-code-cli', label: 'Claude Code' },
|
||||
{ value: 'opencode-cli', label: 'OpenCode' },
|
||||
{ value: 'codex-cli', label: 'Codex' },
|
||||
]
|
||||
|
||||
/** 各后端默认的可执行文件名,用作「自定义路径」输入框的 placeholder。 */
|
||||
const DEFAULT_EXE: Record<CodingAgentProviderId, string> = {
|
||||
'claude-code-cli': 'claude',
|
||||
'opencode-cli': 'opencode',
|
||||
'codex-cli': 'codex',
|
||||
}
|
||||
|
||||
export function CodingAgentSection() {
|
||||
const { t } = useTranslation()
|
||||
const { prefs, updatePrefs: savePrefs } = useHotkeySettings()
|
||||
@@ -36,7 +69,35 @@ export function CodingAgentSection() {
|
||||
const [opencodeModels, setOpencodeModels] = useState<string[]>([])
|
||||
const [opencodeModelsStatus, setOpencodeModelsStatus] = useState<OpenCodeModelsStatus>('idle')
|
||||
const [opencodeModelsError, setOpencodeModelsError] = useState('')
|
||||
const useOpencode = prefs?.codingAgentEnabled && prefs?.codingAgentProvider === 'opencode-cli'
|
||||
|
||||
const provider: CodingAgentProviderId = prefs?.codingAgentProvider ?? 'claude-code-cli'
|
||||
const useOpencode = prefs?.codingAgentEnabled && provider === 'opencode-cli'
|
||||
const useCodex = prefs?.codingAgentEnabled && provider === 'codex-cli'
|
||||
// 只有沙箱档位、没有逐命令 deny 清单的后端:审批卡对它们不生效。
|
||||
const sandboxOnly = Boolean(useCodex)
|
||||
|
||||
// Codex 的安装检测(走通用检测命令,后续接别的后端时可以复用)。
|
||||
const [cliDetection, setCliDetection] = useState<OpenCodeDetection | null>(null)
|
||||
useEffect(() => {
|
||||
if (!sandboxOnly) {
|
||||
setCliDetection(null)
|
||||
return
|
||||
}
|
||||
let alive = true
|
||||
setCliDetection(null)
|
||||
void (async () => {
|
||||
try {
|
||||
const detection = await codingAgentDetectCli(provider, prefs?.codingAgentExe ?? undefined)
|
||||
if (alive) setCliDetection(detection)
|
||||
} catch {
|
||||
// 检测失败按「没装」处理:这里只是提示,不阻断用户保存配置。
|
||||
if (alive) setCliDetection({ installed: false, version: null, exe: DEFAULT_EXE[provider] })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [sandboxOnly, provider, prefs?.codingAgentExe])
|
||||
useEffect(() => {
|
||||
if (!useOpencode) {
|
||||
setOpencode(null)
|
||||
@@ -76,6 +137,17 @@ export function CodingAgentSection() {
|
||||
}
|
||||
}, [useOpencode, prefs?.codingAgentExe])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!prefs ||
|
||||
provider !== 'codex-cli' ||
|
||||
(prefs.codingAgentPermissionMode !== 'default' && prefs.codingAgentPermissionMode !== 'bypassPermissions')
|
||||
) {
|
||||
return
|
||||
}
|
||||
void savePrefs({ ...prefs, codingAgentPermissionMode: 'plan' })
|
||||
}, [prefs, provider, savePrefs])
|
||||
|
||||
const refreshOpencodeModels = async () => {
|
||||
setOpencodeModelsStatus('loading')
|
||||
setOpencodeModelsError('')
|
||||
@@ -121,17 +193,19 @@ export function CodingAgentSection() {
|
||||
<SettingRow label={t('settings.codingAgent.provider')}>
|
||||
<SelectLite
|
||||
value={prefs.codingAgentProvider}
|
||||
onChange={v =>
|
||||
onChange={v => {
|
||||
const nextProvider = v as CodingAgentProviderId
|
||||
void savePrefs({
|
||||
...prefs,
|
||||
codingAgentProvider: v as CodingAgentProviderId,
|
||||
codingAgentProvider: nextProvider,
|
||||
codingAgentModel: null,
|
||||
codingAgentPermissionMode: normalizePermissionMode(
|
||||
nextProvider,
|
||||
prefs.codingAgentPermissionMode,
|
||||
),
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'claude-code-cli', label: 'Claude Code' },
|
||||
{ value: 'opencode-cli', label: 'OpenCode' },
|
||||
]}
|
||||
}}
|
||||
options={PROVIDERS}
|
||||
ariaLabel={t('settings.codingAgent.provider')}
|
||||
style={{ ...inputStyle, maxWidth: 240 }}
|
||||
/>
|
||||
@@ -153,13 +227,63 @@ export function CodingAgentSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Codex:装没装 + 版本。没装时按警示色提示。 */}
|
||||
{sandboxOnly && cliDetection && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: cliDetection.installed ? 'var(--ol-ink-3)' : 'var(--ol-warn, #b8860b)',
|
||||
margin: '-4px 0 8px',
|
||||
}}
|
||||
>
|
||||
{cliDetection.installed
|
||||
? t('settings.codingAgent.cliReady', {
|
||||
name: DEFAULT_EXE[provider],
|
||||
version: cliDetection.version ?? '?',
|
||||
})
|
||||
: t('settings.codingAgent.cliMissing', { name: DEFAULT_EXE[provider] })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 护栏差异说明:这两家没有逐命令 deny 清单,审批卡不会弹。别让用户以为有。 */}
|
||||
{sandboxOnly && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--ol-ink-4)',
|
||||
margin: '-4px 0 8px',
|
||||
}}
|
||||
>
|
||||
{t('settings.codingAgent.sandboxGuardHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{useCodex && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--ol-ink-4)',
|
||||
margin: '-4px 0 8px',
|
||||
}}
|
||||
>
|
||||
{t('settings.codingAgent.codexBudgetHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingRow label={t('settings.codingConsole.permissionMode')}>
|
||||
<SelectLite
|
||||
value={prefs.codingAgentPermissionMode}
|
||||
value={normalizePermissionMode(provider, prefs.codingAgentPermissionMode)}
|
||||
onChange={v => void savePrefs({ ...prefs, codingAgentPermissionMode: v as CodingAgentPermissionMode })}
|
||||
options={PERMISSION_MODES.map(m => ({
|
||||
options={permissionModesForProvider(provider).map(m => ({
|
||||
value: m,
|
||||
label: t(`settings.codingConsole.mode.${m}`),
|
||||
label: t(
|
||||
provider === 'codex-cli'
|
||||
? `settings.codingAgent.codexMode.${m === 'acceptEdits' ? 'workspaceWrite' : 'plan'}`
|
||||
: `settings.codingConsole.mode.${m}`,
|
||||
),
|
||||
}))}
|
||||
ariaLabel={t('settings.codingConsole.permissionMode')}
|
||||
style={{ ...inputStyle, maxWidth: 240 }}
|
||||
@@ -171,10 +295,28 @@ export function CodingAgentSection() {
|
||||
desc={t(
|
||||
useOpencode
|
||||
? 'settings.codingAgent.opencodeModelHint'
|
||||
: 'settings.codingAgent.modelHint',
|
||||
: useCodex
|
||||
? 'settings.codingAgent.codexModelHint'
|
||||
: 'settings.codingAgent.modelHint',
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{useCodex ? (
|
||||
// Codex 的模型名是裸名(gpt-5 / o3 / 自建网关的任意名字),枚举不过来,
|
||||
// 给自由文本;留空 = 用 ~/.codex/config.toml 里的设置。
|
||||
<input
|
||||
type="text"
|
||||
value={prefs.codingAgentModel ?? ''}
|
||||
placeholder={t('settings.codingAgent.codexModelPlaceholder')}
|
||||
spellCheck={false}
|
||||
aria-label={t('settings.codingAgent.model')}
|
||||
onChange={e => {
|
||||
const v = e.target.value.trim()
|
||||
void savePrefs({ ...prefs, codingAgentModel: v === '' ? null : v })
|
||||
}}
|
||||
style={{ ...inputStyle, maxWidth: 300 }}
|
||||
/>
|
||||
) : (
|
||||
<SelectLite
|
||||
value={
|
||||
useOpencode
|
||||
@@ -207,6 +349,7 @@ export function CodingAgentSection() {
|
||||
ariaLabel={t('settings.codingAgent.model')}
|
||||
style={{ ...inputStyle, maxWidth: 300 }}
|
||||
/>
|
||||
)}
|
||||
{useOpencode && opencode?.installed && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -274,7 +417,7 @@ export function CodingAgentSection() {
|
||||
<input
|
||||
type="text"
|
||||
value={prefs.codingAgentExe ?? ''}
|
||||
placeholder={prefs.codingAgentProvider === 'opencode-cli' ? 'opencode' : 'claude'}
|
||||
placeholder={DEFAULT_EXE[provider]}
|
||||
spellCheck={false}
|
||||
onChange={e => {
|
||||
const v = e.target.value.trim()
|
||||
|
||||
Reference in new issue
Block a user