Skip to content
Draft
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
4 changes: 2 additions & 2 deletions openless-all/app/package-lock.json

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

2 changes: 1 addition & 1 deletion openless-all/app/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "openless-app",
"private": true,
"version": "1.3.15-Beta.2",
"version": "1.3.15-Beta.3",
"type": "module",
"scripts": {
"pretest": "npm run build",
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/scripts/frontend-test-runner.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const discovered = discoverTestFiles();
for (const expected of [
'scripts/check-android-updater-pubkey.mjs',
'scripts/check-hotkey-injection.mjs',
'scripts/less-computer-opencode-contract.test.mjs',
'scripts/macos-capsule-spaces-contract.test.mjs',
'scripts/macos-speech-usage-description-contract.test.mjs',
'scripts/repository-owner-contract.test.mjs',
Expand Down
123 changes: 123 additions & 0 deletions openless-all/app/scripts/less-computer-opencode-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

const appRoot = fileURLToPath(new URL('..', import.meta.url));
const read = (relativePath) => readFile(join(appRoot, relativePath), 'utf8');

const [settings, ipc, opencode, dictation, coordinator, lib, onboarding, lessComputerIpc, qaCommands, credentialCommands, miscCommands] = await Promise.all([
read('src/pages/settings/CodingAgentSection.tsx'),
read('src/lib/ipc/coding-agent.ts'),
read('src-tauri/src/coding_agent/opencode.rs'),
read('src-tauri/src/coordinator/dictation.rs'),
read('src-tauri/src/coordinator.rs'),
read('src-tauri/src/lib.rs'),
read('src/components/Onboarding.tsx'),
read('src/lib/ipc/less-computer.ts'),
read('src-tauri/src/commands/qa.rs'),
read('src-tauri/src/commands/credentials.rs'),
read('src-tauri/src/commands/misc.rs'),
]);

assert(
settings.includes('codingAgentListOpencodeModels(exe, true)'),
'OpenCode settings must automatically refresh the available models after detection',
);
assert(
settings.includes('settings.codingAgent.opencodeModelsRefresh'),
'OpenCode settings must expose the localized manual refresh action',
);
assert(
ipc.includes('"coding_agent_list_opencode_models"'),
'frontend IPC must call the OpenCode model-list command',
);
assert(opencode.includes('"--refresh"'), 'OpenCode model discovery must refresh its model cache');
assert(opencode.includes('"--auto"'), 'OpenCode runs must use the current automatic permission flag');
assert(opencode.includes('"--continue"'), 'OpenCode runs must preserve follow-up session context');
assert(
!opencode.includes('--dangerously-skip-permissions'),
'OpenCode adapter must not use the removed Claude-style permission flag',
);
assert(
dictation.includes('resolve_coding_agent_model(provider'),
'Less Computer must resolve model defaults per provider',
);

const microphoneMenuStart = lib.indexOf('fn build_microphone_tray_menu');
const microphoneMenuEnd = lib.indexOf('pub(crate) fn refresh_tray_microphone_menu');
const microphoneMenu = lib.slice(microphoneMenuStart, microphoneMenuEnd);
assert(
microphoneMenu.includes('TrayMicrophoneDeviceCache'),
'tray menu construction must read the asynchronous microphone-device cache',
);
assert(
!microphoneMenu.includes('recorder::list_input_devices'),
'tray menu construction must never enumerate CoreAudio devices on the AppKit main thread',
);
assert(
lib.includes('.name("openless-tray-mic-event".into())'),
'native microphone notifications must dispatch enumeration to a background thread',
);
assert(
credentialCommands.includes('pub async fn get_credentials()') &&
credentialCommands.includes('tauri::async_runtime::spawn_blocking'),
'Keychain reads must not block the AppKit main thread while settings load',
);
assert(
miscCommands.includes('pub async fn list_microphone_devices()') &&
miscCommands.includes('microphone device worker failed'),
'settings microphone enumeration must not block the AppKit main thread',
);
assert(
onboarding.includes("t('onboarding.continueToSettings')"),
'users must be able to configure OpenCode without granting unrelated system permissions first',
);
assert(
settings.includes('lessComputerWindowOpen()') &&
lessComputerIpc.includes('"less_computer_window_open"') &&
qaCommands.includes('window.label() != "main"'),
'Advanced settings must expose a main-window-only text entry point for Less Computer',
);
const showLessComputerStart = lib.indexOf('pub(crate) fn show_less_computer_window');
const showLessComputerEnd = lib.indexOf('pub(crate) fn hide_less_computer_window');
const showLessComputer = lib.slice(showLessComputerStart, showLessComputerEnd);
assert(
showLessComputer.includes('window_clone.show()'),
'the first lazy Less Computer window must clear Tauri visible=false before ordering its NSPanel',
);
assert(
showLessComputer.indexOf('run_on_main_thread') <
showLessComputer.indexOf('position_less_computer_window(&window_clone)'),
'Less Computer NSPanel positioning must run on the AppKit main thread',
);
const submitTextStart = coordinator.indexOf('pub fn less_computer_submit_text');
const submitTextEnd = coordinator.indexOf('pub fn history', submitTextStart);
const submitText = coordinator.slice(submitTextStart, submitTextEnd);
assert(
submitText.includes('tauri::async_runtime::spawn') && !submitText.includes('tokio::spawn'),
'Less Computer text submit must spawn through the Tauri runtime from the WebKit IPC thread',
);

const localeFiles = ['zh-CN.ts', 'zh-TW.ts', 'en.ts', 'ja.ts', 'ko.ts'];
const localizedKeys = [
'opencodeModelDefault',
'opencodeModelHint',
'opencodeModelsRefresh',
'opencodeModelsRefreshing',
'opencodeModelsLoaded',
'opencodeModelsEmpty',
'opencodeModelsError',
'openPanel',
'openPanelHint',
'openPanelAction',
];
for (const localeFile of localeFiles) {
const locale = await read(`src/i18n/${localeFile}`);
assert(locale.includes('continueToSettings:'), `${localeFile} is missing continueToSettings`);
for (const key of localizedKeys) {
assert(locale.includes(`${key}:`), `${localeFile} is missing ${key}`);
}
}

console.log('less-computer-opencode-contract.test.mjs passed');
2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/Cargo.lock

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

2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "openless"
version = "1.3.15-Beta.2"
version = "1.3.15-Beta.3"
description = "OpenLess — local voice input that types where your cursor is"
authors = ["OpenLess"]
edition = "2021"
Expand Down
41 changes: 41 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 @@ -33,6 +33,21 @@ impl CodingAgentProvider {
}
}

/// 按后端解析用户选择的模型。Claude 保持既有的 sonnet 默认;OpenCode 只接受
/// `provider/model`,未选择或遗留的 Claude 别名均交给 OpenCode 自己的默认配置。
pub fn resolve_coding_agent_model(
provider: CodingAgentProvider,
configured: Option<String>,
) -> Option<String> {
let configured = configured
.map(|model| model.trim().to_string())
.filter(|model| !model.is_empty());
match provider {
CodingAgentProvider::ClaudeCodeCli => configured.or_else(|| Some("sonnet".to_string())),
CodingAgentProvider::OpenCodeCli => configured.filter(|model| model.contains('/')),
}
}

/// 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 @@ -285,4 +300,30 @@ mod tests {
assert_eq!(CodingAgentProvider::ClaudeCodeCli.default_exe(), "claude");
assert_eq!(CodingAgentProvider::OpenCodeCli.default_exe(), "opencode");
}

#[test]
fn provider_specific_model_defaults_do_not_leak_sonnet_into_opencode() {
assert_eq!(
resolve_coding_agent_model(CodingAgentProvider::ClaudeCodeCli, None),
Some("sonnet".to_string())
);
assert_eq!(
resolve_coding_agent_model(CodingAgentProvider::OpenCodeCli, None),
None
);
assert_eq!(
resolve_coding_agent_model(
CodingAgentProvider::OpenCodeCli,
Some("sonnet".to_string())
),
None
);
assert_eq!(
resolve_coding_agent_model(
CodingAgentProvider::OpenCodeCli,
Some("opencode/deepseek-v4-flash-free".to_string())
),
Some("opencode/deepseek-v4-flash-free".to_string())
);
}
}
54 changes: 36 additions & 18 deletions openless-all/app/src-tauri/src/coding_agent/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +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::opencode::{detect_opencode, list_opencode_models};
use super::{
claude_mcp_list, create_git_snapshot, detect_claude, run_claude_agent,
CodingAgentPermissionMode, CodingAgentRequest,
Expand All @@ -38,31 +38,33 @@ fn validate_exe(exe: &str) -> Result<(), String> {
if exe == "claude" {
return Ok(());
}
return Err(format!("不允许的可执行文件名: {exe}(只接受 'claude' 或已知安装目录下的绝对路径)"));
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/",
];
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));
|| home_prefix
.as_deref()
.map_or(false, |p| exe_norm.starts_with(p));
if allowed {
Ok(())
} else {
Err(format!("不允许的 claude 路径: {exe}(必须位于已知安装目录)"))
Err(format!(
"不允许的 claude 路径: {exe}(必须位于已知安装目录)"
))
}
}

