* fix(coding-agent): 版本号解析认预发布号 dsh 的真实输出是 `0.1.0-rc.6`,而解析器要求严格的三段全数字:它被切成 ["0","1","0-rc","6"] 四段,判定失败。用这个判「装没装」的地方就会说它没装。 改成认 `-rc.6` / `+build.7` 这类预发布与构建后缀,并原样保留(用户看到的版本号才是 真的)。同时把排版噪声挡在外面:`(1.2.3)` 取到的是 1.2.3,不是 `1.2.3)`。 单测里的样例串是手抄的,抄错了测试照样绿。所以另加一条 #[ignore] 的 live 测试,直接问 本机四个 CLI 要 --version 输出再喂给解析器——那才是「设置页会不会误报没装」的可信证据。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(coding-agent): 「装没装」改看命令能否跑通,不再依赖版本号解析 上一条修的是解析器,那只是补了一个洞。根子在于判据选错了: installed: version.is_some() // 解析不出版本号 = 判定没装 版本字符串是这些 CLI 里最容易变的东西——改排版、加后缀、换前缀,对它们都不算 breaking change,但对「拿版本号当判据」的我们就是。 改成:命令跑通(进程起得来且 exit 0)= 装了;版本号只用来显示,解析不出来最多让界面少 显示一个号码,不会把能用的后端说成没装。三个后端共用一个 probe_cli,顺手删掉了三份 几乎一样的 detect_* 。 单测拿 `echo` 当替身——`echo --version` 会原样打印并 exit 0,正是「跑得通但读不出版本号」 这个形状,且不依赖本机装了哪些 agent CLI。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(dsh): 无头 dsh 后端,自带事件 tap 插件 dsh(DeepSeek Harness)的 headless 只在结束时往 stdout 打一行最终文本——没有 JSON、 没有逐字流、没有工具事件。但这不是它缺数据:它内部的会话事件总线上 assistant/chunk、 tool/call、turn/end 一应俱全,只是 headless 汇总完就丢了。 dsh 是插件栈架构,`--patch` 是官方留给外部叠自己那层的入口。这里挂上一个订阅 session/event 的小插件,把需要的事件规范化成 NDJSON 打到 stderr,stdout 的「只打最终 文本」契约原样不动。挂载不侵入用户的 profile,插件随运行落到临时目录、跑完删掉。 上游耦合被刻意压缩在那个 30 行的 JS 里:它把 dsh 的内部事件翻译成我们自己定义的稳定 形状,上游改字段只需要改 JS,Rust 的解析和单测都不用动。tap 是纯增益——挂不上也只是 少了逐字流和工具展示,最终结果照样从 stdout 拿得到。 prompt 不进 argv:dsh 的启动器不认 `--` 作为选项终止符(实测 `dsh --profile headless "--version"` 会真的去执行 --version),所以以 `-` 开头的转写会被劫持成 flag。既然本来 就要写 patch 文件,索性用它直接覆盖 runner 的 task 配置,prompt 连进程列表都不会出现 ——这点比另外三家都严。 护栏走 DSH_PERMISSION_MODE 三档沙箱,沙箱根 = 子进程工作目录。和 Codex 一样没有逐命令 deny 清单,所以审批卡对它同样不生效,撞墙时如实报错。 模型解析永远返回 None:headless profile 没有 --model,模型由 dsh 自己的 profile 决定, 返回 Some 只会让调用方以为选得动。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(settings): Less Computer 后端下拉加入 dsh 第四项。它的能力面和另外三家不一样,设置页如实反映,不给假开关: - 模型那一行**整行不显示**,换成一句说明:dsh 的 headless 没有模型开关,模型由它自己的 profile 决定,在这里改不了。给个选不动的下拉比不给更糟。 - 复用 Codex 那条沙箱护栏说明(两家都只有粗粒度档位、都不弹审批卡)。 - 装没装当场检测;可执行文件 placeholder 跟着后端走。 文案 5 语言齐全。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(dsh): tap 插件抽成独立项目 dsh-jsonl,OpenLess 改消费它的公开 schema 原来这个插件是内嵌在 dsh.rs 里的一段 Rust 字符串,只发我们自己要的 4 类事件、用我们 自己的私有形状。它要开源出去(dsh 缺结构化输出,而上游不收 PR,第三方插件是这个能力 唯一可能存在的形式),于是有了「两份会各自跑偏的代码」这个问题。 解法是保证世界上只有一份这个 JS: - 源头在 github.com/bigsongeth/dsh-jsonl,发完整的、有文档、有版本的事件词表。 - OpenLess 用 include_str! 内嵌一份逐字副本(vendor/dsh-jsonl.js),带版本标记。 内嵌而不是依赖 npm 包:用户装机即用,不需要网络、不需要 npm i,也不碰用户自己的 dsh profile 配置。 - Rust 侧改成解析那个公开 schema(text.delta / tool.call / turn.end),不再有私有形状。 用不上的事件按 schema 的兼容性约定忽略——上游加新事件不会把我们打挂。 **跨仓库没有任何自动同步**,这点没被消除,只是变便宜了: - scripts/vendor-dsh-jsonl.sh 把同步变成一条命令 - 单测盯着 vendor 文件的版本标记与 VENDORED_DSH_JSONL_VERSION 一致,防止有人直接手改副本 - 插件报的 schema 大版本与我们支持的不一致时留警告日志(不中止:最终结果始终从 stdout 拿得到,流式展示只是锦上添花) - 真的对不上时,live::tap_plugin_delivers_streaming_and_tool_events 会红 顺带把「禁止任何 import」的断言放宽成「只准 node: 内置」——公开插件要写文件,用了 node:fs,而 node: 内置不需要 node_modules,不影响从临时目录绝对路径加载。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(dsh): 沙箱验收的靶子选错了,它一直是靠运气过的 那条「沙箱挡得住工作目录之外的写入」把靶子放在 $TMPDIR。但 dsh 的 workspace-write 把 可写根写死成 [工作目录, "/tmp", $TMPDIR](见 @deepseek-ai/dsh-sandbox 的 writableRoots) ——往 $TMPDIR 写**本来就是允许的**。 它之前能过,只是因为模型碰巧挑了受限更严的 `write` 工具;这次挑了 `bash`,文件就真的 落下去了。也就是说这条测试的成败取决于模型当次的选择,不取决于沙箱是否失效。 改成打真正的边界(家目录),实测会被拒: bash: /Users/…: Operation not permitted [sandbox: file access denied under workspace-write mode] 另加一条把 dsh 的既有行为钉成契约:$TMPDIR 可写,且**没有配置项能收掉**——Codex 那边 有 -c sandbox_workspace_write.exclude_* 可以收紧,dsh 没有对应物。哪天 dsh 收紧了这条 会红,那是好消息,届时删掉它并更新文档。 所以对用户的措辞只能是「撞到限制会如实报错」,不能说成「写入限制在工作目录内」。 模块文档里把这个边界写清楚了。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(dsh): 收紧运行协议与权限边界 * fix(dsh): 修复权限与连续对话审查项 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Chris233 <h-chris233@outlook.com>
141 lines
6.1 KiB
JavaScript
141 lines
6.1 KiB
JavaScript
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',
|
|
);
|
|
assert(
|
|
settings.includes("const SANDBOX_PERMISSION_MODES: CodingAgentPermissionMode[] = ['plan', 'acceptEdits']") &&
|
|
settings.includes("provider === 'codex-cli' || provider === 'dsh-cli'") &&
|
|
settings.includes('normalizePermissionMode'),
|
|
'Codex and dsh settings must expose only read-only/plan and workspace-write permission modes and normalize legacy values',
|
|
);
|
|
const providerChangeStart = settings.indexOf('const nextProvider = v as CodingAgentProviderId');
|
|
const providerChangeEnd = settings.indexOf('options={PROVIDERS}', providerChangeStart);
|
|
const providerChange = settings.slice(providerChangeStart, providerChangeEnd);
|
|
assert(
|
|
providerChange.includes('codingAgentModel: null') &&
|
|
providerChange.includes('codingAgentExe: null') &&
|
|
providerChange.includes('normalizePermissionMode'),
|
|
'switching coding-agent provider must clear provider-specific model/executable state and normalize permissions',
|
|
);
|
|
|
|
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',
|
|
'codexBudgetHint',
|
|
'codexMode',
|
|
'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');
|