* feat(coding-agent): OpenCode CLI 适配器,语音 Agent 可选 OpenCode 后端 (issue #579) 把 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 <noreply@anthropic.com> * fix(coding-agent): OpenCode 防参数注入 + 可配置 Agent 可执行文件路径 (#638) 注入风险(@H-Chris233 评审):`opencode run` 的 prompt 作为最后位置参数,但之前缺 end-of-options 分隔。以 `-` / `--` 开头的 prompt(语音转写或被 prompt 注入诱导)会被 OpenCode 当作 flag 解析,可绕过护栏(混入 --dangerously-skip-permissions、--dir 改写 工作目录等)。修复:build_opencode_args 末尾追加 `--`,运行器把 prompt 接在其后;新增单测。 可配置路径(@PR-Agent 指出之前 hardcode "opencode"):新增 prefs.coding_agent_exe, 语音 Agent 路径据此取 exe(留空回退默认 claude / opencode),检测命令亦用配置路径; 设置「高级 → Less Computer」新增「可执行文件路径」输入;补 5 语言 i18n。 --------- Co-authored-by: 吕柏青 <lubaiqing9@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
19 files changed
+797
-71
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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::opencode::detect_opencode;
|
||||
use super::{
|
||||
claude_mcp_list, create_git_snapshot, detect_claude, run_claude_agent,
|
||||
CodingAgentPermissionMode, CodingAgentRequest,
|
||||
@@ -122,6 +123,45 @@ pub async fn coding_agent_detect(
|
||||
})
|
||||
}
|
||||
|
||||
/// OpenCode 检测结果(回前端,camelCase)。issue #579。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenCodeDetectionWire {
|
||||
/// 是否检测到可运行的 opencode。
|
||||
pub installed: bool,
|
||||
/// 版本号(如 "0.x.y")。
|
||||
pub version: Option<String>,
|
||||
/// 实际使用的可执行文件名/路径。
|
||||
pub exe: String,
|
||||
}
|
||||
|
||||
/// 检测 `opencode` 是否安装、版本。语音 Agent 选了 OpenCode 后端时,设置页据此提示
|
||||
/// 用户是否需要先 `npm i -g opencode-ai` / 登录。
|
||||
#[tauri::command]
|
||||
pub async fn coding_agent_detect_opencode(
|
||||
window: Window,
|
||||
exe: Option<String>,
|
||||
) -> Result<OpenCodeDetectionWire, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let exe = exe
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|e| !e.is_empty())
|
||||
.unwrap_or_else(|| "opencode".to_string());
|
||||
// 拒绝路径遍历和相对路径(--version 探测仅允许裸名或绝对路径)。
|
||||
if exe.contains("..") {
|
||||
return Err("不允许的可执行文件路径: 包含 '..'".into());
|
||||
}
|
||||
if (exe.contains('/') || exe.contains('\\')) && !std::path::Path::new(&exe).is_absolute() {
|
||||
return Err("不允许的相对路径,仅接受裸可执行文件名或绝对路径".into());
|
||||
}
|
||||
let version = detect_opencode(&exe).await;
|
||||
Ok(OpenCodeDetectionWire {
|
||||
installed: version.is_some(),
|
||||
version,
|
||||
exe,
|
||||
})
|
||||
}
|
||||
|
||||
/// 护栏化地无头跑一次 claude,事件流式 emit 到前端 `coding-agent:test`。
|
||||
///
|
||||
/// 安全:附 `--settings`(acceptEdits + 高风险 deny)、`--max-budget-usd` 成本上限;
|
||||
|
||||
@@ -150,6 +150,87 @@ 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(<prefix>:*)` 说明符是同类东西,但写法不同。这里把
|
||||
/// [`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.edit` / `permission.write`:补齐与 Claude [`default_deny_rules`] 对等的
|
||||
/// 文件级 deny(.env / .git/** / macOS 持久化面)。若 OpenCode 版本不支持 per-file glob deny,
|
||||
/// 这些键被静默忽略(无害);若支持则获得与 Claude 路径同级的文件保护。
|
||||
/// - `permission.webfetch = "deny"`:去掉直抓任意 URL 面(与 Claude 路径不放 WebFetch 一致)。
|
||||
/// - 其余工具(read/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<String, serde_json::Value> = 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()));
|
||||
}
|
||||
|
||||
// 文件级 deny:与 Claude default_deny_rules() 对等 —— .env / .git/** / macOS 持久化面。
|
||||
// 若 OpenCode 不支持 edit/write 的 per-file glob deny,该键被静默忽略(无害)。
|
||||
let mut edit_rules: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
|
||||
edit_rules.insert("*".into(), "allow".into());
|
||||
for pat in [".env", ".git/**"] {
|
||||
edit_rules.insert(pat.to_string(), "deny".into());
|
||||
}
|
||||
for pat in [
|
||||
"~/Library/LaunchAgents/**",
|
||||
"~/.zshrc",
|
||||
"~/.zprofile",
|
||||
"~/.bash_profile",
|
||||
"~/.bashrc",
|
||||
] {
|
||||
edit_rules.insert(pat.to_string(), "deny".into());
|
||||
}
|
||||
|
||||
let mut write_rules: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
|
||||
write_rules.insert("*".into(), "allow".into());
|
||||
for pat in [
|
||||
"~/Library/LaunchAgents/**",
|
||||
"~/.zshrc",
|
||||
"~/.zprofile",
|
||||
"~/.bash_profile",
|
||||
"~/.bashrc",
|
||||
] {
|
||||
write_rules.insert(pat.to_string(), "deny".into());
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"permission": {
|
||||
"*": "allow",
|
||||
"bash": bash,
|
||||
"edit": edit_rules,
|
||||
"write": write_rules,
|
||||
"webfetch": "deny",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -193,6 +274,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();
|
||||
|
||||
@@ -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<McpServerStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_cancel(cancel: &Arc<AtomicBool>) {
|
||||
pub(super) async fn wait_cancel(cancel: &Arc<AtomicBool>) {
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! 无头 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 <provider/model> --dir <cwd>
|
||||
//! [--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<String> {
|
||||
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 在运行器
|
||||
/// 里作为**最后一个位置参数**追加在末尾的 `--` 之后)。
|
||||
///
|
||||
/// 安全(参数注入防护):参数列表以 `--`(end-of-options 标记)结尾,运行器把 prompt 接在
|
||||
/// 其后。这样以 `-` / `--` 开头的 prompt(语音转写或被 prompt 注入诱导而成)都只会被当作
|
||||
/// 位置参数,绝不会被 OpenCode 解析成 flag —— 杜绝经 prompt 混入 `--dangerously-skip-
|
||||
/// permissions` 绕过护栏、`--dir` 改写工作目录等参数注入。model / cwd 作为各自 flag 的取值
|
||||
/// 紧跟在 flag 之后,天然不会被当成独立 flag。
|
||||
pub fn build_opencode_args(req: &CodingAgentRequest) -> Vec<String> {
|
||||
let mut args: Vec<String> = 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());
|
||||
}
|
||||
// end-of-options:其后由运行器追加的 prompt 一律按位置参数处理,不再解析成 flag。
|
||||
args.push("--".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<CodingAgentEvent> {
|
||||
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<String>,
|
||||
sink: CodingAgentEventSink,
|
||||
cancel: Arc<AtomicBool>,
|
||||
) -> 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()));
|
||||
}
|
||||
}
|
||||
|
||||
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")));
|
||||
// 参数注入防护:末尾必须是 `--`,运行器把 prompt 接在其后。
|
||||
assert_eq!(args.last().map(|s| s.as_str()), Some("--"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_of_options_marker_is_last_even_with_flags() {
|
||||
// 即便带 --model / --dir / --dangerously-skip-permissions,`--` 仍在最末,
|
||||
// 保证以 `-` 开头的 prompt 不会被解析成 flag。
|
||||
let mut req = CodingAgentRequest::new("s", "p");
|
||||
req.model = Some("anthropic/claude-sonnet-4".into());
|
||||
req.cwd = Some(PathBuf::from("/tmp/work"));
|
||||
req.permission_mode = CodingAgentPermissionMode::AcceptEdits;
|
||||
let args = build_opencode_args(&req);
|
||||
assert_eq!(args.last().map(|s| s.as_str()), Some("--"));
|
||||
// `--` 之前才是各 flag,`--dangerously-skip-permissions` 不在 `--` 之后。
|
||||
let dd = args.iter().rposition(|a| a == "--").unwrap();
|
||||
assert!(args[..dd].iter().any(|a| a == "--dangerously-skip-permissions"));
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -646,7 +646,7 @@ async fn run_voice_agent_transcript(
|
||||
CapsuleState::Polishing,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("Claude 处理中…".to_string()),
|
||||
Some("Agent 处理中…".to_string()),
|
||||
None,
|
||||
);
|
||||
|
||||
@@ -742,7 +742,7 @@ async fn run_voice_agent_transcript(
|
||||
LessComputerOutcome::Done { text, cost_usd } => {
|
||||
let text = text.trim().to_string();
|
||||
if text.is_empty() {
|
||||
let msg = "Claude 无结果(确认已登录 claude 且额度充足)".to_string();
|
||||
let msg = "Agent 无结果(确认已登录且额度充足)".to_string();
|
||||
emit_less_computer(
|
||||
inner,
|
||||
serde_json::json!({ "kind": "error", "message": msg }),
|
||||
@@ -806,11 +806,22 @@ 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;
|
||||
|
||||
let provider = CodingAgentProvider::from_pref(&inner.prefs.get().coding_agent_provider);
|
||||
// 可配置可执行文件:用户在「高级 → Less Computer」填了路径就用它,留空/空白按后端取默认
|
||||
// (claude / opencode)。trim 后为空视作未配置。
|
||||
let configured_exe: Option<String> = inner
|
||||
.prefs
|
||||
.get()
|
||||
.coding_agent_exe
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
// 审批放行的高风险子串按「风险等价组」整组放行(如 --force / -f):只放行被点那一个会让
|
||||
// 等价写法仍被拦。Claude / OpenCode 共用这组前缀。见 guard::risk_equivalent_patterns。
|
||||
let approved_patterns: Vec<String> = extra_allow_patterns
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
@@ -821,61 +832,15 @@ async fn run_less_computer_once(
|
||||
group.into_iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
})
|
||||
// 不可安全批准的模式(提权/毁盘/系统级如 "sudo "、"dd if=" 等,deny_rule_for_pattern
|
||||
// 返回 None)在审批阶段保持拦截,不注入 allow 列表也不生成 OpenCode allow glob。
|
||||
.filter(|p| crate::coding_agent::guard::deny_rule_for_pattern(p).is_some())
|
||||
.collect();
|
||||
// 只放行「可批准」的命令:deny_rule_for_pattern 返回该 pattern 在 default_deny_rules 里的
|
||||
// 精确 deny 规则;提权/毁盘/系统级等不可安全表达的命令返回 None → 即使被批准也保持拦截
|
||||
// (fail-closed),且不向 allow 注入畸形规则。允许的 allow 规则与被移除的 deny 严格一致。
|
||||
let allow_rules: Vec<String> = approved_patterns
|
||||
.iter()
|
||||
.filter_map(|p| crate::coding_agent::guard::deny_rule_for_pattern(p))
|
||||
.map(|rule| rule.to_string())
|
||||
.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 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);
|
||||
@@ -887,9 +852,97 @@ 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<std::path::PathBuf>;
|
||||
let run = match provider {
|
||||
CodingAgentProvider::ClaudeCodeCli => {
|
||||
// 护栏 deny:默认全量;审批放行的模式从 deny 中剔除。
|
||||
let mut deny = crate::coding_agent::guard::default_deny_rules();
|
||||
// 只放行「可批准」的命令:deny_rule_for_pattern 返回该 pattern 在 default_deny_rules
|
||||
// 里的精确 deny 规则;提权/毁盘/系统级等不可安全表达的命令返回 None → 即使被批准也
|
||||
// 保持拦截(fail-closed),且不向 allow 注入畸形规则。
|
||||
let allow_rules: Vec<String> = approved_patterns
|
||||
.iter()
|
||||
.filter_map(|p| crate::coding_agent::guard::deny_rule_for_pattern(p))
|
||||
.map(|rule| rule.to_string())
|
||||
.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);
|
||||
let exe = configured_exe.unwrap_or_else(|| "claude".to_string());
|
||||
async_runtime::spawn(async move {
|
||||
crate::coding_agent::run_claude_agent(&exe, 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;
|
||||
let exe = configured_exe.unwrap_or_else(|| "opencode".to_string());
|
||||
async_runtime::spawn(async move {
|
||||
crate::coding_agent::run_opencode_agent(
|
||||
&exe,
|
||||
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 {
|
||||
@@ -934,7 +987,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!(
|
||||
@@ -957,7 +1013,7 @@ async fn run_less_computer_once(
|
||||
Ok(Err(e)) => Some(e.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string());
|
||||
.unwrap_or_else(|| "Agent 无结果(确认已登录且额度充足)".to_string());
|
||||
LessComputerOutcome::Failed { message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@ macro_rules! app_invoke_handler_desktop {
|
||||
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,
|
||||
|
||||
@@ -644,6 +644,10 @@ pub struct UserPreferences {
|
||||
/// Agent 工作目录(`None` = 临时目录)。
|
||||
#[serde(default)]
|
||||
pub coding_agent_workdir: Option<String>,
|
||||
/// Agent 可执行文件路径/命令(`None` 或空白 = 按后端取默认 `claude` / `opencode`)。
|
||||
/// 供用户在「高级 → Less Computer」填自定义路径(例如未加入 PATH 的 opencode 二进制)。
|
||||
#[serde(default)]
|
||||
pub coding_agent_exe: Option<String>,
|
||||
/// Less Computer 语音触发键。macOS 生效;支持单修饰键(左/右 Control、左/右 Option、Fn)
|
||||
/// 和普通组合键。`None` = 停用。
|
||||
#[serde(default = "default_coding_agent_voice_hotkey")]
|
||||
@@ -904,6 +908,8 @@ struct UserPreferencesWire {
|
||||
coding_agent_permission_mode: String,
|
||||
#[serde(default)]
|
||||
coding_agent_workdir: Option<String>,
|
||||
#[serde(default)]
|
||||
coding_agent_exe: Option<String>,
|
||||
#[serde(default = "default_coding_agent_voice_hotkey")]
|
||||
coding_agent_voice_hotkey: Option<ShortcutBinding>,
|
||||
#[serde(default = "default_coding_agent_panel_hotkey")]
|
||||
@@ -1020,6 +1026,7 @@ impl Default for UserPreferencesWire {
|
||||
coding_agent_model: prefs.coding_agent_model,
|
||||
coding_agent_permission_mode: prefs.coding_agent_permission_mode,
|
||||
coding_agent_workdir: prefs.coding_agent_workdir,
|
||||
coding_agent_exe: prefs.coding_agent_exe,
|
||||
coding_agent_voice_hotkey: prefs.coding_agent_voice_hotkey,
|
||||
coding_agent_panel_hotkey: prefs.coding_agent_panel_hotkey,
|
||||
coding_agent_quick_hotkey: prefs.coding_agent_quick_hotkey,
|
||||
@@ -1115,6 +1122,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
||||
coding_agent_model: wire.coding_agent_model,
|
||||
coding_agent_permission_mode: wire.coding_agent_permission_mode,
|
||||
coding_agent_workdir: wire.coding_agent_workdir,
|
||||
coding_agent_exe: wire.coding_agent_exe,
|
||||
coding_agent_voice_hotkey: wire.coding_agent_voice_hotkey,
|
||||
coding_agent_panel_hotkey: wire.coding_agent_panel_hotkey,
|
||||
coding_agent_quick_hotkey: wire.coding_agent_quick_hotkey,
|
||||
@@ -1862,6 +1870,7 @@ impl Default for UserPreferences {
|
||||
coding_agent_model: None,
|
||||
coding_agent_permission_mode: default_coding_agent_permission_mode(),
|
||||
coding_agent_workdir: None,
|
||||
coding_agent_exe: None,
|
||||
coding_agent_voice_hotkey: default_coding_agent_voice_hotkey(),
|
||||
coding_agent_panel_hotkey: default_coding_agent_panel_hotkey(),
|
||||
coding_agent_quick_hotkey: None,
|
||||
|
||||
@@ -608,7 +608,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',
|
||||
@@ -617,6 +618,7 @@ export const en: typeof zhCN = {
|
||||
modelPlaceholder: 'Default: sonnet',
|
||||
modelDefault: 'Default (auto sonnet)',
|
||||
modelHint: 'Haiku = fastest · Sonnet = balanced · Opus = strongest',
|
||||
exe: 'Executable path',
|
||||
},
|
||||
debug: {
|
||||
title: 'Debug tools',
|
||||
|
||||
@@ -610,7 +610,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: 'クイック取得キー',
|
||||
@@ -619,6 +620,7 @@ export const ja: typeof zhCN = {
|
||||
modelPlaceholder: 'デフォルト: sonnet',
|
||||
modelDefault: 'デフォルト(自動 sonnet)',
|
||||
modelHint: 'Haiku = 最速 · Sonnet = バランス · Opus = 最強',
|
||||
exe: '実行ファイルのパス',
|
||||
},
|
||||
debug: {
|
||||
title: 'デバッグツール',
|
||||
|
||||
@@ -610,7 +610,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: '빠른 가져오기 키',
|
||||
@@ -619,6 +620,7 @@ export const ko: typeof zhCN = {
|
||||
modelPlaceholder: '기본: sonnet',
|
||||
modelDefault: '기본(자동 sonnet)',
|
||||
modelHint: 'Haiku = 가장 빠름 · Sonnet = 균형 · Opus = 최강',
|
||||
exe: '실행 파일 경로',
|
||||
},
|
||||
debug: {
|
||||
title: '디버그 도구',
|
||||
|
||||
@@ -606,7 +606,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: '快取用键',
|
||||
@@ -615,6 +616,7 @@ export const zhCN = {
|
||||
modelPlaceholder: '默认 sonnet',
|
||||
modelDefault: '默认(自动 sonnet)',
|
||||
modelHint: 'Haiku 最快 · Sonnet 均衡 · Opus 最强',
|
||||
exe: '可执行文件路径',
|
||||
},
|
||||
debug: {
|
||||
title: '调试工具',
|
||||
|
||||
@@ -608,7 +608,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: '快取用鍵',
|
||||
@@ -617,6 +618,7 @@ export const zhTW: typeof zhCN = {
|
||||
modelPlaceholder: '預設 sonnet',
|
||||
modelDefault: '預設(自動 sonnet)',
|
||||
modelHint: 'Haiku 最快 · Sonnet 均衡 · Opus 最強',
|
||||
exe: '可執行檔路徑',
|
||||
},
|
||||
debug: {
|
||||
title: '除錯工具',
|
||||
|
||||
@@ -18,6 +18,26 @@ export interface ClaudeDetection {
|
||||
hasComputerUse: boolean
|
||||
}
|
||||
|
||||
/** OpenCode CLI 检测结果(issue #579)。 */
|
||||
export interface OpenCodeDetection {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
exe: string
|
||||
}
|
||||
|
||||
/** 检测 `opencode` 是否安装(语音 Agent 选 OpenCode 后端时设置页据此提示)。 */
|
||||
export function codingAgentDetectOpencode(exe?: string): Promise<OpenCodeDetection> {
|
||||
return invokeOrMock(
|
||||
"coding_agent_detect_opencode",
|
||||
{ exe },
|
||||
() => ({
|
||||
installed: false,
|
||||
version: null,
|
||||
exe: exe || "opencode",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** 无头 Claude 运行事件,由后端 `coding-agent:test` 流式推送(tag 为 `kind`)。 */
|
||||
export type CodingAgentEvent =
|
||||
| { kind: "started"; session_id: string }
|
||||
|
||||
@@ -161,10 +161,12 @@ export type {
|
||||
export type {
|
||||
McpServerStatus,
|
||||
ClaudeDetection,
|
||||
OpenCodeDetection,
|
||||
CodingAgentRunTestArgs,
|
||||
} from "./coding-agent"
|
||||
export {
|
||||
codingAgentDetect,
|
||||
codingAgentDetectOpencode,
|
||||
codingAgentRunTest,
|
||||
codingAgentCancelTest,
|
||||
codingAgentCommandRisk,
|
||||
|
||||
@@ -67,6 +67,7 @@ export let mockSettings: UserPreferences = {
|
||||
codingAgentModel: null,
|
||||
codingAgentPermissionMode: "acceptEdits",
|
||||
codingAgentWorkdir: null,
|
||||
codingAgentExe: null,
|
||||
codingAgentVoiceHotkey: { primary: "LeftControl", modifiers: [] },
|
||||
codingAgentPanelHotkey: { primary: "Enter", modifiers: ["cmd", "shift"] },
|
||||
codingAgentQuickHotkey: null,
|
||||
|
||||
@@ -54,6 +54,7 @@ const previousPrefs: UserPreferences = {
|
||||
codingAgentModel: null,
|
||||
codingAgentPermissionMode: 'acceptEdits',
|
||||
codingAgentWorkdir: null,
|
||||
codingAgentExe: null,
|
||||
codingAgentVoiceHotkey: { primary: 'LeftControl', modifiers: [] },
|
||||
codingAgentPanelHotkey: { primary: 'Enter', modifiers: ['cmd', 'shift'] },
|
||||
codingAgentQuickHotkey: null,
|
||||
|
||||
@@ -298,6 +298,8 @@ export interface UserPreferences {
|
||||
codingAgentPermissionMode: CodingAgentPermissionMode;
|
||||
/** Agent 工作目录,null = 临时目录。 */
|
||||
codingAgentWorkdir: string | null;
|
||||
/** Agent 可执行文件路径/命令,null 或空 = 按后端取默认(claude / opencode)。 */
|
||||
codingAgentExe: string | null;
|
||||
/** Less Computer 按住说话快捷键。null = 停用;目前仅 macOS 显示/生效。 */
|
||||
codingAgentVoiceHotkey: ShortcutBinding | null;
|
||||
/** 热键 1:语音 Agent 面板键。null = 停用。 */
|
||||
|
||||
@@ -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,24 @@ export function CodingAgentSection() {
|
||||
const { prefs, updatePrefs: savePrefs } = useHotkeySettings()
|
||||
const os = detectOS()
|
||||
|
||||
// OpenCode 安装检测:仅当启用 + 选了 OpenCode 后端时探测一次,用于提示是否需先安装。
|
||||
const [opencode, setOpencode] = useState<OpenCodeDetection | null>(null)
|
||||
const useOpencode = prefs?.codingAgentEnabled && prefs?.codingAgentProvider === 'opencode-cli'
|
||||
useEffect(() => {
|
||||
if (!useOpencode) {
|
||||
setOpencode(null)
|
||||
return
|
||||
}
|
||||
let alive = true
|
||||
// 把配置的可执行路径传给检测,用户改了路径后能重新探测对应二进制。
|
||||
void codingAgentDetectOpencode(prefs?.codingAgentExe ?? undefined).then(d => {
|
||||
if (alive) setOpencode(d)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [useOpencode, prefs?.codingAgentExe])
|
||||
|
||||
if (os === 'win') return null
|
||||
|
||||
if (!prefs) {
|
||||
@@ -60,10 +80,26 @@ export function CodingAgentSection() {
|
||||
style={{ ...inputStyle, maxWidth: 240, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="claude-code-cli">Claude Code</option>
|
||||
<option value="opencode-cli">{t('settings.codingAgent.providerOpenCodeSoon')}</option>
|
||||
<option value="opencode-cli">OpenCode</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
{/* OpenCode 后端:提示安装/登录状态。issue #579。 */}
|
||||
{useOpencode && opencode && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: opencode.installed ? 'var(--ol-ink-3)' : 'var(--ol-warn, #b8860b)',
|
||||
margin: '-4px 0 8px',
|
||||
}}
|
||||
>
|
||||
{opencode.installed
|
||||
? t('settings.codingAgent.opencodeReady', { version: opencode.version ?? '?' })
|
||||
: t('settings.codingAgent.opencodeMissing')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingRow label={t('settings.codingConsole.permissionMode')}>
|
||||
<select
|
||||
value={prefs.codingAgentPermissionMode}
|
||||
@@ -112,6 +148,20 @@ export function CodingAgentSection() {
|
||||
style={inputStyle}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label={t('settings.codingAgent.exe')}>
|
||||
<input
|
||||
type="text"
|
||||
value={prefs.codingAgentExe ?? ''}
|
||||
placeholder={prefs.codingAgentProvider === 'opencode-cli' ? 'opencode' : 'claude'}
|
||||
spellCheck={false}
|
||||
onChange={e => {
|
||||
const v = e.target.value.trim()
|
||||
void savePrefs({ ...prefs, codingAgentExe: v === '' ? null : v })
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
Reference in new issue
Block a user