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:
authored and GitHub committed 2026-08-21 22:07:10 +08:00
1 parent 62a3d86721
commit 5ba0fa725c
18 files changed
+1674 -91

No files matched your search

+19
View File
@@ -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(&current, &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(&current, &[], 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();
+1
View File
@@ -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,
+10
View File
@@ -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',
+10
View File
@@ -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: 'クイック取得キー',
+10
View File
@@ -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: '빠른 가져오기 키',
+10
View File
@@ -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: '快取用键',
+10
View File
@@ -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,
+1
View File
@@ -199,6 +199,7 @@ export type {
} from "./coding-agent"
export {
codingAgentDetect,
codingAgentDetectCli,
codingAgentDetectOpencode,
codingAgentListOpencodeModels,
codingAgentRunTest,
+9 -3
View File
@@ -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()