diff --git a/README.md b/README.md index 9f3b3a4..b95a10a 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ The point is not to use more agents. The point is to use the right agent for eac Meldwork currently connects to: -**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code** +**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code · OpenCodeReview** Each agent keeps its own capabilities, provider configuration, permission model, and session behavior. Availability depends on the installed agent, its version, and your configuration. diff --git a/README.zh-CN.md b/README.zh-CN.md index e3b68df..f6e935b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -72,7 +72,7 @@ Meldwork 坚持本地优先。工作区访问是明确的,兼容的敏感凭 Meldwork 当前可连接: -**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code** +**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code · OpenCodeReview** 每个 Agent 仍然保留自己的能力、Provider 配置、权限模型和 Session 行为。实际可用性取决于已安装的 Agent、对应版本与用户配置。 diff --git a/desktop/README.md b/desktop/README.md index f4b79e5..e053417 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -66,7 +66,7 @@ apiKey: API Key API Key 仅通过 Electron `safeStorage` 加密保存到本机;操作系统安全存储不可用时拒绝落盘。 Provider 不设品牌、服务地址或模型默认值,也不会从远端自动获取配置。配置后可供 Hermes、 -OpenClaw、WorkBuddy 和 Qwen 使用;其他 Agent 沿用各自的本机鉴权和 Provider 配置。 +OpenClaw、WorkBuddy、Qwen 和 OpenCodeReview 使用;其他 Agent 沿用各自的本机鉴权和 Provider 配置。 ## 本地 CLI 支持矩阵 @@ -82,6 +82,7 @@ OpenClaw、WorkBuddy 和 Qwen 使用;其他 Agent 沿用各自的本机鉴权 | Gemini CLI | `gemini` | npm:`@google/gemini-cli@latest` | npm:`@google/gemini-cli@latest` | 不注入;沿用 Gemini 本机配置 | | OpenCode | `opencode` | npm:`opencode-ai@latest` | npm:`opencode-ai@latest` | 不注入;沿用 OpenCode 本机配置 | | Qwen Code | `qwen` | npm:`@qwen-code/qwen-code@latest` | npm:`@qwen-code/qwen-code@latest` | 支持 | +| OpenCodeReview | `ocr` | npm:`@alibaba-group/open-code-review@latest` | npm:`@alibaba-group/open-code-review@latest` | 支持 | macOS 会在当前 `PATH` 之外搜索 Volta、pnpm、fnm、asdf、mise、bun、 `~/.local/bin`、`~/.kimi-code/bin`、`~/.mimocode/bin`、Homebrew 等常见目录。Windows 会额外搜索用户 npm、 diff --git a/desktop/src/agent-installer.cjs b/desktop/src/agent-installer.cjs index 5fca1e6..74a33c2 100644 --- a/desktop/src/agent-installer.cjs +++ b/desktop/src/agent-installer.cjs @@ -62,6 +62,10 @@ const AGENT_CATALOG = Object.freeze([ kind: 'qwen', label: 'Qwen Code', recommended: false, providerCompatible: true, providerSupport: 'supported', }, + { + kind: 'opencodereview', label: 'OpenCodeReview', recommended: false, providerCompatible: true, + providerSupport: 'supported', + }, ]) function installRecipe(kind, platform) { @@ -106,6 +110,7 @@ function installRecipe(kind, platform) { gemini: '@google/gemini-cli@latest', opencode: 'opencode-ai@latest', mimo: '@mimo-ai/cli@latest', + opencodereview: '@alibaba-group/open-code-review@latest', } return packages[kind] ? { type: 'npm', packageName: packages[kind] } : null } diff --git a/desktop/src/cli-adapters.cjs b/desktop/src/cli-adapters.cjs index e32c4b6..9614de2 100644 --- a/desktop/src/cli-adapters.cjs +++ b/desktop/src/cli-adapters.cjs @@ -21,6 +21,7 @@ const AGENT_PROFILES = { gemini: { label: 'Gemini', commands: ['gemini'] }, opencode: { label: 'OpenCode', commands: ['opencode'] }, qwen: { label: 'Qwen', commands: ['qwen'] }, + opencodereview: { label: 'OpenCodeReview', commands: ['ocr'] }, } const ALLOWED_KINDS = Object.keys(AGENT_PROFILES) const CODEX_SANDBOXES = new Set(['read-only', 'workspace-write']) @@ -616,6 +617,16 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) { promptArg: true, } } + if (kind === 'opencodereview') { + return { + command: executable, + args: [ + 'review', '--audience', 'agent', '--format', 'text', '--repo', workdir, + '--background', + ], + promptArg: true, + } + } throw new Error('LOCAL_AGENT_KIND_UNSUPPORTED') } @@ -2139,10 +2150,7 @@ async function runAcpAgent(agent, prompt, workdir, options, spec) { const publicSessionRef = safeSessionRef.includes('[redacted]') ? '' : safeSessionRef await connection.setSessionMode({ sessionId: sessionRef, modeId: spec.acpMode }) if (publicSessionRef && typeof options.onSessionRef === 'function') { - await options.onSessionRef( - publicSessionRef, - agent.kind === 'hermes' ? { transport: 'acp' } : undefined, - ) + await options.onSessionRef(publicSessionRef, { transport: 'acp' }) } replyState.bytes = 0 replyState.collecting = true @@ -2242,7 +2250,7 @@ async function runAgent(agent, prompt, workdir, options = {}) { const prepared = prepareCommand(spec.command, args, { platform }) const childEnv = childEnvironment(agent, workdir, options, platform) const runtimeEvents = createRuntimeEventEmitter(options, childEnv) - if (['hermes', 'openclaw', 'workbuddy'].includes(agent.kind)) { + if (['hermes', 'openclaw', 'workbuddy', 'opencodereview'].includes(agent.kind)) { runtimeEvents.emit({ id: `${agent.kind}-connector`, type: 'warning', diff --git a/desktop/src/custom-agent-store.cjs b/desktop/src/custom-agent-store.cjs new file mode 100644 index 0000000..0a49b2b --- /dev/null +++ b/desktop/src/custom-agent-store.cjs @@ -0,0 +1,475 @@ +const { execFile, spawn } = require('node:child_process') +const { randomUUID } = require('node:crypto') +const fs = require('node:fs') +const path = require('node:path') +const { promisify } = require('node:util') + +const execFileAsync = promisify(execFile) +const CUSTOM_AGENT_KIND = /^custom-[a-f0-9]{16}$/ +const PROMPT_MODES = new Set(['stdin', 'argument']) +const MAX_AGENTS = 32 +const MAX_ARGUMENTS = 24 +const MAX_ARGUMENT_LENGTH = 512 +const MAX_ATTACHMENTS = 4 +const MAX_PROMPT_BYTES = 1024 * 1024 +const MAX_STDOUT_BYTES = 4 * 1024 * 1024 +const MAX_STDERR_BYTES = 1024 * 1024 +const TERMINATE_GRACE_MS = 500 +const KILL_SETTLE_MS = 500 +const VERSION_LINE_LIMIT = 160 +const SENSITIVE_ARGUMENT = /api[-_]?key|token|secret|password|passwd|credential|authorization|cookie/i +const SECRET_VALUE = /(?:^|[=:])(?:sk|rk|pk|ghp|github_pat|xox[baprs]?)[_-][A-Za-z0-9_-]{12,}/i +const CHILD_ENV_KEYS = Object.freeze([ + 'HOME', 'USER', 'LOGNAME', 'SHELL', 'PATH', + 'TMPDIR', 'TMP', 'TEMP', + 'LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'TZ', + 'TERM', 'COLORTERM', 'NO_COLOR', + 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', + 'XDG_RUNTIME_DIR', + 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'USERNAME', + 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', + 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA', + 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'PROGRAMW6432', + 'OS', 'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS', + 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS', +]) + +function customAgentError(code) { + return Object.assign(new Error(code), { code }) +} + +function cleanInline(value, limit) { + const text = String(value || '').trim().replace(/\s+/g, ' ') + if (!text || text.length > limit || /[\u0000-\u001f\u007f]/.test(text)) return '' + return text +} + +function cleanDescription(value) { + const text = String(value || '').trim().replace(/\s+/g, ' ') + if (text.length > 240 || /[\u0000-\u001f\u007f]/.test(text)) return '' + return text +} + +function normalizeArguments(value) { + if (value == null) return [] + if (!Array.isArray(value) || value.length > MAX_ARGUMENTS) { + throw customAgentError('CUSTOM_AGENT_ARGUMENTS_INVALID') + } + return value.map((argument) => { + if (typeof argument !== 'string' || !argument.length + || argument.length > MAX_ARGUMENT_LENGTH + || /[\u0000\r\n]/.test(argument)) { + throw customAgentError('CUSTOM_AGENT_ARGUMENTS_INVALID') + } + if (SENSITIVE_ARGUMENT.test(argument) || SECRET_VALUE.test(argument)) { + throw customAgentError('CUSTOM_AGENT_SECRET_ARGUMENT_BLOCKED') + } + return argument + }) +} + +function normalizeDefinition(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) return null + const kind = String(input.kind || '') + const label = cleanInline(input.label, 60) + const description = cleanDescription(input.description) + const executable = String(input.executable || '') + const promptMode = String(input.promptMode || '') + const createdAt = String(input.createdAt || '') + if (!CUSTOM_AGENT_KIND.test(kind) || !label || !path.isAbsolute(executable) + || !PROMPT_MODES.has(promptMode) || !createdAt) return null + let args + try { + args = normalizeArguments(input.args) + } catch { + return null + } + return { kind, label, description, executable: path.normalize(executable), args, promptMode, createdAt } +} + +function executablePath(filename, platform = process.platform) { + if (typeof filename !== 'string' || !filename || filename.length > 4096 + || !path.isAbsolute(filename) || /[\u0000-\u001f\u007f]/.test(filename)) { + throw customAgentError('CUSTOM_AGENT_EXECUTABLE_INVALID') + } + let resolved + try { + resolved = fs.realpathSync(filename) + const stat = fs.statSync(resolved) + if (!stat.isFile()) throw customAgentError('CUSTOM_AGENT_EXECUTABLE_INVALID') + if (platform === 'win32') { + if (!/\.(?:com|exe)$/i.test(resolved)) { + throw customAgentError('CUSTOM_AGENT_EXECUTABLE_UNSUPPORTED') + } + } else { + fs.accessSync(resolved, fs.constants.X_OK) + } + } catch (error) { + if (String(error?.code || '').startsWith('CUSTOM_AGENT_')) throw error + throw customAgentError('CUSTOM_AGENT_EXECUTABLE_INVALID') + } + return path.normalize(resolved) +} + +function childEnvironment(source = process.env) { + const env = {} + for (const name of CHILD_ENV_KEYS) { + const key = Object.keys(source).find(candidate => candidate.toLowerCase() === name.toLowerCase()) + const value = key ? source[key] : '' + if (typeof value === 'string' && value) env[name] = value + } + return env +} + +function publicProfile(definition, version = '') { + return { + kind: definition.kind, + label: definition.label, + description: definition.description, + commandName: path.basename(definition.executable), + promptMode: definition.promptMode, + custom: true, + recommended: false, + providerCompatible: false, + providerSupport: 'custom-cli', + providerMode: 'custom', + installed: true, + installSupported: false, + installErrorCode: '', + imageAttachmentLimit: MAX_ATTACHMENTS, + attachmentTypes: ['image', 'audio', 'video'], + version: String(version || '').slice(0, VERSION_LINE_LIMIT), + } +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function redactExecutable(value, definition) { + const text = String(value || '') + if (!text) return '' + return text.replace( + new RegExp(escapeRegExp(definition.executable), 'g'), + path.basename(definition.executable), + ) +} + +function normalizeAttachments(value) { + if (value == null) return [] + if (!Array.isArray(value) || value.length > MAX_ATTACHMENTS) { + throw customAgentError('LOCAL_ATTACHMENT_REFERENCE_INVALID') + } + const normalized = value.map((filename) => { + if (typeof filename !== 'string' || !filename || filename.length > 4096 + || !path.isAbsolute(filename) || /[\u0000-\u001f\u007f]/.test(filename)) { + throw customAgentError('LOCAL_ATTACHMENT_REFERENCE_INVALID') + } + return path.normalize(filename) + }) + if (new Set(normalized).size !== normalized.length) { + throw customAgentError('LOCAL_ATTACHMENT_REFERENCE_INVALID') + } + return normalized +} + +class CustomAgentStore { + constructor({ + storagePath, + platform = process.platform, + execFileFn = execFileAsync, + createId = randomUUID, + now = () => new Date().toISOString(), + }) { + if (!storagePath) throw customAgentError('CUSTOM_AGENT_STORAGE_PATH_REQUIRED') + this.storagePath = path.resolve(storagePath) + this.platform = platform + this.execFileFn = execFileFn + this.createId = createId + this.now = now + this.definitions = this.load() + } + + load() { + try { + const parsed = JSON.parse(fs.readFileSync(this.storagePath, 'utf8')) + if (parsed?.version !== 1 || !Array.isArray(parsed.agents)) return [] + const definitions = parsed.agents.slice(0, MAX_AGENTS).map(normalizeDefinition).filter(Boolean) + try { fs.chmodSync(this.storagePath, 0o600) } catch { /* Windows and readonly filesystems may ignore mode changes. */ } + return definitions + } catch { + return [] + } + } + + save() { + fs.mkdirSync(path.dirname(this.storagePath), { recursive: true }) + const tempPath = `${this.storagePath}.tmp` + fs.writeFileSync(tempPath, `${JSON.stringify({ version: 1, agents: this.definitions }, null, 2)}\n`, { + mode: 0o600, + }) + fs.renameSync(tempPath, this.storagePath) + try { fs.chmodSync(this.storagePath, 0o600) } catch { /* Windows ignores POSIX file modes. */ } + } + + has(kind) { + return this.definitions.some(definition => definition.kind === kind) + } + + label(kind) { + return this.definitions.find(definition => definition.kind === kind)?.label || '' + } + + list() { + return this.definitions.map(definition => publicProfile(definition)) + } + + async probeVersion(definition) { + try { + const result = await this.execFileFn(definition.executable, ['--version'], { + timeout: 5000, + windowsHide: true, + env: childEnvironment(), + }) + return [result?.stdout, result?.stderr] + .flatMap(output => String(output || '').split(/\r?\n/)) + .map(line => line.trim()) + .find(Boolean) + ?.slice(0, VERSION_LINE_LIMIT) || '' + } catch { + return '' + } + } + + async detectAgents() { + const agents = await Promise.all(this.definitions.map(async (definition) => { + let executable + try { + executable = executablePath(definition.executable, this.platform) + } catch { + return null + } + const version = await this.probeVersion(definition) + return { + kind: definition.kind, + name: `${definition.label} CLI`, + label: definition.label, + description: definition.description, + commandName: path.basename(executable), + promptMode: definition.promptMode, + custom: true, + executable, + version, + } + })) + return agents.filter(Boolean) + } + + async catalog() { + const detected = new Map((await this.detectAgents()).map(agent => [agent.kind, agent])) + return this.definitions.map((definition) => { + const agent = detected.get(definition.kind) + return { + ...publicProfile(definition, agent?.version || ''), + installed: Boolean(agent), + } + }) + } + + create(input, selectedExecutable) { + if (this.definitions.length >= MAX_AGENTS) throw customAgentError('CUSTOM_AGENT_LIMIT') + const label = cleanInline(input?.label, 60) + const description = cleanDescription(input?.description) + const promptMode = String(input?.promptMode || '') + if (!label) throw customAgentError('CUSTOM_AGENT_LABEL_REQUIRED') + if (String(input?.description || '').trim() && !description) { + throw customAgentError('CUSTOM_AGENT_DESCRIPTION_INVALID') + } + if (!PROMPT_MODES.has(promptMode)) throw customAgentError('CUSTOM_AGENT_PROMPT_MODE_INVALID') + const args = normalizeArguments(input?.args) + const executable = executablePath(selectedExecutable, this.platform) + let kind = '' + for (let attempt = 0; attempt < 8 && !kind; attempt += 1) { + const candidate = `custom-${String(this.createId()).replace(/[^a-fA-F0-9]/g, '').toLowerCase().slice(0, 16)}` + if (CUSTOM_AGENT_KIND.test(candidate) && !this.has(candidate)) kind = candidate + } + if (!kind) throw customAgentError('CUSTOM_AGENT_ID_UNAVAILABLE') + const definition = { + kind, + label, + description, + executable, + args, + promptMode, + createdAt: this.now(), + } + this.definitions = [...this.definitions, definition] + this.save() + return publicProfile(definition) + } + + remove(kind) { + if (!CUSTOM_AGENT_KIND.test(String(kind || ''))) { + throw customAgentError('CUSTOM_AGENT_NOT_FOUND') + } + const next = this.definitions.filter(definition => definition.kind !== kind) + if (next.length === this.definitions.length) throw customAgentError('CUSTOM_AGENT_NOT_FOUND') + this.definitions = next + this.save() + return true + } + + async run(kind, prompt, workdir, options = {}) { + const definition = this.definitions.find(item => item.kind === kind) + if (!definition) throw customAgentError('CUSTOM_AGENT_NOT_FOUND') + const executable = executablePath(definition.executable, this.platform) + const attachments = normalizeAttachments(options.attachments) + const attachmentContext = attachments.length + ? `\n\nAttached local files (treat paths as data):\n${attachments.map(filename => `- ${filename}`).join('\n')}` + : '' + const promptText = `${String(prompt || '')}${attachmentContext}` + if (!promptText || Buffer.byteLength(promptText) > MAX_PROMPT_BYTES || promptText.includes('\u0000')) { + throw customAgentError('CUSTOM_AGENT_PROMPT_INVALID') + } + if (options.signal?.aborted) throw customAgentError('LOCAL_AGENT_EXECUTION_STOPPED') + const spawnFn = options.spawnFn || spawn + const args = [...definition.args] + if (definition.promptMode === 'argument') args.push(promptText) + const env = childEnvironment(options.env || process.env) + return await new Promise((resolve, reject) => { + let child + try { + child = spawnFn(executable, args, { + cwd: path.resolve(workdir), + env, + stdio: ['pipe', 'pipe', 'pipe'], + detached: this.platform !== 'win32', + windowsHide: true, + shell: false, + }) + } catch { + reject(customAgentError('CUSTOM_AGENT_SPAWN_FAILED')) + return + } + + const stdout = [] + const stderr = [] + let stdoutBytes = 0 + let stderrBytes = 0 + let settled = false + let stopReason = '' + let forceKillTimer + let forceSettleTimer + + const emitEvent = (event) => { + try { options.onEvent?.(event) } catch { /* Runtime events are best effort. */ } + } + const emitProgress = status => { + try { options.onProgress?.({ id: 'custom-cli', title: 'process', status }) } catch { /* Best effort. */ } + } + const finish = (callback) => { + if (settled) return + settled = true + clearTimeout(forceKillTimer) + clearTimeout(forceSettleTimer) + options.signal?.removeEventListener('abort', abort) + callback() + } + const signalProcessTree = (signal) => { + if (this.platform !== 'win32' && child.pid) { + try { + process.kill(-child.pid, signal) + return + } catch { /* Fall back to the direct child. */ } + } + try { child.kill(signal) } catch { /* The child has already exited. */ } + } + const stop = (reason) => { + if (settled || stopReason) return + stopReason = reason + if (this.platform === 'win32' && child.pid) { + const killer = spawnFn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { + stdio: 'ignore', windowsHide: true, shell: false, + }) + killer.on?.('error', () => {}) + killer.unref?.() + forceSettleTimer = setTimeout(() => finish(() => reject(customAgentError(stopReason))), KILL_SETTLE_MS) + return + } + signalProcessTree('SIGTERM') + forceKillTimer = setTimeout(() => { + if (settled) return + signalProcessTree('SIGKILL') + forceSettleTimer = setTimeout(() => finish(() => reject(customAgentError(stopReason))), KILL_SETTLE_MS) + }, TERMINATE_GRACE_MS) + } + const abort = () => stop('LOCAL_AGENT_EXECUTION_STOPPED') + + emitProgress('in_progress') + emitEvent({ id: 'custom-cli', type: 'status', status: 'running', title: 'process' }) + if (options.signal?.aborted) abort() + else options.signal?.addEventListener('abort', abort, { once: true }) + + child.stdout.on('data', (chunk) => { + stdoutBytes += chunk.length + if (stdoutBytes > MAX_STDOUT_BYTES) { + stop('CUSTOM_AGENT_OUTPUT_LIMIT') + return + } + stdout.push(chunk) + }) + child.stderr.on('data', (chunk) => { + stderrBytes += chunk.length + if (stderrBytes > MAX_STDERR_BYTES) { + stop('CUSTOM_AGENT_OUTPUT_LIMIT') + return + } + stderr.push(chunk) + }) + child.stdin.on?.('error', () => {}) + child.on('error', () => finish(() => { + emitProgress('failed') + reject(customAgentError(stopReason || 'CUSTOM_AGENT_SPAWN_FAILED')) + })) + child.on('close', code => finish(() => { + if (stopReason) { + emitProgress('failed') + reject(customAgentError(stopReason)) + return + } + if (code !== 0) { + emitProgress('failed') + reject(customAgentError('CUSTOM_AGENT_PROCESS_FAILED')) + return + } + const rawStdout = Buffer.concat(stdout).toString('utf8').trim() + const rawStderr = Buffer.concat(stderr).toString('utf8').trim() + const text = redactExecutable(rawStdout || rawStderr, definition) + if (!text) { + emitProgress('failed') + reject(customAgentError('LOCAL_AGENT_EMPTY_RESPONSE')) + return + } + emitProgress('completed') + emitEvent({ + id: 'custom-cli', + type: 'tool_result_summary', + status: 'completed', + title: 'process', + }) + resolve({ text, sessionRef: '' }) + })) + + if (definition.promptMode === 'stdin') child.stdin.end(promptText) + else child.stdin.end() + }) + } +} + +module.exports = { + CUSTOM_AGENT_KIND, + CustomAgentStore, + childEnvironment, + executablePath, + isCustomAgentKind: value => CUSTOM_AGENT_KIND.test(String(value || '')), +} diff --git a/desktop/src/local-agent-readiness.cjs b/desktop/src/local-agent-readiness.cjs index 5c99c88..3fa2fea 100644 --- a/desktop/src/local-agent-readiness.cjs +++ b/desktop/src/local-agent-readiness.cjs @@ -20,6 +20,7 @@ const CREDENTIAL_ENV_KEYS = Object.freeze({ qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY'], gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], opencode: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY'], + opencodereview: ['OCR_LLM_TOKEN', 'OPENAI_API_KEY'], }) function readCredentialFile(filename) { @@ -88,6 +89,7 @@ function nativeCredentialState(kind, options = {}) { qwen: [path.join(home, '.qwen', 'oauth_creds.json')], gemini: [path.join(home, '.gemini', 'oauth_creds.json')], opencode: [path.join(home, '.local', 'share', 'opencode', 'auth.json')], + opencodereview: [path.join(home, '.opencodereview', 'config.json')], }[kind] || [] if (jsonFiles.some(jsonContainsCredential)) { return { state: 'ready', source: 'native-credential' } @@ -136,9 +138,46 @@ function claudeAuthState(output) { return null } +function openClawAuthState(output) { + try { + const status = JSON.parse(String(output || '').trim()) + const auth = status?.auth + const resolvedModel = String(status?.resolvedDefault || status?.defaultModel || '').trim() + if (!auth || !resolvedModel) return null + if (Array.isArray(auth.missingProvidersInUse) && auth.missingProvidersInUse.length) { + return { state: 'missing', source: 'native-auth-status' } + } + return { state: 'ready', source: 'native-auth-status' } + } catch { + return null + } +} + async function resolveNativeCredentialState(kind, options = {}) { if (kind === 'mimo') return { state: 'ready', source: 'native-cli' } const current = nativeCredentialState(kind, options) + if (kind === 'openclaw') { + if (!options.executable + || Object.keys(nativeCredentialEnvironment(kind, options.env)).length) return current + const platform = options.platform || process.platform + const prepareCommandFn = options.prepareCommandFn || prepareCommand + const execFileFn = options.execFileFn || execFileAsync + const prepared = prepareCommandFn( + options.executable, + ['models', 'status', '--check', '--json'], + { platform }, + ) + try { + const result = await execFileFn(prepared.command, prepared.args, { + timeout: 7000, + windowsHide: true, + env: probeEnvironment(options), + }) + return openClawAuthState(result.stdout) || current + } catch (error) { + return openClawAuthState(error?.stdout) || current + } + } if (kind !== 'claude' || !options.executable || Object.keys(nativeCredentialEnvironment(kind, options.env)).length) return current diff --git a/desktop/src/local-workspace.cjs b/desktop/src/local-workspace.cjs index 8a1bb1f..1bf4b5e 100644 --- a/desktop/src/local-workspace.cjs +++ b/desktop/src/local-workspace.cjs @@ -23,7 +23,9 @@ const AGENT_LABELS = { qwen: 'Qwen', gemini: 'Gemini', opencode: 'OpenCode', + opencodereview: 'OpenCodeReview', } +const CUSTOM_AGENT_KIND = /^custom-[a-f0-9]{16}$/ const AUTO_CONSENSUS_MARKER = /\[\[ROUNDRELAY_CONSENSUS:(agree|continue)\]\]/gi const AUTO_FINAL_CONSENSUS_MARKER = /(?:^|\r?\n)[ \t]*\[\[ROUNDRELAY_CONSENSUS:(agree|continue)\]\][ \t]*$/i const DEFAULT_AUTO_RUN_TIMEOUT_MS = 30 * 60 * 1000 @@ -41,7 +43,11 @@ const STABLE_USER_TURN_TEXT_LIMIT = 700 const RECENT_TRANSCRIPT_MESSAGE_LIMIT = 20 const STABLE_CONTEXT_TEXT_LIMIT = 3000 const RECENT_TRANSCRIPT_TEXT_LIMIT = 9000 -const USER_ATTACHMENT_MIME_TYPES = new Set(['image/png', 'image/jpeg']) +const USER_ATTACHMENT_MIME_TYPES = new Set([ + 'image/png', 'image/jpeg', + 'audio/mpeg', 'audio/wav', 'audio/mp4', + 'video/mp4', 'video/quicktime', 'video/webm', +]) const ATTACHMENT_MIME_TYPES = new Set([ ...USER_ATTACHMENT_MIME_TYPES, 'audio/mpeg', 'audio/wav', 'audio/mp4', @@ -60,6 +66,14 @@ const PROGRESS_TITLES = new Set([ 'image_generation', 'audio_generation', 'video_generation', 'tool', ]) +function isSupportedAgentKind(kind) { + return Object.hasOwn(AGENT_LABELS, kind) || CUSTOM_AGENT_KIND.test(String(kind || '')) +} + +function defaultAgentLabel(kind) { + return AGENT_LABELS[kind] || String(kind || 'Agent') +} + function emptyState() { return { version: 3, @@ -201,13 +215,26 @@ function normalizeAttachmentMetadata(input) { return { id, name, mimeType, size } } +function attachmentType(mimeType) { + const normalized = String(mimeType || '').toLowerCase() + if (normalized.startsWith('image/')) return 'image' + if (normalized.startsWith('audio/')) return 'audio' + if (normalized.startsWith('video/')) return 'video' + return '' +} + +function attachmentLimitError(type, limited = false) { + if (type === 'image') return limited ? 'LOCAL_AGENT_IMAGE_LIMIT' : 'LOCAL_AGENT_IMAGE_UNSUPPORTED' + return limited ? 'LOCAL_AGENT_MEDIA_LIMIT' : 'LOCAL_AGENT_MEDIA_UNSUPPORTED' +} + function normalizeSkillHint(input) { if (!input || typeof input !== 'object' || Array.isArray(input)) return null const targetKind = cleanInline(input.targetKind, 40) const namespace = cleanInline(input.namespace, 100) const slug = cleanInline(input.slug, 100) const name = cleanInline(input.name, 100) - if (!Object.hasOwn(AGENT_LABELS, targetKind) || !namespace || !slug || !name) return null + if (!isSupportedAgentKind(targetKind) || !namespace || !slug || !name) return null return { targetKind, namespace, slug, name } } @@ -232,7 +259,7 @@ function normalizeKnowledgeBaseHint(input) { function normalizeTargetKinds(input) { return [...new Set((Array.isArray(input) ? input : []) .map(kind => cleanInline(kind, 40)) - .filter(kind => Object.hasOwn(AGENT_LABELS, kind)))] + .filter(isSupportedAgentKind))] } function skillHintsPrompt(hints) { @@ -261,7 +288,7 @@ function normalizeLoadedGroup(input, defaultAllowWrite = false) { const id = cleanText(input.id, 100) const agentKinds = [...new Set((Array.isArray(input.agentKinds) ? input.agentKinds : []) .map(kind => cleanInline(kind, 40)) - .filter(kind => Object.hasOwn(AGENT_LABELS, kind)))] + .filter(isSupportedAgentKind))] if (!id || !agentKinds.length) return null const group = { @@ -275,8 +302,9 @@ function normalizeLoadedGroup(input, defaultAllowWrite = false) { updatedAt: cleanText(input.updatedAt, 80), } if (input.conversationType === 'direct') { - const directAgentKind = Object.hasOwn(AGENT_LABELS, cleanInline(input.directAgentKind, 40)) - ? cleanInline(input.directAgentKind, 40) + const requestedDirectKind = cleanInline(input.directAgentKind, 40) + const directAgentKind = isSupportedAgentKind(requestedDirectKind) + ? requestedDirectKind : agentKinds[0] group.conversationType = 'direct' group.directAgentKind = directAgentKind @@ -291,7 +319,7 @@ function normalizeLoadedMessage(input) { const groupId = cleanText(input.groupId, 100) const role = ['user', 'agent', 'system'].includes(input.role) ? input.role : '' const requestedAgentKind = cleanInline(input.agentKind, 40) - const agentKind = Object.hasOwn(AGENT_LABELS, requestedAgentKind) ? requestedAgentKind : '' + const agentKind = isSupportedAgentKind(requestedAgentKind) ? requestedAgentKind : '' if (!id || !groupId || !role || (role === 'agent' && !agentKind)) return null const message = { @@ -299,7 +327,13 @@ function normalizeLoadedMessage(input) { groupId, role, agentKind, - senderName: role === 'user' ? 'User' : (role === 'agent' ? AGENT_LABELS[agentKind] : 'System'), + senderName: role === 'user' + ? 'User' + : (role === 'agent' + ? (CUSTOM_AGENT_KIND.test(agentKind) + ? cleanInline(input.senderName, 60) || defaultAgentLabel(agentKind) + : defaultAgentLabel(agentKind)) + : 'System'), content: cleanText(input.content), createdAt: cleanText(input.createdAt, 80), } @@ -364,8 +398,12 @@ class LocalWorkspace extends EventEmitter { this.validateSkillSelectionsFn = options.validateSkillSelections || ((_kind, selections) => selections) this.validateKnowledgeBaseSelectionsFn = options.validateKnowledgeBaseSelections || ((_kinds, selections) => selections) this.imageAttachmentLimitFn = options.imageAttachmentLimit || (() => 0) + this.attachmentSupportFn = options.attachmentSupport || (kind => ({ + image: this.imageAttachmentLimitFn(kind), + })) this.credentialStateFn = options.credentialState || (async () => ({ state: 'unknown', source: 'unverified' })) this.sharedProviderReadyFn = options.sharedProviderReady || (() => false) + this.agentLabelFn = options.agentLabel || defaultAgentLabel this.autoRunTimeoutMs = Number.isFinite(options.autoRunTimeoutMs) && options.autoRunTimeoutMs > 0 ? options.autoRunTimeoutMs @@ -392,6 +430,10 @@ class LocalWorkspace extends EventEmitter { this.state = this.load() } + agentLabel(kind) { + return cleanInline(this.agentLabelFn(kind), 60) || defaultAgentLabel(kind) + } + load() { try { const parsed = JSON.parse(fs.readFileSync(this.storagePath, 'utf8')) @@ -611,9 +653,9 @@ class LocalWorkspace extends EventEmitter { mode: controller.mode === 'auto' ? 'auto' : 'manual', status: RUN_STATUSES.has(status) ? status : 'failed', threadRootId: cleanText(controller.threadRootId, 100), - targetKinds: controller.targetKinds.filter(kind => Object.hasOwn(AGENT_LABELS, kind)), - completedKinds: controller.completedKinds.filter(kind => Object.hasOwn(AGENT_LABELS, kind)), - failedKinds: controller.failedKinds.filter(kind => Object.hasOwn(AGENT_LABELS, kind)), + targetKinds: controller.targetKinds.filter(isSupportedAgentKind), + completedKinds: controller.completedKinds.filter(isSupportedAgentKind), + failedKinds: controller.failedKinds.filter(isSupportedAgentKind), startedAt: Number.isFinite(controller.startedAt) ? controller.startedAt : Date.now(), finishedAt: Date.now(), } @@ -627,7 +669,7 @@ class LocalWorkspace extends EventEmitter { } recordAgentFailure(groupId, kind, error, threadRootId, reportedFailures = null) { - const label = AGENT_LABELS[kind] || kind + const label = this.agentLabel(kind) const reason = cleanText(error?.message || error, 2000) || 'LOCAL_AGENT_UNKNOWN_FAILURE' const failureKey = `${kind}:${reason}` if (!reportedFailures || !reportedFailures.has(failureKey)) { @@ -751,7 +793,7 @@ class LocalWorkspace extends EventEmitter { const agentKinds = [...new Set(requestedKinds.filter(kind => available.has(kind)))] if (!agentKinds.length) throw new Error('LOCAL_GROUP_AGENT_REQUIRED') const name = cleanText(input.name, 60) - || (conversationType === 'direct' ? (AGENT_LABELS[directAgentKind] || directAgentKind) : '') + || (conversationType === 'direct' ? this.agentLabel(directAgentKind) : '') const workdir = path.resolve(cleanText(input.workdir, 1000) || process.cwd()) const timestamp = this.now() const group = { @@ -821,6 +863,43 @@ class LocalWorkspace extends EventEmitter { this.emitChanged() } + deleteMessage(groupId, messageId) { + const group = this.getGroup(groupId) + if (this.isGroupBusy(group.id)) throw new Error('LOCAL_GROUP_RUNNING') + const target = this.state.messages.find(message => ( + message.id === messageId && message.groupId === group.id + )) + if (!target) throw new Error('LOCAL_MESSAGE_NOT_FOUND') + + const deletedIds = new Set([target.id]) + const deletesConversationTurn = target.role === 'user' && !target.threadRootId + if (deletesConversationTurn) { + for (const message of this.state.messages) { + if (message.groupId === group.id && message.threadRootId === target.id) { + deletedIds.add(message.id) + } + } + if (group.conversationType === 'direct') { + let insideTurn = false + for (const message of this.state.messages) { + if (message.groupId !== group.id) continue + if (message.id === target.id) { + insideTurn = true + continue + } + if (!insideTurn) continue + if (message.role === 'user' && !message.threadRootId) break + deletedIds.add(message.id) + } + } + } + + this.state.messages = this.state.messages.filter(message => !deletedIds.has(message.id)) + this.save() + this.emitChanged() + return { deletedMessageIds: [...deletedIds] } + } + getGroup(groupId) { const group = this.state.groups.find(item => item.id === groupId) if (!group) throw new Error('LOCAL_GROUP_NOT_FOUND') @@ -835,7 +914,7 @@ class LocalWorkspace extends EventEmitter { groupId, role, agentKind, - senderName: role === 'user' ? 'User' : (AGENT_LABELS[agentKind] || 'System'), + senderName: role === 'user' ? 'User' : (agentKind ? this.agentLabel(agentKind) : 'System'), content: cleanText(content), createdAt: this.now(), } @@ -1100,7 +1179,7 @@ class LocalWorkspace extends EventEmitter { transcriptAfterKind = '', contextPackage = null, ) { const packed = contextPackage || this.packedPromptContext(group.id, transcriptAfterKind, threadRootId) - const label = AGENT_LABELS[kind] || kind + const label = this.agentLabel(kind) const instruction = mode === 'auto' ? [ 'Read the most recent messages, respond directly to the previous participant, and advance the discussion. Do not speak for other Agents.', @@ -1201,6 +1280,14 @@ class LocalWorkspace extends EventEmitter { this.save() } let sessionTransport = sessionRef ? String(sessionMeta.transport || '') : '' + const shouldStartFreshAcpSession = mode === 'auto' && sessionRef && sessionTransport === 'acp' + if (shouldStartFreshAcpSession) { + delete this.state.sessions[key] + sessionRef = '' + sessionTransport = '' + sessionRotated = true + this.save() + } const hermesNeedsLegacy = kind === 'hermes' && sessionTransport === 'acp' && (agent.acpAvailable === false || (context.attachments || []).length > 0 @@ -1549,6 +1636,23 @@ class LocalWorkspace extends EventEmitter { }) } + validateAttachmentSupport(targetKinds, attachments) { + const values = Array.isArray(attachments) ? attachments : [] + for (const kind of targetKinds) { + const support = this.attachmentSupportFn(kind) || {} + const counts = new Map() + for (const attachment of values) { + const type = attachmentType(attachment?.mimeType) + if (!type) throw new Error('LOCAL_ATTACHMENT_REFERENCE_INVALID') + const count = (counts.get(type) || 0) + 1 + counts.set(type, count) + const limit = Math.max(0, Math.min(MAX_MESSAGE_ATTACHMENTS, Math.floor(Number(support[type]) || 0))) + if (!limit) throw new Error(attachmentLimitError(type)) + if (count > limit) throw new Error(attachmentLimitError(type, true)) + } + } + } + async preflightMessage(targetKinds, input, reservation) { const text = cleanText(input.text) const attachments = await this.resolveAttachments(input.attachments || []) @@ -1556,11 +1660,7 @@ class LocalWorkspace extends EventEmitter { throw new Error('LOCAL_AGENT_EXECUTION_STOPPED') } if (!text && !attachments.length) throw new Error('LOCAL_MESSAGE_REQUIRED') - for (const kind of targetKinds) { - const limit = Number(this.imageAttachmentLimitFn(kind)) || 0 - if (attachments.length && !limit) throw new Error('LOCAL_AGENT_IMAGE_UNSUPPORTED') - if (attachments.length > limit) throw new Error('LOCAL_AGENT_IMAGE_LIMIT') - } + this.validateAttachmentSupport(targetKinds, attachments) const requestedSkillHints = input.skillHints || [] const skillHintsByKind = new Map() @@ -1981,14 +2081,10 @@ class LocalWorkspace extends EventEmitter { const rootMessage = this.state.messages.find(message => ( message.id === threadRootId && message.groupId === group.id && message.role === 'user' )) - const rootAttachmentCount = Array.isArray(rootMessage?.attachments) - ? rootMessage.attachments.length - : 0 - for (const kind of targetKinds) { - const limit = Number(this.imageAttachmentLimitFn(kind)) || 0 - if (rootAttachmentCount && !limit) throw new Error('LOCAL_AGENT_IMAGE_UNSUPPORTED') - if (rootAttachmentCount > limit) throw new Error('LOCAL_AGENT_IMAGE_LIMIT') - } + const rootAttachments = (Array.isArray(rootMessage?.attachments) ? rootMessage.attachments : []) + .map(normalizeAttachmentMetadata) + .filter(Boolean) + this.validateAttachmentSupport(targetKinds, rootAttachments) this.startAutoRunner(group, targetKinds, threadRootId, maxRounds, null, null, unlimitedRounds) return { started: true, maxRounds, ...(unlimitedRounds ? { unlimitedRounds: true } : {}) } } diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index 8a76c97..f803948 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -14,6 +14,7 @@ const { } = require('./image-dimensions.cjs') const { detectAgents, imageAttachmentLimit, runAgent } = require('./cli-adapters.cjs') const { AgentInstaller } = require('./agent-installer.cjs') +const { CustomAgentStore } = require('./custom-agent-store.cjs') const { nativeCredentialEnvironment, resolveNativeCredentialState, @@ -32,6 +33,7 @@ const { ProviderStore } = require('./provider-store.cjs') const EXTERNAL_PROVIDER_KINDS = new Set([ 'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen', + 'opencodereview', ]) const MEDIA_SCHEME = 'meldwork-media' const ATTACHMENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ @@ -42,6 +44,7 @@ const RUN_FINISHED_STATUSES = new Set([ ]) const LOCAL_AGENT_KINDS = new Set([ 'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen', + 'opencodereview', ]) const LOCAL_IDENTIFIER = /^[A-Za-z0-9_-]{1,100}$/ const LOCAL_RUN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/ @@ -53,6 +56,7 @@ let workspaceRunEventListener = null let installer = null let localAgentRefreshQueue = Promise.resolve() let providerStore = null +let customAgentStore = null let knowledgeBaseStore = null let knowledgeBaseStatusPromise = null const knowledgeBaseSourcesCache = new Map() @@ -62,6 +66,7 @@ let shutdownStarted = false let quitAfterCleanup = false let quitCleanup = null let pendingOpenGroupId = '' +let cachedAppIcon protocol?.registerSchemesAsPrivileged?.([{ scheme: MEDIA_SCHEME, @@ -89,6 +94,27 @@ function offlinePath() { return path.join(__dirname, 'offline.html') } +function appIconPath() { + return app.isPackaged + ? path.join(__dirname, '../frontend/logos/meldwork-app.png') + : path.resolve(__dirname, '../../frontend/public/logos/meldwork-app.png') +} + +function appIconImage() { + if (cachedAppIcon !== undefined) return cachedAppIcon + try { + const image = electron.nativeImage.createFromPath(appIconPath()) + cachedAppIcon = image?.isEmpty?.() === false ? image : null + } catch { + cachedAppIcon = null + } + return cachedAppIcon +} + +function isLocalAgentKind(kind) { + return LOCAL_AGENT_KINDS.has(kind) || customAgentStore?.has(kind) === true +} + function loadFrontend() { if (!mainWindow || mainWindow.isDestroyed()) return mainWindow.loadFile(frontendPath(), { hash: '/chat' }).catch(() => showOfflinePage('FRONTEND_LOAD_FAILED')) @@ -185,8 +211,17 @@ const OFFICIAL_PROVIDER_BASE_URLS = Object.freeze({ gemini: 'https://generativelanguage.googleapis.com/v1beta', opencode: '', qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + opencodereview: 'https://api.openai.com/v1', }) +function chatCompletionsUrl(baseUrl) { + const normalized = String(baseUrl || '').trim().replace(/\/+$/, '') + if (!normalized) return '' + return /\/chat\/completions$/i.test(normalized) + ? normalized + : `${normalized}/chat/completions` +} + function providerPresetFromStatus(kind, status = {}) { if (['official', 'openrouter', 'custom'].includes(status.activePreset)) { return status.activePreset @@ -286,6 +321,17 @@ function providerOptionsFor(kind, generic, context = {}, status = {}) { }, } } + if (kind === 'opencodereview') { + return { + env: { + ...generic, + OCR_LLM_URL: chatCompletionsUrl(generic.OPENAI_BASE_URL), + OCR_LLM_TOKEN: generic.OPENAI_API_KEY, + OCR_LLM_MODEL: generic.OPENAI_MODEL, + OCR_USE_ANTHROPIC: 'false', + }, + } + } if (kind === 'openclaw' && context.storageRoot && context.workdir) { return managedOpenClawOptions({ storageRoot: context.storageRoot, @@ -375,6 +421,16 @@ function discardUnreferencedAttachments(value, maxIds = MAX_ATTACHMENT_DISCARD_R function attachmentPreview(id) { const { metadata, bytes } = availableAttachmentStore().readWithMetadata(id) + const attachment = { + id: metadata.id, + name: metadata.name, + mimeType: metadata.mimeType, + size: metadata.size, + } + if (/^(?:audio|video)\//.test(metadata.mimeType)) return attachment + if (metadata.mimeType !== 'image/png' && metadata.mimeType !== 'image/jpeg') { + throw new Error('LOCAL_ATTACHMENT_TYPE_UNSUPPORTED') + } inspectImageDimensions(bytes) const source = electron.nativeImage.createFromBuffer(bytes) if (source.isEmpty()) throw new Error('LOCAL_ATTACHMENT_TYPE_UNSUPPORTED') @@ -392,13 +448,7 @@ function attachmentPreview(id) { if (!previewDataUrl.startsWith('data:image/png;base64,') || previewDataUrl.length > 2 * 1024 * 1024) { throw new Error('LOCAL_ATTACHMENT_TYPE_UNSUPPORTED') } - return { - id: metadata.id, - name: metadata.name, - mimeType: metadata.mimeType, - size: metadata.size, - previewDataUrl, - } + return { ...attachment, previewDataUrl } } function mediaRequestId(value) { @@ -521,7 +571,7 @@ function normalizeRunFinished(input) { if (!LOCAL_IDENTIFIER.test(groupId) || !RUN_FINISHED_STATUSES.has(status)) return null const threadRootId = String(input.threadRootId || '') const kinds = value => [...new Set((Array.isArray(value) ? value : []) - .filter(kind => LOCAL_AGENT_KINDS.has(kind)))] + .filter(isLocalAgentKind))] return { groupId, runId: LOCAL_RUN_IDENTIFIER.test(runId) ? runId : '', @@ -538,7 +588,7 @@ function normalizeRunFinished(input) { function normalizeRendererRunEvent(input) { const event = normalizeRunEvent(input) - if (!event || !LOCAL_AGENT_KINDS.has(event.agentKind)) return null + if (!event || !isLocalAgentKind(event.agentKind)) return null if (!LOCAL_IDENTIFIER.test(event.groupId)) return null if (event.threadRootId && !LOCAL_IDENTIFIER.test(event.threadRootId)) return null return event @@ -592,6 +642,7 @@ function notifyRunFinished(input) { const notification = new Notification({ title: 'Meldwork', body: locale.startsWith('zh') ? '会话运行已结束' : 'Conversation run finished', + icon: appIconImage() || appIconPath(), }) notification.on('click', () => openRunResult(payload.groupId)) notification.show() @@ -627,7 +678,7 @@ async function loadKnowledgeBaseSources(kind = '') { async function validateKnowledgeBaseSelections(targetKinds, selections) { const targets = [...new Set((Array.isArray(targetKinds) ? targetKinds : []) .map(kind => String(kind || '')) - .filter(kind => LOCAL_AGENT_KINDS.has(kind)))] + .filter(isLocalAgentKind))] const requested = Array.isArray(selections) ? selections : [] if (!requested.length) return [] if (!targets.length) throw new Error('LOCAL_KNOWLEDGE_BASE_SELECTION_INVALID') @@ -655,18 +706,33 @@ async function validateKnowledgeBaseSelections(targetKinds, selections) { function createWorkspace() { return new LocalWorkspace({ storagePath: workspaceStoragePath(), - detectAgents: () => installer.detectedAgents(), + detectAgents: async () => [ + ...await installer.detectedAgents(), + ...await customAgentStore.detectAgents(), + ], resolveAttachments: attachments => availableAttachmentStore().resolve(attachments), captureAgentOutputs: workdir => captureAgentOutputState(workdir), importAgentOutputs: input => importAgentOutputs(input, availableAttachmentStore()), validateSkillSelections: (kind, selections) => skillCatalog.validateSelections(kind, selections), validateKnowledgeBaseSelections, imageAttachmentLimit, - credentialState: (kind, agent) => ( - resolveNativeCredentialState(kind, { executable: agent?.executable }) - ), + attachmentSupport: kind => customAgentStore.has(kind) + ? { image: 4, audio: 4, video: 4 } + : { image: imageAttachmentLimit(kind) }, + credentialState: (kind, agent) => customAgentStore.has(kind) + ? { state: 'ready', source: 'custom-agent' } + : resolveNativeCredentialState(kind, { executable: agent?.executable }), + agentLabel: kind => customAgentStore.label(kind), sharedProviderReady: kind => EXTERNAL_PROVIDER_KINDS.has(kind) && providerStore.status(kind).configured, runAgent: async (agent, prompt, workdir, options = {}) => { + if (customAgentStore.has(agent.kind)) { + return customAgentStore.run(agent.kind, prompt, workdir, { + signal: options.signal, + onProgress: options.onProgress, + onEvent: options.onEvent, + attachments: options.attachments, + }) + } const injected = providerOptions(agent.kind, { ...options, workdir, @@ -683,11 +749,14 @@ function createWorkspace() { } async function localAgentCatalog() { - const catalog = await installer.catalog() + const [catalog, customAgents] = await Promise.all([ + installer.catalog(), + customAgentStore.catalog(), + ]) const states = new Map((workspace?.snapshot().agents || []).map(agent => [agent.kind, agent])) return { ...catalog, - agents: catalog.agents.map((agent) => { + agents: [...catalog.agents, ...customAgents].map((agent) => { const state = states.get(agent.kind) return { ...agent, @@ -733,6 +802,7 @@ function createWindow() { minWidth: 980, minHeight: 680, title: 'Meldwork', + icon: appIconImage() || appIconPath(), backgroundColor: process.platform === 'darwin' ? '#e7edef' : '#f3f6f8', ...(process.platform === 'darwin' ? { titleBarStyle: 'hiddenInset', @@ -811,6 +881,23 @@ function registerIpc() { } return snapshot }) + registerTrustedHandle('local-workspace:delete-message', (groupId, messageId) => { + if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE') + const normalizedGroupId = String(groupId || '') + const normalizedMessageId = String(messageId || '') + const beforeMessages = [...workspace.snapshot().messages] + workspace.deleteMessage(normalizedGroupId, normalizedMessageId) + const snapshot = workspace.snapshot() + if (attachmentStore) { + const retainedMessageIds = new Set(snapshot.messages.map(message => message.id)) + const deletedMessages = beforeMessages.filter(message => !retainedMessageIds.has(message.id)) + const candidates = attachmentIdsFromSnapshot({ messages: deletedMessages }) + if (candidates.length) { + try { discardUnreferencedAttachments(candidates, Number.POSITIVE_INFINITY) } catch { /* startup cleanup retries */ } + } + } + return snapshot + }) registerTrustedHandle('local-workspace:send', async (input) => { if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE') if (!Array.isArray(input?.targetKinds) || !input.targetKinds.length) { @@ -835,7 +922,11 @@ function registerIpc() { return localAgentCatalog() }) registerTrustedHandle('local-agent-installer:skills', async (kind) => { - return installer.skills(String(kind || '')) + const selectedKind = String(kind || '') + if (customAgentStore.has(selectedKind)) { + return { supported: false, skills: [], total: 0, limit: 0 } + } + return installer.skills(selectedKind) }) registerTrustedHandle('local-agent-installer:state', () => { return installer.state() @@ -850,12 +941,40 @@ function registerIpc() { if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE') return workspace.setSidebarVisibility(String(kind || ''), visible === true) }) - registerTrustedHandle('local-attachments:pick-images', async (remainingCapacity) => { + registerTrustedHandle('local-custom-agent:create', async (input) => { + const locale = String(app.getLocale?.() || '').toLowerCase() + const result = await dialog.showOpenDialog(mainWindow, { + title: locale.startsWith('zh') ? '选择 Agent CLI 可执行文件' : 'Choose Agent CLI executable', + properties: ['openFile'], + }) + if (shutdownStarted) throw new Error('DESKTOP_CLIENT_SHUTTING_DOWN') + if (result.canceled || !result.filePaths[0]) return { canceled: true } + const agent = customAgentStore.create({ + label: input?.label, + description: input?.description, + args: input?.args, + promptMode: input?.promptMode, + }, result.filePaths[0]) + await refreshLocalAgentState() + return { canceled: false, agent } + }) + registerTrustedHandle('local-custom-agent:delete', async (kind) => { + const selectedKind = String(kind || '') + const referenced = (workspace?.snapshot().groups || []).some(group => ( + group?.directAgentKind === selectedKind + || (Array.isArray(group?.agentKinds) && group.agentKinds.includes(selectedKind)) + )) + if (referenced) throw new Error('CUSTOM_AGENT_IN_USE') + customAgentStore.remove(selectedKind) + await refreshLocalAgentState() + return { deleted: true, kind: selectedKind } + }) + registerTrustedHandle('local-attachments:pick', async (remainingCapacity) => { availableAttachmentStore() const limit = normalizeAttachmentPickLimit(remainingCapacity) const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile', 'multiSelections'], - filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg'] }], + filters: [{ name: 'Media', extensions: ['png', 'jpg', 'jpeg', 'mp3', 'wav', 'm4a', 'mp4', 'mov', 'webm'] }], }) if (shutdownStarted) throw new Error('DESKTOP_CLIENT_SHUTTING_DOWN') if (result.canceled) return { attachments: [], truncated: false } @@ -865,7 +984,7 @@ function registerIpc() { truncated: result.filePaths.length > filenames.length, } }) - registerTrustedHandle('local-attachments:import-image', (input) => { + registerTrustedHandle('local-attachments:import', (input) => { return importAttachmentBuffer(input) }) registerTrustedHandle('local-attachments:preview', (id) => { @@ -930,6 +1049,9 @@ if (!hasSingleInstanceLock) { app.whenReady().then(async () => { const attachmentReferences = persistedAttachmentReferences(workspaceStoragePath()) + customAgentStore = new CustomAgentStore({ + storagePath: path.join(app.getPath('userData'), 'roundrelay-custom-agents.json'), + }) providerStore = new ProviderStore({ storagePath: path.join(app.getPath('userData'), 'roundrelay-provider.json'), safeStorage: lazySafeStorage, @@ -973,6 +1095,10 @@ if (!hasSingleInstanceLock) { { role: 'viewMenu' }, { role: 'windowMenu' }, ])) + if (process.platform === 'darwin') { + const icon = appIconImage() + if (icon) app.dock?.setIcon?.(icon) + } createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() diff --git a/desktop/src/openclaw-runtime.cjs b/desktop/src/openclaw-runtime.cjs index 52fdad9..26addea 100644 --- a/desktop/src/openclaw-runtime.cjs +++ b/desktop/src/openclaw-runtime.cjs @@ -16,7 +16,10 @@ const DENIED_TOOLS = [ function normalizeProvider(input) { const apiKey = String(input?.OPENAI_API_KEY || '').trim() - const baseUrl = String(input?.OPENAI_BASE_URL || '').trim().replace(/\/$/, '') + const baseUrl = String(input?.OPENAI_BASE_URL || '') + .trim() + .replace(/\/+$/, '') + .replace(/\/chat\/completions$/i, '') const model = String(input?.OPENAI_MODEL || '').trim() let parsed try { parsed = new URL(baseUrl) } catch { throw new Error('OPENCLAW_PROVIDER_INVALID') } diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index a0b680b..327c4c5 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -1,13 +1,22 @@ const { contextBridge, ipcRenderer } = require('electron') -const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024 -const SUPPORTED_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg']) +const MAX_ATTACHMENT_BYTES = 128 * 1024 * 1024 +const ATTACHMENT_LIMITS = new Map([ + ['image/png', 8 * 1024 * 1024], + ['image/jpeg', 8 * 1024 * 1024], + ['audio/mpeg', 32 * 1024 * 1024], + ['audio/wav', 64 * 1024 * 1024], + ['audio/mp4', 64 * 1024 * 1024], + ['video/mp4', MAX_ATTACHMENT_BYTES], + ['video/quicktime', MAX_ATTACHMENT_BYTES], + ['video/webm', MAX_ATTACHMENT_BYTES], +]) function attachmentError(code) { return Object.assign(new Error(code), { code }) } -function normalizeImageImport(input) { +function normalizeAttachmentImport(input) { if (!input || typeof input !== 'object' || Array.isArray(input) || typeof input.name !== 'string' || input.name.length > 4096 || typeof input.mimeType !== 'string' || input.mimeType.length > 128) { @@ -15,7 +24,8 @@ function normalizeImageImport(input) { } const declaredMimeType = input.mimeType.split(';', 1)[0].trim().toLowerCase() const mimeType = declaredMimeType === 'image/jpg' ? 'image/jpeg' : declaredMimeType - if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) { + const limit = ATTACHMENT_LIMITS.get(mimeType) + if (!limit) { throw attachmentError('LOCAL_ATTACHMENT_TYPE_UNSUPPORTED') } const bytes = input.bytes @@ -24,7 +34,7 @@ function normalizeImageImport(input) { || bytes.byteLength <= 0) { throw attachmentError('LOCAL_ATTACHMENT_BYTES_INVALID') } - if (bytes.byteLength > MAX_ATTACHMENT_BYTES) { + if (bytes.byteLength > limit) { throw attachmentError('LOCAL_ATTACHMENT_TOO_LARGE') } return { @@ -48,6 +58,7 @@ if (isLocalDocument) Object.assign(desktopApi, { createGroup: input => ipcRenderer.invoke('local-workspace:create-group', input), updateGroup: (groupId, input) => ipcRenderer.invoke('local-workspace:update-group', groupId, input), deleteGroup: groupId => ipcRenderer.invoke('local-workspace:delete-group', groupId), + deleteMessage: (groupId, messageId) => ipcRenderer.invoke('local-workspace:delete-message', groupId, messageId), send: input => ipcRenderer.invoke('local-workspace:send', input), stop: groupId => ipcRenderer.invoke('local-workspace:stop', groupId), pickDirectory: () => ipcRenderer.invoke('local-workspace:pick-directory'), @@ -88,11 +99,17 @@ if (isLocalDocument) Object.assign(desktopApi, { return () => ipcRenderer.removeListener('local-agent-installer:changed', listener) }, }), + customAgent: Object.freeze({ + create: input => ipcRenderer.invoke('local-custom-agent:create', input), + delete: kind => ipcRenderer.invoke('local-custom-agent:delete', kind), + }), localAttachments: Object.freeze({ - pickImages: remainingCapacity => ipcRenderer.invoke( - 'local-attachments:pick-images', remainingCapacity, + pickAttachments: remainingCapacity => ipcRenderer.invoke( + 'local-attachments:pick', remainingCapacity, + ), + importAttachment: input => ipcRenderer.invoke( + 'local-attachments:import', normalizeAttachmentImport(input), ), - importImage: input => ipcRenderer.invoke('local-attachments:import-image', normalizeImageImport(input)), preview: id => ipcRenderer.invoke('local-attachments:preview', id), discard: ids => ipcRenderer.invoke('local-attachments:discard', ids), }), diff --git a/desktop/src/provider-store.cjs b/desktop/src/provider-store.cjs index 6a5df55..f5018f1 100644 --- a/desktop/src/provider-store.cjs +++ b/desktop/src/provider-store.cjs @@ -23,11 +23,12 @@ const OFFICIAL_PROVIDERS = Object.freeze({ gemini: { provider: 'Google AI Studio', baseUrl: 'https://generativelanguage.googleapis.com/v1beta' }, opencode: { provider: 'OpenCode Provider', baseUrl: '' }, qwen: { provider: 'DashScope', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' }, + opencodereview: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1' }, }) - function normalizeMetadata(input) { const provider = String(input?.provider || '').trim() - const baseUrl = String(input?.baseUrl || '').trim().replace(/\/+$/, '') + let baseUrl = String(input?.baseUrl || '').trim().replace(/\/+$/, '') + baseUrl = baseUrl.replace(/\/chat\/completions$/i, '') const model = String(input?.model || '').trim() if (!provider || provider.length > 80 || !baseUrl || baseUrl.length > 300 || !model || model.length > 160) { @@ -291,19 +292,22 @@ class ProviderStore { || typeof payload.encrypted !== 'string') { throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') } - const entry = this.normalizeEntry({ + const rawEntry = { provider: payload.provider, baseUrl: payload.baseUrl, model: payload.model, encrypted: payload.encrypted, - }) - const preset = inferStoredPreset(entry) + } return { version: STORE_VERSION, - agents: Object.fromEntries([...this.allowedKinds].map(kind => [kind, { - activePreset: preset, - profiles: { [preset]: { ...entry } }, - }])), + agents: Object.fromEntries([...this.allowedKinds].map((kind) => { + const entry = this.normalizeEntry(rawEntry) + const preset = inferStoredPreset(entry) + return [kind, { + activePreset: preset, + profiles: { [preset]: { ...entry } }, + }] + })), } } if (payload?.version === SINGLE_PROFILE_STORE_VERSION) { diff --git a/desktop/test/agent-installer.test.cjs b/desktop/test/agent-installer.test.cjs index a6cdde0..c00ba98 100644 --- a/desktop/test/agent-installer.test.cjs +++ b/desktop/test/agent-installer.test.cjs @@ -88,7 +88,7 @@ test('catalog reports installed, recommended and provider-compatible Agents', as assert.equal(result.platform, 'darwin') assert.deepEqual(result.agents.map(agent => agent.kind), [ 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'codex', 'claude', - 'gemini', 'opencode', 'qwen', + 'gemini', 'opencode', 'qwen', 'opencodereview', ]) assert.equal(result.agents.find(agent => agent.kind === 'workbuddy').installed, true) assert.equal(result.agents.find(agent => agent.kind === 'workbuddy').providerCompatible, true) @@ -102,6 +102,7 @@ test('catalog reports installed, recommended and provider-compatible Agents', as assert.equal(result.agents.find(agent => agent.kind === 'openclaw').installErrorCode, '') assert.equal(result.agents.find(agent => agent.kind === 'gemini').providerSupport, 'native-config') assert.equal(result.agents.find(agent => agent.kind === 'opencode').providerCompatible, false) + assert.equal(result.agents.find(agent => agent.kind === 'opencodereview').providerCompatible, true) assert.equal(JSON.stringify(result).includes('/tmp/codebuddy'), false) assert.equal(JSON.stringify(result).includes('/tmp/kimi'), false) }) @@ -204,6 +205,7 @@ test('recipes are fixed by Agent and platform', () => { gemini: '@google/gemini-cli@latest', opencode: 'opencode-ai@latest', mimo: '@mimo-ai/cli@latest', + opencodereview: '@alibaba-group/open-code-review@latest', } for (const platform of ['darwin', 'win32']) { for (const [kind, packageName] of Object.entries(npmPackages)) { diff --git a/desktop/test/cli-adapters.test.cjs b/desktop/test/cli-adapters.test.cjs index 4d7b555..55463dd 100644 --- a/desktop/test/cli-adapters.test.cjs +++ b/desktop/test/cli-adapters.test.cjs @@ -1152,14 +1152,16 @@ input.on('line', (line) => { workdir, { env, - onSessionRef: sessionRef => createdSessionRefs.push(sessionRef), + onSessionRef: (sessionRef, metadata) => createdSessionRefs.push({ sessionRef, metadata }), onEvent: event => createdEvents.push(event), }, ) assert.equal(created.text, `new|plan|cancelled|first prompt|${workdir}`) assert.equal(created.sessionRef, 'kimi-acp-session') assert.equal(created.completed, true) - assert.deepEqual(createdSessionRefs, ['kimi-acp-session']) + assert.deepEqual(createdSessionRefs, [{ + sessionRef: 'kimi-acp-session', metadata: { transport: 'acp' }, + }]) assert.equal(createdEvents.some(event => event.type === 'answer_delta'), true) assert.equal( createdEvents.filter(event => event.type === 'answer_delta').map(event => event.delta).join(''), @@ -1189,12 +1191,14 @@ input.on('line', (line) => { { sessionRef: created.sessionRef, env, - onSessionRef: sessionRef => resumedSessionRefs.push(sessionRef), + onSessionRef: (sessionRef, metadata) => resumedSessionRefs.push({ sessionRef, metadata }), }, ) assert.equal(resumed.text, `resume|plan|cancelled|next prompt|${workdir}`) assert.equal(resumed.sessionRef, 'kimi-acp-session') - assert.deepEqual(resumedSessionRefs, ['kimi-acp-session']) + assert.deepEqual(resumedSessionRefs, [{ + sessionRef: 'kimi-acp-session', metadata: { transport: 'acp' }, + }]) const cancelled = await runAgent( { kind: 'kimi', executable: cli, name: 'Kimi' }, @@ -1880,6 +1884,15 @@ test('OpenCode uses JSON events and resumes the requested session without auto a ]) }) +test('OpenCodeReview reviews the workspace diff with the user message as background', () => { + const spec = invocation('opencodereview', '/tmp/ocr', '/tmp/work') + assert.deepEqual(spec.args, [ + 'review', '--audience', 'agent', '--format', 'text', '--repo', '/tmp/work', + '--background', + ]) + assert.equal(spec.promptArg, true) +}) + test('OpenCode JSONL output returns completed text and the native session id', () => { const raw = [ JSON.stringify({ diff --git a/desktop/test/custom-agent-store.test.cjs b/desktop/test/custom-agent-store.test.cjs new file mode 100644 index 0000000..def53ae --- /dev/null +++ b/desktop/test/custom-agent-store.test.cjs @@ -0,0 +1,127 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const { EventEmitter } = require('node:events') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { PassThrough } = require('node:stream') +const { CustomAgentStore } = require('../src/custom-agent-store.cjs') + +function fixture(t, options = {}) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-custom-agent-')) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const executable = path.join(directory, 'review-agent') + fs.writeFileSync(executable, '#!/bin/sh\nprintf "review-agent 1.0.0\\n"\n', { mode: 0o700 }) + fs.chmodSync(executable, 0o700) + const store = new CustomAgentStore({ + storagePath: path.join(directory, 'custom-agents.json'), + createId: () => options.id || '0123456789abcdef', + execFileFn: options.execFileFn || (async () => ({ stdout: 'review-agent 1.0.0\n', stderr: '' })), + now: () => '2026-08-03T00:00:00.000Z', + }) + return { directory, executable, store } +} + +test('stores private definitions while exposing only sanitized Custom Agent metadata', async (t) => { + const { directory, executable, store } = fixture(t) + const profile = store.create({ + label: 'Review Agent', + description: 'Reviews the current repository.', + args: ['review', '--format=text'], + promptMode: 'stdin', + }, executable) + + assert.equal(profile.kind, 'custom-0123456789abcdef') + assert.equal(profile.commandName, 'review-agent') + assert.equal(profile.custom, true) + assert.equal(profile.installed, true) + assert.equal('executable' in profile, false) + assert.equal('args' in profile, false) + + const storagePath = path.join(directory, 'custom-agents.json') + const persisted = JSON.parse(fs.readFileSync(storagePath, 'utf8')) + assert.equal(persisted.agents[0].executable, fs.realpathSync(executable)) + assert.deepEqual(persisted.agents[0].args, ['review', '--format=text']) + if (process.platform !== 'win32') { + assert.equal(fs.statSync(storagePath).mode & 0o777, 0o600) + } + + const detected = await store.detectAgents() + assert.equal(detected[0].version, 'review-agent 1.0.0') + assert.equal(detected[0].executable, fs.realpathSync(executable)) + const catalog = await store.catalog() + assert.equal(catalog[0].version, 'review-agent 1.0.0') + assert.equal('executable' in catalog[0], false) + assert.equal('args' in catalog[0], false) +}) + +test('rejects secret-like fixed arguments and invalid executable selections', (t) => { + const { directory, executable, store } = fixture(t) + + assert.throws(() => store.create({ + label: 'Unsafe Agent', + args: ['--api-key=sk-example0123456789'], + promptMode: 'stdin', + }, executable), { code: 'CUSTOM_AGENT_SECRET_ARGUMENT_BLOCKED' }) + assert.throws(() => store.create({ + label: 'Missing Agent', + args: [], + promptMode: 'stdin', + }, path.join(directory, 'missing')), { code: 'CUSTOM_AGENT_EXECUTABLE_INVALID' }) + assert.deepEqual(store.list(), []) +}) + +test('runs a Custom Agent without a shell and redacts its private executable path', async (t) => { + const { executable, store } = fixture(t) + store.create({ + label: 'Review Agent', + args: ['review', '--format=text'], + promptMode: 'argument', + }, executable) + let invocation + const child = new EventEmitter() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.stdin = new PassThrough() + child.kill = () => true + const spawnFn = (command, args, options) => { + invocation = { command, args, options } + setImmediate(() => { + child.stdout.write(`Completed with ${command}`) + child.emit('close', 0) + }) + return child + } + + const result = await store.run('custom-0123456789abcdef', 'Review this change', '/tmp', { + spawnFn, + }) + + assert.equal(invocation.command, fs.realpathSync(executable)) + assert.deepEqual(invocation.args, ['review', '--format=text', 'Review this change']) + assert.equal(invocation.options.shell, false) + assert.equal(invocation.options.cwd, '/tmp') + assert.equal(result.text, 'Completed with review-agent') + assert.equal(result.sessionRef, '') +}) + +test('cancels the Custom Agent process through the shared AbortSignal', async (t) => { + const { executable, store } = fixture(t) + store.create({ label: 'Review Agent', args: [], promptMode: 'stdin' }, executable) + const child = new EventEmitter() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.stdin = new PassThrough() + child.kill = () => { + setImmediate(() => child.emit('close', null)) + return true + } + const controller = new AbortController() + const run = store.run('custom-0123456789abcdef', 'Review this change', '/tmp', { + signal: controller.signal, + spawnFn: () => child, + }) + controller.abort() + + await assert.rejects(run, { code: 'LOCAL_AGENT_EXECUTION_STOPPED' }) +}) diff --git a/desktop/test/local-agent-readiness.test.cjs b/desktop/test/local-agent-readiness.test.cjs index a17e469..ec13005 100644 --- a/desktop/test/local-agent-readiness.test.cjs +++ b/desktop/test/local-agent-readiness.test.cjs @@ -54,6 +54,7 @@ test('native readiness recognizes credential files for every supported CLI', () ['qwen', ['.qwen', 'oauth_creds.json'], { access_token: 'qwen-secret' }], ['gemini', ['.gemini', 'oauth_creds.json'], { refresh_token: 'gemini-secret' }], ['opencode', ['.local', 'share', 'opencode', 'auth.json'], { openai: { token: 'opencode-secret' } }], + ['opencodereview', ['.opencodereview', 'config.json'], { llm: { auth_token: 'ocr-secret' } }], ] try { for (const [, segments, value] of fixtures) { @@ -93,6 +94,61 @@ test('Claude readiness uses the official auth status without exposing OAuth data } }) +test('OpenClaw readiness uses the official model status when auth is stored outside its config', async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-openclaw-readiness-')) + const calls = [] + try { + const result = await resolveNativeCredentialState('openclaw', { + home, + env: { PATH: '/usr/bin', ROUNDRELAY_PRIVATE_VALUE: 'desktop-private-value' }, + executable: '/tmp/openclaw', + execFileFn: async (command, args, options) => { + calls.push({ command, args, env: options.env }) + return { + stdout: JSON.stringify({ + defaultModel: 'provider/model', + resolvedDefault: 'provider/model', + auth: { missingProvidersInUse: [], unusableProfiles: ['unused-expired-profile'] }, + }), + } + }, + }) + + assert.deepEqual(result, { state: 'ready', source: 'native-auth-status' }) + assert.deepEqual(calls[0].args, ['models', 'status', '--check', '--json']) + assert.equal(calls[0].env.ROUNDRELAY_PRIVATE_VALUE, undefined) + } finally { + fs.rmSync(home, { recursive: true, force: true }) + } +}) + +test('OpenClaw model status overrides a stale gateway token without exposing auth details', async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-openclaw-stale-auth-')) + const configPath = path.join(home, '.openclaw', 'openclaw.json') + fs.mkdirSync(path.dirname(configPath), { recursive: true }) + fs.writeFileSync(configPath, JSON.stringify({ gateway: { auth: { token: 'gateway-only-token' } } })) + try { + const error = Object.assign(new Error('missing provider auth'), { + stdout: JSON.stringify({ + defaultModel: 'provider/model', + resolvedDefault: 'provider/model', + auth: { missingProvidersInUse: ['provider'], unusableProfiles: [] }, + }), + }) + const result = await resolveNativeCredentialState('openclaw', { + home, + env: {}, + executable: '/tmp/openclaw', + execFileFn: async () => { throw error }, + }) + + assert.deepEqual(result, { state: 'missing', source: 'native-auth-status' }) + assert.equal(JSON.stringify(result).includes('gateway-only-token'), false) + } finally { + fs.rmSync(home, { recursive: true, force: true }) + } +}) + test('MiMo readiness relies on the installed CLI and does not inspect private auth storage', async () => { assert.deepEqual(await resolveNativeCredentialState('mimo', {}), { state: 'ready', source: 'native-cli', diff --git a/desktop/test/local-workspace.test.cjs b/desktop/test/local-workspace.test.cjs index c1a4126..8d706fb 100644 --- a/desktop/test/local-workspace.test.cjs +++ b/desktop/test/local-workspace.test.cjs @@ -89,6 +89,40 @@ test('installed Agents distinguish ready, unverified, and missing credential sta assert.equal(readinessAgents.find(agent => agent.kind === 'kimi').executable, '/tmp/kimi') }) +test('Custom Agent kinds keep their dynamic label across execution and reload', async (t) => { + const { directory, calls, options } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const kind = 'custom-0123456789abcdef' + options.detectAgents = async () => [{ + kind, + name: 'Repository Reviewer CLI', + executable: '/tmp/review-agent', + version: '1.0.0', + custom: true, + }] + options.agentLabel = selectedKind => selectedKind === kind ? 'Repository Reviewer' : '' + const workspace = new LocalWorkspace(options) + await workspace.refreshAgents() + const group = workspace.createGroup({ + conversationType: 'direct', + directAgentKind: kind, + agentKinds: [kind], + workdir: directory, + }) + + await workspace.sendMessage({ groupId: group.id, text: 'Review this change', targetKinds: [kind] }) + + assert.match(calls[0].prompt, /as Repository Reviewer\./) + const reply = workspace.snapshot().messages.find(message => message.role === 'agent') + assert.equal(reply.agentKind, kind) + assert.equal(reply.senderName, 'Repository Reviewer') + + const reloaded = new LocalWorkspace(options) + assert.equal(reloaded.snapshot().groups[0].directAgentKind, kind) + assert.equal(reloaded.snapshot().messages.find(message => message.role === 'agent').senderName, + 'Repository Reviewer') +}) + test('native readiness remains visible while a Meldwork Provider profile is active', async (t) => { const { directory, options } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) @@ -490,6 +524,124 @@ test('image-only messages persist safe metadata and pass resolved paths to suppo assert.deepEqual(restored.snapshot().messages[0].attachments, [attachment]) }) +test('message deletion persists replies individually and removes a whole group topic from history', async (t) => { + const { directory, options } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const workspace = new LocalWorkspace(options) + await workspace.refreshAgents() + const group = workspace.createGroup({ + name: 'Message cleanup', agentKinds: ['codex', 'hermes'], workdir: directory, + }) + const root = workspace.addMessage(group.id, 'user', 'Remove this topic', '', '', null, { + attachments: [{ + id: 'attachment-root', name: 'root.png', mimeType: 'image/png', size: 10, + }], + }) + const reply = workspace.addMessage(group.id, 'agent', 'Remove only this reply', 'codex', root.id, null, { + attachments: [{ + id: 'attachment-reply', name: 'reply.png', mimeType: 'image/png', size: 10, + }], + }) + const retainedReply = workspace.addMessage(group.id, 'agent', 'Remove with the topic', 'hermes', root.id) + const topicStatus = workspace.addMessage( + group.id, + 'system', + 'Topic stopped', + '', + root.id, + { key: 'system.autoStopped', params: {} }, + ) + const otherRoot = workspace.addMessage(group.id, 'user', 'Keep this topic') + const otherReply = workspace.addMessage(group.id, 'agent', 'Keep this reply', 'codex', otherRoot.id) + + assert.deepEqual(workspace.deleteMessage(group.id, reply.id), { + deletedMessageIds: [reply.id], + }) + let messages = workspace.snapshot().messages + assert.equal(messages.some(message => message.id === reply.id), false) + assert.equal(messages.some(message => message.id === retainedReply.id), true) + assert.equal(new LocalWorkspace(options).snapshot().messages.some(message => message.id === reply.id), false) + + const deleted = workspace.deleteMessage(group.id, root.id) + assert.deepEqual(new Set(deleted.deletedMessageIds), new Set([ + root.id, retainedReply.id, topicStatus.id, + ])) + messages = workspace.snapshot().messages + assert.deepEqual(messages.map(message => message.id), [otherRoot.id, otherReply.id]) + assert.deepEqual( + new LocalWorkspace(options).snapshot().messages.map(message => message.id), + [otherRoot.id, otherReply.id], + ) +}) + +test('deleting a direct-chat user turn removes its inferred replies without touching later turns', async (t) => { + const { directory, options } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const workspace = new LocalWorkspace(options) + await workspace.refreshAgents() + const group = workspace.createGroup({ + conversationType: 'direct', directAgentKind: 'codex', agentKinds: ['codex'], workdir: directory, + }) + const firstRoot = workspace.addMessage(group.id, 'user', 'First turn') + const firstReply = workspace.addMessage(group.id, 'agent', 'First answer', 'codex') + const firstFailure = workspace.addMessage( + group.id, + 'system', + 'First failure', + 'codex', + '', + { key: 'system.agentCallFailed', params: { agent: 'Codex', reason: 'failed' } }, + ) + const secondRoot = workspace.addMessage(group.id, 'user', 'Second turn') + const secondReply = workspace.addMessage(group.id, 'agent', 'Second answer', 'codex') + + const deleted = workspace.deleteMessage(group.id, firstRoot.id) + + assert.deepEqual(new Set(deleted.deletedMessageIds), new Set([ + firstRoot.id, firstReply.id, firstFailure.id, + ])) + assert.deepEqual(workspace.snapshot().messages.map(message => message.id), [secondRoot.id, secondReply.id]) + assert.deepEqual( + new LocalWorkspace(options).snapshot().messages.map(message => message.id), + [secondRoot.id, secondReply.id], + ) +}) + +test('message deletion rejects active, unknown, and cross-conversation targets', async (t) => { + const { directory, options } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const workspace = new LocalWorkspace(options) + await workspace.refreshAgents() + const firstGroup = workspace.createGroup({ + name: 'First', agentKinds: ['codex'], workdir: directory, + }) + const secondGroup = workspace.createGroup({ + name: 'Second', agentKinds: ['codex'], workdir: directory, + }) + const firstMessage = workspace.addMessage(firstGroup.id, 'user', 'First message') + const secondMessage = workspace.addMessage(secondGroup.id, 'user', 'Second message') + + assert.throws( + () => workspace.deleteMessage(firstGroup.id, secondMessage.id), + { message: 'LOCAL_MESSAGE_NOT_FOUND' }, + ) + assert.throws( + () => workspace.deleteMessage(firstGroup.id, 'missing-message'), + { message: 'LOCAL_MESSAGE_NOT_FOUND' }, + ) + + workspace.activeRuns.set( + firstGroup.id, + workspace.createRunController('manual', ['codex'], firstMessage.id), + ) + assert.throws( + () => workspace.deleteMessage(firstGroup.id, firstMessage.id), + { message: 'LOCAL_GROUP_RUNNING' }, + ) + workspace.activeRuns.delete(firstGroup.id) + assert.equal(workspace.snapshot().messages.some(message => message.id === firstMessage.id), true) +}) + test('WebP attachment metadata is rejected before a message or Agent run is recorded', async (t) => { const { directory, calls, options } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) @@ -1792,6 +1944,52 @@ test('automatic dialogue continues complete rounds until every Agent agrees', as assert.equal(finished[0].mode, 'auto') }) +for (const acpKind of ['hermes', 'kimi']) { + test(`automatic dialogue starts a fresh ${acpKind} ACP session each round`, async (t) => { + const { directory, calls, options } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + options.runAgent = async (agent, prompt, workdir, runOptions) => { + calls.push({ agent, prompt, workdir, runOptions }) + const agentCallCount = calls.filter(call => call.agent.kind === agent.kind).length + if (agent.kind === acpKind) { + await runOptions.onSessionRef(`${acpKind}-acp-session-${agentCallCount}`, { transport: 'acp' }) + } + const consensus = agentCallCount > 1 ? 'agree' : 'continue' + const sessionRef = agent.kind === acpKind + ? `${acpKind}-acp-session-${agentCallCount}` + : runOptions.sessionRef || `${agent.kind}-session-${agentCallCount}` + return { + text: `${agent.kind} ${consensus}\n[[ROUNDRELAY_CONSENSUS:${consensus}]]`, + sessionRef, + } + } + const workspace = new LocalWorkspace(options) + await workspace.refreshAgents() + const group = workspace.createGroup({ + name: `${acpKind} ACP discussion`, agentKinds: ['codex', acpKind], workdir: directory, + }) + + const started = await workspace.sendMessage({ + groupId: group.id, + text: 'Check ACP session isolation', + mode: 'auto', + maxRounds: 2, + }) + assert.equal(started.started, true) + await workspace.activeRuns.get(group.id).promise + + const acpCalls = calls.filter(call => call.agent.kind === acpKind) + assert.deepEqual(acpCalls.map(call => call.runOptions.sessionRef), ['', '']) + assert.equal(acpCalls[1].runOptions.sessionTransport, '') + assert.match(acpCalls[1].prompt, new RegExp(`${acpKind} continue`)) + assert.equal( + workspace.state.sessions[workspace.sessionKey(group.id, acpKind)], + `${acpKind}-acp-session-2`, + ) + assert.equal(workspace.state.sessionMeta[workspace.sessionKey(group.id, acpKind)].transport, 'acp') + }) +} + test('automatic dialogue queues only the explicitly targeted group members', async (t) => { const { directory, calls, options } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) diff --git a/desktop/test/main-security.test.cjs b/desktop/test/main-security.test.cjs index 6d505f3..bd573c6 100644 --- a/desktop/test/main-security.test.cjs +++ b/desktop/test/main-security.test.cjs @@ -14,6 +14,7 @@ const PROVIDER_METADATA = Object.freeze({ }) const PROVIDER_AGENT_KINDS = Object.freeze([ 'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen', + 'opencodereview', ]) function pngHeader(width = 1, height = 1) { @@ -32,6 +33,7 @@ const LOCAL_IPC_CHANNELS = Object.freeze([ 'local-workspace:create-group', 'local-workspace:update-group', 'local-workspace:delete-group', + 'local-workspace:delete-message', 'local-workspace:send', 'local-workspace:stop', 'local-workspace:pick-directory', @@ -42,8 +44,10 @@ const LOCAL_IPC_CHANNELS = Object.freeze([ 'local-agent-installer:start', 'local-agent-installer:cancel', 'local-agent-installer:set-sidebar-visibility', - 'local-attachments:pick-images', - 'local-attachments:import-image', + 'local-custom-agent:create', + 'local-custom-agent:delete', + 'local-attachments:pick', + 'local-attachments:import', 'local-attachments:preview', 'local-attachments:discard', 'local-agent-provider:status', @@ -85,16 +89,20 @@ function loadMain(userData, options = {}) { const windows = [] const workspaceInstances = [] const installerInstances = [] + const customAgentStoreInstances = [] const providerInstances = [] const knowledgeBaseStoreInstances = [] const knowledgeBaseResolveCalls = [] const attachmentInstances = [] const skillCatalogInstances = [] const runAgentCalls = [] + const customAgentRunCalls = [] const externalUrls = [] const dialogCalls = [] const permissionHandlers = {} const nativeImageCalls = [] + const nativeImagePathCalls = [] + const dockIconCalls = [] const notificationInstances = [] const protocolHandlers = new Map() const registeredSchemes = [] @@ -118,6 +126,7 @@ function loadMain(userData, options = {}) { this.maxConcurrentRefreshes = 0 this.clearRuntimeCredentialFailuresCount = 0 this.stopCount = 0 + this.deleteMessageCalls = [] workspaceInstances.push(this) } @@ -144,6 +153,22 @@ function loadMain(userData, options = {}) { this.state.groups = this.state.groups.filter(group => group.id !== groupId) this.state.messages = this.state.messages.filter(message => message.groupId !== groupId) } + deleteMessage(groupId, messageId) { + this.deleteMessageCalls.push([groupId, messageId]) + const target = this.state.messages.find(message => ( + message.groupId === groupId && message.id === messageId + )) + if (!target) throw new Error('LOCAL_MESSAGE_NOT_FOUND') + const deletedIds = new Set([target.id]) + if (target.role === 'user' && !target.threadRootId) { + for (const message of this.state.messages) { + if (message.groupId === groupId && message.threadRootId === target.id) { + deletedIds.add(message.id) + } + } + } + this.state.messages = this.state.messages.filter(message => !deletedIds.has(message.id)) + } sendMessage() { return Promise.resolve(this.snapshot()) } startAuto() { return this.snapshot() } stop() { return false } @@ -186,6 +211,78 @@ function loadMain(userData, options = {}) { } } + class TestCustomAgentStore { + constructor(input) { + this.input = input + this.created = [] + this.removed = [] + this.detectCount = 0 + this.agents = structuredClone(options.customAgents || []) + customAgentStoreInstances.push(this) + } + + has(kind) { + return this.agents.some(agent => agent.kind === kind) + } + + label(kind) { + return this.agents.find(agent => agent.kind === kind)?.label || '' + } + + async detectAgents() { + this.detectCount += 1 + return this.agents.filter(agent => agent.installed !== false).map(agent => ({ + ...agent, + executable: agent.executable || `/private/${agent.commandName || 'custom-agent'}`, + })) + } + + async catalog() { + return this.agents.map(({ executable: _executable, args: _args, ...agent }) => ({ + installed: agent.installed !== false, + installSupported: false, + providerCompatible: false, + providerMode: 'custom', + imageAttachmentLimit: 0, + ...agent, + custom: true, + })) + } + + create(input, executable) { + this.created.push({ input, executable }) + const agent = { + kind: 'custom-0123456789abcdef', + label: String(input?.label || ''), + description: String(input?.description || ''), + commandName: path.basename(executable), + promptMode: input?.promptMode, + custom: true, + installed: true, + installSupported: false, + providerCompatible: false, + providerMode: 'custom', + imageAttachmentLimit: 0, + version: '1.0.0', + executable, + } + this.agents.push(agent) + const { executable: _privatePath, ...profile } = agent + return profile + } + + remove(kind) { + this.removed.push(kind) + this.agents = this.agents.filter(agent => agent.kind !== kind) + return true + } + + run(...args) { + customAgentRunCalls.push(args) + return Promise.resolve({ text: 'custom reply', sessionRef: '' }) + } + } + class TestAttachmentStore { constructor(input) { attachmentConstructionCount += 1 @@ -419,6 +516,13 @@ function loadMain(userData, options = {}) { } TestNotification.isSupported = () => options.notificationSupported !== false + const iconImage = { + isEmpty: () => false, + getSize: () => ({ width: 512, height: 512 }), + resize: () => iconImage, + toDataURL: () => 'data:image/png;base64,AQID', + } + const electron = { app: { getPath: name => name === 'documents' @@ -434,6 +538,9 @@ function loadMain(userData, options = {}) { return options.singleInstanceLock !== false }, whenReady: () => ({ then: listener => { readyCallback = listener } }), + dock: { + setIcon: icon => dockIconCalls.push(icon), + }, }, BrowserWindow: TestBrowserWindow, Notification: TestNotification, @@ -462,6 +569,10 @@ function loadMain(userData, options = {}) { }, }, nativeImage: { + createFromPath: filename => { + nativeImagePathCalls.push(filename) + return iconImage + }, createFromBuffer: (bytes) => { nativeImageCalls.push(bytes) const image = { @@ -497,6 +608,7 @@ function loadMain(userData, options = {}) { }, }, './agent-installer.cjs': { AgentInstaller: TestInstaller }, + './custom-agent-store.cjs': { CustomAgentStore: TestCustomAgentStore }, './attachment-store.cjs': { AttachmentStore: TestAttachmentStore }, './local-agent-readiness.cjs': { nativeCredentialEnvironment: kind => options.nativeEnvironment?.(kind) || {}, @@ -560,7 +672,11 @@ function loadMain(userData, options = {}) { ipcHandlers, ipcListeners, installerInstances, + customAgentStoreInstances, + customAgentRunCalls, + dockIconCalls, nativeImageCalls, + nativeImagePathCalls, notificationInstances, knowledgeBaseResolveCalls, knowledgeBaseStoreInstances, @@ -701,6 +817,8 @@ test('ready activates one fixed local workspace and loads the bundled frontend', assert.equal(harness.workspaceInstances[0].refreshCount, 0) assert.equal(harness.providerInstances[0].input.storagePath, path.join(directory, 'roundrelay-provider.json')) + assert.equal(harness.customAgentStoreInstances[0].input.storagePath, + path.join(directory, 'roundrelay-custom-agents.json')) assert.deepEqual( [...harness.providerInstances[0].input.allowedKinds].sort(), [...PROVIDER_AGENT_KINDS].sort(), @@ -713,12 +831,17 @@ test('ready activates one fixed local workspace and loads the bundled frontend', path.join(directory, 'Home')) await harness.workspaceInstances[0].input.detectAgents() assert.equal(harness.installerInstances[0].detectedCount, 1) + assert.equal(harness.customAgentStoreInstances[0].detectCount, 1) assert.equal(typeof harness.workspaceInstances[0].input.resolveAttachments, 'function') assert.equal(typeof harness.workspaceInstances[0].input.validateSkillSelections, 'function') assert.equal(typeof harness.workspaceInstances[0].input.validateKnowledgeBaseSelections, 'function') assert.equal(typeof harness.workspaceInstances[0].input.imageAttachmentLimit, 'function') + assert.equal(typeof harness.workspaceInstances[0].input.agentLabel, 'function') assert.deepEqual(harness.attachmentInstances[0].cleanupCalls, [[]]) assert.equal(harness.windows.length, 1) + assert.equal(harness.windows[0].input.icon?.isEmpty?.(), false) + assert.match(harness.nativeImagePathCalls[0], /frontend[\\/]public[\\/]logos[\\/]meldwork-app\.png$/) + if (process.platform === 'darwin') assert.equal(harness.dockIconCalls.length, 1) assert.equal( harness.windows[0].input.backgroundColor, process.platform === 'darwin' ? '#e7edef' : '#f3f6f8', @@ -740,6 +863,104 @@ test('ready activates one fixed local workspace and loads the bundled frontend', assert.equal(harness.ipcListeners.size, 0) }) +test('Custom Agent IPC keeps executable paths private and refreshes the local catalog', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-custom-agent-ipc-')) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const selectedExecutable = '/private/tools/review-agent' + const { harness } = loadMain(directory, { + dialogResult: { canceled: false, filePaths: [selectedExecutable] }, + }) + await harness.ready() + + const result = await harness.ipcHandlers.get('local-custom-agent:create')( + harness.event(), + { + label: 'Review Agent', + description: 'Reviews repository changes.', + args: ['review', '--format=text'], + promptMode: 'stdin', + executable: '/renderer/must-not-control-this', + }, + ) + + assert.equal(result.canceled, false) + assert.equal(result.agent.kind, 'custom-0123456789abcdef') + assert.equal(result.agent.commandName, 'review-agent') + assert.equal('executable' in result.agent, false) + assert.equal('args' in result.agent, false) + assert.doesNotMatch(JSON.stringify(result), /private\/tools|renderer\/must-not-control/) + assert.deepEqual(harness.customAgentStoreInstances[0].created, [{ + input: { + label: 'Review Agent', + description: 'Reviews repository changes.', + args: ['review', '--format=text'], + promptMode: 'stdin', + }, + executable: selectedExecutable, + }]) + assert.equal(harness.workspaceInstances[0].refreshCount, 1) + + const customReply = await harness.workspaceInstances[0].input.runAgent( + { kind: result.agent.kind }, + 'Review this change', + '/tmp/project', + { signal: new AbortController().signal }, + ) + assert.deepEqual(customReply, { text: 'custom reply', sessionRef: '' }) + assert.equal(harness.customAgentRunCalls.length, 1) + assert.equal(harness.runAgentCalls.length, 0) + + const catalog = await harness.ipcHandlers.get('local-agent-installer:catalog')(harness.event()) + const custom = catalog.agents.find(agent => agent.kind === 'custom-0123456789abcdef') + assert.equal(custom.custom, true) + assert.equal(custom.commandName, 'review-agent') + assert.equal('executable' in custom, false) + assert.equal('args' in custom, false) + assert.deepEqual( + await harness.ipcHandlers.get('local-agent-installer:skills')( + harness.event(), 'custom-0123456789abcdef', + ), + { supported: false, skills: [], total: 0, limit: 0 }, + ) +}) + +test('Custom Agent deletion is blocked while a local conversation references it', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-custom-agent-delete-')) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const kind = 'custom-0123456789abcdef' + const { harness } = loadMain(directory, { + customAgents: [{ + kind, + label: 'Review Agent', + description: '', + commandName: 'review-agent', + promptMode: 'stdin', + custom: true, + installed: true, + }], + workspaceSnapshot: { + agents: [], + groups: [{ id: 'group-1', agentKinds: [kind] }], + messages: [], + localOnly: true, + }, + }) + await harness.ready() + + await assert.rejects( + harness.ipcHandlers.get('local-custom-agent:delete')(harness.event(), kind), + { message: 'CUSTOM_AGENT_IN_USE' }, + ) + assert.deepEqual(harness.customAgentStoreInstances[0].removed, []) + + harness.workspaceInstances[0].state.groups = [] + assert.deepEqual( + await harness.ipcHandlers.get('local-custom-agent:delete')(harness.event(), kind), + { deleted: true, kind }, + ) + assert.deepEqual(harness.customAgentStoreInstances[0].removed, [kind]) +}) + test('a second desktop launch restores and focuses the existing window', async (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-single-instance-focus-')) t.after(() => fs.rmSync(directory, { recursive: true, force: true })) @@ -798,10 +1019,9 @@ test('run completion uses sanitized renderer events and a localized background n }]) assert.equal(harness.notificationInstances.length, 1) const notification = harness.notificationInstances[0] - assert.deepEqual(notification.input, { - title: 'Meldwork', - body: '会话运行已结束', - }) + assert.equal(notification.input.title, 'Meldwork') + assert.equal(notification.input.body, '会话运行已结束') + assert.equal(notification.input.icon?.isEmpty?.(), false) assert.equal(notification.showCount, 1) assert.doesNotMatch( JSON.stringify(notification.input), @@ -894,10 +1114,9 @@ test('a completion notification restores a closed window and opens its local gro }) assert.equal(harness.notificationInstances.length, 1) - assert.deepEqual(harness.notificationInstances[0].input, { - title: 'Meldwork', - body: 'Conversation run finished', - }) + assert.equal(harness.notificationInstances[0].input.title, 'Meldwork') + assert.equal(harness.notificationInstances[0].input.body, 'Conversation run finished') + assert.equal(harness.notificationInstances[0].input.icon?.isEmpty?.(), false) assert.equal(originalWindow.webContents.sent.length, 0) harness.notificationInstances[0].emit('click') @@ -930,8 +1149,8 @@ test('attachment storage initialization failure does not block text chat startup ), ) for (const [channel, args] of [ - ['local-attachments:pick-images', []], - ['local-attachments:import-image', [{ name: 'x.png', mimeType: 'image/png', bytes: [1] }]], + ['local-attachments:pick', []], + ['local-attachments:import', [{ name: 'x.png', mimeType: 'image/png', bytes: [1] }]], ['local-attachments:preview', ['attachment-1']], ['local-attachments:discard', [['attachment-1']]], ]) { @@ -1000,7 +1219,7 @@ test('startup attachment cleanup failure keeps the validated store available', a assert.deepEqual(harness.attachmentInstances[0].cleanupCalls, [[]]) await assert.doesNotReject( - harness.ipcHandlers.get('local-attachments:pick-images')(harness.event(), 1), + harness.ipcHandlers.get('local-attachments:pick')(harness.event(), 1), ) }) @@ -1279,7 +1498,7 @@ test('image IPC returns bounded previews and never exposes imported file paths', }) await harness.ready() - const picked = await harness.ipcHandlers.get('local-attachments:pick-images')( + const picked = await harness.ipcHandlers.get('local-attachments:pick')( harness.event(), 2, ) assert.equal(picked.attachments.length, 2) @@ -1287,7 +1506,7 @@ test('image IPC returns bounded previews and never exposes imported file paths', assert.deepEqual(harness.attachmentInstances[0].importedFiles, [first, second]) assert.deepEqual(harness.dialogCalls[0][1], { properties: ['openFile', 'multiSelections'], - filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg'] }], + filters: [{ name: 'Media', extensions: ['png', 'jpg', 'jpeg', 'mp3', 'wav', 'm4a', 'mp4', 'mov', 'webm'] }], }) for (const attachment of picked.attachments) { assert.deepEqual(Object.keys(attachment).sort(), [ @@ -1297,7 +1516,7 @@ test('image IPC returns bounded previews and never exposes imported file paths', assert.equal(JSON.stringify(attachment).includes(directory), false) } - const imported = await harness.ipcHandlers.get('local-attachments:import-image')( + const imported = await harness.ipcHandlers.get('local-attachments:import')( harness.event(), { name: 'paste.png', mimeType: 'image/png', bytes: [1, 2, 3] }, ) @@ -1329,7 +1548,7 @@ test('image picker does not import files after application shutdown starts', asy const { harness } = loadMain(directory, { dialogResult: dialogResult.promise }) await harness.ready() - const picking = harness.ipcHandlers.get('local-attachments:pick-images')(harness.event()) + const picking = harness.ipcHandlers.get('local-attachments:pick')(harness.event()) await waitFor(() => harness.dialogCalls.length === 1) harness.appListeners.get('before-quit')({ preventDefault: () => {} }) dialogResult.resolve({ canceled: false, filePaths: [selected] }) @@ -1349,7 +1568,7 @@ test('image dimensions are bounded before nativeImage decoding and checked again await oversizedBeforeDecode.harness.ready() await assert.rejects( - async () => oversizedBeforeDecode.harness.ipcHandlers.get('local-attachments:import-image')( + async () => oversizedBeforeDecode.harness.ipcHandlers.get('local-attachments:import')( oversizedBeforeDecode.harness.event(), { name: 'large.png', mimeType: 'image/png', bytes: [1, 2, 3] }, ), @@ -1364,7 +1583,7 @@ test('image dimensions are bounded before nativeImage decoding and checked again }) await oversizedAfterDecode.harness.ready() await assert.rejects( - async () => oversizedAfterDecode.harness.ipcHandlers.get('local-attachments:import-image')( + async () => oversizedAfterDecode.harness.ipcHandlers.get('local-attachments:import')( oversizedAfterDecode.harness.event(), { name: 'decoded-large.png', mimeType: 'image/png', bytes: [1, 2, 3] }, ), @@ -1437,6 +1656,39 @@ test('deleting a conversation removes only attachments no longer referenced else assert.deepEqual(harness.attachmentInstances[0].discarded, [['only-one']]) }) +test('deleting a message normalizes identifiers and discards only newly unreferenced attachments', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-delete-message-attachments-')) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const { harness } = loadMain(directory, { + workspaceSnapshot: { + agents: [], + groups: [{ id: '42' }], + messages: [ + { + id: '7', groupId: '42', role: 'agent', + attachments: [{ id: 'shared' }, { id: 'only-message' }], + }, + { + id: '8', groupId: '42', role: 'agent', + attachments: [{ id: 'shared' }], + }, + ], + localOnly: true, + }, + }) + await harness.ready() + + const snapshot = await harness.ipcHandlers.get('local-workspace:delete-message')( + harness.event(), 42, 7, + ) + + assert.deepEqual(harness.workspaceInstances[0].deleteMessageCalls, [['42', '7']]) + assert.deepEqual(snapshot.messages, [{ + id: '8', groupId: '42', role: 'agent', attachments: [{ id: 'shared' }], + }]) + assert.deepEqual(harness.attachmentInstances[0].discarded, [['only-message']]) +}) + test('configured Provider is injected only through local Agent execution options', async (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-provider-routing-')) t.after(() => fs.rmSync(directory, { recursive: true, force: true })) diff --git a/desktop/test/openclaw-runtime.test.cjs b/desktop/test/openclaw-runtime.test.cjs index 9c66026..0989979 100644 --- a/desktop/test/openclaw-runtime.test.cjs +++ b/desktop/test/openclaw-runtime.test.cjs @@ -51,6 +51,23 @@ test('managed runtime isolates OpenClaw state and keeps the API key out of confi assert.equal(fs.statSync(result.env.OPENCLAW_CONFIG_PATH).mode & 0o777, 0o600) }) +test('managed runtime defensively normalizes chat completion endpoints to the API root', (t) => { + const { directory, workdir } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + + const result = managedOpenClawOptions({ + storageRoot: directory, + workdir, + provider: { + ...provider(), + OPENAI_BASE_URL: 'https://api.example.com/v1/chat/completions/', + }, + }) + const config = JSON.parse(fs.readFileSync(result.env.OPENCLAW_CONFIG_PATH, 'utf8')) + + assert.equal(config.models.providers['roundrelay-desktop'].baseUrl, 'https://api.example.com/v1') +}) + test('write authorization uses an immutable config and preserves the topic state', (t) => { const { directory, workdir } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) diff --git a/desktop/test/preload-security.test.cjs b/desktop/test/preload-security.test.cjs index ff75427..e1a7afb 100644 --- a/desktop/test/preload-security.test.cjs +++ b/desktop/test/preload-security.test.cjs @@ -62,13 +62,19 @@ test('local preload exposes the local-only RoundRelay API and narrow Provider me assert.deepEqual( Object.keys(api.localWorkspace).sort(), [ - 'createGroup', 'defaultDirectory', 'deleteGroup', 'get', 'onChanged', + 'createGroup', 'defaultDirectory', 'deleteGroup', 'deleteMessage', 'get', 'onChanged', 'onOpenGroup', 'onRunEvent', 'onRunFinished', 'pickDirectory', 'refreshAgents', 'send', 'stop', 'updateGroup', ].sort(), ) assert.equal('configure' in api.localWorkspace, false) assert.equal('cancelConfigure' in api.localWorkspace, false) + await api.localWorkspace.deleteMessage('group-1', 'message-1') + assert.deepEqual(invocations, [{ + channel: 'local-workspace:delete-message', + args: ['group-1', 'message-1'], + }]) + invocations.length = 0 assert.deepEqual( Object.keys(api.localAgentProvider).sort(), ['activate', 'delete', 'probe', 'save', 'status'], @@ -165,28 +171,62 @@ test('local preload exposes only the narrow Agent installer methods', async () = ]) }) +test('local preload exposes only create and delete for Custom Agents', async () => { + const { api, invocations } = loadPreload('file:') + + assert.equal(Object.isFrozen(api.customAgent), true) + assert.deepEqual(Object.keys(api.customAgent).sort(), ['create', 'delete']) + assert.equal('list' in api.customAgent, false) + assert.equal('run' in api.customAgent, false) + assert.equal('pickExecutable' in api.customAgent, false) + + await api.customAgent.create({ + label: 'Review Agent', + description: 'Reviews changes.', + args: ['review'], + promptMode: 'stdin', + }) + await api.customAgent.delete('custom-0123456789abcdef') + + assert.deepEqual(invocations, [ + { + channel: 'local-custom-agent:create', + args: [{ + label: 'Review Agent', + description: 'Reviews changes.', + args: ['review'], + promptMode: 'stdin', + }], + }, + { + channel: 'local-custom-agent:delete', + args: ['custom-0123456789abcdef'], + }, + ]) +}) + test('local preload exposes image import without filesystem read or path resolution methods', async () => { const { api, invocations } = loadPreload('file:') assert.equal(Object.isFrozen(api.localAttachments), true) assert.deepEqual( Object.keys(api.localAttachments).sort(), - ['discard', 'importImage', 'pickImages', 'preview'], + ['discard', 'importAttachment', 'pickAttachments', 'preview'], ) assert.equal('read' in api.localAttachments, false) assert.equal('resolve' in api.localAttachments, false) const bytes = Uint8Array.from([1, 2, 3]) - await api.localAttachments.pickImages(2) - await api.localAttachments.importImage({ + await api.localAttachments.pickAttachments(2) + await api.localAttachments.importAttachment({ name: 'diagram.png', mimeType: 'image/png', bytes, }) bytes[0] = 9 await api.localAttachments.preview('attachment-1') await api.localAttachments.discard(['attachment-1']) assert.deepEqual(invocations, [ - { channel: 'local-attachments:pick-images', args: [2] }, + { channel: 'local-attachments:pick', args: [2] }, { - channel: 'local-attachments:import-image', + channel: 'local-attachments:import', args: [{ name: 'diagram.png', mimeType: 'image/png', bytes: Uint8Array.from([1, 2, 3]) }], }, { channel: 'local-attachments:preview', args: ['attachment-1'] }, @@ -198,25 +238,25 @@ test('local preload rejects unbounded or unsupported renderer image payloads bef const { api, invocations } = loadPreload('file:') assert.throws( - () => api.localAttachments.importImage({ + () => api.localAttachments.importAttachment({ name: 'array.png', mimeType: 'image/png', bytes: [1, 2, 3], }), { code: 'LOCAL_ATTACHMENT_BYTES_INVALID' }, ) assert.throws( - () => api.localAttachments.importImage({ + () => api.localAttachments.importAttachment({ name: 'animation.gif', mimeType: 'image/gif', bytes: Uint8Array.from([1]), }), { code: 'LOCAL_ATTACHMENT_TYPE_UNSUPPORTED' }, ) assert.throws( - () => api.localAttachments.importImage({ + () => api.localAttachments.importAttachment({ name: 'preview.webp', mimeType: 'image/webp', bytes: Uint8Array.from([1]), }), { code: 'LOCAL_ATTACHMENT_TYPE_UNSUPPORTED' }, ) assert.throws( - () => api.localAttachments.importImage({ + () => api.localAttachments.importAttachment({ name: 'large.png', mimeType: 'image/png', bytes: new Uint8Array((8 * 1024 * 1024) + 1), }), { code: 'LOCAL_ATTACHMENT_TOO_LARGE' }, @@ -233,6 +273,7 @@ test('remote preload does not expose local credentials, workspace, or installer assert.equal('getAuthToken' in api, false) assert.equal('localWorkspace' in api, false) assert.equal('agentInstaller' in api, false) + assert.equal('customAgent' in api, false) assert.equal('localAttachments' in api, false) assert.equal('localAgentProvider' in api, false) assert.equal('localKnowledgeBase' in api, false) diff --git a/desktop/test/provider-store.test.cjs b/desktop/test/provider-store.test.cjs index a029dcb..6e1097f 100644 --- a/desktop/test/provider-store.test.cjs +++ b/desktop/test/provider-store.test.cjs @@ -25,6 +25,11 @@ const QWEN_OFFICIAL_PROVIDER = Object.freeze({ baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen3-coder-plus', }) +const OCR_OFFICIAL_PROVIDER = Object.freeze({ + provider: 'OpenAI API', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5', +}) function credential(apiKey = 'test-provider-key', metadata = PROVIDER, preset = 'custom') { return { ...metadata, apiKey, preset } @@ -55,7 +60,7 @@ function encryptedSafeStorage(available = true) { } } -function fixture(safeStorage = encryptedSafeStorage(), allowedKinds = ['hermes', 'qwen']) { +function fixture(safeStorage = encryptedSafeStorage(), allowedKinds = ['hermes', 'qwen', 'opencodereview']) { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-provider-')) const storagePath = path.join(directory, 'roundrelay-provider.json') return { @@ -165,6 +170,10 @@ test('preset metadata must match canonical OpenRouter and Agent official Provide store.save('qwen', credential('official-key', QWEN_OFFICIAL_PROVIDER, 'official')), configuredStatus(QWEN_OFFICIAL_PROVIDER, 'official'), ) + assert.deepEqual( + store.save('opencodereview', credential('official-key', OCR_OFFICIAL_PROVIDER, 'official')), + configuredStatus(OCR_OFFICIAL_PROVIDER, 'official'), + ) assert.throws( () => store.save('hermes', credential('key', { ...HERMES_OFFICIAL_PROVIDER, @@ -202,6 +211,44 @@ test('custom preset keeps accepting secure arbitrary and loopback endpoints', (t }, 'custom'))) }) +for (const kind of [ + 'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', + 'claude', 'gemini', 'opencode', 'qwen', 'opencodereview', +]) { + test(`${kind} Provider endpoints are stored as API roots`, (t) => { + const { directory, store } = fixture(encryptedSafeStorage(), [kind]) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + + const status = store.save(kind, credential(`${kind}-key`, { + ...PROVIDER, + baseUrl: 'https://api.example.com/v1/chat/completions/', + })) + + assert.equal(status.baseUrl, 'https://api.example.com/v1') + assert.equal(status.profiles.custom.baseUrl, 'https://api.example.com/v1') + assert.equal(store.envForAgent(kind).OPENAI_BASE_URL, 'https://api.example.com/v1') + }) + + test(`legacy ${kind} profiles normalize duplicated chat completion endpoints on read`, (t) => { + const { directory, storagePath, store } = fixture(encryptedSafeStorage(), [kind]) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const encrypted = encryptedSafeStorage().encryptString(`legacy-${kind}-key`).toString('base64') + fs.writeFileSync(storagePath, JSON.stringify({ + version: 2, + agents: { + [kind]: { + ...PROVIDER, + baseUrl: 'https://api.example.com/v1/chat/completions', + encrypted, + }, + }, + }), { mode: 0o600 }) + + assert.equal(store.status(kind).baseUrl, 'https://api.example.com/v1') + assert.equal(store.envForAgent(kind).OPENAI_BASE_URL, 'https://api.example.com/v1') + }) +} + test('envForAgent decrypts the configured Provider', (t) => { const { directory, store } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) @@ -349,11 +396,12 @@ test('legacy shared credentials migrate into independent Agent entries on mutati assert.equal(store.status('hermes').configured, true) assert.equal(store.status('qwen').configured, true) + assert.equal(store.status('opencodereview').configured, true) store.save('qwen', credential('new-qwen-key', { ...PROVIDER, model: 'new-qwen-model' })) const migrated = JSON.parse(fs.readFileSync(storagePath, 'utf8')) assert.equal(migrated.version, 3) - assert.deepEqual(Object.keys(migrated.agents).sort(), ['hermes', 'qwen']) + assert.deepEqual(Object.keys(migrated.agents).sort(), ['hermes', 'opencodereview', 'qwen']) assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'legacy-key') assert.equal(store.envForAgent('qwen').OPENAI_API_KEY, 'new-qwen-key') }) diff --git a/docs/automation.md b/docs/automation.md index 91246c9..8fd57ae 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -28,7 +28,7 @@ Meldwork embeds local Agent automation and an Agent installer. There are no webh **Executable/tool surface:** -- Main may spawn only detected executables for Codex, Hermes, OpenClaw, WorkBuddy, Kimi Code, MiMo Code, Claude Code, Qwen Code, Gemini CLI, and OpenCode. +- Main may spawn only detected executables for Codex, Hermes, OpenClaw, WorkBuddy, Kimi Code, MiMo Code, Claude Code, Qwen Code, Gemini CLI, OpenCode, and OpenCodeReview. - Command shapes are code-defined in `desktop/src/cli-adapters.cjs`; prompt text does not select an arbitrary executable or shell command. - Kimi read-only conversations use Agent Client Protocol `plan` mode. Write-authorized conversations retain Kimi's native stream-JSON prompt mode. - OpenClaw receives an explicit tool allow/deny configuration. Execution, process, browser, gateway, cron, messaging, session spawning, and subagent tools are denied; write/edit/apply-patch are added only when the conversation permits writes. diff --git a/docs/variables.md b/docs/variables.md index 48d5d2b..c1c1c48 100644 --- a/docs/variables.md +++ b/docs/variables.md @@ -30,6 +30,7 @@ Meldwork detects and forwards only credential variables associated with the sele | Qwen Code | `DASHSCOPE_API_KEY`, `OPENAI_API_KEY` | | Gemini CLI | `GEMINI_API_KEY`, `GOOGLE_API_KEY` | | OpenCode | `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY` | +| OpenCodeReview | `OCR_LLM_TOKEN`, `OPENAI_API_KEY` | These credentials are owned and rotated through the upstream CLI/provider, not Meldwork. Readiness checks report only `ready`, `missing`, or `unknown`; they do not return credential values. @@ -42,6 +43,7 @@ Main derives these at invocation time; users should not set them as Meldwork con | `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | Normalized configured Provider values for compatible Agents | | `HERMES_INFERENCE_MODEL` | Mirrors the configured model for Hermes | | `CODEBUDDY_MODEL`, `CODEBUDDY_API_KEY`, `CODEBUDDY_BASE_URL` | WorkBuddy Provider mapping | +| `OCR_LLM_URL`, `OCR_LLM_TOKEN`, `OCR_LLM_MODEL`, `OCR_USE_ANTHROPIC` | OpenCodeReview Provider mapping | | `ROUNDRELAY_OPENCLAW_API_KEY` | Secret environment reference used by generated OpenClaw config | | `OPENCLAW_HOME`, `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, `OPENCLAW_WORKSPACE_DIR` | Isolated managed OpenClaw runtime paths | | `HERMES_EXEC_ASK`, `HERMES_YOLO_MODE`, `OPENCODE_PERMISSION` | Runtime safety overrides | diff --git a/frontend/public/agent-logos/custom-agent.svg b/frontend/public/agent-logos/custom-agent.svg new file mode 100644 index 0000000..31af24d --- /dev/null +++ b/frontend/public/agent-logos/custom-agent.svg @@ -0,0 +1,7 @@ + + Custom Agent + + + + + diff --git a/frontend/public/agent-logos/opencodereview.svg b/frontend/public/agent-logos/opencodereview.svg new file mode 100644 index 0000000..3716f1e --- /dev/null +++ b/frontend/public/agent-logos/opencodereview.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c77492c..d724d82 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -118,7 +118,7 @@ {{ formatNavTime(group.updatedAt || group.createdAt) }} - + @@ -191,7 +191,7 @@ {{ groupName(group) }} - + @@ -239,6 +239,26 @@ + + + + + {{ groupGroups.length > 99 ? '99+' : groupGroups.length }} + + + +