Expand Down Expand Up @@ -135,25 +137,29 @@ pub struct OpenCodeDetectionWire {
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)?;
fn normalize_opencode_exe(exe: Option<String>) -> Result<String, String> {
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());
}
Ok(exe)
}

/// 检测 `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 = normalize_opencode_exe(exe)?;
let version = detect_opencode(&exe).await;
Ok(OpenCodeDetectionWire {
installed: version.is_some(),
Expand All @@ -162,6 +168,18 @@ pub async fn coding_agent_detect_opencode(
})
}

/// 拉取 OpenCode 当前账号可用模型,供 Less Computer 设置页自动填充模型选择器。
#[tauri::command]
pub async fn coding_agent_list_opencode_models(
window: Window,
exe: Option<String>,
refresh: Option<bool>,
) -> Result<Vec<String>, String> {
ensure_main_window(&window)?;
let exe = normalize_opencode_exe(exe)?;
list_opencode_models(&exe, refresh.unwrap_or(true)).await
}

/// 护栏化地无头跑一次 claude,事件流式 emit 到前端 `coding-agent:test`。
///
/// 安全:附 `--settings`(acceptEdits + 高风险 deny)、`--max-budget-usd` 成本上限;
Expand Down
3 changes: 2 additions & 1 deletion openless-all/app/src-tauri/src/coding_agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;

pub use args::{
build_claude_args, CodingAgentPermissionMode, CodingAgentProvider, CodingAgentRequest,
build_claude_args, resolve_coding_agent_model, CodingAgentPermissionMode, CodingAgentProvider,
CodingAgentRequest,
};
pub use detect::McpServerStatus;
pub use opencode::run_opencode_agent;
Expand Down
Loading
Loading