Skip to content
Merged
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
3 changes: 2 additions & 1 deletion openless-all/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"merge:android-overlay-manifest": "node scripts/merge-android-overlay-manifest.mjs",
"copy:android-scaffolding": "node scripts/copy-android-scaffolding.mjs",
"check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs",
"check:hotkey-injection": "node scripts/check-hotkey-injection.mjs"
"check:hotkey-injection": "node scripts/check-hotkey-injection.mjs",
"check:windows-startup-lifecycle": "node scripts/windows-startup-lifecycle-contract.test.mjs"
},
"dependencies": {
"@formkit/auto-animate": "^0.9.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,16 @@ if (!mainWindow) {
}

assertEqual(mainWindow.visible, false, 'main window should stay hidden until startup contract allows first show');

// Windows 走 while 循环轮询 hotkey 状态,等到 state !== 'starting' 再 setGate('ready')。
// 该路径在 if (os === 'win') 分支内,使用内联循环而非独立函数。
assertMatch(
appTsx,
/const \[gate, setGate\] = useState<Gate>\(isTauri \? 'checking' : 'ready'\);/,
'desktop app should start in checking gate before claiming ready',
);
assertMatch(
appTsx,
/if \(os === 'win' && gate === 'checking'\) return;/,
'windows should not show the main shell while startup gate is still checking',
/if \(os === 'win'\)/,
'windows startup gate should branch on os === win',
);
assertMatch(
appTsx,
/const pollHotkeyStatus = async \(\) => \{[\s\S]*?if \(status\.state !== 'starting'\) \{[\s\S]*?setGate\('ready'\);/m,
/status\.state !== 'starting'[\s\S]*?setGate\('ready'\)/m,
'windows startup should wait for hotkey status to leave the starting phase before entering ready',
);
91 changes: 91 additions & 0 deletions openless-all/app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 15 additions & 7 deletions openless-all/app/src-tauri/src/android/jni.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,13 +182,21 @@ pub mod android {
&[JValue::Object(&action_obj)],
)
.map_err(|error| format!("set service action: {error}"))?;
let start_method = if action.ends_with(".HIDE") || action.ends_with(".SHOW") {
"startService"
} else if android_sdk_int(env)? >= 26 {
"startForegroundService"
} else {
"startService"
};
// REPLACE_OVERLAY 和 REFRESH_LAYOUT 与 SHOW/HIDE 一样,发送到已在运行的服务,
// 不应使用 startForegroundService(Android 12+ 在后台调用会抛
// ForegroundServiceStartNotAllowedException)。
let start_method =
if action.ends_with(".HIDE")
|| action.ends_with(".SHOW")
|| action.ends_with(".REPLACE_OVERLAY")
|| action.ends_with(".REFRESH_LAYOUT")
{
"startService"
} else if android_sdk_int(env)? >= 26 {
"startForegroundService"
} else {
"startService"
};
env.call_method(
context,
start_method,
Expand Down
21 changes: 17 additions & 4 deletions openless-all/app/src-tauri/src/android/updater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,12 @@ mod android_impl {
fn device_arch() -> Result<&'static str, String> {
crate::android::jni::android::with_android_env(|env, _context| {
let abis_obj = env
.call_static_method(
.get_static_field(
"android/os/Build",
"SUPPORTED_ABIS",
"()[Ljava/lang/String;",
&[],
"[Ljava/lang/String;",
)
.and_then(|value| value.l())
.and_then(|v| v.l())
.map_err(|e| format!("read SUPPORTED_ABIS: {e}"))?;
let abis_array = jni::objects::JObjectArray::from(abis_obj);
let len = env
Expand Down Expand Up @@ -220,13 +219,27 @@ mod android_impl {
return Err(format!("download status {}", resp.status()));
}
let total = resp.content_length();
// 安全:防止无限流耗尽内存。200 MB 远超任何实际 APK 大小(当前约 50 MB)。
const MAX_APK_BYTES: u64 = 200 * 1024 * 1024;
if let Some(len) = total {
if len > MAX_APK_BYTES {
return Err(format!(
"APK 声明大小 {len} 字节超过上限 {MAX_APK_BYTES},拒绝下载"
));
}
}
let mut downloaded: u64 = 0;
let mut bytes = Vec::new();
let mut stream = resp.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("download chunk: {e}"))?;
downloaded += chunk.len() as u64;
if downloaded > MAX_APK_BYTES {
return Err(format!(
"下载字节数 {downloaded} 超过上限 {MAX_APK_BYTES},已终止"
));
}
bytes.extend_from_slice(&chunk);
let _ = app.emit(
"android-update:progress",
Expand Down
71 changes: 62 additions & 9 deletions openless-all/app/src-tauri/src/coding_agent/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::sync::Arc;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use tauri::{AppHandle, Emitter, Window};

use super::detect::{has_computer_use_mcp, McpServerStatus};
use super::guard::build_guard_settings_json;
Expand All @@ -29,10 +29,57 @@ fn next_session_id() -> String {
format!("console-{}", *c)
}

fn normalize_exe(exe: Option<String>) -> String {
exe.map(|e| e.trim().to_string())
/// 仅允许裸名 "claude" 或规范化到已知安装目录下的绝对路径。
/// 拒绝包含路径分隔符的相对路径(如 "../../evil")。
fn validate_exe(exe: &str) -> Result<(), String> {
// 纯可执行文件名,不含任何路径分隔符 — 交给 PATH 解析即可
if !exe.contains('/') && !exe.contains('\\') {
if exe == "claude" {
return Ok(());
}
return Err(format!("不允许的可执行文件名: {exe}(只接受 'claude' 或已知安装目录下的绝对路径)"));
}
// 绝对路径:必须规范化到已知 claude 安装目录之一
let path = std::path::Path::new(exe);
if !path.is_absolute() {
return Err(format!("不允许的相对路径: {exe}"));
}
// 已知 claude 安装目录前缀
let known_prefixes: &[&str] = &[
"/usr/local/bin/",
"/usr/bin/",
"/opt/homebrew/bin/",
];
// 也允许 ~/.local/bin/claude(用户目录绝对路径,动态计算)
let home_prefix = std::env::var("HOME")
.ok()
.map(|h| format!("{h}/.local/bin/"));

let exe_norm = exe.replace('\\', "/");
let allowed = known_prefixes.iter().any(|p| exe_norm.starts_with(p))
|| home_prefix.as_deref().map_or(false, |p| exe_norm.starts_with(p));
if allowed {
Ok(())
} else {
Err(format!("不允许的 claude 路径: {exe}(必须位于已知安装目录)"))
}
}

fn normalize_exe(exe: Option<String>) -> Result<String, String> {
let exe = exe
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty())
.unwrap_or_else(|| "claude".to_string())
.unwrap_or_else(|| "claude".to_string());
validate_exe(&exe)?;
Ok(exe)
}

fn ensure_main_window(window: &Window) -> Result<(), String> {
if window.label() == "main" {
Ok(())
} else {
Err("coding agent commands are only allowed from the main window".to_string())
}
}

/// Claude Code 检测结果(回前端,camelCase)。
Expand All @@ -53,22 +100,26 @@ pub struct ClaudeDetectionWire {

/// 检测 claude 是否安装、版本、已配置的 MCP server(即「computer use 技能」检测口径)。
#[tauri::command]
pub async fn coding_agent_detect(exe: Option<String>) -> ClaudeDetectionWire {
let exe = normalize_exe(exe);
pub async fn coding_agent_detect(
window: Window,
exe: Option<String>,
) -> Result<ClaudeDetectionWire, String> {
ensure_main_window(&window)?;
let exe = normalize_exe(exe)?;
let version = detect_claude(&exe).await;
let mcp_servers = if version.is_some() {
claude_mcp_list(&exe).await
} else {
Vec::new()
};
let has_computer_use = has_computer_use_mcp(&mcp_servers);
ClaudeDetectionWire {
Ok(ClaudeDetectionWire {
installed: version.is_some(),
version,
exe,
mcp_servers,
has_computer_use,
}
})
}

/// 护栏化地无头跑一次 claude,事件流式 emit 到前端 `coding-agent:test`。
Expand All @@ -77,6 +128,7 @@ pub async fn coding_agent_detect(exe: Option<String>) -> ClaudeDetectionWire {
/// 若 workdir 是 git 仓库,运行前做一次 `git stash create` 快照(可回滚)。
#[tauri::command]
pub async fn coding_agent_run_test(
window: Window,
app: AppHandle,
prompt: String,
exe: Option<String>,
Expand All @@ -85,11 +137,12 @@ pub async fn coding_agent_run_test(
model: Option<String>,
max_budget_usd: Option<f64>,
) -> Result<(), String> {
ensure_main_window(&window)?;
let prompt = prompt.trim().to_string();
if prompt.is_empty() {
return Err("指令为空".into());
}
let exe = normalize_exe(exe);
let exe = normalize_exe(exe)?;
let mode = permission_mode.unwrap_or_default();

let cwd = workdir
Expand Down
14 changes: 13 additions & 1 deletion openless-all/app/src-tauri/src/commands/qa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,19 @@ pub fn less_computer_window_resize(coord: CoordinatorState<'_>, height: f64) {
}

/// 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。
///
/// 安全:审批 UI 渲染在 less-computer 窗口(LessComputerPanel),故仅允许该窗口提交,
/// 拦截 main / capsule / qa / glow 等其它窗口伪造审批 —— 把可调用窗口从 5 个收紧到 1 个。
#[tauri::command]
pub fn less_computer_approve(coord: CoordinatorState<'_>, token: String, approved: bool) {
pub fn less_computer_approve(
window: Window,
coord: CoordinatorState<'_>,
token: String,
approved: bool,
) -> Result<(), String> {
if window.label() != "less-computer" {
return Err("approval can only be submitted from the Less Computer window".to_string());
}
coord.less_computer_approve(&token, approved);
Ok(())
}
Loading
Loading