* fix(coding-agent): 从用户 shell 取真实 PATH,打包后也找得到 CLI macOS 从 Finder 启动的 GUI 进程拿到的是一份极简 PATH,不经过用户的 shell 配置。 后果是:凡是版本管理器装的 agent CLI 在打包版里一个都找不到——`opencode` 官方脚本 装在 `~/.opencode/bin`,全局 npm 前缀和 nvm 的路径更是带版本号、根本没法硬编码。 用户只能手动去高级设置里填绝对路径。 改成先问用户的 shell 要一份真实 PATH。三个细节是踩出来的,都写进注释了: - 用 `-lic` 而不是 `-lc`。nvm 这类版本管理器通常在 .zshrc 里初始化,而 .zshrc 只有 交互式 shell 才读;`-lc` 拿到的 PATH 里 nvm 排在 /usr/local/bin 之后, `#!/usr/bin/env node` 会命中错版本的 node,子进程直接崩。 - 拼接必须保序。逐项前插等于把整个 PATH 倒过来,同样会选错解释器。 - 分隔符交给 std::env::split_paths / join_paths,不能手写 `:`。Windows 用 `;`, 而且盘符自带冒号,手写会把 `C:\Windows` 切成两半、整条 PATH 报废。 shell 卡住有 5 秒硬上限,超时回落到静态目录列表。整段探测限定 unix:Windows 没有 「登录 shell 的 PATH」这个概念,装了 Git Bash 的机器 SHELL 有值,真去跑只会莫名其妙 拉起一个 bash。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(codex): 无头 Codex 适配器 Less Computer 现在只认 Claude Code 和 OpenCode,因为每个后端都得手写一套适配器。 这个是 Codex 的那套,形状和前两家一致,共用同一套请求/事件/错误类型。 - 运行:`codex exec --json`,逐行解析 JSONL。 - prompt 走 stdin(末尾的 `-`),不进 argv,也不会出现在进程列表里。 - 续接用 `resume --last`。参数顺序有坑:`-s` / `--json` 这些必须排在 `resume` 之前, 写反了 CLI 直接报 unexpected argument,已对 0.146.0 核实并写进注释。 - 工具展示会剥掉 `/bin/zsh -lc '…'` 这层 shell 包装,胶囊里显示的是真正执行的命令名。 - 护栏:Codex 给不了逐命令 deny 清单(`.rules` 只从 $CODEX_HOME / 项目目录读,实测 `-c rules=[…]` 会被静默忽略)。它的护栏是自带的 seatbelt 沙箱,走 `-s <mode>`。 额外关掉 workspace-write 对 /tmp 与 $TMPDIR 的默认放行——不关的话「写入限制在工作 目录内」就是句假话,实测任务真的能在 $TMPDIR 里落文件。 有一处刻意不做:Codex 会把「模型元数据没找到」这类警告也塞成 error 事件,本机每次运行 都会发一条。把它当终局失败会让所有运行都被误判,所以只认 turn.failed。 联机验收默认 #[ignore],其中一条不调模型不花钱:它比对加固前后实际生效的可写根, 盯住护栏别被上游无声改宽(codex 对未知 -c 键是静默接受的,改名了不会报错)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(coding-agent): Less Computer 可选 Codex 后端,审批卡按能力启用 把 Codex 接进语音链路,后端从两家变三家。 护栏这块两类后端不一样,代码里如实分开:Claude / OpenCode 有逐命令 deny 清单,撞了能 弹审批卡、批准后放行那一条重跑;Codex 只有粗粒度沙箱档位,唯一的「放行」办法是整体 降档,那等于把护栏关掉,不是放行这一条。 所以给 provider 加了一个能力位,只有支持逐命令放行的后端才弹审批卡。否则用户点了批准、 重跑还是同样被拦,那是个假承诺。撞墙时直接把失败如实报出去。 模型解析也按后端分开:Codex 收裸模型名(gpt-5 / o3 这种),留空交给它自己的 ~/.codex/config.toml,不替用户瞎猜一个默认。 另加一个通用的安装检测命令:选了后端却没装 CLI 时,在设置页当场看到,而不是等说完 一句话才发现跑不起来。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(settings): Less Computer 后端下拉加入 Codex 设置页的后端下拉从两项变三项,并且如实反映能力差异,不给假开关: - 模型:Claude 用别名下拉,OpenCode 拉账号可用列表,Codex 改成自由文本——它的模型名 是裸名(gpt-5 / 自建网关的任意名字),枚举不过来;留空 = 用 Codex 自己的默认。 - 护栏:选了 Codex 会挂一条说明,讲清楚它只有粗粒度沙箱、撞到限制会直接报错而不是弹 「批准这条命令」的卡片。免得用户按 Claude 的心智去用。 - 装没装:当场检测并提示,没装时按警示色显示。 - 可执行文件的 placeholder 跟着后端走。 文案 5 语言齐全。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(coding-agent): harden Codex execution --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Chris233 <h-chris233@outlook.com>
131 lines
5.5 KiB
JavaScript
131 lines
5.5 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 CODEX_PERMISSION_MODES: CodingAgentPermissionMode[] = ['plan', 'acceptEdits']") &&
|
|
settings.includes('normalizePermissionMode'),
|
|
'Codex settings must expose only read-only/plan and workspace-write permission modes and normalize legacy values',
|
|
);
|
|
|
|
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');
|