From f72e4c7263eb93a735998c152eabc2e76c30a4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Wed, 10 Jun 2026 15:46:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(coding-agent):=20OpenCode=20CLI=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=EF=BC=8C=E8=AF=AD=E9=9F=B3=20Agent?= =?UTF-8?q?=20=E5=8F=AF=E9=80=89=20OpenCode=20=E5=90=8E=E7=AB=AF=20(issue?= =?UTF-8?q?=20#579)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 OpenCode 当作与 Claude Code 同类的 coding agent CLI 接入,照既有 coding_agent 适配模式(不另造架构),复用同一套 CodingAgentRequest / CodingAgentEvent / CodingAgentError / 审批护栏链路。 后端: - coding_agent/opencode.rs(新):detect_opencode(opencode --version)、 build_opencode_args(opencode run --format json --model --dir [--dangerously-skip-permissions],prompt 作为 argv)、parse_opencode_json_line (NDJSON:text/tool_use/error;text 块累计,EOF 合成 Completed)、 run_opencode_agent(取消/超时 kill,护栏经 OPENCODE_CONFIG_CONTENT 注入)。 - coding_agent/args.rs:CodingAgentProvider 枚举(claude-code-cli/opencode-cli, 未知回落 Claude)+ from_pref/default_exe。 - coding_agent/guard.rs:build_opencode_guard_config —— 把高风险 bash 前缀翻译成 OpenCode permission.bash deny glob + webfetch deny,审批放行的前缀显式 allow。 - coordinator/dictation_voice_agent: run_less_computer_once 按 coding_agent_provider 分派 Claude / OpenCode 运行器;两路都 fail-closed(护栏生成失败即中止,不裸跑)。 审批拦截探测/重跑放行链路 provider 无关,自动复用。 - commands: coding_agent_detect_opencode 命令(已注册 lib.rs)。 前端: - CodingAgentSection: OpenCode 选项从「即将支持」改为可用 + 选中后探测安装状态并提示。 - ipc: codingAgentDetectOpencode + OpenCodeDetection。 - i18n: opencodeReady / opencodeMissing(5 语言,替换 providerOpenCodeSoon)。 验证:cargo check + cargo test(492 通过)+ npm run build 均通过。 Refs #579 (#579 还含「语音润色模型配置」:prefs 字段已存在但全链路未实现/未接 UI, 转写直接进 agent 未经语音润色模型整理 —— 故用 Refs 不用 Closes。) Co-Authored-By: Claude Fable 5 --- .../app/src-tauri/src/coding_agent/args.rs | 55 +++ .../src-tauri/src/coding_agent/commands.rs | 29 ++ .../app/src-tauri/src/coding_agent/guard.rs | 72 ++++ .../app/src-tauri/src/coding_agent/mod.rs | 12 +- .../src-tauri/src/coding_agent/opencode.rs | 344 ++++++++++++++++++ .../src/coordinator/dictation_voice_agent.rs | 148 +++++--- openless-all/app/src-tauri/src/lib.rs | 1 + openless-all/app/src/i18n/en.ts | 3 +- openless-all/app/src/i18n/ja.ts | 3 +- openless-all/app/src/i18n/ko.ts | 3 +- openless-all/app/src/i18n/zh-CN.ts | 3 +- openless-all/app/src/i18n/zh-TW.ts | 3 +- openless-all/app/src/lib/ipc.ts | 20 + .../src/pages/settings/CodingAgentSection.tsx | 37 +- 14 files changed, 669 insertions(+), 64 deletions(-) create mode 100644 openless-all/app/src-tauri/src/coding_agent/opencode.rs diff --git a/openless-all/app/src-tauri/src/coding_agent/args.rs b/openless-all/app/src-tauri/src/coding_agent/args.rs index ecaed77b..2a7f3ea0 100644 --- a/openless-all/app/src-tauri/src/coding_agent/args.rs +++ b/openless-all/app/src-tauri/src/coding_agent/args.rs @@ -5,6 +5,34 @@ use std::path::PathBuf; +/// 后端 coding agent 提供商,对应 `UserPreferences.coding_agent_provider` 的取值。 +/// 未知/缺省一律回落 Claude(既有默认),不破坏现有用户。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodingAgentProvider { + /// Claude Code CLI(`claude`)。默认。 + ClaudeCodeCli, + /// OpenCode CLI(`opencode`),issue #579。 + OpenCodeCli, +} + +impl CodingAgentProvider { + /// 从 prefs 字符串解析。`"opencode-cli"` → OpenCode;其余(含 `"claude-code-cli"`)→ Claude。 + pub fn from_pref(s: &str) -> Self { + match s.trim() { + "opencode-cli" => Self::OpenCodeCli, + _ => Self::ClaudeCodeCli, + } + } + + /// 该 provider 默认的可执行文件名。 + pub fn default_exe(self) -> &'static str { + match self { + Self::ClaudeCodeCli => "claude", + Self::OpenCodeCli => "opencode", + } + } +} + /// Claude Code 权限模式,对应 CLI `--permission-mode` 的取值(已对本机 v2.1.161 核实)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -230,4 +258,31 @@ mod tests { assert!(arg_value(&args, "--max-budget-usd").is_none()); assert!(!args.contains(&"--no-session-persistence".to_string())); } + + #[test] + fn provider_parses_from_pref_with_claude_fallback() { + assert_eq!( + CodingAgentProvider::from_pref("opencode-cli"), + CodingAgentProvider::OpenCodeCli + ); + assert_eq!( + CodingAgentProvider::from_pref("claude-code-cli"), + CodingAgentProvider::ClaudeCodeCli + ); + // 未知/空 → 回落 Claude(不破坏现有用户)。 + assert_eq!( + CodingAgentProvider::from_pref(""), + CodingAgentProvider::ClaudeCodeCli + ); + assert_eq!( + CodingAgentProvider::from_pref("something-else"), + CodingAgentProvider::ClaudeCodeCli + ); + } + + #[test] + fn provider_default_exe() { + assert_eq!(CodingAgentProvider::ClaudeCodeCli.default_exe(), "claude"); + assert_eq!(CodingAgentProvider::OpenCodeCli.default_exe(), "opencode"); + } } diff --git a/openless-all/app/src-tauri/src/coding_agent/commands.rs b/openless-all/app/src-tauri/src/coding_agent/commands.rs index 7216651f..24526f01 100644 --- a/openless-all/app/src-tauri/src/coding_agent/commands.rs +++ b/openless-all/app/src-tauri/src/coding_agent/commands.rs @@ -12,6 +12,7 @@ use tauri::{AppHandle, Emitter}; use super::detect::{has_computer_use_mcp, McpServerStatus}; use super::guard::build_guard_settings_json; +use super::opencode::detect_opencode; use super::{ claude_mcp_list, create_git_snapshot, detect_claude, run_claude_agent, CodingAgentPermissionMode, CodingAgentRequest, @@ -71,6 +72,34 @@ pub async fn coding_agent_detect(exe: Option) -> ClaudeDetectionWire { } } +/// OpenCode 检测结果(回前端,camelCase)。issue #579。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCodeDetectionWire { + /// 是否检测到可运行的 opencode。 + pub installed: bool, + /// 版本号(如 "0.x.y")。 + pub version: Option, + /// 实际使用的可执行文件名/路径。 + pub exe: String, +} + +/// 检测 `opencode` 是否安装、版本。语音 Agent 选了 OpenCode 后端时,设置页据此提示 +/// 用户是否需要先 `npm i -g opencode-ai` / 登录。 +#[tauri::command] +pub async fn coding_agent_detect_opencode(exe: Option) -> OpenCodeDetectionWire { + let exe = exe + .map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()) + .unwrap_or_else(|| "opencode".to_string()); + let version = detect_opencode(&exe).await; + OpenCodeDetectionWire { + installed: version.is_some(), + version, + exe, + } +} + /// 护栏化地无头跑一次 claude,事件流式 emit 到前端 `coding-agent:test`。 /// /// 安全:附 `--settings`(acceptEdits + 高风险 deny)、`--max-budget-usd` 成本上限; diff --git a/openless-all/app/src-tauri/src/coding_agent/guard.rs b/openless-all/app/src-tauri/src/coding_agent/guard.rs index 9d984740..4990ded7 100644 --- a/openless-all/app/src-tauri/src/coding_agent/guard.rs +++ b/openless-all/app/src-tauri/src/coding_agent/guard.rs @@ -122,6 +122,52 @@ pub fn build_guard_settings_json(mode: &str, extra_deny: &[String]) -> serde_jso }) } +/// OpenCode `permission` 护栏的高风险 bash 子命令前缀(OpenCode glob 语法,如 `"rm *"`)。 +/// +/// OpenCode 的 `permission.bash` 是「glob → allow/ask/deny」映射(见官方 permissions 文档), +/// 与 Claude 的 `Bash(:*)` 说明符是同类东西,但写法不同。这里把 +/// [`default_deny_rules`] 里的 Bash 前缀翻译成 OpenCode glob。管道执行远程脚本 +/// (`| sh`)、fork 炸弹等中段/shell 语法仍无法用前缀 glob 表达,由运行级 +/// [`is_high_risk_command`] 兜底。 +pub fn opencode_bash_deny_prefixes() -> Vec<&'static str> { + vec![ + "rm -rf", "rm -fr", "sudo", "git push --force", "git push -f", "git reset --hard", + "git clean -fd", "git clean -f -d", "mkfs", "dd", "shutdown", "reboot", "chmod", "chown", + "crontab", "osascript", "launchctl", "kextload", "nvram", + ] +} + +/// 生成传给 OpenCode 的护栏配置(写入 `OPENCODE_CONFIG_CONTENT` inline JSON)。 +/// +/// 与 [`build_guard_settings_json`](Claude `--settings`)等价的 OpenCode 形态: +/// - `permission.bash`:默认 `allow`(放行可恢复轻动作),高风险前缀 `deny`; +/// `extra_allow_prefixes` 里的前缀(审批通过的)显式 `allow`,盖掉默认 deny。 +/// - `permission.webfetch = "deny"`:去掉直抓任意 URL 面(与 Claude 路径不放 WebFetch 一致)。 +/// - 其余工具(read/edit/write/glob/grep/websearch)默认 `allow`,靠 `*: allow` 兜底, +/// 保持「放行 + 护栏」语义(高风险只在 bash 前缀这一层拦)。 +pub fn build_opencode_guard_config(extra_allow_prefixes: &[String]) -> serde_json::Value { + let mut bash: serde_json::Map = serde_json::Map::new(); + // 先放默认:未命中规则一律 allow(轻动作不打断无头执行)。 + bash.insert("*".into(), serde_json::Value::String("allow".into())); + for prefix in opencode_bash_deny_prefixes() { + bash.insert(format!("{prefix} *"), serde_json::Value::String("deny".into())); + // 无参形式(如 `reboot`)也要拦:glob `reboot *` 不匹配光秃秃的 `reboot`。 + bash.insert(prefix.to_string(), serde_json::Value::String("deny".into())); + } + // 审批放行:把通过的高风险前缀显式 allow,盖掉上面的 deny(后写覆盖)。 + for prefix in extra_allow_prefixes { + bash.insert(format!("{prefix} *"), serde_json::Value::String("allow".into())); + bash.insert(prefix.clone(), serde_json::Value::String("allow".into())); + } + serde_json::json!({ + "permission": { + "*": "allow", + "bash": bash, + "webfetch": "deny", + } + }) +} + #[cfg(test)] mod tests { use super::*; @@ -165,6 +211,32 @@ mod tests { assert!(deny.iter().any(|d| d == "Bash(npm publish:*)")); } + #[test] + fn opencode_guard_denies_high_risk_bash_and_webfetch() { + let v = build_opencode_guard_config(&[]); + let perm = &v["permission"]; + assert_eq!(perm["*"], "allow"); + assert_eq!(perm["webfetch"], "deny"); + let bash = &perm["bash"]; + assert_eq!(bash["*"], "allow"); + // 高风险前缀两种形态都拦(带参 glob + 无参)。 + assert_eq!(bash["rm -rf *"], "deny"); + assert_eq!(bash["sudo *"], "deny"); + assert_eq!(bash["reboot"], "deny"); + assert_eq!(bash["git push --force *"], "deny"); + } + + #[test] + fn opencode_guard_extra_allow_overrides_deny() { + let v = build_opencode_guard_config(&["git push --force".to_string()]); + let bash = &v["permission"]["bash"]; + // 审批放行后,被批准的前缀变 allow(覆盖默认 deny)。 + assert_eq!(bash["git push --force *"], "allow"); + assert_eq!(bash["git push --force"], "allow"); + // 未放行的仍然 deny。 + assert_eq!(bash["rm -rf *"], "deny"); + } + #[test] fn default_deny_covers_perms_and_macos_persistence() { let deny = default_deny_rules(); diff --git a/openless-all/app/src-tauri/src/coding_agent/mod.rs b/openless-all/app/src-tauri/src/coding_agent/mod.rs index f98a1c2d..8cce6188 100644 --- a/openless-all/app/src-tauri/src/coding_agent/mod.rs +++ b/openless-all/app/src-tauri/src/coding_agent/mod.rs @@ -12,6 +12,7 @@ pub mod args; pub mod commands; pub mod detect; pub mod guard; +pub mod opencode; pub mod stream; use std::path::Path; @@ -23,8 +24,11 @@ use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; -pub use args::{build_claude_args, CodingAgentPermissionMode, CodingAgentRequest}; +pub use args::{ + build_claude_args, CodingAgentPermissionMode, CodingAgentProvider, CodingAgentRequest, +}; pub use detect::McpServerStatus; +pub use opencode::run_opencode_agent; pub use stream::{parse_stream_json_line, CodingAgentEvent}; /// 无头 Claude 的「自动化前置说明」。 @@ -68,7 +72,7 @@ pub enum CodingAgentError { /// 给 GUI 进程补 PATH / HOME:macOS 从 Finder 启动的进程不继承登录 shell 环境, /// `claude` 常装在 `~/.local/bin`、Homebrew 在 `/opt/homebrew/bin`。 -fn augment_env(cmd: &mut Command) { +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(); @@ -91,7 +95,7 @@ fn augment_env(cmd: &mut Command) { cmd.env("PATH", path); } -fn augmented_command(exe: &str) -> Command { +pub(super) fn augmented_command(exe: &str) -> Command { let mut cmd = Command::new(exe); augment_env(&mut cmd); cmd @@ -138,7 +142,7 @@ pub async fn claude_mcp_list(exe: &str) -> Vec { } } -async fn wait_cancel(cancel: &Arc) { +pub(super) async fn wait_cancel(cancel: &Arc) { loop { if cancel.load(Ordering::Relaxed) { return; diff --git a/openless-all/app/src-tauri/src/coding_agent/opencode.rs b/openless-all/app/src-tauri/src/coding_agent/opencode.rs new file mode 100644 index 00000000..67e01c89 --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/opencode.rs @@ -0,0 +1,344 @@ +//! 无头 OpenCode(`opencode run`)适配器(issue #579)。 +//! +//! 与 Claude Code 适配器([`super`] 顶层 + [`super::args`] / [`super::stream`])同形、复用 +//! 同一套 [`CodingAgentRequest`] / [`CodingAgentEvent`] / [`CodingAgentError`],但对接的是 +//! OpenCode CLI([opencode.ai](https://opencode.ai)): +//! +//! - 检测:`opencode --version` → 复用 [`super::detect::parse_claude_version`] 取 `x.y.z`。 +//! - 运行:`opencode run --format json --model --dir +//! [--dangerously-skip-permissions]`,**prompt 作为命令行参数**(OpenCode 从 argv 读, +//! 不像 claude 从 stdin),逐行解析 JSON 事件。 +//! - 护栏:OpenCode 无 `--settings`,但支持 `permission` 配置;用 +//! `OPENCODE_CONFIG_CONTENT` 环境变量注入 inline 护栏 JSON(precedence 高于项目 +//! `opencode.json`),见 [`super::guard::build_opencode_guard_config`]。 +//! - 输出:OpenCode 的 `text` 事件是「完成的文本块」(带 `part.time.end`)而非逐字 delta, +//! 且**没有**带 cost 的终局 `result` 事件——本模块在 EOF 处合成一条 +//! [`CodingAgentEvent::Completed`],`cost_usd = None`(OpenCode CLI 不在 JSON 里给单次成本)。 + +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; + +use super::stream::CodingAgentEvent; +use super::{augmented_command, wait_cancel, CodingAgentEventSink, CodingAgentRequest}; +use super::{CodingAgentError, CodingAgentPermissionMode}; + +/// 探测 `opencode` 版本(`None` 表示未安装或无法运行)。复用 claude 的 `x.y.z` 解析。 +pub async fn detect_opencode(exe: &str) -> Option { + let out = augmented_command(exe).arg("--version").output().await.ok()?; + if !out.status.success() { + return None; + } + super::detect::parse_claude_version(&String::from_utf8_lossy(&out.stdout)) +} + +/// OpenCode 权限模式到 CLI 的映射。OpenCode 只有一个二元的 +/// `--dangerously-skip-permissions`(绕过所有非 deny 规则),没有 claude 的四档模式。 +/// 我们靠注入的 `permission` 护栏配置(deny 高风险)做真正的拦截,因此除 `Plan` 外都需要 +/// 加 skip 标志,否则无头下「ask」会卡死或被默认拒。`Plan` 不传 skip(OpenCode 自身只读)。 +fn skip_permissions_flag(mode: CodingAgentPermissionMode) -> bool { + !matches!(mode, CodingAgentPermissionMode::Plan) +} + +/// 构造 `opencode run` 的命令行参数(不含可执行文件本身,也不含 prompt——prompt 在运行器 +/// 里作为**最后一个位置参数**追加,避免和带空格的 prompt 混进 flag 解析)。 +pub fn build_opencode_args(req: &CodingAgentRequest) -> Vec { + let mut args: Vec = vec!["run".into(), "--format".into(), "json".into()]; + if let Some(model) = &req.model { + args.push("--model".into()); + args.push(model.clone()); + } + if let Some(cwd) = &req.cwd { + args.push("--dir".into()); + args.push(cwd.to_string_lossy().into_owned()); + } + if skip_permissions_flag(req.permission_mode) { + args.push("--dangerously-skip-permissions".into()); + } + args +} + +/// 解析一行 `opencode run --format json` 的 NDJSON。 +/// +/// OpenCode 事件形如 `{"type":"text|tool_use|step_start|step_finish|reasoning|error", +/// "sessionID":"…","part":{…}|"error":…}`。我们只关心: +/// - `text` → `part.text`(完整文本块,作为 Delta 抛出;运行器累计成最终结果)。 +/// - `tool_use` → `part.tool`(工具名)。 +/// - `error` → `error`(字符串或对象)。 +/// +/// 其余(step_start/step_finish/reasoning/未知)忽略。解析失败返回 `None`,不 panic。 +pub fn parse_opencode_json_line(session_id: &str, line: &str) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + let v: serde_json::Value = serde_json::from_str(line).ok()?; + match v.get("type")?.as_str()? { + "text" => { + let text = v.get("part")?.get("text")?.as_str()?.to_string(); + if text.is_empty() { + return None; + } + Some(CodingAgentEvent::Delta { + session_id: session_id.to_string(), + text, + }) + } + "tool_use" => { + let name = v.get("part")?.get("tool")?.as_str()?.to_string(); + Some(CodingAgentEvent::ToolUse { + session_id: session_id.to_string(), + name, + }) + } + "error" => { + let message = match v.get("error") { + Some(serde_json::Value::String(s)) => s.clone(), + Some(other) => other.to_string(), + None => "agent 返回错误".to_string(), + }; + Some(CodingAgentEvent::Error { + session_id: session_id.to_string(), + message, + }) + } + _ => None, + } +} + +/// 无头跑一次 OpenCode:prompt 作为 argv,逐行解析 NDJSON,把事件投到 `sink`。 +/// 护栏 JSON 经 `OPENCODE_CONFIG_CONTENT` 注入。支持取消与超时(都会 kill 子进程)。 +/// +/// `guard_config_json`:[`super::guard::build_opencode_guard_config`] 的产物。`None` 表示 +/// 不注入护栏(仅 Console 显式无护栏场景用;语音路径必须传 Some,调用方 fail-closed)。 +pub async fn run_opencode_agent( + exe: &str, + req: CodingAgentRequest, + guard_config_json: Option, + sink: CodingAgentEventSink, + cancel: Arc, +) -> Result<(), CodingAgentError> { + let args = build_opencode_args(&req); + let mut cmd = augmented_command(exe); + cmd.args(&args) + // prompt 作为最后的位置参数(OpenCode 从 argv 读取 message)。 + .arg(&req.prompt) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(cwd) = &req.cwd { + cmd.current_dir(cwd); + } + if let Some(cfg) = &guard_config_json { + cmd.env("OPENCODE_CONFIG_CONTENT", cfg); + } + + 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()) + } + })?; + + 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)); + // OpenCode 没有带最终文本/成本的 result 事件:累计所有 text 块,EOF 时合成 Completed。 + let mut accumulated = String::new(); + let mut got_error = false; + 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), + }); + got_error = true; + outcome = Err(CodingAgentError::Timeout(req.timeout_secs)); + break; + } + line = lines.next_line() => { + match line { + Ok(Some(l)) => { + if let Some(ev) = parse_opencode_json_line(&req.session_id, &l) { + match &ev { + CodingAgentEvent::Delta { text, .. } => accumulated.push_str(text), + CodingAgentEvent::Error { .. } => got_error = true, + _ => {} + } + 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()))?; + + // 正常 EOF 收尾(没取消/超时/IO 错):进程成功且没报错 → 合成 Completed。 + if outcome.is_ok() { + if status.success() && !got_error { + let _ = sink.send(CodingAgentEvent::Completed { + session_id: req.session_id.clone(), + text: accumulated.trim().to_string(), + cost_usd: None, + duration_ms: None, + }); + return Ok(()); + } + if !status.success() && !got_error { + // 非 0 退出且没解析到 error 事件:补一条 Error(取 stderr 末行作摘要)。 + let stderr = match stderr_task { + Some(t) => t.await.unwrap_or_default(), + None => String::new(), + }; + let summary = stderr.lines().last().unwrap_or("").trim().to_string(); + let _ = sink.send(CodingAgentEvent::Error { + session_id: req.session_id.clone(), + message: if summary.is_empty() { + format!("agent 异常退出 (code={:?})", status.code()) + } else { + summary + }, + }); + return Err(CodingAgentError::ProcessExit(status.code())); + } + } + + let _ = cancel.load(Ordering::Relaxed); // 静默标记 cancel 已被消费(kill_on_drop 兜底)。 + outcome +} + +#[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_format_and_prompt_not_in_argv() { + let req = CodingAgentRequest::new("s1", "hello world"); + let args = build_opencode_args(&req); + assert_eq!(args.first().map(|s| s.as_str()), Some("run")); + assert_eq!(arg_value(&args, "--format"), Some("json")); + // prompt 不进 build_opencode_args(运行器单独追加)。 + assert!(!args.iter().any(|a| a.contains("hello world"))); + } + + #[test] + fn plan_mode_omits_skip_permissions_other_modes_add_it() { + let mut req = CodingAgentRequest::new("s", "p"); + req.permission_mode = CodingAgentPermissionMode::Plan; + assert!(!build_opencode_args(&req).contains(&"--dangerously-skip-permissions".to_string())); + req.permission_mode = CodingAgentPermissionMode::AcceptEdits; + assert!(build_opencode_args(&req).contains(&"--dangerously-skip-permissions".to_string())); + req.permission_mode = CodingAgentPermissionMode::BypassPermissions; + assert!(build_opencode_args(&req).contains(&"--dangerously-skip-permissions".to_string())); + } + + #[test] + fn model_and_dir_flags_emitted_when_set() { + let mut req = CodingAgentRequest::new("s", "p"); + req.model = Some("anthropic/claude-sonnet-4".into()); + req.cwd = Some(PathBuf::from("/tmp/work")); + let args = build_opencode_args(&req); + assert_eq!(arg_value(&args, "--model"), Some("anthropic/claude-sonnet-4")); + assert_eq!(arg_value(&args, "--dir"), Some("/tmp/work")); + } + + #[test] + fn parses_text_part_as_delta() { + let line = r#"{"type":"text","sessionID":"x","part":{"type":"text","text":"你好"}}"#; + assert_eq!( + parse_opencode_json_line("s1", line), + Some(CodingAgentEvent::Delta { + session_id: "s1".into(), + text: "你好".into() + }) + ); + } + + #[test] + fn parses_tool_use_part() { + let line = r#"{"type":"tool_use","sessionID":"x","part":{"type":"tool","tool":"bash"}}"#; + assert_eq!( + parse_opencode_json_line("s1", line), + Some(CodingAgentEvent::ToolUse { + session_id: "s1".into(), + name: "bash".into() + }) + ); + } + + #[test] + fn parses_error_event() { + let line = r#"{"type":"error","sessionID":"x","error":"boom"}"#; + assert_eq!( + parse_opencode_json_line("s1", line), + Some(CodingAgentEvent::Error { + session_id: "s1".into(), + message: "boom".into() + }) + ); + } + + #[test] + fn ignores_step_and_reasoning_and_garbage() { + assert_eq!( + parse_opencode_json_line("s1", r#"{"type":"step_start","part":{}}"#), + None + ); + assert_eq!( + parse_opencode_json_line("s1", r#"{"type":"reasoning","part":{"text":"…"}}"#), + None + ); + assert_eq!(parse_opencode_json_line("s1", "not json"), None); + assert_eq!(parse_opencode_json_line("s1", ""), None); + } +} diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_voice_agent.rs b/openless-all/app/src-tauri/src/coordinator/dictation_voice_agent.rs index 71f5467a..81a9a792 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_voice_agent.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_voice_agent.rs @@ -217,11 +217,10 @@ async fn run_less_computer_once( extra_allow_patterns: &[String], continue_session: bool, ) -> LessComputerOutcome { - // 护栏 deny:默认全量;审批放行的模式从 deny 中剔除。 - // 审批 UI 只回传命中的单个高风险子串,但同一风险有等价写法(如 --force / -f)。 - // 按「风险等价组」整组放行:只放行被点那一个会让等价写法仍卡在 deny(deny 优先级高于 - // allow)→ 命令仍被拦。见 guard::risk_equivalent_patterns。 - let mut deny = crate::coding_agent::guard::default_deny_rules(); + use crate::coding_agent::CodingAgentProvider; + + // 审批放行的高风险子串按「风险等价组」整组放行(如 --force / -f):只放行被点那一个会让 + // 等价写法仍被拦。见 guard::risk_equivalent_patterns。Claude / OpenCode 共用这组前缀。 let approved_patterns: Vec = extra_allow_patterns .iter() .flat_map(|p| { @@ -233,56 +232,13 @@ async fn run_less_computer_once( } }) .collect(); - let allow_rules: Vec = approved_patterns - .iter() - .map(|p| format!("Bash({p}:*)")) - .collect(); - if !allow_rules.is_empty() { - deny.retain(|d| !allow_rules.iter().any(|a| a == d)); - } - let settings_json = serde_json::json!({ - "permissions": { "defaultMode": mode.as_cli_arg(), "deny": deny } - }); - let settings_path = std::env::temp_dir().join(format!( - "openless-less-computer-guard-{}.json", - uuid::Uuid::new_v4() - )); - // fail-closed:序列化或写入失败时立即中止,绝不在「无护栏」下把无效路径交给 - // `claude -p --settings`(找不到文件 = 完全裸跑)。宁可不跑也不裸跑。 - let settings_bytes = match serde_json::to_vec_pretty(&settings_json) { - Ok(b) => b, - Err(e) => { - log::warn!("[less-computer] 序列化护栏配置失败: {e}"); - return LessComputerOutcome::Failed { - message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(), - }; - } - }; - if let Err(e) = std::fs::write(&settings_path, settings_bytes) { - log::warn!("[less-computer] 写护栏配置失败: {e}"); - return LessComputerOutcome::Failed { - message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(), - }; - } + + let provider = CodingAgentProvider::from_pref(&inner.prefs.get().coding_agent_provider); let mut req = crate::coding_agent::CodingAgentRequest::new("less-computer", prompt.to_string()); req.cwd = cwd.map(|p| p.to_path_buf()); req.model = model.map(|m| m.to_string()); req.permission_mode = mode; - // 写护栏成功后才设置:写失败已在上面 fail-closed 返回,不会带无效路径裸跑。 - req.settings_json_path = Some(settings_path.clone()); - // 去掉 WebFetch:无出站白名单时它是 prompt 注入 SSRF 面(诱导拉取内网/元数据端点)。 - // 保留 WebSearch(走搜索引擎,不直接抓任意 URL)。 - req.allowed_tools = vec![ - "Bash".into(), - "Read".into(), - "Edit".into(), - "Write".into(), - "Glob".into(), - "Grep".into(), - "WebSearch".into(), - ]; - req.allowed_tools.extend(allow_rules); // 真实任务(开应用、多步操作、读写文件)常超过 120s/0.5$ → 老是「运行超时」。放宽到 // 5 分钟 / 2$,给多步任务足够空间;仍有硬上限兜底,不会无限跑/烧钱。 req.max_budget_usd = Some(2.0); @@ -294,9 +250,90 @@ async fn run_less_computer_once( let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let cancel = Arc::new(AtomicBool::new(false)); let cancel_for_runner = Arc::clone(&cancel); - let run = async_runtime::spawn(async move { - crate::coding_agent::run_claude_agent("claude", req, tx, cancel_for_runner).await - }); + + // 护栏 + 运行器按 provider 分派。两条路径都 fail-closed:护栏配置生成失败一律中止, + // 绝不在无护栏下裸跑。`settings_path` 仅 Claude 路径用临时文件(OpenCode 走 env 注入, + // 无临时文件需清理)。 + let settings_path: Option; + let run = match provider { + CodingAgentProvider::ClaudeCodeCli => { + // 护栏 deny:默认全量;审批放行的模式从 deny 中剔除。 + let mut deny = crate::coding_agent::guard::default_deny_rules(); + let allow_rules: Vec = approved_patterns + .iter() + .map(|p| format!("Bash({p}:*)")) + .collect(); + if !allow_rules.is_empty() { + deny.retain(|d| !allow_rules.iter().any(|a| a == d)); + } + let settings_json = serde_json::json!({ + "permissions": { "defaultMode": mode.as_cli_arg(), "deny": deny } + }); + let path = std::env::temp_dir().join(format!( + "openless-less-computer-guard-{}.json", + uuid::Uuid::new_v4() + )); + // fail-closed:序列化或写入失败时立即中止,绝不把无效路径交给 `claude -p --settings` + //(找不到文件 = 完全裸跑)。宁可不跑也不裸跑。 + let settings_bytes = match serde_json::to_vec_pretty(&settings_json) { + Ok(b) => b, + Err(e) => { + log::warn!("[less-computer] 序列化护栏配置失败: {e}"); + return LessComputerOutcome::Failed { + message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(), + }; + } + }; + if let Err(e) = std::fs::write(&path, settings_bytes) { + log::warn!("[less-computer] 写护栏配置失败: {e}"); + return LessComputerOutcome::Failed { + message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(), + }; + } + settings_path = Some(path.clone()); + req.settings_json_path = Some(path); + // 去掉 WebFetch:无出站白名单时它是 prompt 注入 SSRF 面。保留 WebSearch(走搜索引擎)。 + req.allowed_tools = vec![ + "Bash".into(), + "Read".into(), + "Edit".into(), + "Write".into(), + "Glob".into(), + "Grep".into(), + "WebSearch".into(), + ]; + req.allowed_tools.extend(allow_rules); + async_runtime::spawn(async move { + crate::coding_agent::run_claude_agent("claude", req, tx, cancel_for_runner).await + }) + } + CodingAgentProvider::OpenCodeCli => { + // OpenCode 无 `--settings`,护栏走 `permission` 配置经 OPENCODE_CONFIG_CONTENT 注入。 + // build_opencode_guard_config 默认 bash deny 高风险前缀、webfetch deny,审批放行的 + // 前缀显式 allow。fail-closed:序列化失败立即中止,绝不无护栏裸跑。 + let guard = crate::coding_agent::guard::build_opencode_guard_config(&approved_patterns); + let guard_str = match serde_json::to_string(&guard) { + Ok(s) => s, + Err(e) => { + log::warn!("[less-computer] 序列化 OpenCode 护栏配置失败: {e}"); + return LessComputerOutcome::Failed { + message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(), + }; + } + }; + settings_path = None; + async_runtime::spawn(async move { + crate::coding_agent::run_opencode_agent( + "opencode", + req, + Some(guard_str), + tx, + cancel_for_runner, + ) + .await + }) + } + }; let cancel_for_watcher = Arc::clone(&cancel); let inner_for_cancel = Arc::clone(inner); let cancel_watcher = async_runtime::spawn(async move { @@ -341,7 +378,10 @@ async fn run_less_computer_once( let run_result = run.await; cancel.store(true, Ordering::Relaxed); let _ = cancel_watcher.await; - let _ = std::fs::remove_file(&settings_path); + // 仅 Claude 路径有临时护栏文件需清理;OpenCode 走 env 注入无文件。 + if let Some(path) = &settings_path { + let _ = std::fs::remove_file(path); + } if cancelled || matches!( diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 567d5a8b..c5c84f0c 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -389,6 +389,7 @@ pub fn run() { commands::stop_dictation, commands::cancel_dictation, coding_agent::commands::coding_agent_detect, + coding_agent::commands::coding_agent_detect_opencode, coding_agent::commands::coding_agent_run_test, coding_agent::commands::coding_agent_cancel_test, coding_agent::commands::coding_agent_command_risk, diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 326e2c07..6c4e7cdc 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -562,7 +562,8 @@ export const en: typeof zhCN = { voiceHotkey: 'Hold-to-talk key', voiceHotkeyDesc: 'Hold to talk, release to run. Supports Ctrl/Option/Fn single keys.', provider: 'Agent backend', - providerOpenCodeSoon: 'OpenCode (coming soon)', + 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.', panelHotkey: 'Panel hotkey (voice agent)', panelHotkeyDesc: 'Record voice → ASR → Claude → streamed into a panel. Default Cmd/Ctrl+Shift+Enter.', quickHotkey: 'Quick-take hotkey', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c3d695c0..94d09f4e 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -564,7 +564,8 @@ export const ja: typeof zhCN = { voiceHotkey: '押しながら話すキー', voiceHotkeyDesc: '押して話す、離して実行。Ctrl/Option/Fn などの単キー対応。', provider: 'Agent バックエンド', - providerOpenCodeSoon: 'OpenCode(近日対応)', + opencodeReady: 'OpenCode v{{version}} を検出しました。', + opencodeMissing: 'opencode コマンドが見つかりません。先にインストール(npm i -g opencode-ai)して opencode auth login でログインしてください。', panelHotkey: 'パネルキー(音声 Agent)', panelHotkeyDesc: '録音 → ASR → Claude → パネルにストリーミング表示。デフォルト Cmd/Ctrl+Shift+Enter。', quickHotkey: 'クイック取得キー', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 0633be29..2a2ed13a 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -564,7 +564,8 @@ export const ko: typeof zhCN = { voiceHotkey: '누르고 말하기 키', voiceHotkeyDesc: '누르고 말하고 놓으면 실행. Ctrl/Option/Fn 단일 키 지원.', provider: 'Agent 백엔드', - providerOpenCodeSoon: 'OpenCode(곧 지원)', + opencodeReady: 'OpenCode v{{version}} 감지됨.', + opencodeMissing: 'opencode 명령을 찾을 수 없습니다. 먼저 설치(npm i -g opencode-ai)하고 opencode auth login으로 로그인하세요.', panelHotkey: '패널 키(음성 Agent)', panelHotkeyDesc: '녹음 → ASR → Claude → 패널에 스트리밍. 기본 Cmd/Ctrl+Shift+Enter.', quickHotkey: '빠른 가져오기 키', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index d7844014..a639b8ff 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -560,7 +560,8 @@ export const zhCN = { voiceHotkey: '按住说话键', voiceHotkeyDesc: '按住说话、松开执行。支持 Ctrl/Option/Fn 等单键。', provider: 'Agent 后端', - providerOpenCodeSoon: 'OpenCode(即将支持)', + opencodeReady: '已检测到 OpenCode v{{version}}。', + opencodeMissing: '未检测到 opencode 命令。请先安装(npm i -g opencode-ai)并用 opencode auth login 登录后再使用。', panelHotkey: '面板键(语音 Agent)', panelHotkeyDesc: '录音 → ASR → Claude → 结果流式进面板。默认 Cmd/Ctrl+Shift+Enter。', quickHotkey: '快取用键', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index f01e6be2..d4b45e1c 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -562,7 +562,8 @@ export const zhTW: typeof zhCN = { voiceHotkey: '按住說話鍵', voiceHotkeyDesc: '按住說話、放開執行。支援 Ctrl/Option/Fn 等單鍵。', provider: 'Agent 後端', - providerOpenCodeSoon: 'OpenCode(即將支援)', + opencodeReady: '已偵測到 OpenCode v{{version}}。', + opencodeMissing: '未偵測到 opencode 指令。請先安裝(npm i -g opencode-ai)並用 opencode auth login 登入後再使用。', panelHotkey: '面板鍵(語音 Agent)', panelHotkeyDesc: '錄音 → ASR → Claude → 結果串流進面板。預設 Cmd/Ctrl+Shift+Enter。', quickHotkey: '快取用鍵', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 8f8d246b..c058c82f 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -1236,6 +1236,26 @@ export function codingAgentDetect(exe?: string): Promise { ) } +/** OpenCode CLI 检测结果(issue #579)。 */ +export interface OpenCodeDetection { + installed: boolean + version: string | null + exe: string +} + +/** 检测 `opencode` 是否安装(语音 Agent 选 OpenCode 后端时设置页据此提示)。 */ +export function codingAgentDetectOpencode(exe?: string): Promise { + return invokeOrMock( + "coding_agent_detect_opencode", + { exe }, + () => ({ + installed: false, + version: null, + exe: exe || "opencode", + }), + ) +} + export interface CodingAgentRunTestArgs { prompt: string exe?: string diff --git a/openless-all/app/src/pages/settings/CodingAgentSection.tsx b/openless-all/app/src/pages/settings/CodingAgentSection.tsx index 352e1d5d..beed0cc0 100644 --- a/openless-all/app/src/pages/settings/CodingAgentSection.tsx +++ b/openless-all/app/src/pages/settings/CodingAgentSection.tsx @@ -2,8 +2,10 @@ // 「按住说话键」在 通用 → 快捷键 里配置(见 ShortcutsSection),这里不再重复。 // 配置经 UserPreferences 持久化;启用后 coordinator 才注册热键。 +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { detectOS } from '../../components/WindowChrome' +import { codingAgentDetectOpencode, type OpenCodeDetection } from '../../lib/ipc' import type { CodingAgentPermissionMode, CodingAgentProviderId } from '../../lib/types' import { useHotkeySettings } from '../../state/HotkeySettingsContext' import { Card } from '../_atoms' @@ -21,6 +23,23 @@ export function CodingAgentSection() { const { prefs, updatePrefs: savePrefs } = useHotkeySettings() const os = detectOS() + // OpenCode 安装检测:仅当启用 + 选了 OpenCode 后端时探测一次,用于提示是否需先安装。 + const [opencode, setOpencode] = useState(null) + const useOpencode = prefs?.codingAgentEnabled && prefs?.codingAgentProvider === 'opencode-cli' + useEffect(() => { + if (!useOpencode) { + setOpencode(null) + return + } + let alive = true + void codingAgentDetectOpencode().then(d => { + if (alive) setOpencode(d) + }) + return () => { + alive = false + } + }, [useOpencode]) + if (os === 'win') return null if (!prefs) { @@ -60,10 +79,26 @@ export function CodingAgentSection() { style={{ ...inputStyle, maxWidth: 240, cursor: 'pointer' }} > - + + {/* OpenCode 后端:提示安装/登录状态。issue #579。 */} + {useOpencode && opencode && ( +
+ {opencode.installed + ? t('settings.codingAgent.opencodeReady', { version: opencode.version ?? '?' }) + : t('settings.codingAgent.opencodeMissing')} +
+ )} +