Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions openless-all/app/src-tauri/src/coding_agent/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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");
}
}
29 changes: 29 additions & 0 deletions openless-all/app/src-tauri/src/coding_agent/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -71,6 +72,34 @@ pub async fn coding_agent_detect(exe: Option<String>) -> 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<String>,
/// 实际使用的可执行文件名/路径。
pub exe: String,
}

/// 检测 `opencode` 是否安装、版本。语音 Agent 选了 OpenCode 后端时,设置页据此提示
/// 用户是否需要先 `npm i -g opencode-ai` / 登录。
#[tauri::command]
pub async fn coding_agent_detect_opencode(exe: Option<String>) -> 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` 成本上限;
Expand Down
72 changes: 72 additions & 0 deletions openless-all/app/src-tauri/src/coding_agent/guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(<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.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<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()));
}
serde_json::json!({
"permission": {
"*": "allow",
"bash": bash,
"webfetch": "deny",
}
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 8 additions & 4 deletions openless-all/app/src-tauri/src/coding_agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 的「自动化前置说明」。
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading