diff --git a/AGENTS.md b/AGENTS.md index 8d6bcb3..4e6b304 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ - Meldwork is a local-first desktop application. Do not introduce a required server, container, or cluster dependency unless the task explicitly calls for it. - Keep Agent execution in the Electron main process. Renderer access must go through narrow, validated preload APIs. -- Never expose executable paths, tokens, provider credentials, or unrestricted shell execution to renderer code. +- Never expose executable paths, tokens, credentials, or unrestricted shell execution to renderer code. - Store secrets only through operating-system-backed secure storage. Do not add plaintext fallbacks. - Keep workspace write access opt-in. Conversations and groups stay local; do not add remote participants, cloud channels, or remote conversation storage. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29fbf2c..b386b8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ Unless a separate written agreement applies, a contribution accepted into this r - Keep changes small and tied to a clear product or engineering need. - Do not submit secrets, tokens, private logs, private prompts, or user data. - Do not add remote services, telemetry, credential collection, or server dependencies without explicit project approval. -- Preserve the local-first boundary: renderer code must not gain unrestricted filesystem, shell, Provider credential, or executable-path access. +- Preserve the local-first boundary: renderer code must not gain unrestricted filesystem, shell, credential, or executable-path access. - Include focused tests for behavior, security boundary, storage, or IPC changes. - Confirm that all submitted code and assets are yours to contribute and do not introduce incompatible license obligations. diff --git a/README.md b/README.md index 04621ac..2ae2a4d 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ npm --prefix desktop test ## Current boundary -Meldwork is a local Electron desktop app for multi-agent orchestration and review — not a hosted agent fleet or a general agent framework. The current preview does not provide remote/cloud agent execution, automatic participant selection, enterprise SSO/RBAC/governance, or an Outcome Network. Local-first does not mean fully offline: a selected Agent may send prompts, attachments, or Skills to its configured Provider. Workspace writes are opt-in workflow controls, not an operating-system sandbox. +Meldwork is a local Electron desktop app for multi-agent orchestration and review — not a hosted agent fleet or a general agent framework. The current preview does not provide remote/cloud agent execution, automatic participant selection, enterprise SSO/RBAC/governance, or an Outcome Network. Local-first does not mean fully offline: a selected Agent may send prompts, attachments, or Skills to its own CLI credentials' model provider. Workspace writes are opt-in workflow controls, not an operating-system sandbox. ## Docs diff --git a/README.zh-CN.md b/README.zh-CN.md index a43820e..e76ac29 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -137,7 +137,7 @@ npm --prefix desktop test ## 当前边界 -Meldwork 是本地 Electron 应用,不是托管式 Agent Fleet,也不是通用 Agent 框架。当前预览版不提供远程、云端或频道 Agent 执行,不提供自动选人组队、企业 SSO/RBAC/治理或 Outcome Network。Local-first 不等于完全离线:选定的 Agent 仍可能把 Prompt、附件或 Skill(技能)发送到其配置的 Provider(模型服务商)。工作区写入是可选的工作流控制,不是操作系统级沙箱。 +Meldwork 是本地 Electron 应用,不是托管式 Agent Fleet,也不是通用 Agent 框架。当前预览版不提供远程、云端或频道 Agent 执行,不提供自动选人组队、企业 SSO/RBAC/治理或 Outcome Network。Local-first 不等于完全离线:选定的 Agent 仍可能把 Prompt、附件或 Skill(技能)发送到其自身 CLI 凭据对应的模型服务商。工作区写入是可选的工作流控制,不是操作系统级沙箱。 ## 文档 diff --git a/SECURITY.md b/SECURITY.md index c441798..567eeca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -Meldwork is a local-first desktop application that invokes local Agent CLIs, stores local conversations, imports validated image, media, document, code, and archive attachments, and manages Provider credentials through the Electron main process. +Meldwork is a local-first desktop application that invokes local Agent CLIs, stores local conversations, imports validated image, media, document, code, and archive attachments, and runs each Agent with its own native CLI credentials through the Electron main process. ## Reporting @@ -11,10 +11,10 @@ Do not open a public issue with exploit details, secrets, local file paths, or p ## In Scope - Renderer-to-main IPC authorization bypasses. -- Exposure of executable paths, attachment paths, native session references, Provider API keys, or Skill file paths to renderer code. +- Exposure of executable paths, attachment paths, native session references, CLI API keys, or Skill file paths to renderer code. - Arbitrary shell execution, arbitrary file read/write, unsafe navigation, or unsafe installer behavior. - Attachment validation, private storage, checksum, or preview isolation failures. -- Credential storage or Provider configuration failures that expose secrets. +- Credential storage failures that expose secrets. ## Out of Scope diff --git a/architecture.md b/architecture.md index 08f05a3..177c4e9 100644 --- a/architecture.md +++ b/architecture.md @@ -11,8 +11,8 @@ The supported product surface is the packaged Electron application. The frontend | Component | Responsibility | Primary evidence | | --- | --- | --- | | Vue renderer | Agent-first multi-session UI, direct-session rename/delete, live direct-chat conclusions and inline execution summaries, group-chat result streaming with an expandable per-Agent/right-side trace panel, per-round navigation, background completion markers, first-run carousel, conversation/Skill/image/knowledge composer UI, bounded attachment-preview cache, theme/i18n, user confirmation and input collection | `frontend/src/App.vue`, `frontend/src/components/RunTracePanel.vue`, `frontend/src/composables/useAttachmentPreviews.js`, `frontend/src/desktop.js`, `frontend/src/knowledgeBaseCatalog.js` | -| Electron preload | Exposes narrow workspace, Human Gate, Cloud control, installer/Skill, attachment, Provider, and knowledge-source methods through `window.meldworkDesktop` only to a local `file:` document; Channel ingress has no renderer API | `desktop/src/preload.cjs` | -| Electron main | Trust boundary for IPC authorization and shutdown gating, persistence, attachment import/preview, Connector registration, Skill and knowledge-source validation, local/Cloud execution, Channel receivers, navigation, sanitized run events, operating-system notifications, Provider storage, and installer control | `desktop/src/main.cjs` | +| Electron preload | Exposes narrow workspace, Human Gate, Cloud control, installer/Skill, attachment, and knowledge-source methods through `window.meldworkDesktop` only to a local `file:` document; Channel ingress has no renderer API | `desktop/src/preload.cjs` | +| Electron main | Trust boundary for IPC authorization and shutdown gating, persistence, attachment import/preview, Connector registration, Skill and knowledge-source validation, local/Cloud execution, Channel receivers, navigation, sanitized run events, operating-system notifications, and installer control | `desktop/src/main.cjs` | | Local workspace | Persists Meldwork conversations, final replies, compact evidence capsules, safe message metadata, task-scoped group Sessions, conversation-scoped direct Sessions, legacy Session migration, Agent-specific transcript deltas, Agent preferences/runtime evidence, and active run coordination | `desktop/src/workspace/local-workspace.cjs` | | V4 orchestration | Validates frozen snapshots, phase-aware slots, typed collaboration receipts, negotiated responsibility graphs, work receipts, synthesis recovery, verification, and idempotent commits | `desktop/src/collaboration/orchestration-v4-records.cjs`, `desktop/src/workspace/local-workspace-message-submission.cjs`, `desktop/src/workspace/local-workspace-auto-runner.cjs` | | Run Ledger | Atomically checkpoints bounded, sanitized per-run and per-Agent state for stop/crash recovery, marks nonterminal runs and Agent attempts interrupted after restart, supports idempotent reconciliation of missing terminal Agent messages, and purges records with their conversation | `desktop/src/runs/run-ledger.cjs` | @@ -30,7 +30,6 @@ The supported product surface is the packaged Electron application. The frontend | Knowledge-source store | Persists only the user-selected Obsidian Vault path; Feishu and DingTalk credentials remain owned by their local CLIs | `desktop/src/knowledge/knowledge-base-store.cjs` | | CLI adapters | Detect and invoke supported local Agent executables with per-Agent arguments and constrained child environments | `desktop/src/agents/cli/cli-adapters.cjs` | | Agent installer | Detects Agents and runs allowlisted npm packages or official installer scripts after user confirmation | `desktop/src/agents/installer/agent-installer.cjs` | -| Provider store | Validates Provider metadata and encrypts the API key with Electron `safeStorage` | `desktop/src/providers/provider-store.cjs` | | Managed OpenClaw runtime | Creates isolated local configuration and a restricted tool policy for OpenClaw | `desktop/src/agents/cli/openclaw-runtime.cjs` | ## Concurrent Collaboration V4 @@ -45,11 +44,11 @@ Automatic participant selection from a larger roster, production remote Agents, ## Trust Boundaries -1. The renderer is not trusted with Node.js, Agent executable paths, native Agent session IDs or storage paths, attachment paths, Skill paths/contents, raw Provider credentials after saving, arbitrary filesystem reads, or arbitrary process execution. User-selected conversation workdirs and the user-selected Obsidian Vault path are intentional UI-visible paths; pasted image bytes use one exact import payload. +1. The renderer is not trusted with Node.js, Agent executable paths, native Agent session IDs or storage paths, attachment paths, Skill paths/contents, raw CLI credentials, arbitrary filesystem reads, or arbitrary process execution. User-selected conversation workdirs and the user-selected Obsidian Vault path are intentional UI-visible paths; pasted image bytes use one exact import payload. 2. Preload exposes only named IPC methods. Main validates that every request comes from the current main frame and exact bundled frontend file. 3. Main owns local files, OS dialogs, attachment validation/storage, Skill and knowledge-source discovery/revalidation, Connector approval/registration, Agent discovery, child processes, Cloud observation, Channel authentication, installer downloads, and credential encryption. 4. Agent CLIs are external processes. They receive a selected working directory, a constrained environment, permission flags, main-resolved image paths, target-scoped immutable Skill snapshots, and validated knowledge snapshots or read-only hints after capability checks. -5. Provider, Agent Connector, Cloud Connector, Channel Connector, and installer destinations are external trust boundaries. Provider URLs are user supplied and validated; manifests and Connector instances fail closed unless explicitly approved; Channel signatures and Workspace scope are verified before Task delivery. +5. Agent Connector, Cloud Connector, Channel Connector, and installer destinations are external trust boundaries. Manifests and Connector instances fail closed unless explicitly approved; Channel signatures and Workspace scope are verified before Task delivery. 6. Harness events expose only allowlisted status, conclusion deltas, reasoning summaries, plans, tool lifecycle summaries, bounded result metadata, and source message IDs. Raw chain-of-thought, commands, executable paths, native session references, credentials, unrestricted stdout/stderr, and arbitrary tool payloads remain outside the renderer and shared Agent context. ## Local Data @@ -61,7 +60,6 @@ Automatic participant selection from a larger roster, production remote Agents, | Immutable Context Packs, delivery records, content blobs, typed outcomes, and Human Gates | Electron user data under `meldwork-private/` | Content-addressed or atomically written main-only records; renderer snapshots expose only bounded IDs, provenance summaries, budgets, and pending Gate choices | | Cloud operations and Channel Inbox | Electron user data under `meldwork-private/` | Private `0600` stores keep opaque remote references, idempotency state, signed-event-derived Task data, delivery cursors, Task mappings, and outbound audit records; raw signatures, headers, credentials, and complete IM history are not exposed to the renderer | | Imported attachments | Electron user data under `attachments/` | App-owned copy; `0700` directories, `0600` files, type/MIME/size validation, image-dimension checks where applicable, metadata checksum, ID-only renderer access | -| Provider metadata and encrypted key | Electron user data: `meldwork-provider.json` | API key encrypted with `safeStorage`; file written atomically with mode `0600` | | Knowledge-source preference | Electron user data: `meldwork-knowledge-base.json` | Stores the selected absolute Obsidian Vault path only; written atomically as a private file | | Managed OpenClaw state/config | Electron user data under `openclaw-managed/` | Per-scope directories, mode `0700`; config mode `0600`; API key passed by child environment | | Theme, locale, and onboarding completion | Renderer `localStorage` | Non-secret UI preferences | @@ -74,7 +72,6 @@ There is no Meldwork-hosted remote data store or application account namespace. - Agent CLIs may call their own configured model providers. - Selected attachments and Skill-influenced prompts may be sent by an Agent CLI to its configured model provider after local validation and capability checks. -- Compatible Agents may receive the user-configured Provider URL/model/key from main. - Feishu and DingTalk readiness probes may invoke their configured local CLIs; a selected Agent may then use those CLI connections with read-only instructions. - An explicit Obsidian selection is searched and fetched through the main-owned read-only filesystem Connector, then captured as an immutable snapshot or explicit live reference before Agent invocation. - Approved Agent Connectors may contact only their declared outbound destinations and receive main-owned CredentialRefs rather than renderer-provided secrets. @@ -95,7 +92,7 @@ There is no Meldwork-hosted remote data store or application account namespace. - Obsidian selections use a main-owned read-only Connector with bounded retrieval, immutable snapshots, and citation hash verification. Generic Feishu and DingTalk access remains CLI-owned and prompt-mediated; their authentication and document permissions are not controlled by Meldwork. Notion, Confluence, Google Drive, and SharePoint are reference-only planned sources and are not active Connectors. - Native image support is currently limited to Codex, Hermes, and OpenCode, with different per-run limits; mixed groups fail before execution when they cannot receive equal image context (`desktop/src/agents/cli/cli-adapters.cjs`). - Read-only enforcement is owned by the Obsidian Connector for captured selections. Agent workspace and CLI-mediated remote access still depends partly on each upstream CLI honoring its permission flags; Meldwork adds per-CLI hardening where available (`desktop/src/agents/cli/cli-adapters.cjs`). -- Provider requests and native Agent authentication are external dependencies; Meldwork cannot guarantee their availability or data handling. +- Native Agent authentication and model requests are external dependencies; Meldwork cannot guarantee their availability or data handling. - Agent/Cloud/Channel Connector contracts validate provenance, scope, and idempotency, but the external implementation must honor its CredentialRef, outbound destination, cancellation, and signature-verification obligations. The production build registers no Cloud or Channel Connector by default. - macOS packaging uses ad-hoc signing for local validation, not a notarized public release (`desktop/scripts/after-pack.cjs`). diff --git a/desktop/scripts/test-discovery.cjs b/desktop/scripts/test-discovery.cjs index f6b1b11..9eeba98 100644 --- a/desktop/scripts/test-discovery.cjs +++ b/desktop/scripts/test-discovery.cjs @@ -5,7 +5,7 @@ const TEST_SUITES = Object.freeze({ agents: ['agents'], attachments: ['attachments'], collaboration: ['collaboration'], - core: ['channels', 'knowledge', 'media', 'providers', 'skills'], + core: ['channels', 'knowledge', 'media', 'skills'], runs: ['runs'], security: ['security', 'shell'], workspace: ['workspace', 'gates'], diff --git a/desktop/src/agents/cli/cli-adapters.cjs b/desktop/src/agents/cli/cli-adapters.cjs index 51a975f..d59477a 100644 --- a/desktop/src/agents/cli/cli-adapters.cjs +++ b/desktop/src/agents/cli/cli-adapters.cjs @@ -125,7 +125,6 @@ async function runAgent(agent, prompt, workdir, options = {}) { const spec = invocation(agent.kind, agent.executable, workdir, sessionRef, { platform, sandbox: options.sandbox, - provider: options.provider, attachments: options.attachments, hermesAcpAvailable, invocationTransport: options.invocationTransport, diff --git a/desktop/src/agents/cli/cli-invocations.cjs b/desktop/src/agents/cli/cli-invocations.cjs index 98fcee2..6856141 100644 --- a/desktop/src/agents/cli/cli-invocations.cjs +++ b/desktop/src/agents/cli/cli-invocations.cjs @@ -94,8 +94,6 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) { 'chat', '--quiet', ...(options.sandbox === 'workspace-write' ? ['--yolo'] : []), - ...(options.provider?.id ? ['--provider', options.provider.id] : []), - ...(options.provider?.model ? ['--model', options.provider.model] : []), ...(legacySessionRef ? ['--resume', legacySessionRef] : []), ...(attachments[0] ? ['--image', attachments[0]] : []), '--query', @@ -216,24 +214,6 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) { '--include-partial-messages', '--verbose', '--permission-mode', options.sandbox === 'workspace-write' ? 'acceptEdits' : 'plan', - ...(options.provider?.id === 'anthropic' ? [ - '--settings', JSON.stringify({ - // Native settings.env overrides process env. The helper keeps the key out of argv. - apiKeyHelper: (options.platform || process.platform) === 'win32' - ? 'powershell.exe -NoLogo -NoProfile -NonInteractive -Command "[Console]::Write([Environment]::GetEnvironmentVariable(\'MELDWORK_PROVIDER_API_KEY\'))"' - : 'printf \'%s\' "$MELDWORK_PROVIDER_API_KEY"', - env: { - CLAUDE_CODE_USE_BEDROCK: '0', CLAUDE_CODE_USE_VERTEX: '0', - CLAUDE_CODE_USE_FOUNDRY: '0', CLAUDE_CODE_USE_MANTLE: '0', - ANTHROPIC_BASE_URL: options.provider.baseUrl, - ANTHROPIC_API_KEY: '', ANTHROPIC_AUTH_TOKEN: '', CLAUDE_CODE_OAUTH_TOKEN: '', - ANTHROPIC_CUSTOM_HEADERS: '', - ANTHROPIC_DEFAULT_OPUS_MODEL: '', ANTHROPIC_DEFAULT_SONNET_MODEL: '', - ANTHROPIC_DEFAULT_HAIKU_MODEL: '', - }, - }), - ] : []), - ...(options.provider?.model ? ['--model', options.provider.model] : []), ...(sessionRef ? ['--resume', sessionRef] : []), ], promptArg: true, @@ -246,8 +226,6 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) { '--output-format', 'stream-json', '--include-partial-messages', '--approval-mode', options.sandbox === 'workspace-write' ? 'auto-edit' : 'plan', - ...(options.provider?.id === 'openai' ? ['--auth-type', 'openai'] : []), - ...(options.provider?.model ? ['--model', options.provider.model] : []), ...(sessionRef ? ['--resume', sessionRef] : []), ], promptArg: true, diff --git a/desktop/src/agents/cli/openclaw-runtime.cjs b/desktop/src/agents/cli/openclaw-runtime.cjs index f1e8990..6686e69 100644 --- a/desktop/src/agents/cli/openclaw-runtime.cjs +++ b/desktop/src/agents/cli/openclaw-runtime.cjs @@ -5,7 +5,6 @@ const { isDeepStrictEqual } = require('node:util') const { atomicWritePrivateFile } = require('../../security/private-file.cjs') -const MANAGED_OPENCLAW_PROVIDER_ID = 'meldwork-desktop' const READ_ONLY_TOOLS = [ 'read', 'web_search', 'web_fetch', 'memory_search', 'memory_get', 'session_status', ] @@ -37,7 +36,6 @@ const OPENCLAW_RUNTIME_PATH_ENV_KEYS = Object.freeze([ 'OPENCLAW_WORKSPACE_DIR', ]) const OPENCLAW_RUNTIME_CREDENTIAL_KEYS = new Set([ - 'MELDWORK_OPENCLAW_API_KEY', 'MELDWORK_OPENCLAW_NATIVE_API_KEY', 'OPENCLAW_GATEWAY_TOKEN', ]) @@ -173,25 +171,6 @@ function validateFileIdentity(expected) { } } -function normalizeProvider(input) { - const apiKey = typeof input?.OPENAI_API_KEY === 'string' ? input.OPENAI_API_KEY.trim() : '' - 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') } - const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname) - if (!apiKey || apiKey.length > 8192 || isOpenClawSecretReference(apiKey) - || !model || model.length > 120 - || (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) - || parsed.username || parsed.password || parsed.search || parsed.hash) { - throw new Error('OPENCLAW_PROVIDER_INVALID') - } - return { apiKey, baseUrl, model } -} - function runtimePaths({ storageRoot, workdir, sessionRef, configName, runtimeVersion }) { if (!path.isAbsolute(storageRoot) || !path.isAbsolute(workdir)) { throw new Error('OPENCLAW_RUNTIME_INVALID_SCOPE') @@ -465,72 +444,6 @@ function normalizedNativeModel(input) { return model } -function managedOpenClawOptions({ - storageRoot, workdir, sessionRef = '', allowWrite = false, provider, openclawVersion, -}) { - const normalized = normalizeProvider(provider) - const configName = allowWrite - ? 'openclaw.provider.workspace-write.json' - : 'openclaw.provider.read-only.json' - const runtime = runtimePaths({ - storageRoot, workdir, sessionRef, configName, runtimeVersion: openclawVersion, - }) - const { resolvedWorkdir, home, state, configPath } = runtime - - const modelRef = `${MANAGED_OPENCLAW_PROVIDER_ID}/${normalized.model}` - const gatewayCredentialKey = 'OPENCLAW_GATEWAY_TOKEN' - const gatewayToken = crypto.randomBytes(32).toString('base64url') - const config = { - agents: { - defaults: { - workspace: resolvedWorkdir, - model: { primary: modelRef }, - skipBootstrap: true, - }, - }, - models: { - mode: 'replace', - providers: { - [MANAGED_OPENCLAW_PROVIDER_ID]: { - baseUrl: normalized.baseUrl, - apiKey: { - source: 'env', provider: 'default', id: 'MELDWORK_OPENCLAW_API_KEY', - }, - api: 'openai-completions', - models: [{ id: normalized.model, name: normalized.model, input: ['text'] }], - }, - }, - }, - tools: toolPolicy(allowWrite), - gateway: { - mode: 'local', - bind: 'loopback', - auth: { - mode: 'token', - token: { source: 'env', provider: 'default', id: gatewayCredentialKey }, - }, - controlUi: { enabled: false }, - }, - } - const credentialKey = 'MELDWORK_OPENCLAW_API_KEY' - const openClawRuntimeGuard = writeRuntimeConfig(runtime, config, { - [credentialKey]: normalized.apiKey, - [gatewayCredentialKey]: gatewayToken, - }, credentialKey) - - return { - openClawRuntimeGuard, - env: { - OPENCLAW_HOME: home, - OPENCLAW_STATE_DIR: state, - OPENCLAW_CONFIG_PATH: configPath, - OPENCLAW_WORKSPACE_DIR: resolvedWorkdir, - [credentialKey]: normalized.apiKey, - [gatewayCredentialKey]: gatewayToken, - }, - } -} - function nativeOpenClawOptions({ storageRoot, workdir, sessionRef = '', allowWrite = false, runtime, openclawVersion, }) { @@ -625,7 +538,6 @@ function nativeOpenClawOptions({ module.exports = { configureOpenClawGatewayRuntime, isOpenClawSecretReference, - managedOpenClawOptions, nativeOpenClawOptions, refreshOpenClawRuntimeAfterStartup, validateOpenClawRuntimeGuard, diff --git a/desktop/src/agents/cloud/cloud-agent-bridge.cjs b/desktop/src/agents/cloud/cloud-agent-bridge.cjs index 987e987..90619b5 100644 --- a/desktop/src/agents/cloud/cloud-agent-bridge.cjs +++ b/desktop/src/agents/cloud/cloud-agent-bridge.cjs @@ -632,8 +632,6 @@ class CloudAgentBridgeStore { installed: true, installSupported: false, installErrorCode: '', - providerCompatible: false, - providerMode: 'connector', imageAttachmentLimit: 0, custom: false, connector: true, diff --git a/desktop/src/agents/connectors/agent-connector-local.cjs b/desktop/src/agents/connectors/agent-connector-local.cjs index 7659f23..2221d8b 100644 --- a/desktop/src/agents/connectors/agent-connector-local.cjs +++ b/desktop/src/agents/connectors/agent-connector-local.cjs @@ -603,8 +603,6 @@ class LocalAgentConnectors { installed: true, installSupported: false, installErrorCode: '', - providerCompatible: false, - providerMode: 'connector', imageAttachmentLimit: manifest.inputTypes.includes('image') ? 32 : 0, custom: !cloud, connector: true, diff --git a/desktop/src/agents/custom-agent-contract.cjs b/desktop/src/agents/custom-agent-contract.cjs index c74298a..b3426e1 100644 --- a/desktop/src/agents/custom-agent-contract.cjs +++ b/desktop/src/agents/custom-agent-contract.cjs @@ -127,9 +127,6 @@ function publicProfile(definition, version = '') { promptMode: definition.promptMode, custom: true, recommended: false, - providerCompatible: false, - providerSupport: 'custom-cli', - providerMode: 'custom', installed: true, installSupported: false, installErrorCode: '', diff --git a/desktop/src/agents/installer/agent-installer-contract.cjs b/desktop/src/agents/installer/agent-installer-contract.cjs index df213f0..57f1398 100644 --- a/desktop/src/agents/installer/agent-installer-contract.cjs +++ b/desktop/src/agents/installer/agent-installer-contract.cjs @@ -99,50 +99,17 @@ const NPM_RELEASES = Object.freeze({ const ALLOWED_SCRIPT_URLS = new Set(Object.values(HERMES_RECIPES).map(recipe => recipe.url)) const AGENT_CATALOG = Object.freeze([ - { - kind: 'codex', label: 'Codex', recommended: false, providerCompatible: false, - providerSupport: 'responses-required', - }, - { - kind: 'claude', label: 'Claude Code', recommended: false, providerCompatible: false, - providerSupport: 'anthropic-required', - }, - { - kind: 'hermes', label: 'Hermes', recommended: true, providerCompatible: true, - providerSupport: 'supported', - }, - { - kind: 'workbuddy', label: 'WorkBuddy', recommended: true, providerCompatible: true, - providerSupport: 'experimental', - }, - { - kind: 'openclaw', label: 'OpenClaw', recommended: true, providerCompatible: true, - providerSupport: 'supported', - }, - { - kind: 'pi', label: 'Pi Agent', recommended: false, providerCompatible: false, - providerSupport: 'native-config', - }, - { - kind: 'opencode', label: 'OpenCode', recommended: false, providerCompatible: false, - providerSupport: 'native-config', - }, - { - kind: 'opencodereview', label: 'OpenCodeReview', recommended: false, providerCompatible: true, - providerSupport: 'supported', - }, - { - kind: 'kimi', label: 'Kimi Code', recommended: false, providerCompatible: false, - providerSupport: 'native-config', - }, - { - kind: 'mimo', label: 'MiMo Code', recommended: false, providerCompatible: false, - providerSupport: 'native-config', - }, - { - kind: 'qwen', label: 'Qwen Code', recommended: false, providerCompatible: true, - providerSupport: 'supported', - }, + { kind: 'codex', label: 'Codex', recommended: false }, + { kind: 'claude', label: 'Claude Code', recommended: false }, + { kind: 'hermes', label: 'Hermes', recommended: true }, + { kind: 'workbuddy', label: 'WorkBuddy', recommended: true }, + { kind: 'openclaw', label: 'OpenClaw', recommended: true }, + { kind: 'pi', label: 'Pi Agent', recommended: false }, + { kind: 'opencode', label: 'OpenCode', recommended: false }, + { kind: 'opencodereview', label: 'OpenCodeReview', recommended: false }, + { kind: 'kimi', label: 'Kimi Code', recommended: false }, + { kind: 'mimo', label: 'MiMo Code', recommended: false }, + { kind: 'qwen', label: 'Qwen Code', recommended: false }, ]) function installRecipe(kind, platform) { diff --git a/desktop/src/agents/local-agent-readiness.cjs b/desktop/src/agents/local-agent-readiness.cjs index d75c8b6..e8c191f 100644 --- a/desktop/src/agents/local-agent-readiness.cjs +++ b/desktop/src/agents/local-agent-readiness.cjs @@ -49,7 +49,6 @@ const GOOGLE_CLOUD_ENV_KEYS = [ 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION', 'GOOGLE_CLOUD_QUOTA_PROJECT', 'CLOUDSDK_CONFIG', ] -const NATIVE_CONFIG_ROOT_KEYS = new Set(['CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'PI_CODING_AGENT_DIR']) const RUNTIME_ENV_KEYS = Object.freeze({ codex: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'CODEX_HOME'], hermes: [ @@ -159,12 +158,11 @@ function textContainsCredential(filename) { } } -function nativeCredentialEnvironment(kind, env = process.env, options = {}) { +function nativeCredentialEnvironment(kind, env = process.env) { const result = {} const keys = [...(RUNTIME_ENV_KEYS[kind] || []), ...(kind === 'claude' ? Object.keys(env).filter(key => VERTEX_MODEL_REGION_KEY.test(key)) : [])] for (const key of keys) { - if (options.providerConfigured && !NATIVE_CONFIG_ROOT_KEYS.has(key)) continue if (typeof env[key] === 'string') result[key] = env[key] } return result diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index 40e41b3..e21f295 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -69,13 +69,8 @@ const { resolveKnowledgeBaseSources, } = require('./knowledge/local-knowledge-base.cjs') const { LocalKnowledgeConnectors } = require('./knowledge/local-knowledge-connectors.cjs') -const { ProviderStore } = require('./providers/provider-store.cjs') +const { nativeOpenClawOptions } = require('./agents/cli/openclaw-runtime.cjs') const { migrateLegacyProductData } = require('./product-data-migration.cjs') -const { - EXTERNAL_PROVIDER_KINDS, - providerAgentKind, - providerOptionsFor, -} = require('./providers/provider-options.cjs') const LOCAL_AGENT_KINDS = new Set([ 'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen', 'opencodereview', @@ -88,7 +83,6 @@ let workspaceRunFinishedListener = null let workspaceRunEventListener = null let installer = null let localAgentRefreshQueue = Promise.resolve() -let providerStore = null let customAgentStore = null let agentConnectors = null let cloudAgentRuntime = null @@ -225,18 +219,6 @@ function openExternalUrl(value) { return true } -function providerOptions(kind, context = {}) { - if (!EXTERNAL_PROVIDER_KINDS.has(kind)) return {} - const status = providerStore?.status(kind) || {} - if (!status.configured && !(kind === 'openclaw' && context.nativeRuntime)) return {} - return providerOptionsFor( - kind, - status.configured ? providerStore.envForAgent(kind) : {}, - context, - status, - ) -} - async function nativeShellEnvironment(options = {}) { const home = app.getPath('home') const resolved = await resolveNativeShellEnvironment({ home, refresh: options.refresh === true }) @@ -341,34 +323,30 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) { const connectorCredentialIsolation = options.connectorCredentialIsolation === true const runOptions = { ...options } delete runOptions.connectorCredentialIsolation - const status = providerStore.status(agent.kind) - if (!connectorCredentialIsolation && status.error) { - throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - } const shellEnvironment = connectorCredentialIsolation ? null : await nativeShellEnvironment() - const nativeRuntime = !connectorCredentialIsolation - && agent.kind === 'openclaw' && !status.configured + // OpenClaw runs through its isolated native runtime scope; every other Agent + // CLI uses its own native credentials. + const nativeRuntime = !connectorCredentialIsolation && agent.kind === 'openclaw' ? await resolveNativeOpenClawRuntime({ executable: agent.executable, env: shellEnvironment.env, home: shellEnvironment.home, }) : null - const injected = connectorCredentialIsolation + const injected = connectorCredentialIsolation || agent.kind !== 'openclaw' || !nativeRuntime ? {} - : providerOptions(agent.kind, { - ...runOptions, - workdir, + : nativeOpenClawOptions({ storageRoot: app.getPath('userData'), - nativeRuntime, + workdir, + sessionRef: runOptions.sessionRef, + allowWrite: runOptions.sandbox === 'workspace-write', + runtime: nativeRuntime, openclawVersion: agent.version, }) const nativeEnv = connectorCredentialIsolation || agent.kind === 'openclaw' ? {} : { - ...nativeCredentialEnvironment(agent.kind, shellEnvironment.env, { - providerConfigured: status.configured, - }), + ...nativeCredentialEnvironment(agent.kind, shellEnvironment.env), ...(shellEnvironment.env.PATH ? { PATH: shellEnvironment.env.PATH } : {}), } const callerEnv = connectorCredentialIsolation || agent.kind !== 'openclaw' @@ -543,9 +521,6 @@ function createWorkspace() { credentialState: async (kind, agent) => { if (customAgentStore.has(kind)) return { state: 'ready', source: 'custom-agent' } if (agentConnectors.has(kind)) return { state: 'ready', source: 'agent-connector' } - if (providerStore.status(kind).error) { - return { state: 'unknown', source: 'provider-credential-unavailable' } - } const shellEnvironment = await nativeShellEnvironment() return resolveNativeCredentialState(kind, { executable: agent?.executable, @@ -555,7 +530,6 @@ function createWorkspace() { }) }, agentLabel: kind => customAgentStore.label(kind) || agentConnectors.label(kind), - sharedProviderReady: kind => EXTERNAL_PROVIDER_KINDS.has(kind) && providerStore.status(kind).configured, connectorRuntime: agentConnectors, naturalAgentResponses: true, runAgent: async (agent, prompt, workdir, options = {}) => { @@ -752,8 +726,6 @@ function registerIpc() { openExternalUrl, getOutcomeStore: () => workspace?.outcomeStore, getSkillTrustStore: () => skillTrustStore, - providerStore, - providerAgentKind, refreshLocalAgentState, registerTrustedHandle, skillCatalog, @@ -786,11 +758,12 @@ if (!hasSingleInstanceLock) { storagePath: path.join(app.getPath('userData'), 'meldwork-cloud-agent-bridges.json'), fetch: globalThis.fetch, }) - providerStore = new ProviderStore({ - storagePath: path.join(app.getPath('userData'), 'meldwork-provider.json'), - safeStorage: lazySafeStorage, - allowedKinds: [...LOCAL_AGENT_KINDS], - }) + // The shared Provider store was removed; drop any legacy encrypted + // credentials so they do not linger on disk unread. + try { + const { rmSync } = require('node:fs') + rmSync(path.join(app.getPath('userData'), 'meldwork-provider.json'), { force: true }) + } catch { /* best-effort cleanup */ } knowledgeBaseStore = new KnowledgeBaseStore({ storagePath: path.join(app.getPath('userData'), 'meldwork-knowledge-base.json'), }) diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index 0ab6b03..d8a51b7 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -176,13 +176,6 @@ if (isLocalDocument) Object.assign(desktopApi, { open: id => ipcRenderer.invoke('local-attachments:open', id), discard: ids => ipcRenderer.invoke('local-attachments:discard', ids), }), - localAgentProvider: Object.freeze({ - status: kind => ipcRenderer.invoke('local-agent-provider:status', kind), - probe: kind => ipcRenderer.invoke('local-agent-provider:probe', kind), - save: (kind, input) => ipcRenderer.invoke('local-agent-provider:save', kind, input), - activate: (kind, preset) => ipcRenderer.invoke('local-agent-provider:activate', kind, preset), - delete: (kind, preset) => ipcRenderer.invoke('local-agent-provider:delete', kind, preset), - }), localKnowledgeBase: Object.freeze({ status: kind => (kind ? ipcRenderer.invoke('local-knowledge-base:status', kind) : ipcRenderer.invoke('local-knowledge-base:status')), openGuide: (kind, action) => ipcRenderer.invoke('local-knowledge-base:open-guide', kind, action), diff --git a/desktop/src/product-data-migration.cjs b/desktop/src/product-data-migration.cjs index 2f35e06..864bf1a 100644 --- a/desktop/src/product-data-migration.cjs +++ b/desktop/src/product-data-migration.cjs @@ -7,7 +7,6 @@ const MIGRATIONS = Object.freeze([ ['workspace.json', 'workspace.json'], ['run-ledger.json', 'run-ledger.json'], ['custom-agents.json', 'custom-agents.json'], - ['provider.json', 'provider.json'], ['knowledge-base.json', 'knowledge-base.json'], ['private', 'private'], ]) diff --git a/desktop/src/providers/provider-options.cjs b/desktop/src/providers/provider-options.cjs deleted file mode 100644 index 9f8f290..0000000 --- a/desktop/src/providers/provider-options.cjs +++ /dev/null @@ -1,184 +0,0 @@ -const { managedOpenClawOptions, nativeOpenClawOptions } = require('../agents/cli/openclaw-runtime.cjs') - -const EXTERNAL_PROVIDER_KINDS = new Set([ - 'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen', - 'opencodereview', -]) - -const OFFICIAL_PROVIDER_BASE_URLS = Object.freeze({ - codex: 'https://api.openai.com/v1', - hermes: 'https://api.openai.com/v1', - openclaw: 'https://api.openai.com/v1', - workbuddy: '', - pi: '', - kimi: 'https://api.moonshot.cn/v1', - mimo: '', - claude: 'https://api.anthropic.com', - 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 - } - const provider = String(status.provider || '').trim().toLowerCase() - const baseUrl = String(status.baseUrl || '').trim().replace(/\/+$/, '') - if (/openrouter/i.test(provider) || /openrouter\.ai$/i.test(new URL(baseUrl || 'https://example.com').hostname)) { - return 'openrouter' - } - if (OFFICIAL_PROVIDER_BASE_URLS[kind] - && baseUrl - && baseUrl === OFFICIAL_PROVIDER_BASE_URLS[kind]) { - return 'official' - } - return 'custom' -} - -function providerOptionsFor(kind, generic, context = {}, status = {}) { - const preset = providerPresetFromStatus(kind, status) - if (kind === 'codex') return { env: generic } - if (kind === 'hermes') { - return { - provider: { id: 'openai-api', model: generic.OPENAI_MODEL }, - env: { - ...generic, - HERMES_INFERENCE_PROVIDER: 'openai-api', - HERMES_INFERENCE_MODEL: generic.OPENAI_MODEL, - }, - } - } - if (kind === 'workbuddy') { - return { - env: { - ...generic, - CODEBUDDY_MODEL: generic.OPENAI_MODEL, - CODEBUDDY_API_KEY: generic.OPENAI_API_KEY, - CODEBUDDY_BASE_URL: generic.OPENAI_BASE_URL, - }, - } - } - if (kind === 'pi') { - return { - env: { - ...generic, - PI_MODEL: generic.OPENAI_MODEL, - PI_API_KEY: generic.OPENAI_API_KEY, - PI_BASE_URL: generic.OPENAI_BASE_URL, - }, - } - } - if (kind === 'kimi') { - return { - env: { - ...generic, - MOONSHOT_API_KEY: generic.OPENAI_API_KEY, - KIMI_API_KEY: generic.OPENAI_API_KEY, - KIMI_MODEL_API_KEY: generic.OPENAI_API_KEY, - KIMI_MODEL_BASE_URL: generic.OPENAI_BASE_URL, - KIMI_MODEL_NAME: generic.OPENAI_MODEL, - }, - } - } - if (kind === 'mimo') { - return { - env: { - ...generic, - MIMO_API_KEY: generic.OPENAI_API_KEY, - MIMO_BASE_URL: generic.OPENAI_BASE_URL, - MIMO_MODEL: generic.OPENAI_MODEL, - }, - } - } - if (kind === 'claude') { - return { - provider: { id: 'anthropic', model: generic.OPENAI_MODEL, baseUrl: generic.OPENAI_BASE_URL }, - env: { - ...generic, - ANTHROPIC_API_KEY: generic.OPENAI_API_KEY, - MELDWORK_PROVIDER_API_KEY: generic.OPENAI_API_KEY, - ANTHROPIC_BASE_URL: generic.OPENAI_BASE_URL, - ANTHROPIC_MODEL: generic.OPENAI_MODEL, - }, - } - } - if (kind === 'gemini') { - return { - env: { - ...generic, - GEMINI_API_KEY: generic.OPENAI_API_KEY, - GOOGLE_API_KEY: generic.OPENAI_API_KEY, - GEMINI_MODEL: generic.OPENAI_MODEL, - }, - } - } - if (kind === 'qwen') { - return { - provider: { id: 'openai', model: generic.OPENAI_MODEL }, - env: { - ...generic, - DASHSCOPE_API_KEY: generic.OPENAI_API_KEY, - }, - } - } - if (kind === 'opencode') { - return { - env: { - ...generic, - ...(preset === 'openrouter' ? { OPENROUTER_API_KEY: generic.OPENAI_API_KEY } : {}), - }, - } - } - 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) { - if (!generic?.OPENAI_API_KEY) { - return nativeOpenClawOptions({ - storageRoot: context.storageRoot, - workdir: context.workdir, - sessionRef: context.sessionRef, - allowWrite: context.sandbox === 'workspace-write', - runtime: context.nativeRuntime, - }) - } - return managedOpenClawOptions({ - storageRoot: context.storageRoot, - workdir: context.workdir, - sessionRef: context.sessionRef, - allowWrite: context.sandbox === 'workspace-write', - provider: generic, - }) - } - return {} -} - -function providerAgentKind(value) { - const kind = String(value || '').trim() - if (!EXTERNAL_PROVIDER_KINDS.has(kind)) throw new Error('PROVIDER_AGENT_UNSUPPORTED') - return kind -} - -module.exports = { - EXTERNAL_PROVIDER_KINDS, - providerAgentKind, - providerOptionsFor, -} diff --git a/desktop/src/providers/provider-store.cjs b/desktop/src/providers/provider-store.cjs deleted file mode 100644 index 8b470ac..0000000 --- a/desktop/src/providers/provider-store.cjs +++ /dev/null @@ -1,382 +0,0 @@ -const fs = require('node:fs') - -const { atomicWritePrivateFile } = require('../security/private-file.cjs') - -const STORE_VERSION = 3 -const SINGLE_PROFILE_STORE_VERSION = 2 -const LEGACY_STORE_VERSION = 1 -const PROVIDER_KIND = /^[a-z0-9][a-z0-9-]{0,31}$/ -const PROVIDER_PRESETS = new Set(['official', 'openrouter', 'custom']) -const EMPTY_METADATA = Object.freeze({ provider: '', baseUrl: '', model: '' }) -const OPENROUTER_PROVIDER = Object.freeze({ - provider: 'OpenRouter', - baseUrl: 'https://openrouter.ai/api/v1', -}) -const OFFICIAL_PROVIDERS = Object.freeze({ - codex: { provider: 'OpenAI', baseUrl: 'https://api.openai.com/v1' }, - hermes: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1' }, - openclaw: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1' }, - workbuddy: { provider: 'WorkBuddy Official', baseUrl: '' }, - pi: { provider: 'Pi Native', baseUrl: '' }, - kimi: { provider: 'Moonshot AI', baseUrl: 'https://api.moonshot.cn/v1' }, - mimo: { provider: 'MiMo Native', baseUrl: '' }, - claude: { provider: 'Anthropic', baseUrl: 'https://api.anthropic.com' }, - 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() - 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) { - throw new Error('PROVIDER_INVALID_METADATA') - } - let parsed - try { parsed = new URL(baseUrl) } catch { throw new Error('PROVIDER_INVALID_METADATA') } - const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname) - if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) { - throw new Error('PROVIDER_INSECURE_BASE_URL') - } - if (parsed.username || parsed.password || parsed.search || parsed.hash) { - throw new Error('PROVIDER_INVALID_METADATA') - } - return { provider, baseUrl, model } -} - -function sortedKeys(value) { - return value && typeof value === 'object' && !Array.isArray(value) - ? Reflect.ownKeys(value).sort().join(',') - : '' -} - -function inferStoredPreset(entry) { - const provider = String(entry?.provider || '').toLowerCase() - const baseUrl = String(entry?.baseUrl || '') - return /openrouter/i.test(provider) || /(^|\.)openrouter\.ai$/i.test(hostname(baseUrl)) - ? 'openrouter' - : 'custom' -} - -function hostname(value) { - try { return new URL(value).hostname } catch { return '' } -} - -function validatePresetMetadata(kind, preset, metadata) { - if (preset === 'custom') return metadata - const expected = preset === 'openrouter' ? OPENROUTER_PROVIDER : OFFICIAL_PROVIDERS[kind] - if (!expected?.baseUrl - || metadata.provider !== expected.provider - || metadata.baseUrl !== expected.baseUrl) { - throw new Error('PROVIDER_INVALID_METADATA') - } - return metadata -} - -class ProviderStore { - constructor({ storagePath, safeStorage, allowedKinds = [] }) { - if (typeof storagePath !== 'string' || !storagePath) { - throw new Error('PROVIDER_STORAGE_PATH_REQUIRED') - } - const normalizedKinds = [...new Set(allowedKinds.map(kind => String(kind || '').trim()))] - if (!normalizedKinds.length || normalizedKinds.some(kind => !PROVIDER_KIND.test(kind))) { - throw new Error('PROVIDER_AGENT_KINDS_REQUIRED') - } - this.storagePath = storagePath - this.safeStorage = safeStorage - this.allowedKinds = new Set(normalizedKinds) - } - - encryptionAvailable() { - try { - return Boolean(this.safeStorage?.isEncryptionAvailable?.()) - } catch { - return false - } - } - - status(kind, { probeEncryption = false } = {}) { - const targetKind = this.normalizeKind(kind) - const credentialStored = fs.existsSync(this.storagePath) - if (!credentialStored && !probeEncryption) { - return { - provider: '', - baseUrl: '', - model: '', - activePreset: 'official', - profiles: {}, - encryptionAvailable: null, - configured: false, - } - } - - const encryptionAvailable = this.encryptionAvailable() - let metadata = EMPTY_METADATA - let configured = false - let credentialUnavailable = false - let activePreset = 'official' - const profiles = {} - if (credentialStored) { - try { - const agent = this.readDocument().agents[targetKind] - if (agent) { - activePreset = agent.activePreset - for (const [preset, entry] of Object.entries(agent.profiles)) { - profiles[preset] = { - provider: entry.provider, - baseUrl: entry.baseUrl, - model: entry.model, - configured: false, - } - try { - this.decryptEntry(entry) - profiles[preset].configured = true - } catch { /* retain saved metadata while the credential is unavailable */ } - } - const activeEntry = profiles[activePreset] - if (activeEntry) { - metadata = activeEntry - configured = activeEntry.configured - credentialUnavailable = !configured - } - } - } catch { credentialUnavailable = true } - } - return { - provider: metadata.provider, - baseUrl: metadata.baseUrl, - model: metadata.model, - activePreset, - profiles, - encryptionAvailable, - configured, - ...(credentialUnavailable ? { error: true } : {}), - } - } - - save(kind, input) { - const targetKind = this.normalizeKind(kind) - if (sortedKeys(input) !== 'apiKey,baseUrl,model,preset,provider' - || typeof input.apiKey !== 'string' || !input.apiKey.trim() - || input.apiKey.trim().length > 8192) { - throw new Error('PROVIDER_INVALID_CREDENTIAL') - } - this.requireEncryption() - - const apiKey = input.apiKey.trim() - const preset = this.normalizePreset(input.preset) - const metadata = validatePresetMetadata(targetKind, preset, normalizeMetadata(input)) - let encrypted - try { - encrypted = this.safeStorage.encryptString(apiKey) - if (!Buffer.isBuffer(encrypted) || encrypted.length === 0 - || this.safeStorage.decryptString(encrypted) !== apiKey) { - throw new Error('invalid encryption result') - } - } catch { - throw new Error('PROVIDER_ENCRYPTION_FAILED') - } - - const document = fs.existsSync(this.storagePath) - ? this.readDocument() - : { version: STORE_VERSION, agents: {} } - const agent = document.agents[targetKind] || { activePreset: 'official', profiles: {} } - agent.profiles[preset] = { - ...metadata, - encrypted: encrypted.toString('base64'), - } - agent.activePreset = preset - document.agents[targetKind] = agent - atomicWritePrivateFile(this.storagePath, JSON.stringify(document)) - return this.status(targetKind) - } - - activate(kind, preset) { - const targetKind = this.normalizeKind(kind) - const targetPreset = this.normalizePreset(preset) - if (!fs.existsSync(this.storagePath)) { - if (targetPreset !== 'official') throw new Error('PROVIDER_PROFILE_UNAVAILABLE') - return this.status(targetKind, { probeEncryption: true }) - } - this.requireEncryption() - const document = this.readDocument() - const agent = document.agents[targetKind] || { activePreset: 'official', profiles: {} } - if (targetPreset !== 'official' && !agent.profiles[targetPreset]) { - throw new Error('PROVIDER_PROFILE_UNAVAILABLE') - } - if (agent.profiles[targetPreset]) this.decryptEntry(agent.profiles[targetPreset]) - agent.activePreset = targetPreset - document.agents[targetKind] = agent - atomicWritePrivateFile(this.storagePath, JSON.stringify(document)) - return this.status(targetKind) - } - - delete(kind, preset = '') { - const targetKind = this.normalizeKind(kind) - if (fs.existsSync(this.storagePath)) { - const document = this.readDocument() - if (preset) { - const targetPreset = this.normalizePreset(preset) - const agent = document.agents[targetKind] - if (agent) { - delete agent.profiles[targetPreset] - if (agent.activePreset === targetPreset) agent.activePreset = 'official' - if (Object.keys(agent.profiles).length) document.agents[targetKind] = agent - else delete document.agents[targetKind] - } - } else { - delete document.agents[targetKind] - } - if (Object.keys(document.agents).length) { - atomicWritePrivateFile(this.storagePath, JSON.stringify(document)) - } else { - fs.unlinkSync(this.storagePath) - } - } - return this.status(targetKind, { probeEncryption: true }) - } - - envForAgent(kind) { - const targetKind = this.normalizeKind(kind) - this.requireEncryption() - const agent = this.readDocument().agents[targetKind] - const entry = agent?.profiles?.[agent.activePreset] - if (!entry) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - return { - OPENAI_API_KEY: this.decryptEntry(entry), - OPENAI_BASE_URL: entry.baseUrl, - OPENAI_MODEL: entry.model, - } - } - - requireEncryption() { - if (!this.encryptionAvailable()) throw new Error('PROVIDER_ENCRYPTION_UNAVAILABLE') - } - - normalizeKind(kind) { - const normalized = String(kind || '').trim() - if (!PROVIDER_KIND.test(normalized) || !this.allowedKinds.has(normalized)) { - throw new Error('PROVIDER_AGENT_UNSUPPORTED') - } - return normalized - } - - normalizePreset(preset) { - const normalized = String(preset || '').trim().toLowerCase() - if (!PROVIDER_PRESETS.has(normalized)) throw new Error('PROVIDER_PRESET_UNSUPPORTED') - return normalized - } - - decryptEntry(entry) { - this.requireEncryption() - try { - const encrypted = Buffer.from(entry.encrypted, 'base64') - if (!encrypted.length || encrypted.toString('base64') !== entry.encrypted) { - throw new Error('invalid ciphertext') - } - const apiKey = this.safeStorage.decryptString(encrypted) - if (typeof apiKey !== 'string' || !apiKey.trim()) throw new Error('invalid credential') - return apiKey - } catch { - throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - } - } - - readDocument() { - let payload - try { - payload = JSON.parse(fs.readFileSync(this.storagePath, 'utf8')) - } catch { - throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - } - if (payload?.version === LEGACY_STORE_VERSION) { - if (sortedKeys(payload) !== 'baseUrl,encrypted,model,provider,version' - || typeof payload.encrypted !== 'string') { - throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - } - const rawEntry = { - provider: payload.provider, - baseUrl: payload.baseUrl, - model: payload.model, - encrypted: payload.encrypted, - } - return { - version: STORE_VERSION, - 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) { - if (sortedKeys(payload) !== 'agents,version' - || !payload.agents || typeof payload.agents !== 'object' - || Array.isArray(payload.agents)) { - throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - } - const agents = {} - for (const [kind, rawEntry] of Object.entries(payload.agents)) { - if (!this.allowedKinds.has(kind)) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - const entry = this.normalizeEntry(rawEntry) - const preset = inferStoredPreset(entry) - agents[kind] = { activePreset: preset, profiles: { [preset]: entry } } - } - return { version: STORE_VERSION, agents } - } - if (sortedKeys(payload) !== 'agents,version' - || payload.version !== STORE_VERSION - || !payload.agents || typeof payload.agents !== 'object' - || Array.isArray(payload.agents)) { - throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - } - const agents = {} - for (const [kind, entry] of Object.entries(payload.agents)) { - if (!this.allowedKinds.has(kind)) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - agents[kind] = this.normalizeAgentEntry(entry) - } - return { version: STORE_VERSION, agents } - } - - normalizeAgentEntry(entry) { - try { - if (sortedKeys(entry) !== 'activePreset,profiles' - || !entry.profiles || typeof entry.profiles !== 'object' - || Array.isArray(entry.profiles)) { - throw new Error('invalid agent entry') - } - const activePreset = this.normalizePreset(entry.activePreset) - const profiles = {} - for (const [preset, profile] of Object.entries(entry.profiles)) { - const normalizedPreset = this.normalizePreset(preset) - profiles[normalizedPreset] = this.normalizeEntry(profile) - } - if (activePreset !== 'official' && !profiles[activePreset]) { - throw new Error('missing active profile') - } - return { activePreset, profiles } - } catch { - throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - } - } - - normalizeEntry(entry) { - if (sortedKeys(entry) !== 'baseUrl,encrypted,model,provider' - || typeof entry.encrypted !== 'string') { - throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - } - const encrypted = Buffer.from(entry.encrypted, 'base64') - if (!encrypted.length || encrypted.toString('base64') !== entry.encrypted) { - throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - } - return { ...normalizeMetadata(entry), encrypted: entry.encrypted } - } -} - -module.exports = { ProviderStore } diff --git a/desktop/src/shell/main-ipc.cjs b/desktop/src/shell/main-ipc.cjs index 4e8dc43..3e16590 100644 --- a/desktop/src/shell/main-ipc.cjs +++ b/desktop/src/shell/main-ipc.cjs @@ -185,8 +185,6 @@ function registerDesktopIpc(options) { loadKnowledgeBaseSources, localAgentCatalog, openExternalUrl, - providerStore, - providerAgentKind, refreshLocalAgentState, registerTrustedHandle, skillCatalog, @@ -660,39 +658,6 @@ function registerDesktopIpc(options) { registerTrustedHandle('local-attachments:preview', id => attachments.preview(String(id || ''))) registerTrustedHandle('local-attachments:open', id => attachments.open(String(id || ''))) registerTrustedHandle('local-attachments:discard', ids => attachments.discardUnreferenced(ids)) - registerTrustedHandle('local-agent-provider:status', kind => ( - providerStore.status(providerAgentKind(kind)) - )) - registerTrustedHandle('local-agent-provider:probe', kind => ( - providerStore.status(providerAgentKind(kind), { probeEncryption: true }) - )) - registerTrustedHandle('local-agent-provider:save', async (kind, input) => { - const selectedKind = providerAgentKind(kind) - const result = providerStore.save(selectedKind, { - apiKey: input?.apiKey, - provider: input?.provider, - baseUrl: input?.baseUrl, - model: input?.model, - preset: input?.preset, - }) - getWorkspace()?.markRuntimeCredential(selectedKind, 'unknown') - await refreshLocalAgentState() - return result - }) - registerTrustedHandle('local-agent-provider:activate', async (kind, preset) => { - const selectedKind = providerAgentKind(kind) - const result = providerStore.activate(selectedKind, preset) - getWorkspace()?.markRuntimeCredential(selectedKind, 'unknown') - await refreshLocalAgentState() - return result - }) - registerTrustedHandle('local-agent-provider:delete', async (kind, preset) => { - const selectedKind = providerAgentKind(kind) - const result = providerStore.delete(selectedKind, preset) - getWorkspace()?.markRuntimeCredential(selectedKind, 'unknown') - await refreshLocalAgentState() - return result - }) registerTrustedHandle('local-knowledge-connector:list', (...args) => { if (args.length) throw new Error('LOCAL_KNOWLEDGE_CONNECTOR_REQUEST_INVALID') const connectors = getKnowledgeConnectors?.() diff --git a/desktop/src/workspace/local-workspace-agent-catalog.cjs b/desktop/src/workspace/local-workspace-agent-catalog.cjs index 688992c..7b78073 100644 --- a/desktop/src/workspace/local-workspace-agent-catalog.cjs +++ b/desktop/src/workspace/local-workspace-agent-catalog.cjs @@ -43,7 +43,6 @@ class LocalWorkspaceAgentCatalog { this.setDetectedAgents = options.setDetectedAgents this.detectAgents = options.detectAgents this.credentialState = options.credentialState - this.sharedProviderReady = options.sharedProviderReady this.attachmentSupport = options.attachmentSupport || (() => ({})) this.save = options.save this.emitChanged = options.emitChanged @@ -77,15 +76,6 @@ class LocalWorkspaceAgentCatalog { const runtimeAtRefreshStart = new Map(Object.entries(this.state().agentRuntime)) const detected = await this.detectAgents() const nativeStates = await Promise.all(detected.map(async (agent) => { - const runtime = runtimeAtRefreshStart.get(agent.kind) - const sharedProviderReady = Boolean(this.sharedProviderReady(agent.kind)) - if (sharedProviderReady && runtime?.credentialState === 'missing' - && !expiredAuthFailure(runtime, this.now())) { - return { state: 'missing', source: 'runtime-auth-failure' } - } - if (sharedProviderReady) { - return { state: 'ready', source: 'shared-provider' } - } try { return await this.credentialState(agent.kind, agent) } catch { @@ -97,11 +87,8 @@ class LocalWorkspaceAgentCatalog { const agents = detected.map((agent, index) => { const native = nativeStates[index] let runtime = state.agentRuntime[agent.kind] - const sharedProviderReady = Boolean(this.sharedProviderReady(agent.kind)) const nativeState = ['ready', 'missing'].includes(native?.state) ? native.state : 'unknown' const authoritativeNativeState = native?.source === 'native-auth-status' - const sharedProviderRequired = native?.source === 'shared-provider-required' - const providerCredentialUnavailable = native?.source === 'provider-credential-unavailable' // Give corrected native credentials a retry after the cooldown, never erase a newer failure. if (expiredAuthFailure(runtime, this.now()) && !(authoritativeNativeState && nativeState === 'ready') @@ -130,38 +117,33 @@ class LocalWorkspaceAgentCatalog { ? (native.source || 'native-credential') : '' let credentialState = 'unknown' - if (runtimeMissing || sharedProviderRequired || nativeState === 'missing') { + if (runtimeMissing || nativeState === 'missing') { credentialState = 'missing' } - if (!runtimeMissing && sharedProviderReady) credentialState = 'ready' - else if (!runtimeMissing && nativeState === 'ready') credentialState = 'ready' + if (!runtimeMissing && nativeState === 'ready') credentialState = 'ready' else if (!runtimeMissing && verifiedReady && nativeState !== 'missing') credentialState = 'ready' - if (providerCredentialUnavailable) credentialState = 'unknown' const installed = true const versionIdentified = agentVersionIdentified(agent) const compatible = agentCompatible(agent) const nativeConfigurationUnknown = !runtimeMissing - && !providerCredentialUnavailable && nativeState === 'unknown' && native?.source !== 'native-runtime-unavailable' && native?.source !== 'native-probe-failed' - const configured = providerCredentialUnavailable || sharedProviderReady - || nativeState === 'ready' + const configured = nativeState === 'ready' || native?.source === 'native-auth-status' || native?.source === 'native-runtime-unavailable' || verifiedReady || runtimeMissing || nativeConfigurationUnknown - const authenticated = !providerCredentialUnavailable && !runtimeMissing && nativeState !== 'missing' && ( - sharedProviderReady || nativeState === 'ready' || verifiedReady || nativeConfigurationUnknown + const authenticated = !runtimeMissing && nativeState !== 'missing' && ( + nativeState === 'ready' || verifiedReady || nativeConfigurationUnknown ) const runtimePrerequisitesReady = native?.source !== 'native-runtime-unavailable' && (native?.source !== 'native-probe-failed' || recentlyVerified(runtime, this.now())) const invocable = installed && versionIdentified && compatible && configured && authenticated && runtimePrerequisitesReady - const verifiedRecently = !providerCredentialUnavailable && ( - (authoritativeNativeState && nativeState === 'ready') || recentlyVerified(runtime, this.now()) - ) + const verifiedRecently = (authoritativeNativeState && nativeState === 'ready') + || recentlyVerified(runtime, this.now()) const available = invocable const preferred = state.agentPreferences[agent.kind]?.showInSidebar const capabilities = agentRuntimeCapabilities(agent.kind, { @@ -170,9 +152,7 @@ class LocalWorkspaceAgentCatalog { }) let availabilitySource = 'unverified' if (!compatible) availabilitySource = 'incompatible' - else if (providerCredentialUnavailable) availabilitySource = 'provider-credential-unavailable' else if (runtimeMissing) availabilitySource = 'runtime-auth-failure' - else if (sharedProviderReady) availabilitySource = nativeReadySource || 'shared-provider' else if (nativeState === 'missing') availabilitySource = native.source || 'none' else if (native?.source === 'native-probe-failed') availabilitySource = 'native-probe-failed' else if (nativeReadySource) availabilitySource = nativeReadySource @@ -220,7 +200,7 @@ class LocalWorkspaceAgentCatalog { checkedAt: this.now(), } const agent = this.detectedAgents().find(item => item.kind === kind) - if (agent && agent.availabilitySource !== 'provider-credential-unavailable') { + if (agent) { agent.credentialState = credentialState agent.versionIdentified = agentVersionIdentified(agent) agent.compatible = agentCompatible(agent) diff --git a/desktop/src/workspace/local-workspace.cjs b/desktop/src/workspace/local-workspace.cjs index 1c19070..86a9ceb 100644 --- a/desktop/src/workspace/local-workspace.cjs +++ b/desktop/src/workspace/local-workspace.cjs @@ -131,7 +131,6 @@ class LocalWorkspace extends EventEmitter { image: this.imageAttachmentLimitFn(kind), })) this.credentialStateFn = options.credentialState || (async () => ({ state: 'unknown', source: 'unverified' })) - this.sharedProviderReadyFn = options.sharedProviderReady || (() => false) this.agentLabelFn = options.agentLabel || defaultAgentLabel this.runSilenceWarningMs = Number.isFinite(options.runSilenceWarningMs) && options.runSilenceWarningMs > 0 @@ -204,7 +203,6 @@ class LocalWorkspace extends EventEmitter { setDetectedAgents: agents => { this.detectedAgents = agents }, detectAgents: () => this.detectAgentsFn(), credentialState: (...args) => this.credentialStateFn(...args), - sharedProviderReady: kind => this.sharedProviderReadyFn(kind), attachmentSupport: kind => this.attachmentSupportFn(kind), save: () => this.save(), emitChanged: () => this.emitChanged(), diff --git a/desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs b/desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs index c166e2e..24d5743 100644 --- a/desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs +++ b/desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs @@ -30,7 +30,7 @@ const { searchPath, } = require('../../../src/agents/cli/cli-adapters.cjs') const { resolveConnectorEventProfile } = require('../../../src/agents/cli/cli-event-profiles.cjs') -const { managedOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs') +const { nativeOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs') const { executable, readJsonWhenReady, @@ -134,14 +134,19 @@ input.on('line', (line) => { } function signedOpenClawRuntime(storageRoot, workdir, sessionRef) { - return managedOpenClawOptions({ + return nativeOpenClawOptions({ storageRoot, workdir, sessionRef, - provider: { - OPENAI_API_KEY: 'adapter-openclaw-key', - OPENAI_BASE_URL: 'https://api.example.com/v1', - OPENAI_MODEL: 'adapter-model', + runtime: { + model: 'native/model', + provider: { + id: 'native', + baseUrl: 'https://native.example.com/v1', + api: 'openai-completions', + apiKey: 'adapter-openclaw-key', + model: { id: 'model', name: 'Native Model', input: ['text'] }, + }, }, }) } @@ -1033,24 +1038,14 @@ test('Claude uses partial stream JSON and resumes its native session', () => { assert.equal(spec.promptArg, true) }) -test('Claude selected Provider overrides native cloud selection without credentials in argv', () => { +test('Claude keeps its native credentials without a settings override', () => { const spec = invocation('claude', '/tmp/claude', '/tmp/work', 'native-session', { - provider: { id: 'anthropic', model: 'selected-model', baseUrl: 'https://selected.example' }, - env: { ANTHROPIC_API_KEY: 'selected-secret-key' }, + sandbox: 'read-only', }) - const settings = JSON.parse(spec.args[spec.args.indexOf('--settings') + 1]) - assert.equal(settings.env.ANTHROPIC_BASE_URL, 'https://selected.example') - assert.equal(settings.env.ANTHROPIC_API_KEY, '') - assert.equal(settings.env.ANTHROPIC_AUTH_TOKEN, '') - assert.equal(settings.env.CLAUDE_CODE_OAUTH_TOKEN, '') - assert.equal(settings.env.ANTHROPIC_CUSTOM_HEADERS, '') - assert.match(settings.apiKeyHelper, /MELDWORK_PROVIDER_API_KEY/) - for (const flag of ['BEDROCK', 'VERTEX', 'FOUNDRY', 'MANTLE']) { - assert.equal(settings.env[`CLAUDE_CODE_USE_${flag}`], '0') - } - assert.equal(spec.args[spec.args.indexOf('--model') + 1], 'selected-model') + assert.equal(spec.args.includes('--settings'), false) + assert.equal(spec.args.includes('--model'), false) assert.equal(spec.args[spec.args.indexOf('--resume') + 1], 'native-session') - assert.equal(JSON.stringify(spec.args).includes('selected-secret-key'), false) + assert.equal(spec.args[spec.args.indexOf('--permission-mode') + 1], 'plan') }) test('MiMo uses ACP by default and declares its JSON setup fallback', () => { @@ -1230,14 +1225,13 @@ test('Qwen uses plan mode by default and auto-edit after authorization', () => { ]) }) -test('Qwen selects OpenAI auth when the shared provider is enabled', () => { +test('Qwen keeps its native auth without a provider override', () => { const spec = invocation('qwen', '/tmp/qwen', '/tmp/work', '', { - provider: { id: 'openai', model: 'glm' }, + sandbox: 'read-only', }) assert.deepEqual(spec.args, [ '--output-format', 'stream-json', '--include-partial-messages', '--approval-mode', 'plan', - '--auth-type', 'openai', '--model', 'glm', ]) }) diff --git a/desktop/test/agents/cli/cli-adapters.test.cjs b/desktop/test/agents/cli/cli-adapters.test.cjs index d55cd28..79866c3 100644 --- a/desktop/test/agents/cli/cli-adapters.test.cjs +++ b/desktop/test/agents/cli/cli-adapters.test.cjs @@ -26,7 +26,25 @@ const { searchPath, } = require('../../../src/agents/cli/cli-adapters.cjs') const { resolveConnectorEventProfile } = require('../../../src/agents/cli/cli-event-profiles.cjs') -const { managedOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs') +const { nativeOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs') +function openClawAdapterRuntime(storageRoot, workdir, sessionRef = '') { + return nativeOpenClawOptions({ + storageRoot, + workdir, + ...(sessionRef ? { sessionRef } : {}), + runtime: { + model: 'native/model', + provider: { + id: 'native', + baseUrl: 'https://native.example.com/v1', + api: 'openai-completions', + apiKey: 'adapter-openclaw-key', + model: { id: 'model', name: 'Native Model', input: ['text'] }, + }, + }, + }) +} + const { executable, readJsonWhenReady, @@ -2008,16 +2026,7 @@ process.stdout.write(JSON.stringify({ })) `) t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const runtime = managedOpenClawOptions({ - storageRoot: directory, - workdir, - sessionRef: 'agent:main:adapter-workspace', - provider: { - OPENAI_API_KEY: 'adapter-openclaw-key', - OPENAI_BASE_URL: 'https://api.example.com/v1', - OPENAI_MODEL: 'adapter-model', - }, - }) + const runtime = openClawAdapterRuntime(directory, workdir, 'agent:main:adapter-workspace') const events = [] const result = await runAgent( @@ -2092,16 +2101,7 @@ process.stdout.write(JSON.stringify({ `) t.after(() => fs.rmSync(directory, { recursive: true, force: true })) const stableSessionRef = 'agent:main:openclaw-acp-fallback' - const runtime = managedOpenClawOptions({ - storageRoot: directory, - workdir, - sessionRef: stableSessionRef, - provider: { - OPENAI_API_KEY: 'adapter-openclaw-key', - OPENAI_BASE_URL: 'https://api.example.com/v1', - OPENAI_MODEL: 'adapter-model', - }, - }) + const runtime = openClawAdapterRuntime(directory, workdir, stableSessionRef) const result = await runAgent( { kind: 'openclaw', executable: cli, name: 'OpenClaw' }, diff --git a/desktop/test/agents/cli/cli-claude-provider.test.cjs b/desktop/test/agents/cli/cli-claude-provider.test.cjs deleted file mode 100644 index 00647de..0000000 --- a/desktop/test/agents/cli/cli-claude-provider.test.cjs +++ /dev/null @@ -1,102 +0,0 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const http = require('node:http') -const os = require('node:os') -const path = require('node:path') -const { execFile } = require('node:child_process') -const { promisify } = require('node:util') -const test = require('node:test') -const { invocation, runAgent } = require('../../../src/agents/cli/cli-adapters.cjs') -const { providerOptionsFor } = require('../../../src/providers/provider-options.cjs') - -test('Claude credential helper reads a secret as data without shell expansion', { - skip: process.platform === 'win32', -}, async () => { - const secret = 'test-$(exit 37)-`exit 38`-"quoted"-$HOME-&-secret' - const spec = invocation('claude', '/tmp/claude', '/tmp', '', { - provider: { id: 'anthropic', model: 'test-model', baseUrl: 'https://example.com' }, - platform: 'linux', - }) - const settings = JSON.parse(spec.args[spec.args.indexOf('--settings') + 1]) - const result = await promisify(execFile)('/bin/sh', ['-c', settings.apiKeyHelper], { - env: { MELDWORK_PROVIDER_API_KEY: secret }, timeout: 5000, - }) - assert.equal(result.stdout, secret) - assert.equal(JSON.stringify(spec.args).includes(secret), false) -}) - -test('Claude Windows credential helper does not interpolate the key into cmd syntax', () => { - const spec = invocation('claude', 'C:\\claude.exe', 'C:\\work', '', { - provider: { id: 'anthropic', model: 'test-model' }, platform: 'win32', - }) - const settings = JSON.parse(spec.args[spec.args.indexOf('--settings') + 1]) - assert.match(settings.apiKeyHelper, /GetEnvironmentVariable\('MELDWORK_PROVIDER_API_KEY'\)/) - assert.doesNotMatch(settings.apiKeyHelper, /%MELDWORK_PROVIDER_API_KEY%/) -}) - -test('native Claude uses the selected Provider despite conflicting native settings', { - skip: !process.env.MELDWORK_TEST_CLAUDE_EXECUTABLE, - timeout: 40000, -}, async (t) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-claude-provider-')) - t.after(() => fs.rmSync(root, { recursive: true, force: true })) - const requests = [] - const selectedKey = 'selected-validation-key' - async function endpoint(label) { - const server = http.createServer((req, res) => { - req.resume() - if (req.method === 'HEAD') { res.writeHead(200); res.end(); return } - requests.push({ label, selectedKey: req.headers['x-api-key'] === selectedKey - || req.headers.authorization === `Bearer ${selectedKey}` }) - res.writeHead(200, { 'Content-Type': 'text/event-stream' }) - for (const event of [ - { type: 'message_start', message: { - id: 'msg_local', type: 'message', role: 'assistant', model: 'claude-sonnet-4-6', - content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 }, - } }, - { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, - { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'LOCAL_PROVIDER_OK' } }, - { type: 'content_block_stop', index: 0 }, - { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } }, - { type: 'message_stop' }, - ]) res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`) - res.end() - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections() })) - return `http://127.0.0.1:${server.address().port}` - } - const selected = await endpoint('selected') - const native = await endpoint('native') - const config = path.join(root, '.claude') - fs.mkdirSync(config) - const filename = path.join(config, 'settings.json') - const settings = JSON.stringify({ - apiKeyHelper: 'exit 39', permissions: { deny: ['Bash(*)'] }, - env: { - CLAUDE_CODE_USE_BEDROCK: '1', ANTHROPIC_BASE_URL: native, - ANTHROPIC_API_KEY: 'native-validation-key', ANTHROPIC_AUTH_TOKEN: 'native-validation-token', - ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer native-header-token', - }, - }) - fs.writeFileSync(filename, settings) - const options = providerOptionsFor('claude', { - OPENAI_API_KEY: selectedKey, OPENAI_BASE_URL: selected, OPENAI_MODEL: 'claude-sonnet-4-6', - }) - const events = [] - const result = await runAgent({ kind: 'claude', executable: process.env.MELDWORK_TEST_CLAUDE_EXECUTABLE }, - 'Reply with OK. Do not use tools.', root, { - ...options, signal: AbortSignal.timeout(25000), onEvent: event => events.push(event), - env: { - ...options.env, HOME: root, USERPROFILE: root, CLAUDE_CONFIG_DIR: config, - XDG_CONFIG_HOME: root, XDG_DATA_HOME: root, XDG_STATE_HOME: root, XDG_CACHE_HOME: root, - HTTP_PROXY: '', HTTPS_PROXY: '', ALL_PROXY: '', http_proxy: '', https_proxy: '', all_proxy: '', - NO_PROXY: '*', no_proxy: '*', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', - }, - }) - assert.equal(result.text, 'LOCAL_PROVIDER_OK') - assert.ok(requests.length > 0) - assert.ok(requests.every(request => request.label === 'selected' && request.selectedKey)) - assert.equal(fs.readFileSync(filename, 'utf8'), settings) - assert.equal(JSON.stringify(events).includes(selectedKey), false) -}) diff --git a/desktop/test/agents/cli/cli-native-acp-lifecycle.test.cjs b/desktop/test/agents/cli/cli-native-acp-lifecycle.test.cjs index 63f9756..eef733e 100644 --- a/desktop/test/agents/cli/cli-native-acp-lifecycle.test.cjs +++ b/desktop/test/agents/cli/cli-native-acp-lifecycle.test.cjs @@ -5,7 +5,25 @@ const os = require('node:os') const path = require('node:path') const adapters = require('../../../src/agents/cli/cli-adapters.cjs') -const { managedOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs') +const { nativeOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs') +function openClawAdapterRuntime(storageRoot, workdir, sessionRef = '') { + return nativeOpenClawOptions({ + storageRoot, + workdir, + ...(sessionRef ? { sessionRef } : {}), + runtime: { + model: 'native/model', + provider: { + id: 'native', + baseUrl: 'https://native.example.com/v1', + api: 'openai-completions', + apiKey: 'adapter-openclaw-key', + model: { id: 'model', name: 'Native Model', input: ['text'] }, + }, + }, + }) +} + const { executable, readJsonWhenReady, @@ -628,16 +646,7 @@ input.on('line', (line) => { const spec = adapters.invocation('openclaw', cli, workdir, stableSessionRef) assert.equal(spec.eventTransport, 'acp') - const runtime = managedOpenClawOptions({ - storageRoot: directory, - workdir, - sessionRef: stableSessionRef, - provider: { - OPENAI_API_KEY: 'adapter-openclaw-key', - OPENAI_BASE_URL: 'https://api.example.com/v1', - OPENAI_MODEL: 'adapter-model', - }, - }) + const runtime = openClawAdapterRuntime(directory, workdir, stableSessionRef) const events = [] let receivedStream const streamReceived = new Promise(resolve => { receivedStream = resolve }) @@ -764,15 +773,7 @@ input.on('line', (line) => { }) `) let settled = false - const runtime = managedOpenClawOptions({ - storageRoot: directory, - workdir, - provider: { - OPENAI_API_KEY: 'adapter-openclaw-key', - OPENAI_BASE_URL: 'https://api.example.com/v1', - OPENAI_MODEL: 'adapter-model', - }, - }) + const runtime = openClawAdapterRuntime(directory, workdir) const resultPromise = adapters.runAgent( { kind: 'openclaw', executable: cli, name: 'OpenClaw' }, 'hold until shutdown', @@ -857,15 +858,7 @@ input.on('line', (line) => { }) `) let settled = false - const runtime = managedOpenClawOptions({ - storageRoot: directory, - workdir, - provider: { - OPENAI_API_KEY: 'adapter-openclaw-key', - OPENAI_BASE_URL: 'https://api.example.com/v1', - OPENAI_MODEL: 'adapter-model', - }, - }) + const runtime = openClawAdapterRuntime(directory, workdir) const resultPromise = adapters.runAgent( { kind: 'openclaw', executable: cli, name: 'OpenClaw' }, 'stop during Gateway readiness', @@ -957,15 +950,7 @@ input.on('line', (line) => { }) `) let settled = false - const runtime = managedOpenClawOptions({ - storageRoot: directory, - workdir, - provider: { - OPENAI_API_KEY: 'adapter-openclaw-key', - OPENAI_BASE_URL: 'https://api.example.com/v1', - OPENAI_MODEL: 'adapter-model', - }, - }) + const runtime = openClawAdapterRuntime(directory, workdir) const resultPromise = adapters.runAgent( { kind: 'openclaw', executable: cli, name: 'OpenClaw' }, 'stop during Gateway health', diff --git a/desktop/test/agents/cli/cli-process-support.test.cjs b/desktop/test/agents/cli/cli-process-support.test.cjs index a10c967..79f35de 100644 --- a/desktop/test/agents/cli/cli-process-support.test.cjs +++ b/desktop/test/agents/cli/cli-process-support.test.cjs @@ -5,7 +5,25 @@ const os = require('node:os') const path = require('node:path') const { childEnvironment, failedAgentProcessError } = require('../../../src/agents/cli/cli-process-support.cjs') -const { managedOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs') +const { nativeOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs') + +function openClawChildEnvRuntime(directory, workdir) { + return nativeOpenClawOptions({ + storageRoot: directory, + workdir, + sessionRef: 'agent:main:child-env', + runtime: { + model: 'native/model', + provider: { + id: 'native', + baseUrl: 'https://native.example.com/v1', + api: 'openai-completions', + apiKey: 'selected-openclaw-key', + model: { id: 'model', name: 'Native Model', input: ['text'] }, + }, + }, + }) +} test('local Agent child environments keep supported runtime settings but reject process injection', () => { const env = childEnvironment( @@ -69,16 +87,7 @@ test('OpenClaw child environment keeps only its guarded runtime and selected cre } }) - const runtime = managedOpenClawOptions({ - storageRoot: directory, - workdir, - sessionRef: 'agent:main:child-env', - provider: { - OPENAI_API_KEY: 'selected-openclaw-key', - OPENAI_BASE_URL: 'https://api.example.com/v1', - OPENAI_MODEL: 'example-model', - }, - }) + const runtime = openClawChildEnvRuntime(directory, workdir) const env = childEnvironment( { kind: 'openclaw' }, workdir, @@ -89,7 +98,7 @@ test('OpenClaw child environment keeps only its guarded runtime and selected cre https_proxy: 'http://proxy.example:3128', NO_PROXY: 'localhost,127.0.0.1,::1', NODE_EXTRA_CA_CERTS: '/certs/company.pem', - MELDWORK_OPENCLAW_NATIVE_API_KEY: 'wrong-openclaw-key', + MELDWORK_OPENCLAW_API_KEY: 'legacy-openclaw-key', GITHUB_TOKEN: 'caller-secret', ANTHROPIC_API_KEY: 'caller-anthropic-secret', XDG_CONFIG_HOME: '/tmp/caller-config', @@ -98,13 +107,13 @@ test('OpenClaw child environment keeps only its guarded runtime and selected cre process.platform, ) - assert.equal(env.MELDWORK_OPENCLAW_API_KEY, 'selected-openclaw-key') + assert.equal(env.MELDWORK_OPENCLAW_NATIVE_API_KEY, 'selected-openclaw-key') assert.equal(env.https_proxy, 'http://proxy.example:3128') assert.equal(env.NO_PROXY, 'localhost,127.0.0.1,::1') assert.equal(env.NODE_EXTRA_CA_CERTS, '/certs/company.pem') assert.equal(env.OPENCLAW_GATEWAY_TOKEN, runtime.env.OPENCLAW_GATEWAY_TOKEN) assert.equal(env.OPENCLAW_DISABLE_BONJOUR, '1') - assert.equal(Object.hasOwn(env, 'MELDWORK_OPENCLAW_NATIVE_API_KEY'), false) + assert.equal(Object.hasOwn(env, 'MELDWORK_OPENCLAW_API_KEY'), false) assert.equal(Object.hasOwn(env, 'GITHUB_TOKEN'), false) assert.equal(Object.hasOwn(env, 'ANTHROPIC_API_KEY'), false) assert.equal(env.OPENCLAW_WORKSPACE_DIR, workdir) diff --git a/desktop/test/agents/cli/openclaw-runtime.test.cjs b/desktop/test/agents/cli/openclaw-runtime.test.cjs index a9816e1..3c0cade 100644 --- a/desktop/test/agents/cli/openclaw-runtime.test.cjs +++ b/desktop/test/agents/cli/openclaw-runtime.test.cjs @@ -7,7 +7,6 @@ const path = require('node:path') const { configureOpenClawGatewayRuntime, - managedOpenClawOptions, nativeOpenClawOptions, refreshOpenClawRuntimeAfterStartup, validateOpenClawRuntimeGuard, @@ -26,14 +25,6 @@ function fixture() { return { directory, workdir } } -function provider(apiKey = 'test-openclaw-key') { - return { - OPENAI_API_KEY: apiKey, - OPENAI_BASE_URL: 'https://api.example.com/v1', - OPENAI_MODEL: 'example-model', - } -} - function nativeRuntime(apiKey = 'native-openclaw-key') { return { model: 'native/model', @@ -60,37 +51,7 @@ function runtimeScope(sessionRef, workdir) { .slice(0, 24) } -test('managed runtime isolates OpenClaw state and keeps the API key out of config', (t) => { - const { directory, workdir } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - - const result = managedOpenClawOptions({ - storageRoot: directory, - workdir, - sessionRef: 'agent:main:desktop-meldwork-group-topic-openclaw', - provider: provider(), - }) - const configText = fs.readFileSync(result.env.OPENCLAW_CONFIG_PATH, 'utf8') - const config = JSON.parse(configText) - - assert.equal(configText.includes('test-openclaw-key'), false) - assert.deepEqual(config.models.providers['meldwork-desktop'].apiKey, { - source: 'env', provider: 'default', id: 'MELDWORK_OPENCLAW_API_KEY', - }) - assert.equal(config.agents.defaults.workspace, workdir) - assert.equal(config.agents.defaults.skipBootstrap, true) - assert.deepEqual(config.tools.allow, [ - 'read', 'web_search', 'web_fetch', 'memory_search', 'memory_get', 'session_status', - ]) - assert.equal(config.tools.fs.workspaceOnly, true) - assert.equal(config.tools.elevated.enabled, false) - assert.equal(result.env.MELDWORK_OPENCLAW_API_KEY, 'test-openclaw-key') - assert.equal(result.env.OPENCLAW_WORKSPACE_DIR, workdir) - assert.ok(result.env.OPENCLAW_STATE_DIR.startsWith(fs.realpathSync(directory))) - assert.equal(fs.statSync(result.env.OPENCLAW_CONFIG_PATH).mode & 0o777, 0o600) -}) - -test('managed runtime scope is keyed by session, workspace, and CLI version', (t) => { +test('native runtime scope is keyed by session, workspace, and CLI version', (t) => { const { directory, workdir } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) @@ -98,12 +59,12 @@ test('managed runtime scope is keyed by session, workspace, and CLI version', (t storageRoot: directory, workdir, sessionRef: 'agent:main:desktop-meldwork-scope-openclaw', - provider: provider(), + runtime: nativeRuntime(), } - const versionA = managedOpenClawOptions({ ...base, openclawVersion: 'OpenClaw 2026.7.1-2 (a1b2c3)' }) - const versionARepeat = managedOpenClawOptions({ ...base, openclawVersion: 'OpenClaw 2026.7.1-2 (a1b2c3)' }) - const versionB = managedOpenClawOptions({ ...base, openclawVersion: 'OpenClaw 2026.9.1 (ad6fe23)' }) - const unversioned = managedOpenClawOptions({ ...base }) + const versionA = nativeOpenClawOptions({ ...base, openclawVersion: 'OpenClaw 2026.7.1-2 (a1b2c3)' }) + const versionARepeat = nativeOpenClawOptions({ ...base, openclawVersion: 'OpenClaw 2026.7.1-2 (a1b2c3)' }) + const versionB = nativeOpenClawOptions({ ...base, openclawVersion: 'OpenClaw 2026.9.1 (ad6fe23)' }) + const unversioned = nativeOpenClawOptions({ ...base }) assert.equal(versionA.env.OPENCLAW_STATE_DIR, versionARepeat.env.OPENCLAW_STATE_DIR) assert.notEqual(versionA.env.OPENCLAW_STATE_DIR, versionB.env.OPENCLAW_STATE_DIR) @@ -114,11 +75,11 @@ test('managed runtime scope is keyed by session, workspace, and CLI version', (t test('Gateway setup rewrites the guarded config without persisting credentials', (t) => { const { directory, workdir } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const runtime = managedOpenClawOptions({ + const runtime = nativeOpenClawOptions({ storageRoot: directory, workdir, sessionRef: 'agent:main:gateway-runtime', - provider: provider(), + runtime: nativeRuntime(), }) const configured = configureOpenClawGatewayRuntime(runtime, 43123) @@ -147,15 +108,15 @@ test('Gateway setup rewrites the guarded config without persisting credentials', assert.equal(config.update.checkOnStart, false) assert.equal(config.update.auto.enabled, false) assert.equal(config.logging.file, path.join(runtime.env.OPENCLAW_STATE_DIR, 'gateway.log')) - assert.equal(configText.includes(runtime.env.MELDWORK_OPENCLAW_API_KEY), false) + assert.equal(configText.includes(runtime.env.MELDWORK_OPENCLAW_NATIVE_API_KEY), false) assert.equal(configText.includes(runtime.env.OPENCLAW_GATEWAY_TOKEN), false) }) test('startup migration refreshes the guard without changing execution policy', (t) => { const { directory, workdir } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const options = configureOpenClawGatewayRuntime(managedOpenClawOptions({ - storageRoot: directory, workdir, provider: provider(), + const options = configureOpenClawGatewayRuntime(nativeOpenClawOptions({ + storageRoot: directory, workdir, runtime: nativeRuntime(), }), 43123) const configPath = options.env.OPENCLAW_CONFIG_PATH const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) @@ -178,7 +139,7 @@ test('startup migration rejects changed policies, credentials, paths and unknown workspace: config => { config.agents.defaults.workspace = os.homedir() }, agentOverride: config => { config.agents.entries = { main: { tools: { allow: ['exec'] } } } }, credentials: config => { config.gateway.auth.token = 'replacement-token' }, - provider: config => { config.models.providers['meldwork-desktop'].baseUrl = 'https://other.example' }, + provider: config => { config.models.providers.native.baseUrl = 'https://other.example' }, bind: config => { config.gateway.bind = 'lan' }, discovery: config => { config.discovery.wideArea.enabled = true }, unknown: config => { config.plugins = { enabled: true } }, @@ -187,8 +148,8 @@ test('startup migration rejects changed policies, credentials, paths and unknown await t.test(name, () => { const { directory, workdir } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const options = configureOpenClawGatewayRuntime(managedOpenClawOptions({ - storageRoot: directory, workdir, provider: provider(), + const options = configureOpenClawGatewayRuntime(nativeOpenClawOptions({ + storageRoot: directory, workdir, runtime: nativeRuntime(), }), 43123) const configPath = options.env.OPENCLAW_CONFIG_PATH const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) @@ -204,7 +165,7 @@ test('startup migration rejects changed policies, credentials, paths and unknown test('startup migration rejects symlinks and broadened file permissions', (t) => { const { directory, workdir } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const options = managedOpenClawOptions({ storageRoot: directory, workdir, provider: provider() }) + const options = nativeOpenClawOptions({ storageRoot: directory, workdir, runtime: nativeRuntime() }) const configPath = options.env.OPENCLAW_CONFIG_PATH fs.renameSync(configPath, `${configPath}.original`) fs.symlinkSync(`${configPath}.original`, configPath) @@ -220,7 +181,7 @@ test('startup migration rejects symlinks and broadened file permissions', (t) => test('startup migration rejects an oversized config before parsing it', (t) => { const { directory, workdir } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const options = managedOpenClawOptions({ storageRoot: directory, workdir, provider: provider() }) + const options = nativeOpenClawOptions({ storageRoot: directory, workdir, runtime: nativeRuntime() }) fs.appendFileSync(options.env.OPENCLAW_CONFIG_PATH, ' '.repeat(256 * 1024)) assert.throws(() => refreshOpenClawRuntimeAfterStartup(options), { message: 'OPENCLAW_RUNTIME_UNSAFE_PATH', @@ -286,11 +247,11 @@ process.on('SIGTERM', () => { setInterval(() => {}, 1000) `) t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const runtime = managedOpenClawOptions({ + const runtime = nativeOpenClawOptions({ storageRoot: directory, workdir, sessionRef: 'agent:main:gateway-lifecycle', - provider: provider(), + runtime: nativeRuntime(), }) let callbackPort @@ -364,11 +325,11 @@ process.on('SIGTERM', () => { setInterval(() => {}, 1000) `) t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const runtime = managedOpenClawOptions({ + const runtime = nativeOpenClawOptions({ storageRoot: directory, workdir, sessionRef: 'agent:main:gateway-callback-failed', - provider: provider(), + runtime: nativeRuntime(), }) await assert.rejects(() => withOpenClawGateway({ @@ -422,11 +383,11 @@ if (args[commandOffset + 1] === 'health') { } `) t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const runtime = managedOpenClawOptions({ + const runtime = nativeOpenClawOptions({ storageRoot: directory, workdir, sessionRef: `agent:main:gateway-health-${scenario.name}`, - provider: provider(), + runtime: nativeRuntime(), }) let callbackCalled = false @@ -457,23 +418,6 @@ test('OpenClaw ACP keeps deterministic global CLI flags before the subcommand', ]) }) -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['meldwork-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 })) @@ -482,10 +426,10 @@ test('write authorization uses an immutable config and preserves the topic state storageRoot: directory, workdir, sessionRef: 'agent:main:desktop-meldwork-group-topic-openclaw', - provider: provider(), + runtime: nativeRuntime(), } - const readOnly = managedOpenClawOptions(input) - const writable = managedOpenClawOptions({ ...input, allowWrite: true }) + const readOnly = nativeOpenClawOptions(input) + const writable = nativeOpenClawOptions({ ...input, allowWrite: true }) const readOnlyConfig = JSON.parse(fs.readFileSync(readOnly.env.OPENCLAW_CONFIG_PATH, 'utf8')) const writableConfig = JSON.parse(fs.readFileSync(writable.env.OPENCLAW_CONFIG_PATH, 'utf8')) @@ -498,17 +442,13 @@ test('write authorization uses an immutable config and preserves the topic state assert.equal(writableConfig.tools.exec.security, 'deny') }) -test('native auth uses the same isolated tool policy without importing user configuration', (t) => { +test('native auth uses the isolated tool policy without importing user configuration', (t) => { const { directory, workdir } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const common = { + const nativeOptions = nativeOpenClawOptions({ storageRoot: directory, workdir, sessionRef: 'agent:main:desktop-meldwork-native-openclaw', - } - const providerOptions = managedOpenClawOptions({ ...common, provider: provider() }) - const nativeOptions = nativeOpenClawOptions({ - ...common, runtime: { ...nativeRuntime(), provider: { @@ -519,10 +459,11 @@ test('native auth uses the same isolated tool policy without importing user conf tools: { allow: ['exec'] }, }, }) - const providerConfig = JSON.parse(fs.readFileSync(providerOptions.env.OPENCLAW_CONFIG_PATH, 'utf8')) const nativeConfig = JSON.parse(fs.readFileSync(nativeOptions.env.OPENCLAW_CONFIG_PATH, 'utf8')) - assert.deepEqual(nativeConfig.tools, providerConfig.tools) + assert.deepEqual(nativeConfig.tools.allow, [ + 'read', 'web_search', 'web_fetch', 'memory_search', 'memory_get', 'session_status', + ]) assert.equal(nativeConfig.models.mode, 'replace') assert.equal(nativeConfig.agents.defaults.model.primary, 'native/model') assert.equal(Object.hasOwn(nativeConfig.agents, 'list'), false) @@ -536,7 +477,6 @@ test('native auth uses the same isolated tool policy without importing user conf assert.equal(JSON.stringify(nativeConfig).includes('native-openclaw-key'), false) assert.equal(Object.hasOwn(nativeConfig.models.providers.native.models[0], 'params'), false) assert.equal(nativeOptions.env.MELDWORK_OPENCLAW_NATIVE_API_KEY, 'native-openclaw-key') - assert.equal(Object.hasOwn(nativeOptions.env, 'MELDWORK_OPENCLAW_API_KEY'), false) assert.ok(nativeOptions.env.OPENCLAW_STATE_DIR.startsWith(fs.realpathSync(directory))) }) @@ -592,7 +532,7 @@ test('native runtime descriptors fail closed before a config is written', (t) => } }) -test('managed and native runtime boundaries reject every SecretRef form', (t) => { +test('native runtime boundaries reject every SecretRef form', (t) => { const { directory, workdir } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) const references = [ @@ -613,11 +553,6 @@ test('managed and native runtime boundaries reject every SecretRef form', (t) => ] for (const apiKey of references) { - assert.throws(() => managedOpenClawOptions({ - storageRoot: directory, - workdir, - provider: provider(apiKey), - }), { message: 'OPENCLAW_PROVIDER_INVALID' }) assert.throws(() => nativeOpenClawOptions({ storageRoot: directory, workdir, @@ -655,11 +590,11 @@ test('runtime paths reject pre-existing symlinks without writing outside app sto }[position] fs.symlinkSync(external, target, 'dir') - assert.throws(() => managedOpenClawOptions({ + assert.throws(() => nativeOpenClawOptions({ storageRoot, workdir, sessionRef, - provider: provider(), + runtime: nativeRuntime(), }), { message: 'OPENCLAW_RUNTIME_UNSAFE_PATH' }) assert.deepEqual(fs.readdirSync(external), []) } @@ -681,7 +616,7 @@ test('runtime paths repair overly broad directory permissions', { fs.chmodSync(target, 0o777) } - managedOpenClawOptions({ storageRoot, workdir, sessionRef, provider: provider() }) + nativeOpenClawOptions({ storageRoot, workdir, sessionRef, runtime: nativeRuntime() }) for (const target of directories) { assert.equal(fs.statSync(target).mode & 0o777, 0o700) @@ -738,11 +673,11 @@ test('runtime config creation revalidates parent identities before writing', { } try { - assert.throws(() => managedOpenClawOptions({ + assert.throws(() => nativeOpenClawOptions({ storageRoot: directory, workdir, sessionRef: 'agent:main:runtime-parent-change', - provider: provider(), + runtime: nativeRuntime(), }), { message: 'OPENCLAW_RUNTIME_UNSAFE_PATH' }) } finally { fs.realpathSync = originalRealpath @@ -756,11 +691,11 @@ test('runtime guard rejects directory and config identity changes before executi const { directory, workdir } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const directoryOptions = managedOpenClawOptions({ + const directoryOptions = nativeOpenClawOptions({ storageRoot: directory, workdir, sessionRef: 'agent:main:runtime-directory-guard', - provider: provider(), + runtime: nativeRuntime(), }) const runtimeRoot = path.dirname(directoryOptions.env.OPENCLAW_CONFIG_PATH) const originalRuntimeRoot = `${runtimeRoot}.original` @@ -774,11 +709,11 @@ test('runtime guard rejects directory and config identity changes before executi directoryOptions.env, ), { message: 'OPENCLAW_RUNTIME_UNSAFE_PATH' }) - const fileOptions = managedOpenClawOptions({ + const fileOptions = nativeOpenClawOptions({ storageRoot: directory, workdir, sessionRef: 'agent:main:runtime-file-guard', - provider: provider(), + runtime: nativeRuntime(), }) const configPath = fileOptions.env.OPENCLAW_CONFIG_PATH const contents = fs.readFileSync(configPath) @@ -795,20 +730,20 @@ test('runtime guard rejects in-place config and credential changes without expos const { directory, workdir } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const configOptions = managedOpenClawOptions({ + const configOptions = nativeOpenClawOptions({ storageRoot: directory, workdir, sessionRef: 'agent:main:runtime-content-guard', - provider: provider(), + runtime: nativeRuntime(), }) const serializedGuard = JSON.stringify(configOptions.openClawRuntimeGuard) const credentialDigest = crypto.createHash('sha256') - .update(configOptions.env.MELDWORK_OPENCLAW_API_KEY) + .update(configOptions.env.MELDWORK_OPENCLAW_NATIVE_API_KEY) .digest('hex') const gatewayDigest = crypto.createHash('sha256') .update(configOptions.env.OPENCLAW_GATEWAY_TOKEN) .digest('hex') - assert.equal(serializedGuard.includes(configOptions.env.MELDWORK_OPENCLAW_API_KEY), false) + assert.equal(serializedGuard.includes(configOptions.env.MELDWORK_OPENCLAW_NATIVE_API_KEY), false) assert.equal(serializedGuard.includes(configOptions.env.OPENCLAW_GATEWAY_TOKEN), false) assert.equal(serializedGuard.includes(credentialDigest), false) assert.equal(serializedGuard.includes(gatewayDigest), false) @@ -823,17 +758,17 @@ test('runtime guard rejects in-place config and credential changes without expos configOptions.env, ), { message: 'OPENCLAW_RUNTIME_UNSAFE_PATH' }) - const credentialOptions = managedOpenClawOptions({ + const credentialOptions = nativeOpenClawOptions({ storageRoot: directory, workdir, sessionRef: 'agent:main:runtime-credential-guard', - provider: provider(), + runtime: nativeRuntime(), }) assert.throws(() => validateOpenClawRuntimeGuard( credentialOptions.openClawRuntimeGuard, { ...credentialOptions.env, - MELDWORK_OPENCLAW_API_KEY: 'tampered-openclaw-key', + MELDWORK_OPENCLAW_NATIVE_API_KEY: 'tampered-openclaw-key', }, ), { message: 'OPENCLAW_RUNTIME_CREDENTIAL_SCOPE_INVALID' }) assert.throws(() => validateOpenClawRuntimeGuard( @@ -852,11 +787,11 @@ test('runtime guard rejects permissions broadened after creation', { t.after(() => fs.rmSync(directory, { recursive: true, force: true })) for (const target of ['directory', 'config']) { - const options = managedOpenClawOptions({ + const options = nativeOpenClawOptions({ storageRoot: directory, workdir, sessionRef: `agent:main:runtime-${target}-permission-guard`, - provider: provider(), + runtime: nativeRuntime(), }) fs.chmodSync( target === 'directory' ? options.env.OPENCLAW_HOME : options.env.OPENCLAW_CONFIG_PATH, @@ -878,13 +813,13 @@ test('runtime paths are stable and isolated per topic and workspace', (t) => { storageRoot: directory, workdir, sessionRef: 'agent:main:desktop-meldwork-group-topic-openclaw', - provider: provider(), + runtime: nativeRuntime(), } - const first = managedOpenClawOptions(input) - const repeated = managedOpenClawOptions(input) - const otherTopic = managedOpenClawOptions({ ...input, sessionRef: `${input.sessionRef}-other` }) - const otherWorkspace = managedOpenClawOptions({ ...input, workdir: otherWorkdir }) + const first = nativeOpenClawOptions(input) + const repeated = nativeOpenClawOptions(input) + const otherTopic = nativeOpenClawOptions({ ...input, sessionRef: `${input.sessionRef}-other` }) + const otherWorkspace = nativeOpenClawOptions({ ...input, workdir: otherWorkdir }) assert.equal(repeated.env.OPENCLAW_STATE_DIR, first.env.OPENCLAW_STATE_DIR) assert.notEqual(otherTopic.env.OPENCLAW_STATE_DIR, first.env.OPENCLAW_STATE_DIR) @@ -892,19 +827,11 @@ test('runtime paths are stable and isolated per topic and workspace', (t) => { assert.equal(otherWorkspace.env.OPENCLAW_WORKSPACE_DIR, otherWorkdir) }) -test('invalid runtime scopes and Provider metadata fail closed', (t) => { +test('invalid runtime scopes fail closed', (t) => { const { directory, workdir } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - assert.throws(() => managedOpenClawOptions({ - storageRoot: 'relative', workdir, provider: provider(), + assert.throws(() => nativeOpenClawOptions({ + storageRoot: 'relative', workdir, runtime: nativeRuntime(), }), { message: 'OPENCLAW_RUNTIME_INVALID_SCOPE' }) - assert.throws(() => managedOpenClawOptions({ - storageRoot: directory, workdir, - provider: provider(''), - }), { message: 'OPENCLAW_PROVIDER_INVALID' }) - assert.throws(() => managedOpenClawOptions({ - storageRoot: directory, workdir, - provider: { ...provider(), OPENAI_BASE_URL: 'http://provider.example/v1' }, - }), { message: 'OPENCLAW_PROVIDER_INVALID' }) }) diff --git a/desktop/test/agents/connectors/agent-connector-local.test.cjs b/desktop/test/agents/connectors/agent-connector-local.test.cjs index e6de2fd..a977961 100644 --- a/desktop/test/agents/connectors/agent-connector-local.test.cjs +++ b/desktop/test/agents/connectors/agent-connector-local.test.cjs @@ -102,7 +102,6 @@ test('discovers an explicitly installed approved sample and exposes generic sani const [catalog] = connectors.catalog() assert.equal(catalog.kind, agent.kind) assert.equal(catalog.connector, true) - assert.equal(catalog.providerMode, 'connector') assert.equal(catalog.description, SAMPLE_AGENT_CONNECTOR_MANIFEST.description) assert.doesNotMatch(JSON.stringify(catalog), /private\/bin|credential-ref/i) diff --git a/desktop/test/agents/installer/agent-installer.test.cjs b/desktop/test/agents/installer/agent-installer.test.cjs index c7d5d8f..89f34c2 100644 --- a/desktop/test/agents/installer/agent-installer.test.cjs +++ b/desktop/test/agents/installer/agent-installer.test.cjs @@ -110,7 +110,7 @@ function detectedRelease(kind, platform = 'darwin', executable = `/tmp/${kind}`) } } -test('catalog reports installed, recommended and provider-compatible Agents', async () => { +test('catalog reports installed and recommended Agents', async () => { const service = installer({ detectAgents: async () => [ { @@ -142,13 +142,7 @@ test('catalog reports installed, recommended and provider-compatible Agents', as 'opencode', 'opencodereview', 'kimi', 'mimo', 'qwen', ]) assert.equal(result.agents.find(agent => agent.kind === 'workbuddy').installed, true) - assert.equal(result.agents.find(agent => agent.kind === 'workbuddy').providerCompatible, true) assert.equal(result.agents.find(agent => agent.kind === 'hermes').recommended, true) - assert.equal(result.agents.find(agent => agent.kind === 'openclaw').providerCompatible, true) - assert.equal(result.agents.find(agent => agent.kind === 'openclaw').providerSupport, - 'supported') - assert.equal(result.agents.find(agent => agent.kind === 'kimi').providerCompatible, false) - assert.equal(result.agents.find(agent => agent.kind === 'kimi').providerSupport, 'native-config') assert.equal(result.agents.find(agent => agent.kind === 'pi').installed, true) assert.equal(result.agents.find(agent => agent.kind === 'pi').version, '') assert.deepEqual( @@ -181,9 +175,6 @@ test('catalog reports installed, recommended and provider-compatible Agents', as ) assert.equal(result.agents.find(agent => agent.kind === 'openclaw').installSupported, true) assert.equal(result.agents.find(agent => agent.kind === 'openclaw').installErrorCode, '') - assert.equal(result.agents.find(agent => agent.kind === 'pi').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) assert.equal(JSON.stringify(result).includes('must-not-cross-ipc'), false) diff --git a/desktop/test/agents/local-agent-readiness.test.cjs b/desktop/test/agents/local-agent-readiness.test.cjs index 1ca92f6..ac3f811 100644 --- a/desktop/test/agents/local-agent-readiness.test.cjs +++ b/desktop/test/agents/local-agent-readiness.test.cjs @@ -877,7 +877,6 @@ test('model-specific Vertex regions participate in cache identity and stay scope assert.equal(before.env[key], 'us-east5') assert.equal(after.env[key], '') assert.deepEqual(nativeCredentialEnvironment('claude', after.env), { [key]: '' }) - assert.deepEqual(nativeCredentialEnvironment('claude', before.env, { providerConfigured: true }), {}) assert.deepEqual(nativeCredentialEnvironment('gemini', before.env), {}) assert.deepEqual(nativeCredentialEnvironment('codex', before.env), {}) const removed = await resolveNativeShellEnvironment({ ...base, env: { PATH: base.env.PATH } }) @@ -936,22 +935,6 @@ test('native cloud configuration is forwarded only to compatible Agents', () => assert.deepEqual(nativeCredentialEnvironment('codex', cloud), {}) }) -test('selected Provider retains native configuration roots without native credentials or selectors', () => { - const env = { - CLAUDE_CONFIG_DIR: '/tmp/claude-config', CODEX_HOME: '/tmp/codex-config', - PI_CODING_AGENT_DIR: '/tmp/pi-config', AWS_PROFILE: 'native', - CLAUDE_CODE_USE_BEDROCK: '1', ANTHROPIC_API_KEY: 'native-key', - ANTHROPIC_BASE_URL: 'https://native.example', OPENAI_API_KEY: 'other-key', - } - for (const [kind, root] of [ - ['claude', 'CLAUDE_CONFIG_DIR'], ['codex', 'CODEX_HOME'], ['pi', 'PI_CODING_AGENT_DIR'], - ]) { - assert.deepEqual(nativeCredentialEnvironment(kind, env, { providerConfigured: true }), { - [root]: env[root], - }) - } -}) - test('an installed Agent without credential evidence remains unverified', () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-missing-readiness-')) try { diff --git a/desktop/test/product-data-migration.test.cjs b/desktop/test/product-data-migration.test.cjs index 3c66a85..fd5060c 100644 --- a/desktop/test/product-data-migration.test.cjs +++ b/desktop/test/product-data-migration.test.cjs @@ -9,7 +9,7 @@ const { migrateLegacyProductData } = require('../src/product-data-migration.cjs' const LEGACY_STEM = ['round', 'relay'].join('') const MIGRATION_SUFFIXES = [ 'workspace.json', 'run-ledger.json', 'custom-agents.json', - 'provider.json', 'knowledge-base.json', 'private', + 'knowledge-base.json', 'private', ] function legacyPath(directory, suffix) { @@ -38,9 +38,6 @@ test('migrates legacy product data to Meldwork names without overwriting current fs.writeFileSync(legacyWorkspace, '{"groups":[]}') fs.mkdirSync(legacyPrivate) fs.writeFileSync(path.join(legacyPrivate, 'outcomes.json'), '{}') - fs.writeFileSync(path.join(directory, 'meldwork-provider.json'), '{"current":true}') - fs.writeFileSync(path.join(directory, `${LEGACY_STEM}-provider.json`), '{"legacy":true}') - const migrated = migrateLegacyProductData(directory) assert.deepEqual(migrated.sort(), [ @@ -50,8 +47,6 @@ test('migrates legacy product data to Meldwork names without overwriting current assert.equal(fs.existsSync(legacyWorkspace), false) assert.equal(fs.readFileSync(path.join(directory, 'meldwork-workspace.json'), 'utf8'), '{"groups":[]}') assert.equal(fs.readFileSync(path.join(directory, 'meldwork-private', 'outcomes.json'), 'utf8'), '{}') - assert.equal(fs.readFileSync(path.join(directory, 'meldwork-provider.json'), 'utf8'), '{"current":true}') - assert.equal(fs.existsSync(path.join(directory, `${LEGACY_STEM}-provider.json`)), true) }) test('rolls back every completed rename when any migration position fails', async (t) => { @@ -98,7 +93,7 @@ test('completes a mixed migration state without overwriting existing Meldwork ta const migrated = migrateLegacyProductData(directory) - assert.equal(migrated.length, 4) + assert.equal(migrated.length, 3) assert.equal(fs.readFileSync(currentPath(directory, 'workspace.json'), 'utf8'), '{"current":true}') assert.equal(fs.readFileSync(path.join(currentPath(directory, 'private'), 'current.json'), 'utf8'), '{}') assert.deepEqual(migrateLegacyProductData(directory), []) @@ -130,7 +125,7 @@ test('reports rollback failure and a second launch recovers the remaining mixed assert.equal(fs.existsSync(legacyPath(directory, 'workspace.json')), true) assert.equal(fs.existsSync(currentPath(directory, 'workspace.json')), false) - assert.equal(migrateLegacyProductData(directory).length, 5) + assert.equal(migrateLegacyProductData(directory).length, 4) for (const suffix of MIGRATION_SUFFIXES) { assert.equal(fs.existsSync(currentPath(directory, suffix)), true, suffix) assert.equal(fs.existsSync(legacyPath(directory, suffix)), false, suffix) diff --git a/desktop/test/providers/provider-options.test.cjs b/desktop/test/providers/provider-options.test.cjs deleted file mode 100644 index 26288a7..0000000 --- a/desktop/test/providers/provider-options.test.cjs +++ /dev/null @@ -1,167 +0,0 @@ -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') - -const { - EXTERNAL_PROVIDER_KINDS, - providerAgentKind, - providerOptionsFor, -} = require('../../src/providers/provider-options.cjs') - -const GENERIC = Object.freeze({ - OPENAI_API_KEY: 'provider-key', - OPENAI_BASE_URL: 'https://api.example.com/v1', - OPENAI_MODEL: 'example-model', -}) - -test('Provider Agent kinds are validated at the domain boundary', () => { - assert.equal(providerAgentKind(' hermes '), 'hermes') - assert.equal(EXTERNAL_PROVIDER_KINDS.has('opencodereview'), true) - assert.throws(() => providerAgentKind('unknown'), { message: 'PROVIDER_AGENT_UNSUPPORTED' }) -}) - -test('Provider options preserve the shared OpenAI-compatible environment', () => { - assert.deepEqual(providerOptionsFor('codex', GENERIC), { env: GENERIC }) - assert.deepEqual(providerOptionsFor('hermes', GENERIC), { - provider: { id: 'openai-api', model: 'example-model' }, - env: { - ...GENERIC, - HERMES_INFERENCE_PROVIDER: 'openai-api', - HERMES_INFERENCE_MODEL: 'example-model', - }, - }) - assert.deepEqual(providerOptionsFor('qwen', GENERIC), { - provider: { id: 'openai', model: 'example-model' }, - env: { ...GENERIC, DASHSCOPE_API_KEY: 'provider-key' }, - }) -}) - -test('Provider options map native Agent credential names without dropping generic values', () => { - const expected = { - workbuddy: { - CODEBUDDY_MODEL: 'example-model', - CODEBUDDY_API_KEY: 'provider-key', - CODEBUDDY_BASE_URL: 'https://api.example.com/v1', - }, - kimi: { - MOONSHOT_API_KEY: 'provider-key', - KIMI_API_KEY: 'provider-key', - KIMI_MODEL_API_KEY: 'provider-key', - KIMI_MODEL_BASE_URL: 'https://api.example.com/v1', - KIMI_MODEL_NAME: 'example-model', - }, - mimo: { - MIMO_API_KEY: 'provider-key', - MIMO_BASE_URL: 'https://api.example.com/v1', - MIMO_MODEL: 'example-model', - }, - claude: { - ANTHROPIC_API_KEY: 'provider-key', - MELDWORK_PROVIDER_API_KEY: 'provider-key', - ANTHROPIC_BASE_URL: 'https://api.example.com/v1', - ANTHROPIC_MODEL: 'example-model', - }, - gemini: { - GEMINI_API_KEY: 'provider-key', - GOOGLE_API_KEY: 'provider-key', - GEMINI_MODEL: 'example-model', - }, - } - - for (const [kind, values] of Object.entries(expected)) { - assert.deepEqual(providerOptionsFor(kind, GENERIC), { - ...(kind === 'claude' ? { provider: { id: 'anthropic', model: 'example-model', baseUrl: GENERIC.OPENAI_BASE_URL } } : {}), - env: { ...GENERIC, ...values }, - }) - } -}) - -test('OpenCode receives OpenRouter credentials only for an OpenRouter preset', () => { - assert.deepEqual(providerOptionsFor('opencode', GENERIC, {}, { - provider: 'Private OpenRouter proxy', - }), { - env: { ...GENERIC, OPENROUTER_API_KEY: 'provider-key' }, - }) - assert.deepEqual(providerOptionsFor('opencode', GENERIC, {}, { - activePreset: 'custom', - provider: 'OpenRouter', - }), { env: GENERIC }) -}) - -test('OpenCodeReview receives an exact chat completions endpoint', () => { - assert.deepEqual(providerOptionsFor('opencodereview', GENERIC), { - env: { - ...GENERIC, - OCR_LLM_URL: 'https://api.example.com/v1/chat/completions', - OCR_LLM_TOKEN: 'provider-key', - OCR_LLM_MODEL: 'example-model', - OCR_USE_ANTHROPIC: 'false', - }, - }) - assert.equal( - providerOptionsFor('opencodereview', { - ...GENERIC, - OPENAI_BASE_URL: 'https://api.example.com/v1/chat/completions/', - }).env.OCR_LLM_URL, - 'https://api.example.com/v1/chat/completions', - ) -}) - -test('OpenClaw Provider options remain scoped to its managed local runtime', (t) => { - const storageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-provider-options-')) - const workdir = path.join(storageRoot, 'workspace') - fs.mkdirSync(workdir) - t.after(() => fs.rmSync(storageRoot, { recursive: true, force: true })) - - assert.deepEqual(providerOptionsFor('openclaw', GENERIC), {}) - const options = providerOptionsFor('openclaw', GENERIC, { - storageRoot, - workdir, - sessionRef: 'agent:main:provider-options', - sandbox: 'workspace-write', - }) - const config = JSON.parse(fs.readFileSync(options.env.OPENCLAW_CONFIG_PATH, 'utf8')) - - assert.equal(options.env.MELDWORK_OPENCLAW_API_KEY, 'provider-key') - assert.equal(config.agents.defaults.workspace, workdir) - assert.equal(config.tools.allow.includes('write'), true) -}) - -test('OpenClaw native auth also receives an app-owned isolated runtime', (t) => { - const storageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-native-openclaw-options-')) - const workdir = path.join(storageRoot, 'workspace') - fs.mkdirSync(workdir) - t.after(() => fs.rmSync(storageRoot, { recursive: true, force: true })) - - const options = providerOptionsFor('openclaw', {}, { - storageRoot, - workdir, - sessionRef: 'agent:main:native-provider-options', - sandbox: 'read-only', - nativeRuntime: { - model: 'native/model', - provider: { - id: 'native', - baseUrl: 'https://native.example.com/v1', - api: 'openai-completions', - apiKey: 'native-provider-key', - model: { id: 'model', name: 'Native Model', input: ['text'] }, - }, - }, - }) - const config = JSON.parse(fs.readFileSync(options.env.OPENCLAW_CONFIG_PATH, 'utf8')) - - assert.equal(config.models.mode, 'replace') - assert.equal(config.agents.defaults.model.primary, 'native/model') - assert.equal(Object.hasOwn(config.agents, 'list'), false) - assert.equal(config.tools.allow.includes('write'), false) - assert.equal(config.tools.deny.includes('exec'), true) - assert.equal(options.env.MELDWORK_OPENCLAW_NATIVE_API_KEY, 'native-provider-key') - assert.equal(Object.hasOwn(options.env, 'MELDWORK_OPENCLAW_API_KEY'), false) -}) - -test('unknown Provider kinds do not create execution options', () => { - assert.deepEqual(providerOptionsFor('unknown', GENERIC), {}) -}) diff --git a/desktop/test/providers/provider-store.test.cjs b/desktop/test/providers/provider-store.test.cjs deleted file mode 100644 index ab9bf97..0000000 --- a/desktop/test/providers/provider-store.test.cjs +++ /dev/null @@ -1,480 +0,0 @@ -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { ProviderStore } = require('../../src/providers/provider-store.cjs') - -const PROVIDER = Object.freeze({ - provider: 'OpenAI Compatible', - baseUrl: 'https://api.example.com/v1', - model: 'example-model', -}) -const OPENROUTER_PROVIDER = Object.freeze({ - provider: 'OpenRouter', - baseUrl: 'https://openrouter.ai/api/v1', - model: 'openrouter/example-model', -}) -const HERMES_OFFICIAL_PROVIDER = Object.freeze({ - provider: 'OpenAI API', - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-5', -}) -const QWEN_OFFICIAL_PROVIDER = Object.freeze({ - provider: 'DashScope', - 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 } -} - -function emptyStatus(encryptionAvailable) { - return { - provider: '', baseUrl: '', model: '', activePreset: 'official', profiles: {}, - encryptionAvailable, configured: false, - } -} - -function configuredStatus(metadata = PROVIDER, preset = 'custom') { - return { - ...metadata, - activePreset: preset, - profiles: { [preset]: { ...metadata, configured: true } }, - encryptionAvailable: true, - configured: true, - } -} - -function encryptedSafeStorage(available = true) { - return { - isEncryptionAvailable: () => available, - encryptString: value => Buffer.from(value, 'utf8').map(byte => byte ^ 0xaa), - decryptString: value => Buffer.from(value).map(byte => byte ^ 0xaa).toString('utf8'), - } -} - -function fixture(safeStorage = encryptedSafeStorage(), allowedKinds = ['hermes', 'qwen', 'opencodereview']) { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-provider-')) - const storagePath = path.join(directory, 'meldwork-provider.json') - return { - directory, - storagePath, - store: new ProviderStore({ storagePath, safeStorage, allowedKinds }), - } -} - -test('status exposes user-configured metadata without the API key', (t) => { - const { directory, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - - assert.deepEqual(store.status('hermes'), emptyStatus(null)) - - store.save('hermes', credential()) - const status = store.status('hermes') - assert.deepEqual(status, configuredStatus()) - assert.equal('apiKey' in status, false) -}) - -test('status defers safeStorage until explicitly requested when no credential exists', (t) => { - let encryptionChecks = 0 - const safeStorage = encryptedSafeStorage() - safeStorage.isEncryptionAvailable = () => { - encryptionChecks += 1 - return true - } - const { directory, store } = fixture(safeStorage) - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - - assert.equal(store.status('hermes').encryptionAvailable, null) - assert.equal(encryptionChecks, 0) - assert.equal(store.status('hermes', { probeEncryption: true }).encryptionAvailable, true) - assert.equal(encryptionChecks, 1) -}) - -test('save encrypts the API key, writes atomically, and restricts file permissions', (t) => { - const { directory, storagePath, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const apiKey = 'test-key-never-plaintext' - - assert.deepEqual(store.save('hermes', credential(apiKey)), configuredStatus()) - const stored = fs.readFileSync(storagePath, 'utf8') - assert.equal(stored.includes(apiKey), false) - assert.equal(fs.statSync(storagePath).mode & 0o777, 0o600) - assert.deepEqual( - fs.readdirSync(directory).filter(name => name !== path.basename(storagePath)), - [], - ) -}) - -test('save accepts only the complete five-field Provider payload', (t) => { - const { directory, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - - assert.throws(() => store.save('hermes', {}), { message: 'PROVIDER_INVALID_CREDENTIAL' }) - assert.throws(() => store.save('hermes', credential('')), { message: 'PROVIDER_INVALID_CREDENTIAL' }) - assert.throws( - () => store.save('hermes', { ...credential(), extra: true }), - { message: 'PROVIDER_INVALID_CREDENTIAL' }, - ) - assert.throws( - () => store.save('hermes', { - apiKey: 'key', provider: 'Example', baseUrl: '', model: 'model', preset: 'custom', - }), - { message: 'PROVIDER_INVALID_METADATA' }, - ) - assert.throws( - () => store.save('hermes', credential('key', PROVIDER, 'unknown')), - { message: 'PROVIDER_PRESET_UNSUPPORTED' }, - ) -}) - -test('preset metadata must match canonical OpenRouter and Agent official Providers', (t) => { - const { directory, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - - assert.deepEqual( - store.save('hermes', credential('openrouter-key', { - ...OPENROUTER_PROVIDER, - baseUrl: `${OPENROUTER_PROVIDER.baseUrl}/`, - }, 'openrouter')), - configuredStatus(OPENROUTER_PROVIDER, 'openrouter'), - ) - assert.throws( - () => store.save('hermes', credential('key', { - ...OPENROUTER_PROVIDER, - provider: 'OpenRouter proxy', - }, 'openrouter')), - { message: 'PROVIDER_INVALID_METADATA' }, - ) - assert.throws( - () => store.save('hermes', credential('key', { - ...OPENROUTER_PROVIDER, - baseUrl: 'https://gateway.example/v1', - }, 'openrouter')), - { message: 'PROVIDER_INVALID_METADATA' }, - ) - store.delete('hermes', 'openrouter') - - assert.deepEqual( - store.save('hermes', credential('official-key', HERMES_OFFICIAL_PROVIDER, 'official')), - configuredStatus(HERMES_OFFICIAL_PROVIDER, 'official'), - ) - assert.deepEqual( - 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, - provider: 'OpenAI', - }, 'official')), - { message: 'PROVIDER_INVALID_METADATA' }, - ) - assert.throws( - () => store.save('qwen', credential('key', { - ...QWEN_OFFICIAL_PROVIDER, - baseUrl: 'https://api.openai.com/v1', - }, 'official')), - { message: 'PROVIDER_INVALID_METADATA' }, - ) - const workbuddy = fixture(encryptedSafeStorage(), ['workbuddy']) - t.after(() => fs.rmSync(workbuddy.directory, { recursive: true, force: true })) - assert.throws( - () => workbuddy.store.save('workbuddy', credential('key', { - provider: 'WorkBuddy Official', - baseUrl: 'https://api.example.com/v1', - model: 'workbuddy-model', - }, 'official')), - { message: 'PROVIDER_INVALID_METADATA' }, - ) -}) - -test('custom preset keeps accepting secure arbitrary and loopback endpoints', (t) => { - const { directory, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - - assert.doesNotThrow(() => store.save('hermes', credential('key', { - provider: 'Private OpenRouter proxy', - baseUrl: 'http://127.0.0.1:11434/v1', - model: 'local-model', - }, 'custom'))) -}) - -for (const kind of [ - 'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', '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 })) - store.save('hermes', credential('test-key-env')) - - assert.deepEqual(store.envForAgent('hermes'), { - OPENAI_API_KEY: 'test-key-env', - OPENAI_BASE_URL: PROVIDER.baseUrl, - OPENAI_MODEL: PROVIDER.model, - }) -}) - -test('multiple Provider profiles stay encrypted and can be activated independently', (t) => { - const { directory, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - - store.save('hermes', credential('openrouter-key', OPENROUTER_PROVIDER, 'openrouter')) - store.save('hermes', credential('custom-key', PROVIDER, 'custom')) - - assert.deepEqual(store.status('hermes'), { - ...PROVIDER, - activePreset: 'custom', - profiles: { - openrouter: { ...OPENROUTER_PROVIDER, configured: true }, - custom: { ...PROVIDER, configured: true }, - }, - encryptionAvailable: true, - configured: true, - }) - - store.activate('hermes', 'openrouter') - assert.equal(store.status('hermes').activePreset, 'openrouter') - assert.deepEqual(store.envForAgent('hermes'), { - OPENAI_API_KEY: 'openrouter-key', - OPENAI_BASE_URL: OPENROUTER_PROVIDER.baseUrl, - OPENAI_MODEL: OPENROUTER_PROVIDER.model, - }) - - const afterDelete = store.delete('hermes', 'openrouter') - assert.equal(afterDelete.activePreset, 'official') - assert.equal(afterDelete.configured, false) - assert.deepEqual(afterDelete.profiles, { - custom: { ...PROVIDER, configured: true }, - }) - store.activate('hermes', 'custom') - assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'custom-key') -}) - -test('encryption unavailability fails closed for status, save, and reads', (t) => { - const { directory, storagePath, store } = fixture(encryptedSafeStorage(false)) - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - - assert.deepEqual(store.status('hermes', { probeEncryption: true }), emptyStatus(false)) - assert.throws(() => store.save('hermes', credential()), { - message: 'PROVIDER_ENCRYPTION_UNAVAILABLE', - }) - assert.throws(() => store.envForAgent('hermes'), { - message: 'PROVIDER_ENCRYPTION_UNAVAILABLE', - }) - assert.equal(fs.existsSync(storagePath), false) -}) - -test('safeStorage encryption failures do not leave a credential file', (t) => { - const { directory, storagePath, store } = fixture({ - isEncryptionAvailable: () => true, - encryptString: value => Buffer.from(value), - decryptString: () => { throw new Error('keychain unavailable') }, - }) - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - - assert.throws(() => store.save('hermes', credential()), { - message: 'PROVIDER_ENCRYPTION_FAILED', - }) - assert.equal(fs.existsSync(storagePath), false) -}) - -test('corrupt credentials stay unavailable and are never returned', (t) => { - const { directory, storagePath, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - fs.writeFileSync(storagePath, JSON.stringify({ version: 1, encrypted: 'broken' }), { - mode: 0o600, - }) - - assert.deepEqual(store.status('hermes'), { ...emptyStatus(true), error: true }) - assert.throws(() => store.envForAgent('hermes'), { - message: 'PROVIDER_CREDENTIAL_UNAVAILABLE', - }) -}) - -test('temporarily locked credentials preserve saved profiles and recover without reconfiguration', (t) => { - const safeStorage = encryptedSafeStorage() - const { directory, storagePath, store } = fixture(safeStorage) - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - store.save('hermes', credential()) - const contents = fs.readFileSync(storagePath, 'utf8') - const decrypt = safeStorage.decryptString - safeStorage.isEncryptionAvailable = () => false - - const locked = store.status('hermes') - assert.equal(locked.error, true) - assert.equal(locked.configured, false) - assert.equal(locked.activePreset, 'custom') - assert.deepEqual(locked.profiles.custom, { ...PROVIDER, configured: false }) - assert.throws(() => store.save('hermes', credential('replacement')), { - message: 'PROVIDER_ENCRYPTION_UNAVAILABLE', - }) - assert.equal(fs.readFileSync(storagePath, 'utf8'), contents) - - safeStorage.isEncryptionAvailable = () => true - safeStorage.decryptString = () => { throw new Error('temporarily locked') } - assert.equal(store.status('hermes').error, true) - assert.equal(fs.readFileSync(storagePath, 'utf8'), contents) - safeStorage.decryptString = decrypt - assert.deepEqual(store.status('hermes'), configuredStatus()) - assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'test-provider-key') -}) - -test('delete removes the local credential even when encryption is unavailable', (t) => { - const safeStorage = encryptedSafeStorage() - const { directory, storagePath, store } = fixture(safeStorage) - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - store.save('hermes', credential()) - safeStorage.isEncryptionAvailable = () => false - - assert.deepEqual(store.delete('hermes'), emptyStatus(false)) - assert.equal(fs.existsSync(storagePath), false) - assert.doesNotThrow(() => store.delete('hermes')) -}) - -test('Provider base URL must use HTTPS or loopback HTTP without embedded data', (t) => { - const { directory, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - - assert.throws( - () => store.save('hermes', credential('key', { ...PROVIDER, baseUrl: 'http://provider.example/v1' })), - { message: 'PROVIDER_INSECURE_BASE_URL' }, - ) - assert.doesNotThrow(() => store.save('hermes', credential('key', { - ...PROVIDER, baseUrl: 'http://127.0.0.1:11434/v1', - }))) - assert.throws( - () => store.save('hermes', credential('key', { ...PROVIDER, baseUrl: 'https://user@api.example/v1' })), - { message: 'PROVIDER_INVALID_METADATA' }, - ) -}) - -test('Provider credentials are isolated per Agent', (t) => { - const { directory, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - - store.save('hermes', credential('hermes-key')) - store.save('qwen', credential('qwen-key', { ...PROVIDER, model: 'qwen-model' })) - - assert.equal(store.status('hermes').model, PROVIDER.model) - assert.equal(store.status('qwen').model, 'qwen-model') - assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'hermes-key') - assert.equal(store.envForAgent('qwen').OPENAI_API_KEY, 'qwen-key') - - store.delete('hermes') - assert.equal(store.status('hermes').configured, false) - assert.equal(store.status('qwen').configured, true) -}) - -test('legacy shared credentials migrate into independent Agent entries on mutation', (t) => { - const { directory, storagePath, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const encrypted = encryptedSafeStorage().encryptString('legacy-key').toString('base64') - fs.writeFileSync(storagePath, JSON.stringify({ - version: 1, - ...PROVIDER, - encrypted, - }), { mode: 0o600 }) - - 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', 'opencodereview', 'qwen']) - assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'legacy-key') - assert.equal(store.envForAgent('qwen').OPENAI_API_KEY, 'new-qwen-key') -}) - -test('single-profile Agent credentials migrate to multi-profile storage on mutation', (t) => { - const { directory, storagePath, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const encrypted = encryptedSafeStorage().encryptString('stored-openrouter-key').toString('base64') - fs.writeFileSync(storagePath, JSON.stringify({ - version: 2, - agents: { - hermes: { ...OPENROUTER_PROVIDER, encrypted }, - }, - }), { mode: 0o600 }) - - assert.equal(store.status('hermes').activePreset, 'openrouter') - assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'stored-openrouter-key') - - store.save('qwen', credential('new-qwen-key', { ...PROVIDER, model: 'qwen-model' })) - const migrated = JSON.parse(fs.readFileSync(storagePath, 'utf8')) - assert.equal(migrated.version, 3) - assert.equal(migrated.agents.hermes.activePreset, 'openrouter') - assert.equal(migrated.agents.qwen.activePreset, 'custom') -}) - -test('malformed multi-profile documents fail as unavailable credentials', (t) => { - const { directory, storagePath, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - fs.writeFileSync(storagePath, JSON.stringify({ - version: 3, - agents: { - hermes: { activePreset: 'unknown', profiles: {} }, - }, - }), { mode: 0o600 }) - - assert.throws(() => store.envForAgent('hermes'), { - message: 'PROVIDER_CREDENTIAL_UNAVAILABLE', - }) -}) - -test('Provider operations reject unsupported Agent kinds', (t) => { - const { directory, store } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - - assert.throws(() => store.status('codex'), { message: 'PROVIDER_AGENT_UNSUPPORTED' }) - assert.throws(() => store.save('../hermes', credential()), { message: 'PROVIDER_AGENT_UNSUPPORTED' }) - assert.throws(() => store.delete(''), { message: 'PROVIDER_AGENT_UNSUPPORTED' }) -}) diff --git a/desktop/test/security/main-security.test.cjs b/desktop/test/security/main-security.test.cjs index 2534c14..eabbd82 100644 --- a/desktop/test/security/main-security.test.cjs +++ b/desktop/test/security/main-security.test.cjs @@ -13,8 +13,6 @@ const { const { serializeAgentConnectorPackage } = require('../../src/agents/connectors/agent-connector-package-store.cjs') const { LOCAL_IPC_CHANNELS, - PROVIDER_AGENT_KINDS, - PROVIDER_METADATA, deferred, eventFor, loadMain, @@ -80,7 +78,6 @@ test('product data migration failure stops ready initialization before stores or assert.equal(harness.quitCount, 1) assert.equal(harness.workspaceInstances.length, 0) - assert.equal(harness.providerInstances.length, 0) assert.equal(harness.knowledgeBaseStoreInstances.length, 0) assert.equal(harness.windows.length, 0) assert.deepEqual(reported, [[ @@ -168,14 +165,8 @@ test('ready activates one fixed local workspace and loads the bundled frontend', /^fit-matrix-[a-f0-9]{64}$/, ) assert.equal(harness.workspaceInstances[0].refreshCount, 0) - assert.equal(harness.providerInstances[0].input.storagePath, - path.join(directory, 'meldwork-provider.json')) assert.equal(harness.customAgentStoreInstances[0].input.storagePath, path.join(directory, 'meldwork-custom-agents.json')) - assert.deepEqual( - [...harness.providerInstances[0].input.allowedKinds].sort(), - [...PROVIDER_AGENT_KINDS].sort(), - ) assert.equal(harness.knowledgeBaseStoreInstances[0].input.storagePath, path.join(directory, 'meldwork-knowledge-base.json')) assert.equal(harness.attachmentInstances[0].input.rootPath, @@ -331,7 +322,6 @@ test('an explicitly installed Agent Connector is available through generic catal const catalog = await harness.ipcHandlers.get('local-agent-installer:catalog')(harness.event()) const profile = catalog.agents.find(agent => agent.kind === connector.kind) assert.equal(profile.connector, true) - assert.equal(profile.providerMode, 'connector') assert.equal(profile.description, SAMPLE_AGENT_CONNECTOR_MANIFEST.description) assert.doesNotMatch(JSON.stringify(profile), /private\/bin|credential-ref/i) @@ -361,7 +351,6 @@ test('Agent Connector IPC persists isolated accounts without returning Credentia serializeAgentConnectorManifest(SAMPLE_CREDENTIAL_AGENT_CONNECTOR_MANIFEST), ) const options = { - providerConfigured: true, detectedAgents: [{ kind: 'codex', name: 'Codex CLI', executable: '/private/bin/codex', version: 'codex-cli 0.147.0', compatibilityState: 'compatible', @@ -410,7 +399,6 @@ test('Agent Connector IPC persists isolated accounts without returning Credentia { runId: 'run-account-b', agentRunId: 'agent-run-account-b', sandbox: 'read-only' }, ) assert.equal(harness.runAgentCalls.at(-1)[3].env.OPENAI_API_KEY, 'connector-main-secret-b') - assert.notEqual(harness.runAgentCalls.at(-1)[3].env.OPENAI_API_KEY, 'provider-key') const instanceFile = fs.readFileSync( path.join(directory, 'agent-connectors', 'instances.json'), @@ -1363,7 +1351,7 @@ test('ready creates the desktop window without eagerly duplicating renderer Agen assert.equal(harness.workspaceInstances[0].refreshCount, 0) }) -test('Electron safeStorage stays lazy until after the desktop window exists', async (t) => { +test('Electron safeStorage stays lazy during startup', async (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-ready-safe-storage-')) const safeStorageAccesses = [] t.after(() => fs.rmSync(directory, { recursive: true, force: true })) @@ -1373,9 +1361,7 @@ test('Electron safeStorage stays lazy until after the desktop window exists', as await harness.ready() assert.deepEqual(safeStorageAccesses, []) - - assert.equal(harness.providerInstances[0].input.safeStorage.isEncryptionAvailable(), true) - assert.deepEqual(safeStorageAccesses, [1]) + assert.equal(harness.windows.length, 1) }) test('window navigation permits only local app pages and credential-free HTTPS links', async (t) => { @@ -1414,64 +1400,6 @@ test('window navigation permits only local app pages and credential-free HTTPS l ]) }) -test('Provider IPC accepts the complete user payload and supports local deletion', async (t) => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-provider-ipc-')) - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const { harness } = loadMain(directory) - await harness.ready() - const input = { ...PROVIDER_METADATA, apiKey: 'renderer-provider-key', preset: 'custom' } - - const initial = await harness.ipcHandlers.get('local-agent-provider:status')( - harness.event(), 'hermes', - ) - assert.equal(initial.configured, false) - assert.deepEqual(harness.providerInstances[0].statusCalls, [{ kind: 'hermes', input: undefined }]) - - await harness.ipcHandlers.get('local-agent-provider:probe')(harness.event(), 'hermes') - assert.deepEqual(harness.providerInstances[0].statusCalls, [ - { kind: 'hermes', input: undefined }, - { kind: 'hermes', input: { probeEncryption: true } }, - ]) - - const saved = await harness.ipcHandlers.get('local-agent-provider:save')( - harness.event(), 'hermes', input, - ) - assert.deepEqual(harness.providerInstances[0].saved, [{ kind: 'hermes', input }]) - assert.equal(saved.configured, true) - assert.equal(harness.workspaceInstances[0].refreshCount, 1) - assert.deepEqual(harness.workspaceInstances[0].runtimeCredentialMarks, [ - { kind: 'hermes', credentialState: 'unknown' }, - ]) - - const activated = await harness.ipcHandlers.get('local-agent-provider:activate')( - harness.event(), 'hermes', 'custom', - ) - assert.deepEqual(harness.providerInstances[0].activated, [{ kind: 'hermes', preset: 'custom' }]) - assert.equal(activated.activePreset, 'custom') - assert.equal(harness.workspaceInstances[0].refreshCount, 2) - assert.deepEqual(harness.workspaceInstances[0].runtimeCredentialMarks, [ - { kind: 'hermes', credentialState: 'unknown' }, - { kind: 'hermes', credentialState: 'unknown' }, - ]) - - await assert.rejects( - async () => harness.ipcHandlers.get('local-agent-provider:status')(harness.event(), 'not-an-agent'), - { message: 'PROVIDER_AGENT_UNSUPPORTED' }, - ) - - const deleted = await harness.ipcHandlers.get('local-agent-provider:delete')( - harness.event(), 'hermes', 'custom', - ) - assert.deepEqual(harness.providerInstances[0].deleted, [{ kind: 'hermes', preset: 'custom' }]) - assert.equal(deleted.configured, false) - assert.equal(harness.workspaceInstances[0].refreshCount, 3) - assert.deepEqual(harness.workspaceInstances[0].runtimeCredentialMarks, [ - { kind: 'hermes', credentialState: 'unknown' }, - { kind: 'hermes', credentialState: 'unknown' }, - { kind: 'hermes', credentialState: 'unknown' }, - ]) -}) - test('Knowledge base IPC exposes status, safe guides, and a local Obsidian directory picker', async (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-knowledge-base-ipc-')) t.after(() => fs.rmSync(directory, { recursive: true, force: true })) @@ -1736,7 +1664,7 @@ test('Cloud Agent catalog and Skills IPC preserve remote metadata without exposi return [{ kind, sourceKind: 'claude', label: 'Claude Code @ Research server', description: 'Remote Claude Code', version: '2.1.123', installed: true, - installSupported: false, providerMode: 'connector', custom: false, + installSupported: false, custom: false, connector: true, cloud: true, available: false, credentialState: 'missing', serverLabel: 'Research server', }] @@ -1990,11 +1918,10 @@ test('deleting a message normalizes identifiers and discards only newly unrefere 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(), 'meldwork-provider-routing-')) +test('native Agent credentials are injected only through local Agent execution options', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-native-routing-')) t.after(() => fs.rmSync(directory, { recursive: true, force: true })) const { harness } = loadMain(directory, { - providerConfigured: true, nativeShellEnvironment: () => ({ env: { PATH: '/opt/native-agent/bin:/usr/bin', https_proxy: 'http://proxy.example:3128', NO_PROXY: 'localhost,127.0.0.1', ANTHROPIC_API_KEY: 'native-hermes-key' }, source: 'native-shell', @@ -2010,45 +1937,13 @@ test('configured Provider is injected only through local Agent execution options ) const options = harness.runAgentCalls[0][3] - assert.deepEqual(options.provider, { id: 'openai-api', model: 'example-model' }) - assert.equal(options.env.OPENAI_API_KEY, 'provider-key') - assert.equal(options.env.OPENAI_BASE_URL, PROVIDER_METADATA.baseUrl) - assert.equal(options.env.OPENAI_MODEL, PROVIDER_METADATA.model) - assert.equal(options.env.HERMES_INFERENCE_PROVIDER, 'openai-api') - assert.equal(options.env.HERMES_INFERENCE_MODEL, PROVIDER_METADATA.model) - assert.equal(options.env.ANTHROPIC_API_KEY, undefined) + assert.equal(options.env.ANTHROPIC_API_KEY, 'native-hermes-key') assert.equal(options.env.CURRENT_RUN, '1') assert.equal(options.env.PATH, '/opt/native-agent/bin:/usr/bin') assert.equal(options.env.https_proxy, 'http://proxy.example:3128') assert.equal(options.env.NO_PROXY, 'localhost,127.0.0.1') }) -test('unreadable saved Provider credentials never silently fall back to native execution', async (t) => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-provider-locked-')) - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const { harness } = loadMain(directory) - await harness.ready() - const store = harness.providerInstances[0] - const originalStatus = store.status.bind(store) - store.status = () => ({ configured: false, error: true, activePreset: 'custom' }) - assert.deepEqual(await harness.workspaceInstances[0].input.credentialState('hermes'), { - state: 'unknown', source: 'provider-credential-unavailable', - }) - await assert.rejects(harness.workspaceInstances[0].input.runAgent( - { kind: 'hermes', executable: '/tmp/hermes' }, 'hello', directory, - ), { message: 'PROVIDER_CREDENTIAL_UNAVAILABLE' }) - assert.equal(harness.runAgentCalls.length, 0) - - store.status = originalStatus - assert.deepEqual(await harness.workspaceInstances[0].input.credentialState('hermes'), { - state: 'ready', source: 'native-credential', - }) - await harness.workspaceInstances[0].input.runAgent( - { kind: 'hermes', executable: '/tmp/hermes' }, 'hello', directory, - ) - assert.equal(harness.runAgentCalls.length, 1) -}) - test('OpenClaw native auth is routed through the app-owned isolated runtime', async (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-openclaw-native-')) t.after(() => fs.rmSync(directory, { recursive: true, force: true })) diff --git a/desktop/test/security/preload-security.test.cjs b/desktop/test/security/preload-security.test.cjs index bde68bb..18bc28f 100644 --- a/desktop/test/security/preload-security.test.cjs +++ b/desktop/test/security/preload-security.test.cjs @@ -51,14 +51,13 @@ function loadPreload(protocol) { return { api: exposed, exposedName, invocations, listeners } } -test('local preload exposes the local-only Meldwork API and narrow Provider methods', async () => { +test('local preload exposes the local-only Meldwork API', async () => { const { api, exposedName, invocations } = loadPreload('file:') assert.equal(exposedName, 'meldworkDesktop') assert.equal(Object.isFrozen(api), true) assert.equal(api.localOnly, true) assert.equal(api.platform, process.platform) - assert.equal(Object.isFrozen(api.localAgentProvider), true) assert.equal(Object.isFrozen(api.localAgentConnector), true) assert.equal(Object.isFrozen(api.localSkillTrust), true) assert.equal(Object.isFrozen(api.cloudAgent), true) @@ -83,38 +82,13 @@ test('local preload exposes the local-only Meldwork API and narrow Provider meth ) assert.equal('configure' in api.localWorkspace, false) assert.equal('cancelConfigure' in api.localWorkspace, false) + assert.equal('localAgentProvider' in api, 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'], - ) - assert.equal('envForAgent' in api.localAgentProvider, false) - assert.equal('read' in api.localAgentProvider, false) - - await api.localAgentProvider.status('hermes') - await api.localAgentProvider.probe('hermes') - await api.localAgentProvider.save('hermes', { - provider: 'Example', baseUrl: 'https://api.example.com/v1', - model: 'example-model', apiKey: 'test-renderer-key', preset: 'custom', - }) - await api.localAgentProvider.activate('hermes', 'custom') - await api.localAgentProvider.delete('hermes', 'custom') - assert.deepEqual(invocations.map(call => call.channel), [ - 'local-agent-provider:status', - 'local-agent-provider:probe', - 'local-agent-provider:save', - 'local-agent-provider:activate', - 'local-agent-provider:delete', - ]) - assert.deepEqual(invocations.map(call => call.args[0]), [ - 'hermes', 'hermes', 'hermes', 'hermes', 'hermes', - ]) - invocations.length = 0 assert.deepEqual( Object.keys(api.localAgentConnector).sort(), diff --git a/desktop/test/support/local-workspace-test-helpers.cjs b/desktop/test/support/local-workspace-test-helpers.cjs index cb1edbc..d7b7b4c 100644 --- a/desktop/test/support/local-workspace-test-helpers.cjs +++ b/desktop/test/support/local-workspace-test-helpers.cjs @@ -26,7 +26,6 @@ function fixture() { storagePath: path.join(directory, 'workspace.json'), detectAgents: async () => agents, credentialState: async () => ({ state: 'ready', source: 'native-credential' }), - sharedProviderReady: () => false, resolveAttachments: async refs => refs.map(ref => ({ id: ref.id, name: ref.name, diff --git a/desktop/test/support/main-security-test-harness.cjs b/desktop/test/support/main-security-test-harness.cjs index cb86821..6b768b8 100644 --- a/desktop/test/support/main-security-test-harness.cjs +++ b/desktop/test/support/main-security-test-harness.cjs @@ -4,16 +4,6 @@ const { pathToFileURL } = require('node:url') const Module = require('node:module') const { EventEmitter } = require('node:events') -const PROVIDER_METADATA = Object.freeze({ - provider: 'OpenAI Compatible', - baseUrl: 'https://api.example.com/v1', - model: 'example-model', -}) -const PROVIDER_AGENT_KINDS = Object.freeze([ - 'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen', - 'opencodereview', -]) - function pngHeader(width = 1, height = 1) { const bytes = Buffer.alloc(24) Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes) @@ -74,11 +64,6 @@ const LOCAL_IPC_CHANNELS = Object.freeze([ 'local-attachments:preview', 'local-attachments:open', 'local-attachments:discard', - 'local-agent-provider:status', - 'local-agent-provider:probe', - 'local-agent-provider:save', - 'local-agent-provider:activate', - 'local-agent-provider:delete', 'local-knowledge-connector:list', 'local-knowledge-connector:authorize', 'local-knowledge-connector:revoke', @@ -123,7 +108,6 @@ function loadMain(userData, options = {}) { const workspaceInstances = [] const installerInstances = [] const customAgentStoreInstances = [] - const providerInstances = [] const knowledgeBaseStoreInstances = [] const knowledgeBaseResolveCalls = [] const attachmentInstances = [] @@ -153,8 +137,6 @@ function loadMain(userData, options = {}) { const exitCalls = [] let singleInstanceLockCalls = 0 let attachmentConstructionCount = 0 - const providerConfiguredKinds = new Set(options.providerConfigured === true ? ['hermes'] : []) - const providerActivePresets = new Map(options.providerConfigured === true ? [['hermes', 'custom']] : []) class TestWorkspace extends EventEmitter { constructor(input) { @@ -307,8 +289,6 @@ function loadMain(userData, options = {}) { return this.agents.map(({ executable: _executable, args: _args, ...agent }) => ({ installed: agent.installed !== false, installSupported: false, - providerCompatible: false, - providerMode: 'custom', imageAttachmentLimit: 0, ...agent, custom: true, @@ -326,8 +306,6 @@ function loadMain(userData, options = {}) { custom: true, installed: true, installSupported: false, - providerCompatible: false, - providerMode: 'custom', imageAttachmentLimit: 0, version: '1.0.0', executable, @@ -550,66 +528,6 @@ function loadMain(userData, options = {}) { } } - class TestProviderStore { - constructor(input) { - this.input = input - this.saved = [] - this.activated = [] - this.deleted = [] - this.statusCalls = [] - providerInstances.push(this) - } - - status(kind, input) { - this.statusCalls.push({ kind, input }) - return providerConfiguredKinds.has(kind) - ? { - ...PROVIDER_METADATA, - activePreset: providerActivePresets.get(kind) || 'custom', - profiles: { - [providerActivePresets.get(kind) || 'custom']: { ...PROVIDER_METADATA, configured: true }, - }, - encryptionAvailable: true, - configured: true, - } - : { - provider: '', baseUrl: '', model: '', - activePreset: 'official', profiles: {}, encryptionAvailable: true, configured: false, - } - } - - save(kind, input) { - this.saved.push({ kind, input }) - providerConfiguredKinds.add(kind) - providerActivePresets.set(kind, input.preset) - return this.status(kind) - } - - activate(kind, preset) { - this.activated.push({ kind, preset }) - providerActivePresets.set(kind, preset) - if (preset === 'official') providerConfiguredKinds.delete(kind) - else providerConfiguredKinds.add(kind) - return this.status(kind) - } - - delete(kind, preset) { - this.deleted.push({ kind, preset }) - providerConfiguredKinds.delete(kind) - providerActivePresets.set(kind, 'official') - return this.status(kind) - } - - envForAgent(kind) { - assert.equal(providerConfiguredKinds.has(kind), true) - return options.providerEnv || { - OPENAI_API_KEY: 'provider-key', - OPENAI_BASE_URL: PROVIDER_METADATA.baseUrl, - OPENAI_MODEL: PROVIDER_METADATA.model, - } - } - } - class TestKnowledgeBaseStore { constructor(input) { this.input = input @@ -803,7 +721,6 @@ function loadMain(userData, options = {}) { }) const openClawRuntimeMock = { - managedOpenClawOptions: input => ({ env: { MANAGED_OPENCLAW: JSON.stringify(input) } }), nativeOpenClawOptions: input => ({ env: { NATIVE_OPENCLAW: JSON.stringify(input) } }), } @@ -851,7 +768,6 @@ function loadMain(userData, options = {}) { './skills/local-skill-trust-store.cjs': { LocalSkillTrustStore: TestLocalSkillTrustStore }, './agents/cli/openclaw-runtime.cjs': openClawRuntimeMock, '../agents/cli/openclaw-runtime.cjs': openClawRuntimeMock, - './providers/provider-store.cjs': { ProviderStore: TestProviderStore }, './knowledge/knowledge-base-store.cjs': { KnowledgeBaseStore: TestKnowledgeBaseStore }, './knowledge/local-knowledge-base.cjs': { knowledgeBaseSelectionHint: (source, targetKinds) => { @@ -922,7 +838,6 @@ function loadMain(userData, options = {}) { permissionHandlers, protocolHandlers, registeredSchemes, - providerInstances, runAgentCalls, skillCatalogInstances, skillSnapshotStoreInstances, @@ -953,8 +868,6 @@ function loadMain(userData, options = {}) { module.exports = { LOCAL_IPC_CHANNELS, - PROVIDER_AGENT_KINDS, - PROVIDER_METADATA, deferred, eventFor, loadMain, diff --git a/desktop/test/workspace/local-workspace-agent-catalog.test.cjs b/desktop/test/workspace/local-workspace-agent-catalog.test.cjs index 30e388b..0aa16d8 100644 --- a/desktop/test/workspace/local-workspace-agent-catalog.test.cjs +++ b/desktop/test/workspace/local-workspace-agent-catalog.test.cjs @@ -30,7 +30,6 @@ function fixture(overrides = {}) { credentialState: overrides.credentialState || (async () => ({ state: 'unknown', source: 'unverified', })), - sharedProviderReady: overrides.sharedProviderReady || (() => false), save: () => events.push('save'), emitChanged: () => events.push('emit'), snapshot: () => { @@ -62,42 +61,6 @@ test('one credential probe failure retains that installation and does not hide h assert.equal(agents[0].availabilitySource, 'native-probe-failed') }) -test('unreadable selected Provider overrides historical success without losing installation or native auth', async () => { - let locked = true - const { catalog, state } = fixture({ - detectAgents: async () => ['hermes', 'codex'].map(kind => ({ kind, compatibilityState: 'compatible' })), - credentialState: async kind => kind === 'hermes' && locked - ? { state: 'unknown', source: 'provider-credential-unavailable' } - : { state: 'ready', source: 'native-auth-status' }, - sharedProviderReady: kind => kind === 'hermes' && !locked, - }) - state.messages.push({ role: 'agent', agentKind: 'hermes' }) - const verified = { credentialState: 'ready', checkedAt: '2026-08-03T00:00:00.000Z' } - state.agentRuntime.hermes = verified - - const first = await catalog.refresh() - const agent = first.agents[0] - assert.equal(agent.installed, true) - assert.equal(agent.configured, true) - assert.equal(agent.authenticated, false) - assert.equal(agent.invocable, false) - assert.equal(agent.available, false) - assert.equal(agent.recentlyVerified, false) - assert.equal(agent.credentialState, 'unknown') - assert.equal(agent.availabilitySource, 'provider-credential-unavailable') - assert.equal(first.agents[1].available, true) - assert.equal(state.agentRuntime.hermes, verified) - catalog.markRuntimeCredential('hermes', 'ready') - assert.equal(agent.available, false) - assert.equal(agent.credentialState, 'unknown') - assert.equal(agent.availabilitySource, 'provider-credential-unavailable') - - locked = false - const second = await catalog.refresh() - assert.equal(second.agents[0].available, true) - assert.equal(second.agents[0].availabilitySource, 'shared-provider') -}) - test('refresh starts credential checks concurrently and reads current runtime state after them', async () => { const codex = deferred() const hermes = deferred() @@ -173,7 +136,6 @@ test('incompatible Agents remain installed but unavailable with ready credential incompatibilityReason: 'LOCAL_AGENT_VERSION_UNSUPPORTED', }], credentialState: async () => ({ state: 'ready', source: 'native-auth-status' }), - sharedProviderReady: () => true, }) await catalog.refresh() @@ -224,13 +186,12 @@ test('locally detected compatible Agents remain invocable when native configurat }]) }) -test('shared Provider readiness cannot override a recorded runtime auth failure', async () => { +test('native readiness cannot override a recorded runtime auth failure', async () => { const { agents, catalog, events, state } = fixture({ detectAgents: async () => [{ kind: 'codex', executable: '/tmp/codex', compatibilityState: 'compatible', }], credentialState: async () => ({ state: 'ready', source: 'native-credential' }), - sharedProviderReady: () => true, }) state.agentRuntime.codex = { credentialState: 'missing', checkedAt: '2026-08-03T00:00:00.000Z', @@ -244,46 +205,18 @@ test('shared Provider readiness cannot override a recorded runtime auth failure' assert.deepEqual(events, ['emit', 'snapshot']) }) -test('shared Provider readiness skips slow native credential probes', async () => { - let nativeProbeCalls = 0 - const { agents, catalog } = fixture({ - detectAgents: async () => [{ - kind: 'codex', executable: '/tmp/codex', compatibilityState: 'compatible', - }], - credentialState: async () => { - nativeProbeCalls += 1 - return { state: 'missing', source: 'native-auth-status' } - }, - sharedProviderReady: () => true, +test('expired auth failures allow native credentials to be tried again', async () => { + const { agents, catalog, state } = fixture({ + detectAgents: async () => [{ kind: 'codex', compatibilityState: 'compatible' }], + credentialState: async () => ({ state: 'unknown', source: 'unverified' }), }) - - await catalog.refresh() - - assert.equal(nativeProbeCalls, 0) - assert.equal(agents()[0].credentialState, 'ready') - assert.equal(agents()[0].availabilitySource, 'shared-provider') - assert.equal(agents()[0].configured, true) - assert.equal(agents()[0].authenticated, true) - assert.equal(agents()[0].invocable, true) - assert.equal(agents()[0].recentlyVerified, false) - assert.equal(agents()[0].available, true) -}) - -test('expired auth failures allow native and shared credentials to be tried again', async () => { - for (const shared of [false, true]) { - const { agents, catalog, state } = fixture({ - detectAgents: async () => [{ kind: 'codex', compatibilityState: 'compatible' }], - credentialState: async () => ({ state: 'unknown', source: 'unverified' }), - sharedProviderReady: () => shared, - }) - state.agentRuntime.codex = { - credentialState: 'missing', checkedAt: '2026-08-02T23:59:29.000Z', - } - await catalog.refresh() - assert.equal(agents()[0].invocable, true) - assert.equal(state.agentRuntime.codex, undefined) - assert.equal(agents()[0].recentlyVerified, false) + state.agentRuntime.codex = { + credentialState: 'missing', checkedAt: '2026-08-02T23:59:29.000Z', } + await catalog.refresh() + assert.equal(agents()[0].invocable, true) + assert.equal(state.agentRuntime.codex, undefined) + assert.equal(agents()[0].recentlyVerified, false) }) test('legacy authentication failures with missing or invalid timestamps permit re-evaluation', async () => { diff --git a/desktop/test/workspace/local-workspace.test.cjs b/desktop/test/workspace/local-workspace.test.cjs index a8da1f9..0c4f81d 100644 --- a/desktop/test/workspace/local-workspace.test.cjs +++ b/desktop/test/workspace/local-workspace.test.cjs @@ -13,7 +13,7 @@ test('installed Agents distinguish ready, local CLI, and missing credential stat const readinessAgents = [] options.credentialState = async (kind, agent) => { readinessAgents.push(agent) - if (kind === 'hermes') return { state: 'missing', source: 'shared-provider-required' } + if (kind === 'hermes') return { state: 'missing', source: 'native-auth-status' } if (kind === 'kimi') return { state: 'unknown', source: 'unverified' } return { state: 'ready', source: 'native-credential' } } @@ -262,28 +262,6 @@ test('Custom Agent kinds keep their dynamic label across execution and reload', assert.equal(calls.at(-1).runOptions.sandbox, 'workspace-write') }) -test('shared Provider readiness skips slow native probes while a profile is active', async (t) => { - const { directory, options } = fixture() - t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - const probedKinds = [] - options.credentialState = async kind => { - probedKinds.push(kind) - return kind === 'codex' - ? { state: 'ready', source: 'native-auth-status' } - : { state: 'ready', source: 'native-credential' } - } - options.sharedProviderReady = kind => kind === 'codex' - const workspace = new LocalWorkspace(options) - - let snapshot = await workspace.refreshAgents() - let codex = snapshot.agents.find(agent => agent.kind === 'codex') - assert.equal(codex.credentialState, 'ready') - assert.equal(codex.availabilitySource, 'shared-provider') - assert.equal(codex.available, true) - assert.equal(probedKinds.includes('codex'), false) - assert.equal(probedKinds.length, 4) -}) - test('a detected local Agent stays usable while native credential state is unverified', async (t) => { const { directory, calls, options } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) diff --git a/docs/README.md b/docs/README.md index 5e6b3d4..2cdbc97 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,9 +21,9 @@ Meldwork 的候选定位是 **本地优先、跨 Agent 的任务与交付工作 ## 产品、运行与信任边界 - [流程](flows.md):启动、任务、Agent 调用、恢复和外部副作用。 -- [权限边界](permissions.md):Renderer、Preload、Main、Agent、Provider 和 Connector 的信任边界。 +- [权限边界](permissions.md):Renderer、Preload、Main、Agent 和 Connector 的信任边界。 - [自动化清单](automation.md):当前自动讨论、运行记录、安装器与外部适配边界。 -- [变量与密钥](variables.md):Provider、Agent 凭证和本地运行变量。 +- [变量与密钥](variables.md):Agent 凭证和本地运行变量。 - [架构](../architecture.md):当前 Electron、Main、Connector 和本地数据边界。 ## 契约与验证 diff --git a/docs/ai-discoverability.md b/docs/ai-discoverability.md index 431c85d..77c78ab 100644 --- a/docs/ai-discoverability.md +++ b/docs/ai-discoverability.md @@ -7,7 +7,7 @@ This page is a factual index for search systems and AI assistants. Statements be - **Project:** Meldwork - **Type:** Local-first Electron desktop application with a Vue renderer (`desktop/package.json`, `frontend/package.json`, `desktop/src/main.cjs`, `frontend/src/main.js`). - **Primary subject:** A local-first multi-agent orchestration desktop app that coordinates multiple AI coding agent CLIs (Codex, Claude Code, Gemini CLI, Hermes, OpenCode, and 7 more) from one workspace. The Electron main process discovers and invokes local Agent CLIs, freezes task snapshots for concurrent multi-agent responses, captures evidence trails (Finding → Evidence → Decision → Disposition), and gates workspace writes behind human adoption. The renderer receives a constrained preload API. -- **Runtime boundary:** Conversations, orchestration state, run checkpoints, attachments, Skill snapshots, Provider metadata, and selected knowledge-source state are stored locally. The repository does not require an application server, account/tenant system, or Meldwork-hosted remote conversation store (`architecture.md`). +- **Runtime boundary:** Conversations, orchestration state, run checkpoints, attachments, Skill snapshots, and selected knowledge-source state are stored locally. The repository does not require an application server, account/tenant system, or Meldwork-hosted remote conversation store (`architecture.md`). - **Repository terms:** local-first multi-agent orchestration desktop app; multi-agent collaboration tool; agent CLI orchestrator; coordinate multiple AI coding agents; local-first agent workspace; Electron desktop; Vue renderer; Harness; evidence-aware runs; human-in-the-loop decisions; Agent Connector SDK; run Codex and Claude Code together; alternative to tmux for AI agents. ## Search intents this repository answers @@ -32,7 +32,7 @@ Use these phrases when looking for this project or related implementation eviden - JSONL and stream-json Agent runtime normalization / JSONL、stream-json Agent 事件归一化 - local Agent Connector SDK with content-addressed manifests / 内容寻址的本地 Agent Connector SDK - durable sanitized run ledger, evidence-aware runs, and human-in-the-loop Human Gate recovery / 脱敏运行账本、Evidence-aware 运行与 human-in-the-loop 人类审批门恢复 -- local Skills, image/file attachments, Provider profiles, and Obsidian knowledge selection / 本地 Skill、附件、Provider 与 Obsidian 知识源 +- local Skills, image/file attachments, and Obsidian knowledge selection / 本地 Skill、附件与 Obsidian 知识源 - deterministic local Eval Harness for Agent workflows / 本地确定性 Agent Eval Harness - multi-agent orchestration without writing orchestration code / 无需编写编排代码的多 Agent 编排 - cross-vendor agent CLI coordination (OpenAI Codex + Anthropic Claude Code + Google Gemini CLI) / 跨厂商 Agent CLI 协调(OpenAI Codex + Anthropic Claude Code + Google Gemini CLI) @@ -62,7 +62,7 @@ The fixed catalog is defined in `desktop/src/agents/cli/cli-discovery.cjs`. Comm | --- | --- | --- | --- | | `codex` | Codex / `codex` | Codex App Server JSONL (`codex exec --json`) | Streaming answer deltas, plans, reasoning, tool start/result, sessions; read-only or workspace-write sandbox. | | `hermes` | Hermes / `hermes` | ACP JSON-RPC (`hermes acp`), with legacy terminal-text fallback (`hermes chat --quiet`) | Session continuity; legacy path includes result recovery from a local message watermark when available. | -| `openclaw` | OpenClaw / `openclaw` | ACP JSON-RPC (`openclaw ... acp`), with legacy terminal-document JSON fallback | Managed local runtime and explicit tool policy; live behavior depends on the installed CLI and Provider. | +| `openclaw` | OpenClaw / `openclaw` | ACP JSON-RPC (`openclaw ... acp`), with legacy terminal-document JSON fallback | Isolated local runtime and explicit tool policy; live behavior depends on the installed CLI and its native credentials. | | `workbuddy` | WorkBuddy / `codebuddy` | `stream-json` invocation with bounded nested turn count | Read-only/plan or workspace-write/acceptEdits permission mode; session resume supported by the invocation. | | `pi` | Pi Agent / `pi`, `pi-agent`, `piagent` | Pi JSONL (`--mode json --print`) | Streaming answer, reasoning, and tool lifecycle events; session resume supported. | | `kimi` | Kimi / `kimi` | ACP `plan` mode for read-only; native `stream-json --prompt` for workspace-write | The two permission paths use different transports. | @@ -70,7 +70,7 @@ The fixed catalog is defined in `desktop/src/agents/cli/cli-discovery.cjs`. Comm | `claude` | Claude Code / `claude` | Anthropic-style `stream-json` | Streaming answer, reasoning, plans, tool lifecycle, and session resume. | | `gemini` | Gemini CLI / `gemini` | Gemini `stream-json` | Streaming answer and tool start/result events; session resume supported. | | `opencode` | OpenCode / `opencode` | ACP JSON-RPC (`opencode acp --pure`), JSON fallback (`opencode run --format json`) | Plan/build mode follows permission; session resume supported; image/file arguments are validated. | -| `qwen` | Qwen Code / `qwen` | Anthropic-style `stream-json` | Plan/auto-edit approval mode; optional configured Provider and session resume. | +| `qwen` | Qwen Code / `qwen` | Anthropic-style `stream-json` | Plan/auto-edit approval mode and session resume through the CLI's own credentials. | | `opencodereview` | OpenCodeReview / `ocr` | Terminal JSON document (`ocr review --format json`) | Read-only code-review adapter; output is final text/evidence without session or tool lifecycle events. | The event profile mapping is explicit in `desktop/src/agents/cli/cli-event-profiles.cjs`; ACP transport is implemented in `desktop/src/agents/cli/cli-acp-runner.cjs`. The runtime capability contract (`desktop/src/agents/agent-runtime-contract.cjs`) recognizes domains including `software-development`, `software-review`, `research`, `document-production`, `tool-use`, and `automation`, input types including text/image/audio/video/file/structured-data, and permission modes `read-only` and `workspace-write`. @@ -125,13 +125,13 @@ npm --prefix desktop run pack - The current packaged release target is Apple silicon macOS. The V1.0.3 prerelease is ad-hoc signed, not Apple Developer ID signed or notarized (`docs/releases/Meldwork-V1.0.3.md`, `docs/tests.md`). - Automatic participant selection from a larger roster, cross-user remote collaboration, enterprise RBAC/governance, a production Cloud/Channel Agent service, and an Outcome Network are outside the current release boundary. - Cloud and Channel Connector runtimes exist as optional main-process contracts, but no concrete Cloud or Channel Connector is enabled by default. The documented SSH bridge currently supports Codex read-only only; remote writes, attachments, and session resume are not supported (`docs/integrations/cloud-agent-bridge.md`). -- Agent output and model access depend on the locally installed CLI, authentication, Provider, declared capabilities, and upstream protocol behavior. Repository fixtures and unit tests do not certify every Agent installation, version, operating system, or live Provider. +- Agent output and model access depend on the locally installed CLI, its own authentication, declared capabilities, and upstream protocol behavior. Repository fixtures and unit tests do not certify every Agent installation, version, or operating system. - Workspace writes default to disabled. Agent processes run with the local operating-system user's permissions; permission flags and read-only instructions are not an OS sandbox (`architecture.md`, `docs/permissions.md`). - The repository is licensed under the **Apache License 2.0**. Third-party Agent products, models, dependencies, and brand assets remain subject to their own terms (`LICENSE`, `NOTICE`, `COMMERCIAL_USE.md`). ## Aliases and keyword index -`Meldwork`, `meldwork`, `local-first AI workspace`, `multi-Agent collaboration`, `multi-agent orchestration`, `multi-agent orchestration tool`, `AI agent coordination`, `agent CLI orchestrator`, `AI Agent work cell`, `Electron Agent desktop`, `Vue Agent workspace`, `Agent CLI manager`, `evidence-aware`, `human-in-the-loop`, `Codex`, `Claude Code`, `Gemini CLI`, `Hermes`, `OpenCode`, `OpenClaw`, `WorkBuddy`, `Pi Agent`, `Kimi Code`, `MiMo Code`, `Qwen Code`, `OpenCodeReview`, `ACP`, `Agent Client Protocol`, `JSONL`, `stream-json`, `Agent Connector SDK`, `Context Pack`, `Run Ledger`, `Human Gate`, `Auto Discussion V4`, `Concurrent Responses`, `frozen task snapshot`, `responsibility graph`, `typed Artifact`, `Evidence`, `Skill snapshot`, `Provider profile`, `Obsidian knowledge source`, `local Electron multi-agent app`, `coordinate multiple AI coding agents`, `run Codex and Claude Code together`, `multi-agent code review`, `alternative to tmux for AI agents`, `alternative to Claude Code Agent Teams`, `local-first agent workspace`, `本地优先`, `多 Agent 协作`, `多 Agent 编排`, `Agent CLI 编排`, `桌面端 Agent 工作空间`, `多 Agent 群聊`, `并发回复`, `自动讨论`, `职责协商`, `运行账本`, `人类审批门`, `Agent 连接器 SDK`. +`Meldwork`, `meldwork`, `local-first AI workspace`, `multi-Agent collaboration`, `multi-agent orchestration`, `multi-agent orchestration tool`, `AI agent coordination`, `agent CLI orchestrator`, `AI Agent work cell`, `Electron Agent desktop`, `Vue Agent workspace`, `Agent CLI manager`, `evidence-aware`, `human-in-the-loop`, `Codex`, `Claude Code`, `Gemini CLI`, `Hermes`, `OpenCode`, `OpenClaw`, `WorkBuddy`, `Pi Agent`, `Kimi Code`, `MiMo Code`, `Qwen Code`, `OpenCodeReview`, `ACP`, `Agent Client Protocol`, `JSONL`, `stream-json`, `Agent Connector SDK`, `Context Pack`, `Run Ledger`, `Human Gate`, `Auto Discussion V4`, `Concurrent Responses`, `frozen task snapshot`, `responsibility graph`, `typed Artifact`, `Evidence`, `Skill snapshot`, `Obsidian knowledge source`, `local Electron multi-agent app`, `coordinate multiple AI coding agents`, `run Codex and Claude Code together`, `multi-agent code review`, `alternative to tmux for AI agents`, `alternative to Claude Code Agent Teams`, `local-first agent workspace`, `本地优先`, `多 Agent 协作`, `多 Agent 编排`, `Agent CLI 编排`, `桌面端 Agent 工作空间`, `多 Agent 群聊`, `并发回复`, `自动讨论`, `职责协商`, `运行账本`, `人类审批门`, `Agent 连接器 SDK`. ## Competitor and alternative relationships diff --git a/docs/public-mvp-release.md b/docs/public-mvp-release.md index 8b4ffad..5cfc575 100644 --- a/docs/public-mvp-release.md +++ b/docs/public-mvp-release.md @@ -6,7 +6,7 @@ Meldwork-V1.0.3 对应桌面包版本 `0.1.3`,当前验证目标是 Apple sili V1.0.3 是一个本地优先的多 Agent Work Cell,由用户选择参与 Agent、工作目录和写入权限。在群聊并发回复模式中,未显式指定 Agent 代表调用群聊全部成员。 -- 直接会话保留本地历史、附件、Skill、Provider、受控权限、兼容的原生 Session 和脱敏运行事件。 +- 直接会话保留本地历史、附件、Skill、受控权限、兼容的原生 Session 和脱敏运行事件。 - 群聊“并发回复”在调度前为所有已选 Agent 冻结同一份任务快照,保留全部成员,并在批次屏障后按稳定顺序提交独立回复。 - Auto Discussion 从全员独立并行提案开始。已选 Agent 交叉质询并自主协商一份职责图;只有全员同意同一 `planHash` 后,Harness 才按该图调度各自的工作包。 - 提案、质询、分工工作和复核默认只读。开启工作区写入时,只有协商确定的整合者在合成阶段拥有写权,不同的复核 Agent 独立验证当前候选 Artifact。 diff --git a/docs/tests.md b/docs/tests.md index f34c749..b238eb8 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -73,7 +73,7 @@ A Codex direct acceptance run produced no answer delta inside the 300-second obs | --- | --- | --- | | Agent stream and tool-event normalization | Normalizes supported Agent outputs into answer deltas, plans, status, tool lifecycle, warnings, and terminal results without exposing commands, paths, secrets, or raw chain-of-thought | `desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs`; `desktop/test/agents/cli/cli-adapters.test.cjs`; `desktop/test/security/preload-security.test.cjs` | | Hermes result recovery | Uses a read-only message watermark and a post-watermark final assistant row when available, with sanitized official stdout fallback | `desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs`; `desktop/test/agents/cli/cli-adapters.test.cjs` | -| Managed OpenClaw | Isolates runtime state, keeps Provider secrets out of generated config, validates permission scopes, normalizes supported streaming/tool events in protocol fixtures, and closes disposable ACP sessions plus the authenticated loopback Gateway and in-flight health probes during shutdown | `desktop/test/agents/cli/openclaw-runtime.test.cjs`; `desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs`; `desktop/test/agents/cli/cli-native-acp-lifecycle.test.cjs` | +| OpenClaw runtime | Isolates native runtime state, keeps API keys out of generated config, validates permission scopes, normalizes supported streaming/tool events in protocol fixtures, and closes disposable ACP sessions plus the authenticated loopback Gateway and in-flight health probes during shutdown | `desktop/test/agents/cli/openclaw-runtime.test.cjs`; `desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs`; `desktop/test/agents/cli/cli-native-acp-lifecycle.test.cjs` | | Conversation controls | Covers group/direct timelines, per-Agent states, retry/stop/replace controls, message actions, and the animated return-to-bottom control including reduced-motion behavior | `frontend/src/__tests__/meldwork/conversationViewport.spec.js`; `frontend/src/__tests__/meldwork/conversationTimeline.spec.js`; `frontend/src/__tests__/meldwork/App.conversation-trace.spec.js` | | Renderer privacy boundary | Uses the narrow preload bridge and exposes only allowlisted run fields; rejects non-main-frame IPC and unsafe navigation | `frontend/src/__tests__/meldwork/security.spec.js`; `desktop/test/security/main-security.test.cjs`; `desktop/test/security/preload-security.test.cjs` | | Attachments and Skills | Validates bounded attachment import/storage/preview behavior and target-scoped Skill snapshots without exposing local paths to the renderer | `frontend/src/__tests__/meldwork/App.attachments.spec.js`; `desktop/test/attachments/attachment-store.test.cjs`; `desktop/test/skills/local-skill-catalog.test.cjs` | @@ -96,7 +96,7 @@ The workflow file does not prove branch-protection configuration or the status o | --- | --- | --- | | High | No Developer ID signing, Apple notarization, Stapling, or clean-machine Gatekeeper acceptance | V1.0.3 remains an ad-hoc signed prerelease candidate that may require Open Anyway | | High | OpenClaw has not completed the latest packaged live streaming/tool-lifecycle run | The runtime fails closed with `LOCAL_AGENT_PROCESS_FAILED`; fixture and managed-runtime coverage do not replace live certification | -| Medium | Codex exceeded the 300-second direct acceptance observation window before completing late | UI behavior was accepted with Claude direct; Codex Provider and CLI timing still needs a repeatable live matrix | +| Medium | Codex exceeded the 300-second direct acceptance observation window before completing late | UI behavior was accepted with Claude direct; Codex CLI timing still needs a repeatable live matrix | | Medium | No clean-machine live matrix for every listed Agent and installer recipe | Upstream CLI versions, authentication, and output formats can diverge from fixtures | | Medium | No production Cloud Agent provider or task-oriented Channel Connector is configured | Mock/framework coverage does not establish a production remote integration | | Medium | Windows and Intel Mac packages were not built or accepted | V1.0.3 distribution evidence applies only to Apple silicon macOS | diff --git a/frontend/public/onboarding/provider-setup-v2.png b/frontend/public/onboarding/provider-setup-v2.png deleted file mode 100644 index ba181d4..0000000 Binary files a/frontend/public/onboarding/provider-setup-v2.png and /dev/null differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6a41122..a77456e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -64,13 +64,11 @@ @@ -225,7 +222,6 @@ import { useKnowledgeBaseSettings } from './composables/useKnowledgeBaseSettings import { useMessageActions } from './composables/useMessageActions.js' import { useOnboarding } from './composables/useOnboarding.js' import { useOverlayLifecycle } from './composables/useOverlayLifecycle.js' -import { useProviderSettings } from './composables/useProviderSettings.js' import { useRunFinishedNotifications } from './composables/useRunFinishedNotifications.js' import { useSystemSettingsNavigation } from './composables/useSystemSettingsNavigation.js' import { useWorkspaceNavigationState } from './composables/useWorkspaceNavigationState.js' @@ -298,7 +294,6 @@ const desktopPlatform = computed(() => api.value?.platform || installCatalog.val const workspace = computed(() => api.value?.localWorkspace || null) const installer = computed(() => api.value?.agentInstaller || null) const customAgent = computed(() => api.value?.customAgent || null) -const provider = computed(() => api.value?.localAgentProvider || null) const knowledgeBase = computed(() => api.value?.localKnowledgeBase || null) const cloudAgentBridge = computed(() => api.value?.cloudAgentBridge || null) const attachmentsApi = computed(() => api.value?.localAttachments || null) @@ -586,13 +581,6 @@ const { messageDeleteArmedId } = messageActions const contentInteractionBlocked = computed(() => ( blockingOverlayOpen.value || traceDrawerBlocking.value || agentDiscoveryPending.value )) -const providerSettings = useProviderSettings({ - agents: mergedCatalog, - provider, - saving, - formError, - refreshAgents, -}) const composerContext = useComposerContext({ activeGroup, activeRun, @@ -610,14 +598,6 @@ const composerContext = useComposerContext({ t, theme, }) -const { - configurableProviderAgents, - loadProviderWorkspace, - providerConfiguredCount, - providerRemoveArmed, - selectedProviderKind, - supportsExternalProvider, -} = providerSettings systemSettingsNavigation = useSystemSettingsNavigation({ activeView, closeModal, @@ -625,12 +605,8 @@ systemSettingsNavigation = useSystemSettingsNavigation({ formError, installConfirmKind, loadKnowledgeBaseStatuses, - loadProviderWorkspace, modal, - providerRemoveArmed, saving, - selectedProviderKind, - supportsExternalProvider, systemSettingsSection, }) const { @@ -877,7 +853,6 @@ const { isGroupRunning, modal, openAgentManager, - openProvider, openSystemSettings, productMark, refreshing, @@ -900,7 +875,6 @@ const { conversationActions, conversationNavigation, knowledgeBase: knowledgeBaseSettings, - providerSettings, timeline: conversationTimeline, }) @@ -945,7 +919,6 @@ const { booting, bridgeMissing } = useDesktopWorkspaceLifecycle({ agentDiscoveryPending.value = true void refreshAgents().finally(() => { agentDiscoveryPending.value = false }) } - void loadProviderWorkspace() void loadKnowledgeBaseStatuses() }, beforeBoot: () => { @@ -958,7 +931,6 @@ const { booting, bridgeMissing } = useDesktopWorkspaceLifecycle({ handleRunFinished, installer, installerState, - provider, refreshAgents, showError, snapshot, @@ -1033,10 +1005,6 @@ function restoreRoundSettings(group = activeGroup.value) { unlimitedRounds.value = saved?.unlimitedRounds === true } -function openProvider(kind = '') { - return systemSettingsNavigation.openProvider(kind) -} - function closeModal(options = {}) { if (saving.value && options?.force !== true) return false if (!modal.value) return false @@ -1045,7 +1013,6 @@ function closeModal(options = {}) { formError.value = '' deleteArmed.value = false customAgentDeleteArmed.value = false - providerRemoveArmed.value = false installConfirmKind.value = '' focusedAgentKind.value = '' unlimitedConfirmationGroupId.value = '' diff --git a/frontend/src/__tests__/meldwork/App.navigation.spec.js b/frontend/src/__tests__/meldwork/App.navigation.spec.js index 92dd737..108b093 100644 --- a/frontend/src/__tests__/meldwork/App.navigation.spec.js +++ b/frontend/src/__tests__/meldwork/App.navigation.spec.js @@ -91,7 +91,6 @@ describe('Meldwork workbench', () => { await wrapper.get('.sidebar-settings-entry').trigger('click') expect(wrapper.get('.agent-card .agent-capability-list').text()).toContain('12 local skills') expect(wrapper.get('.agent-card .agent-capability-list').text()).toContain('Up to 4 images') - expect(wrapper.get('.agent-card .agent-capability-list').text()).toContain('Not configured') await wrapper.get('.sidebar-agent-new').trigger('click') await flushPromises() diff --git a/frontend/src/__tests__/meldwork/App.settings.spec.js b/frontend/src/__tests__/meldwork/App.settings.spec.js index c9e7eed..b76efeb 100644 --- a/frontend/src/__tests__/meldwork/App.settings.spec.js +++ b/frontend/src/__tests__/meldwork/App.settings.spec.js @@ -2,7 +2,6 @@ import { readFileSync as readNodeFileSync } from 'node:fs' import { resolve } from 'node:path' import { flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENTS } from '../../catalog.js' import RunTracePanel from '../../components/RunTracePanel.vue' import { setLocale } from '../../i18n.js' import { deferred, imageAttachment, mountApp } from './app-test-harness.js' @@ -67,7 +66,7 @@ describe('Meldwork workbench', () => { }) expect(wrapper.get('.onboarding-dialog').attributes('aria-modal')).toBe('true') - expect(wrapper.findAll('.onboarding-dot')).toHaveLength(5) + expect(wrapper.findAll('.onboarding-dot')).toHaveLength(4) expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('discover-local-agents-meldwork.png') expect(wrapper.get('.onboarding-primary').attributes()).toHaveProperty('disabled') expect(wrapper.get('.onboarding-primary').text()).toContain('Detecting local Agents') @@ -76,7 +75,7 @@ describe('Meldwork workbench', () => { expect(wrapper.get('.workspace-pane').attributes()).toHaveProperty('inert') await wrapper.findAll('.onboarding-dot')[1].trigger('click') - expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('provider-setup-v2.png') + expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('agent-collaboration.png') expect(wrapper.findAll('.onboarding-dot')[1].attributes('aria-current')).toBe('step') expect(wrapper.get('.onboarding-primary').attributes()).toHaveProperty('disabled') expect(wrapper.get('.onboarding-primary').text()).toContain('Detecting local Agents') @@ -84,13 +83,13 @@ describe('Meldwork workbench', () => { finishDetection(structuredClone(state)) await flushPromises() - expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('provider-setup-v2.png') + expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('agent-collaboration.png') expect(wrapper.get('.onboarding-primary').attributes()).not.toHaveProperty('disabled') expect(wrapper.get('.onboarding-primary').text()).toContain('Start using') await vi.advanceTimersByTimeAsync(3_200) await flushPromises() - expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('agent-collaboration.png') + expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('skills-and-images.png') await wrapper.get('.onboarding-primary').trigger('click') expect(wrapper.find('.onboarding-dialog').exists()).toBe(false) @@ -205,516 +204,27 @@ describe('Meldwork workbench', () => { wrapper.unmount() }) - it('loads home status once at boot and re-probes knowledge bases only from their tab', async () => { + it('re-probes knowledge bases only from their tab', async () => { const { wrapper, bridge } = await mountApp() - expect(bridge.localAgentProvider.status).toHaveBeenCalledTimes(AGENTS.length) - expect(bridge.localAgentProvider.status.mock.calls.map(([kind]) => kind).sort()) - .toEqual(AGENTS.map(agent => agent.kind).sort()) - expect(bridge.localAgentProvider.probe).not.toHaveBeenCalled() expect(bridge.localKnowledgeBase.status).toHaveBeenCalledTimes(1) expect(wrapper.find('.home-overview-item').exists()).toBe(false) await wrapper.get('.sidebar-settings-entry').trigger('click') expect(wrapper.get('.system-settings-page').exists()).toBe(true) - expect(bridge.localAgentProvider.status).toHaveBeenCalledTimes(AGENTS.length) - expect(bridge.localKnowledgeBase.status).toHaveBeenCalledTimes(1) - - await wrapper.findAll(".settings-tabs button")[2].trigger("click") - await flushPromises() - expect(bridge.localAgentProvider.status).toHaveBeenCalledTimes((AGENTS.length * 2) - 1) - expect(bridge.localAgentProvider.probe).toHaveBeenCalledTimes(1) - expect(bridge.localAgentProvider.probe).toHaveBeenCalledWith('codex') - expect(wrapper.get('.provider-summary-count').text()).toContain(`0 usable of ${AGENTS.length}`) expect(bridge.localKnowledgeBase.status).toHaveBeenCalledTimes(1) await wrapper.findAll('.settings-tabs button')[0].trigger('click') await flushPromises() expect(bridge.localKnowledgeBase.status).toHaveBeenCalledTimes(1) - await wrapper.findAll(".settings-tabs button")[3].trigger("click") + await wrapper.findAll('.settings-tabs button')[2].trigger('click') await flushPromises() expect(bridge.localKnowledgeBase.status).toHaveBeenCalledTimes(2) expect(wrapper.get('.knowledge-base-ready-summary').text()).toContain('1 ready, 3 total') wrapper.unmount() }) - it('offers three Provider sources for every Agent and clears the API key when switching sources', async () => { - const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8') - expect(styles).toMatch(/\.provider-source-options\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\);[^}]*gap:\s*6px;/s) - expect(styles).toMatch(/\.provider-source-options button\s*\{[^}]*border:\s*0;[^}]*background:\s*color-mix/s) - expect(styles).toMatch(/\.provider-profile-summary span,[\s\S]*\.provider-profile-summary code\s*\{[^}]*border:\s*0;/s) - expect(styles).toMatch(/\.provider-settings-panel\s*\{[^}]*--provider-success:\s*#4f7564;/s) - expect(styles).toMatch(/\.provider-external-fields\s*\{[^}]*"name model"[^}]*"url url"[^}]*"key key";/s) - expect(styles).toMatch(/\.system-settings-body\s*\{[^}]*container-name:\s*settings-content;[^}]*container-type:\s*inline-size;/s) - const narrowProviderRules = styles.slice( - styles.indexOf('@container settings-content (max-width: 820px)'), - styles.indexOf('@container settings-content (max-width: 600px)'), - ) - expect(narrowProviderRules).toMatch(/\.provider-settings-panel\s*\{[^}]*grid-template-columns:\s*1fr;/s) - expect(narrowProviderRules).toMatch(/\.provider-agent-list\s*\{[^}]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\);/s) - expect(narrowProviderRules).toMatch(/\.provider-editor\s*\{[^}]*width:\s*100%;[^}]*overflow:\s*visible;/s) - - const { wrapper } = await mountApp() - await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[2].trigger("click") - await flushPromises() - - expect(wrapper.findAll('.provider-agent-list button')).toHaveLength(AGENTS.length) - expect(wrapper.find('.provider-agent-mode').exists()).toBe(false) - expect(wrapper.findAll('.provider-agent-list button').every(button => ( - button.findAll('.provider-agent-copy small').length === 1 - ))).toBe(true) - for (const agent of AGENTS) { - const agentButton = wrapper.findAll('.provider-agent-list button') - .find(button => button.text().includes(agent.label)) - expect(agentButton).toBeTruthy() - await agentButton.trigger('click') - await flushPromises() - const sources = wrapper.findAll('.provider-source-options button') - expect(sources).toHaveLength(3) - expect(sources.map(button => button.get('strong').text())).toEqual(['Official', 'OpenRouter', 'Custom']) - expect(wrapper.get('.provider-source-overview p').text()).toContain('official') - } - - const officialInputs = wrapper.findAll('.provider-editor input') - expect(officialInputs[0].attributes()).toHaveProperty('readonly') - expect(officialInputs[1].attributes()).toHaveProperty('readonly') - expect(officialInputs.every(input => !Object.hasOwn(input.attributes(), 'required'))).toBe(true) - const apiKey = wrapper.get('.provider-editor input[type="password"]') - await apiKey.setValue('secret-that-must-not-carry-over') - await wrapper.findAll('.provider-source-options button')[1].trigger('click') - expect(wrapper.get('.provider-editor input[type="password"]').element.value).toBe('') - expect(wrapper.get('.provider-source-overview p').text()).toContain('OpenRouter') - expect(wrapper.findAll('.provider-editor input')[0].attributes()).toHaveProperty('readonly') - expect(wrapper.findAll('.provider-editor input')[1].attributes()).toHaveProperty('readonly') - await wrapper.findAll('.provider-source-options button')[2].trigger('click') - expect(wrapper.findAll('.provider-editor input')[0].attributes()).not.toHaveProperty('readonly') - expect(wrapper.findAll('.provider-editor input')[1].attributes()).not.toHaveProperty('readonly') - wrapper.unmount() - }) - - it('shows real Agent readiness without marking an unconfigured Official source active', async () => { - const emptyStatus = kind => ({ - kind, - provider: '', - baseUrl: '', - model: '', - activePreset: 'official', - profiles: {}, - configured: false, - encryptionAvailable: true, - }) - const qwenStatus = { - kind: 'qwen', - provider: 'OpenRouter', - baseUrl: 'https://openrouter.ai/api/v1', - model: 'qwen/qwen3-coder', - activePreset: 'openrouter', - profiles: { - openrouter: { - provider: 'OpenRouter', - baseUrl: 'https://openrouter.ai/api/v1', - model: 'qwen/qwen3-coder', - configured: true, - }, - }, - configured: true, - encryptionAvailable: true, - } - const { wrapper } = await mountApp(({ state, bridge }) => { - state.agents = [ - { - kind: 'codex', installed: true, available: false, credentialState: 'unknown', - availabilitySource: 'unverified', version: '1.0.0', - }, - { - kind: 'hermes', installed: true, available: false, credentialState: 'missing', - availabilitySource: 'native-auth-status', version: '1.0.0', - }, - { - kind: 'kimi', installed: true, available: true, credentialState: 'ready', - availabilitySource: 'native-credential', version: '1.0.0', - }, - { - kind: 'qwen', installed: true, available: true, credentialState: 'ready', - availabilitySource: 'shared-provider', version: '1.0.0', - }, - ] - bridge.agentInstaller.catalog.mockResolvedValue({ - platform: 'darwin', - agents: AGENTS.map(agent => ({ - kind: agent.kind, - installed: agent.kind !== 'pi', - installSupported: agent.kind !== 'pi', - })), - }) - bridge.localAgentProvider.status.mockImplementation(async kind => ( - kind === 'qwen' ? qwenStatus : emptyStatus(kind) - )) - bridge.localAgentProvider.probe.mockImplementation(bridge.localAgentProvider.status) - }) - - await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[2].trigger("click") - await flushPromises() - const providerButton = label => wrapper.findAll('.provider-agent-list button') - .find(button => button.text().includes(label)) - - expect(providerButton('Pi Agent').text()).toContain('Not installed') - expect(providerButton('Hermes').text()).toContain('Sign-in required') - expect(providerButton('Codex').text()).toContain('Sign-in not verified') - expect(providerButton('Kimi Code').text()).toContain('Native configuration active') - expect(providerButton('Qwen Code').text()).toContain('OpenRouter override active') - - await providerButton('Pi Agent').trigger('click') - await flushPromises() - const officialSource = wrapper.findAll('.provider-source-options button')[0] - expect(officialSource.text()).toContain('Not installed') - expect(officialSource.text()).not.toContain('Active') - wrapper.unmount() - }) - - it('shows verified native CLI Provider readiness separately from a saved profile', async () => { - const { wrapper } = await mountApp(({ state }) => { - state.agents[0].availabilitySource = 'native-credential' - }) - - await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[2].trigger("click") - await flushPromises() - - const codexProvider = wrapper.findAll('.provider-agent-list button') - .find(button => button.text().includes('Codex')) - expect(codexProvider.text()).toContain('Native configuration active') - expect(codexProvider.find('svg.ready').exists()).toBe(true) - expect(wrapper.get('.provider-agent-state').text()).toContain('Native configuration active') - expect(wrapper.get('.provider-native-card').text()).toContain('Agent CLI configuration') - expect(wrapper.get('.provider-doc-card').text()).toContain('~/.codex/config.toml') - expect(wrapper.find('.provider-inline-warning').exists()).toBe(false) - expect(wrapper.find('.provider-editor .danger-button').exists()).toBe(false) - wrapper.unmount() - }) - - it('keeps native Provider readiness pending and unavailable until override status is verified', async () => { - const kimiStatus = deferred() - const emptyStatus = kind => ({ - kind, - provider: '', - baseUrl: '', - model: '', - activePreset: 'official', - profiles: {}, - configured: false, - encryptionAvailable: true, - }) - const { wrapper, bridge } = await mountApp(({ state, bridge }) => { - state.agents.push({ - kind: 'kimi', - installed: true, - available: true, - credentialState: 'ready', - availabilitySource: 'native-credential', - version: '1.0.0', - }) - bridge.localAgentProvider.status.mockImplementation(kind => ( - kind === 'kimi' ? kimiStatus.promise : Promise.resolve(emptyStatus(kind)) - )) - bridge.localAgentProvider.probe.mockImplementation(kind => ( - kind === 'kimi' ? kimiStatus.promise : Promise.resolve(emptyStatus(kind)) - )) - }) - - await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[2].trigger("click") - await wrapper.vm.$nextTick() - - const kimiProvider = wrapper.findAll('.provider-agent-list button') - .find(button => button.text().includes('Kimi Code')) - expect(kimiProvider.text()).toContain('Checking') - expect(kimiProvider.find('svg.ready').exists()).toBe(false) - await kimiProvider.trigger('click') - await wrapper.vm.$nextTick() - - expect(wrapper.get('.provider-editor').attributes('aria-busy')).toBe('true') - expect(wrapper.get('.provider-agent-state').text()).toContain('Checking') - expect(wrapper.find('.provider-inline-warning').exists()).toBe(false) - - kimiStatus.reject(Object.assign(new Error('Provider status unavailable'), { - code: 'PROVIDER_STATUS_UNAVAILABLE', - })) - await flushPromises() - - expect(kimiProvider.text()).toContain('Status unavailable') - expect(kimiProvider.text()).not.toContain('Native configuration active') - expect(kimiProvider.find('svg.ready').exists()).toBe(false) - expect(wrapper.get('.provider-editor').attributes('aria-busy')).toBe('false') - expect(wrapper.get('.provider-inline-warning').text()).toContain('Status unavailable') - expect(wrapper.get('.provider-inline-warning button').text()).toContain('Retry') - - bridge.localAgentProvider.probe.mockResolvedValue(emptyStatus('kimi')) - await wrapper.get('.provider-inline-warning button').trigger('click') - await flushPromises() - expect(bridge.localAgentProvider.probe).toHaveBeenLastCalledWith('kimi') - expect(wrapper.get('.provider-agent-state').text()).toContain('Native configuration active') - expect(wrapper.get('.provider-native-card').text()).toContain('No Provider key is stored') - expect(wrapper.find('.provider-inline-warning').exists()).toBe(false) - wrapper.unmount() - }) - - it('counts a successful native Agent run as Provider readiness', async () => { - const { wrapper } = await mountApp(({ state }) => { - state.agents[0].availabilitySource = 'verified-run' - }) - - await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[2].trigger("click") - await flushPromises() - - const codexProvider = wrapper.findAll('.provider-agent-list button') - .find(button => button.text().includes('Codex')) - expect(codexProvider.text()).toContain('Native configuration active') - expect(codexProvider.find('svg.ready').exists()).toBe(true) - wrapper.unmount() - }) - - it('removes only the selected Provider profile after two-step confirmation', async () => { - let openrouterSaved = true - const providerStatus = kind => (kind === 'kimi' && openrouterSaved - ? { - kind, - provider: 'OpenRouter', - baseUrl: 'https://openrouter.ai/api/v1', - model: 'moonshotai/kimi-k2', - activePreset: 'openrouter', - profiles: { - openrouter: { - provider: 'OpenRouter', - baseUrl: 'https://openrouter.ai/api/v1', - model: 'moonshotai/kimi-k2', - configured: true, - }, - custom: { - provider: 'Private gateway', - baseUrl: 'https://gateway.example/v1', - model: 'kimi-private', - configured: true, - }, - }, - configured: true, - encryptionAvailable: true, - } - : { - kind, - provider: '', - baseUrl: '', - model: '', - activePreset: 'official', - profiles: { - custom: { - provider: 'Private gateway', - baseUrl: 'https://gateway.example/v1', - model: 'kimi-private', - configured: true, - }, - }, - configured: false, - encryptionAvailable: true, - }) - const { wrapper, bridge } = await mountApp(({ state, bridge: desktopBridge }) => { - state.agents.push({ - kind: 'kimi', - installed: true, - available: true, - credentialState: 'ready', - availabilitySource: 'native-credential', - version: '1.0.0', - }) - desktopBridge.localAgentProvider.status.mockImplementation(async kind => providerStatus(kind)) - desktopBridge.localAgentProvider.probe.mockImplementation(async kind => providerStatus(kind)) - desktopBridge.localAgentProvider.delete.mockImplementation(async (kind, preset) => { - if (kind === 'kimi' && preset === 'openrouter') openrouterSaved = false - return providerStatus(kind) - }) - }) - - await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[2].trigger("click") - await wrapper.findAll('.provider-agent-list button') - .find(button => button.text().includes('Kimi Code')) - .trigger('click') - await flushPromises() - expect(bridge.localAgentProvider.delete).not.toHaveBeenCalled() - expect(wrapper.get('.provider-profile-summary').text()).toContain('OpenRouter') - - await wrapper.get('.provider-editor-footer .danger-button').trigger('click') - expect(bridge.localAgentProvider.delete).not.toHaveBeenCalled() - expect(wrapper.get('.provider-editor-footer .danger-button').text()).toContain('Confirm removal') - - await wrapper.get('.provider-editor-footer .danger-button').trigger('click') - await flushPromises() - expect(bridge.localAgentProvider.delete).toHaveBeenCalledWith('kimi', 'openrouter') - expect(wrapper.find('.provider-editor-footer .danger-button').exists()).toBe(false) - await wrapper.findAll('.provider-source-options button')[2].trigger('click') - expect(wrapper.get('.provider-profile-summary').text()).toContain('Private gateway') - wrapper.unmount() - }) - - it('switches between configured Provider profiles for one Agent', async () => { - let activePreset = 'openrouter' - const profiles = { - openrouter: { - provider: 'OpenRouter', - baseUrl: 'https://openrouter.ai/api/v1', - model: 'anthropic/claude-sonnet-4', - configured: true, - }, - custom: { - provider: 'Private gateway', - baseUrl: 'https://gateway.example/v1', - model: 'private-model', - configured: true, - }, - } - const status = kind => ({ - kind, - ...profiles[activePreset], - activePreset, - profiles, - configured: true, - encryptionAvailable: true, - }) - const { wrapper, bridge } = await mountApp(({ bridge: desktopBridge }) => { - desktopBridge.localAgentProvider.status.mockImplementation(async kind => ( - kind === 'hermes' ? status(kind) : { - kind, - provider: '', - baseUrl: '', - model: '', - activePreset: 'official', - profiles: {}, - configured: false, - encryptionAvailable: true, - } - )) - desktopBridge.localAgentProvider.probe.mockImplementation(desktopBridge.localAgentProvider.status) - desktopBridge.localAgentProvider.activate.mockImplementation(async (kind, preset) => { - activePreset = preset - return status(kind) - }) - }) - - await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[2].trigger("click") - await wrapper.findAll('.provider-agent-list button') - .find(button => button.text().includes('Hermes')) - .trigger('click') - await flushPromises() - - await wrapper.findAll('.provider-source-options button')[2].trigger('click') - expect(wrapper.get('.provider-profile-summary').text()).toContain('Private gateway') - expect(wrapper.get('.provider-activate-button').text()).toContain('Use this configuration') - - await wrapper.get('.provider-activate-button').trigger('click') - await flushPromises() - expect(bridge.localAgentProvider.activate).toHaveBeenCalledWith('hermes', 'custom') - expect(wrapper.get('.provider-agent-state').text()).toContain('Custom override active') - wrapper.unmount() - }) - - it('ignores an older Provider status result after a newer probe completes', async () => { - const staleHermesStatus = deferred() - let hermesStatusCalls = 0 - const emptyStatus = kind => ({ - kind, - provider: '', - baseUrl: '', - model: '', - activePreset: 'official', - profiles: {}, - configured: false, - encryptionAvailable: true, - }) - const configuredHermesStatus = { - kind: 'hermes', - provider: 'Private gateway', - baseUrl: 'https://gateway.example/v1', - model: 'private-model', - activePreset: 'custom', - profiles: { - custom: { - provider: 'Private gateway', - baseUrl: 'https://gateway.example/v1', - model: 'private-model', - configured: true, - }, - }, - configured: true, - encryptionAvailable: true, - } - const { wrapper } = await mountApp(({ bridge }) => { - bridge.localAgentProvider.status.mockImplementation((kind) => { - if (kind === 'hermes') { - hermesStatusCalls += 1 - if (hermesStatusCalls === 2) return staleHermesStatus.promise - } - return Promise.resolve(emptyStatus(kind)) - }) - bridge.localAgentProvider.probe.mockImplementation(kind => ( - kind === 'hermes' - ? Promise.resolve(configuredHermesStatus) - : Promise.resolve(emptyStatus(kind)) - )) - }) - - await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[2].trigger("click") - const hermesProvider = wrapper.findAll('.provider-agent-list button') - .find(button => button.text().includes('Hermes')) - await hermesProvider.trigger('click') - await flushPromises() - expect(wrapper.get('.provider-agent-list button.active').text()).toContain('Hermes') - expect(wrapper.get('.provider-agent-state').text()).toContain('Custom override active') - expect(wrapper.get('.provider-profile-summary').text()).toContain('Private gateway') - - staleHermesStatus.resolve(emptyStatus('hermes')) - await flushPromises() - expect(wrapper.get('.provider-agent-list button.active').text()).toContain('Hermes') - expect(wrapper.get('.provider-agent-state').text()).toContain('Custom override active') - expect(wrapper.get('.provider-profile-summary').text()).toContain('Private gateway') - wrapper.unmount() - }) - - it('shows an existing Agent-specific Provider in settings', async () => { - const { wrapper } = await mountApp(({ bridge }) => { - bridge.localAgentProvider.status.mockImplementation(async kind => ( - kind === 'hermes' - ? { - provider: 'Local gateway', - baseUrl: 'https://gateway.example/v1', - model: 'meldwork-model', - configured: true, - encryptionAvailable: true, - } - : { provider: '', baseUrl: '', model: '', configured: false, encryptionAvailable: true } - )) - bridge.localAgentProvider.probe.mockImplementation(bridge.localAgentProvider.status) - }) - - await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[2].trigger("click") - await wrapper.findAll('.provider-agent-list button') - .find(button => button.text().includes('Hermes')) - .trigger('click') - await flushPromises() - - expect(wrapper.find('.provider-agent-list svg.ready').exists()).toBe(true) - expect(wrapper.get('.provider-editor-header h2').text()).toContain('Hermes') - expect(wrapper.get('.provider-agent-state').text()).toContain('Custom override active') - expect(wrapper.get('.provider-profile-summary').text()).toContain('Local gateway') - wrapper.unmount() - }) - it('shows local knowledge sources and can pick an Obsidian vault', async () => { const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8') expect(styles).toMatch(/\.knowledge-base-toolbar\s*\{/s) @@ -735,7 +245,7 @@ describe('Meldwork workbench', () => { const { wrapper, bridge } = await mountApp() await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[3].trigger("click") + await wrapper.findAll(".settings-tabs button")[2].trigger("click") await wrapper.vm.selectSystemSettingsSection('knowledge-bases') await flushPromises() @@ -827,7 +337,7 @@ describe('Meldwork workbench', () => { }) await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[3].trigger("click") + await wrapper.findAll(".settings-tabs button")[2].trigger("click") await flushPromises() expect(wrapper.get('.knowledge-base-ready-summary').text()).toContain('2 ready, 3 total') @@ -851,7 +361,7 @@ describe('Meldwork workbench', () => { }) await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[3].trigger("click") + await wrapper.findAll(".settings-tabs button")[2].trigger("click") await wrapper.vm.$nextTick() const localItems = wrapper.findAll('.knowledge-base-item') @@ -908,7 +418,7 @@ describe('Meldwork workbench', () => { }) await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[3].trigger("click") + await wrapper.findAll(".settings-tabs button")[2].trigger("click") await flushPromises() const feishuCard = wrapper.findAll('.knowledge-base-item') diff --git a/frontend/src/__tests__/meldwork/App.spec.js b/frontend/src/__tests__/meldwork/App.spec.js index a1bc78c..026eb07 100644 --- a/frontend/src/__tests__/meldwork/App.spec.js +++ b/frontend/src/__tests__/meldwork/App.spec.js @@ -158,7 +158,6 @@ describe('Meldwork workbench', () => { available: true, credentialState: 'ready', showInSidebar: true, - providerMode: 'connector', custom: false, connector: true, cloud: true, @@ -201,7 +200,6 @@ describe('Meldwork workbench', () => { expect(wrapper.get('.agent-detail-logo.cloud img').attributes('src')) .toContain('agent-logos/codex.svg') - expect(wrapper.get('.agent-detail-metrics').text()).toContain('Research server · Version 0.146.1') expect(wrapper.get('.agent-detail-skill-grid').text()).toContain('@Review') expect(bridge.agentInstaller.skills).toHaveBeenCalledWith(cloudKind) wrapper.unmount() @@ -217,7 +215,6 @@ describe('Meldwork workbench', () => { custom: true, installed: true, installSupported: false, - providerMode: 'custom', imageAttachmentLimit: 0, version: 'review-agent 1.0.0', } @@ -1266,85 +1263,6 @@ describe('Meldwork workbench', () => { wrapper.unmount() }) - it('saves the exact Provider payload exposed by preload', async () => { - const { wrapper, bridge } = await mountApp() - - await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[2].trigger("click") - await wrapper.findAll('.provider-agent-list button') - .find(button => button.text().includes('Hermes')) - .trigger('click') - await flushPromises() - expect(wrapper.get('.provider-editor .primary-button').text()).toContain('Save and use') - await wrapper.findAll('.provider-source-options button')[2].trigger('click') - const inputs = wrapper.findAll('.provider-editor input') - await inputs[0].setValue('Local gateway') - await inputs[1].setValue('https://gateway.example/v1') - await inputs[2].setValue('meldwork-model') - await inputs[3].setValue('secret-key') - await wrapper.get('form.provider-editor').trigger('submit') - await flushPromises() - - expect(bridge.localAgentProvider.save).toHaveBeenCalledWith('hermes', { - preset: 'custom', - provider: 'Local gateway', - baseUrl: 'https://gateway.example/v1', - model: 'meldwork-model', - apiKey: 'secret-key', - }) - wrapper.unmount() - }) - - it('reruns Agent detection when a Provider save overlaps an existing refresh', async () => { - const firstRefresh = deferred() - const secondRefresh = deferred() - const { wrapper, bridge, state } = await mountApp(({ bridge: nextBridge, state: nextState }) => { - const hermes = nextState.agents.find(agent => agent.kind === 'hermes') - hermes.available = false - hermes.credentialState = 'missing' - const staleSnapshot = structuredClone(nextState) - const readySnapshot = structuredClone(nextState) - const readyHermes = readySnapshot.agents.find(agent => agent.kind === 'hermes') - readyHermes.available = true - readyHermes.credentialState = 'ready' - nextBridge.localWorkspace.refreshAgents - .mockReturnValueOnce(firstRefresh.promise) - .mockReturnValueOnce(secondRefresh.promise) - firstRefresh.snapshot = staleSnapshot - secondRefresh.snapshot = readySnapshot - }) - - expect(bridge.localWorkspace.refreshAgents).toHaveBeenCalledTimes(1) - await wrapper.get('.sidebar-settings-entry').trigger('click') - await wrapper.findAll(".settings-tabs button")[2].trigger("click") - await wrapper.findAll('.provider-agent-list button') - .find(button => button.text().includes('Hermes')) - .trigger('click') - await flushPromises() - await wrapper.findAll('.provider-source-options button')[2].trigger('click') - const inputs = wrapper.findAll('.provider-editor input') - await inputs[0].setValue('Local gateway') - await inputs[1].setValue('https://gateway.example/v1') - await inputs[2].setValue('meldwork-model') - await inputs[3].setValue('secret-key') - await wrapper.get('form.provider-editor').trigger('submit') - await flushPromises() - - firstRefresh.resolve(firstRefresh.snapshot) - await flushPromises() - expect(bridge.localWorkspace.refreshAgents).toHaveBeenCalledTimes(2) - expect(wrapper.get('form.provider-editor .primary-button').attributes()).toHaveProperty('disabled') - - secondRefresh.resolve(secondRefresh.snapshot) - await flushPromises() - await wrapper.findAll('.settings-tabs button')[0].trigger('click') - const hermesCard = wrapper.findAll('.settings-agent-card') - .find(card => card.text().includes('Hermes')) - expect(hermesCard.get('.agent-state').text()).toContain('Ready') - expect(state.agents.find(agent => agent.kind === 'hermes').available).toBe(false) - wrapper.unmount() - }) - it('localizes default group names and structured system messages at render time', async () => { const { wrapper } = await mountApp(({ state }) => { state.groups.push({ @@ -2001,8 +1919,8 @@ describe('Meldwork workbench', () => { }) }) const agent = wrapper.findAll('.sidebar-agent').find(row => row.text().includes('Hermes')) - expect(agent.get('small').text()).toBe('Needs sign-in or Provider') - expect(agent.get('.sidebar-agent-main').attributes('title')).toContain('Needs sign-in or Provider') + expect(agent.get('small').text()).toBe('Needs sign-in') + expect(agent.get('.sidebar-agent-main').attributes('title')).toContain('Needs sign-in') expect(agent.get('.sidebar-agent-new').attributes()).toHaveProperty('disabled') await agent.get('.sidebar-agent-main').trigger('click') await flushPromises() diff --git a/frontend/src/__tests__/meldwork/agentCatalog.spec.js b/frontend/src/__tests__/meldwork/agentCatalog.spec.js index 0a1ccbb..8697752 100644 --- a/frontend/src/__tests__/meldwork/agentCatalog.spec.js +++ b/frontend/src/__tests__/meldwork/agentCatalog.spec.js @@ -34,19 +34,6 @@ describe('Agent compatibility state', () => { expect(catalog.readyAgentKinds.value.has('codex')).toBe(true) }) - it('distinguishes unreadable Provider credentials from missing native login in both languages', () => { - const agent = { - kind: 'hermes', available: false, credentialState: 'unknown', - availabilitySource: 'provider-credential-unavailable', - } - setLocale('en') - expect(catalogFor(agent).label).toBe('Provider credentials unavailable') - expect(catalogFor(agent).tone).toBe('warning') - setLocale('zh') - expect(catalogFor(agent).label).toBe('Provider 凭证不可用') - setLocale('en') - }) - it('retains custom and local connector registrations without changing built-in order', () => { const custom = { kind: 'custom-0123456789abcdef', label: 'My CLI', custom: true, installed: true } const connector = { kind: 'local-example', label: 'Example', connector: true, installed: true } @@ -124,7 +111,7 @@ describe('Agent compatibility state', () => { .toHaveLength(1) expect(catalog.readyAgents.value).toHaveLength(0) const mergedCloud = catalog.mergedCatalog.value.find(agent => agent.kind === 'cloud-claude') - expect(catalog.agentState(mergedCloud).label).toBe('Needs sign-in or Provider') + expect(catalog.agentState(mergedCloud).label).toBe('Needs sign-in') }) it('keeps ready OpenCodeReview available for sidebar and composer targets', () => { diff --git a/frontend/src/__tests__/meldwork/app-test-harness.js b/frontend/src/__tests__/meldwork/app-test-harness.js index 9fc87af..f2326dd 100644 --- a/frontend/src/__tests__/meldwork/app-test-harness.js +++ b/frontend/src/__tests__/meldwork/app-test-harness.js @@ -185,49 +185,6 @@ export function createBridge() { cancel: vi.fn(async () => true), onChanged: vi.fn(() => vi.fn()), } - const localAgentProvider = { - status: vi.fn(async kind => ({ - kind, - provider: '', - baseUrl: '', - model: '', - activePreset: 'official', - profiles: {}, - configured: false, - encryptionAvailable: true, - })), - probe: vi.fn(async kind => ({ - kind, - provider: '', - baseUrl: '', - model: '', - activePreset: 'official', - profiles: {}, - configured: false, - encryptionAvailable: true, - })), - save: vi.fn(async (kind, input) => ({ kind, ...input, configured: true, encryptionAvailable: true })), - activate: vi.fn(async (kind, preset) => ({ - kind, - provider: '', - baseUrl: '', - model: '', - activePreset: preset, - profiles: {}, - configured: false, - encryptionAvailable: true, - })), - delete: vi.fn(async kind => ({ - kind, - provider: '', - baseUrl: '', - model: '', - activePreset: 'official', - profiles: {}, - configured: false, - encryptionAvailable: true, - })), - } const localKnowledgeBase = { status: vi.fn(async () => knowledgeBaseStatuses()), openGuide: vi.fn(async () => true), @@ -262,7 +219,6 @@ export function createBridge() { create: vi.fn(async () => ({ canceled: true })), delete: vi.fn(async kind => ({ deleted: true, kind })), }, - localAgentProvider, localKnowledgeBase, localAttachments, }, diff --git a/frontend/src/__tests__/meldwork/appControllers.spec.js b/frontend/src/__tests__/meldwork/appControllers.spec.js index 05c3832..4ce5d88 100644 --- a/frontend/src/__tests__/meldwork/appControllers.spec.js +++ b/frontend/src/__tests__/meldwork/appControllers.spec.js @@ -71,7 +71,6 @@ function workspaceSources() { conversationActions: source('conversationActions'), conversationNavigation: source('conversationNavigation'), knowledgeBase: source('knowledgeBase'), - providerSettings: source('providerSettings'), timeline: source('timeline'), } } @@ -124,9 +123,6 @@ describe('App controller contracts', () => { expect(workspace.workspaceSidebarController.groupGroups).toBe( workspaceInput.conversationNavigation.groupGroups, ) - expect(workspace.systemSettingsController.providerSettings).toBe( - workspaceInput.providerSettings, - ) expect(workspace.workspaceModalController.customAgentForm).toBe( workspaceInput.agentManagement.customAgentForm, ) diff --git a/frontend/src/__tests__/meldwork/desktop.spec.js b/frontend/src/__tests__/meldwork/desktop.spec.js index b935fc6..e8b3fe1 100644 --- a/frontend/src/__tests__/meldwork/desktop.spec.js +++ b/frontend/src/__tests__/meldwork/desktop.spec.js @@ -50,7 +50,6 @@ describe('desktop bridge access', () => { const bridge = { localWorkspace: { get() {} }, agentInstaller: { catalog() {} }, - localAgentProvider: { status() {} }, } window.meldworkDesktop = bridge diff --git a/frontend/src/__tests__/meldwork/desktopWorkspaceLifecycle.spec.js b/frontend/src/__tests__/meldwork/desktopWorkspaceLifecycle.spec.js index c6dc814..e27f28e 100644 --- a/frontend/src/__tests__/meldwork/desktopWorkspaceLifecycle.spec.js +++ b/frontend/src/__tests__/meldwork/desktopWorkspaceLifecycle.spec.js @@ -31,7 +31,6 @@ function mountLifecycle(overrides = {}) { handleRunFinished: vi.fn(), installer: ref(installerApi), installerState: ref({ phase: 'loading' }), - provider: ref({}), refreshAgents: vi.fn(async () => {}), showError: vi.fn(), snapshot: ref(snapshot()), @@ -76,7 +75,6 @@ describe('desktop workspace lifecycle', () => { const fixture = mountLifecycle({ dependencies: { installer: ref(null), - provider: ref(null), workspace: ref(null), }, }) diff --git a/frontend/src/__tests__/meldwork/i18n.spec.js b/frontend/src/__tests__/meldwork/i18n.spec.js index ac7a91d..f910f76 100644 --- a/frontend/src/__tests__/meldwork/i18n.spec.js +++ b/frontend/src/__tests__/meldwork/i18n.spec.js @@ -111,7 +111,7 @@ describe('Meldwork i18n', () => { } }) - it('covers every renderer-facing installer and Provider error code', () => { + it('covers every renderer-facing installer error code', () => { const expectedCodes = [ 'INSTALL_AGENT_ALREADY_INSTALLED', 'INSTALL_AGENT_BUSY', @@ -124,15 +124,6 @@ describe('Meldwork i18n', () => { 'INSTALL_AGENT_PROCESS_FAILED', 'INSTALL_AGENT_UNSUPPORTED', 'INSTALL_AGENT_VERIFY_FAILED', - 'PROVIDER_CREDENTIAL_REQUIRED', - 'PROVIDER_CREDENTIAL_UNAVAILABLE', - 'PROVIDER_ENCRYPTION_FAILED', - 'PROVIDER_ENCRYPTION_UNAVAILABLE', - 'PROVIDER_INSECURE_BASE_URL', - 'PROVIDER_INVALID_CREDENTIAL', - 'PROVIDER_INVALID_METADATA', - 'PROVIDER_STORAGE_PATH_REQUIRED', - 'OPENCLAW_PROVIDER_INVALID', ] expect(expectedCodes.filter(code => !DESKTOP_ERROR_MESSAGE_KEYS[code])).toEqual([]) @@ -148,8 +139,6 @@ describe('Meldwork i18n', () => { }) setLocale('en') - expect(translateError(new Error('PROVIDER_CREDENTIAL_REQUIRED'))) - .toBe("Configure this Agent's Provider before running it.") expect(translateError(new Error('LOCAL_AGENT_AUTH_REQUIRED'))) .toBe('Sign in to this Agent or configure its credentials, then try again.') expect(translateError(new Error('LOCAL_AGENT_PROCESS_FAILED'))) @@ -168,8 +157,6 @@ describe('Meldwork i18n', () => { .toContain('configured Providers do not offer the required media model') setLocale('zh') - expect(translateError(new Error('PROVIDER_CREDENTIAL_REQUIRED'))) - .toBe('请先配置该 Agent 的独立 Provider,再运行。') expect(translateError(new Error('LOCAL_AGENT_AUTH_REQUIRED'))) .toBe('请先登录该 Agent 或完成凭据配置后再试。') expect(translateError(new Error('LOCAL_AGENT_PROCESS_FAILED'))) diff --git a/frontend/src/__tests__/meldwork/providerSettingsModel.spec.js b/frontend/src/__tests__/meldwork/providerSettingsModel.spec.js deleted file mode 100644 index 9ac2adc..0000000 --- a/frontend/src/__tests__/meldwork/providerSettingsModel.spec.js +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - EMPTY_PROVIDER_STATUS, - activeSavedProviderPreset, - nativeProviderReady, - providerActiveSourceFor, - providerAgentState, - providerProfilesFor, - providerStatusFor, - providerSummaryLabel, - supportsExternalProvider, -} from '../../providerSettingsModel.js' - -const t = (key, params = {}) => `${key}:${JSON.stringify(params)}` -const agent = overrides => ({ - kind: 'codex', - label: 'Codex', - installed: true, - ready: false, - availabilitySource: '', - credentialState: 'unknown', - ...overrides, -}) - -describe('Provider settings model', () => { - it('normalizes missing and legacy single-profile statuses without exposing mutable defaults', () => { - expect(providerStatusFor({}, 'codex')).toBe(EMPTY_PROVIDER_STATUS) - expect(providerProfilesFor({}, 'codex')).toEqual({}) - - const status = { - provider: 'openrouter', - baseUrl: 'https://openrouter.ai/api/v1', - model: 'model-1', - activePreset: 'openrouter', - configured: true, - } - expect(providerProfilesFor({ codex: status }, 'codex')).toEqual({ - openrouter: { - provider: 'openrouter', - baseUrl: 'https://openrouter.ai/api/v1', - model: 'model-1', - configured: true, - }, - }) - expect(activeSavedProviderPreset({ codex: status }, 'codex')).toBe('openrouter') - }) - - it('keeps native readiness restricted to verified local evidence', () => { - expect(nativeProviderReady(agent({ ready: true, availabilitySource: 'native-cli' }))).toBe(true) - expect(nativeProviderReady(agent({ ready: true, availabilitySource: 'native-shell' }))).toBe(true) - expect(nativeProviderReady(agent({ ready: true, availabilitySource: 'provider-profile' }))).toBe(false) - expect(supportsExternalProvider(agent())).toBe(true) - expect(supportsExternalProvider({ kind: 'custom-0123456789abcdef' })).toBe(false) - }) - - it('shows invocable local CLIs without requiring duplicate keys or claiming verified login', () => { - const native = agent({ ready: true, availabilitySource: 'local-cli' }) - const statuses = { codex: { configured: false } } - expect(nativeProviderReady(native)).toBe(false) - expect(providerActiveSourceFor([native], statuses, 'codex')).toBe('official') - expect(providerAgentState({ agents: [native], checking: false, kind: 'codex', statuses, t }).id) - .toBe('native-available') - expect(providerSummaryLabel({ agent: native, statuses, t })).toBe('provider.localCliAvailable:{}') - expect(providerAgentState({ - agents: [{ ...native, credentialState: 'missing', ready: false }], - checking: false, kind: 'codex', statuses, t, - }).id).toBe('login-required') - }) - - it('does not turn a capability timeout into a missing Provider configuration', () => { - const timedOut = agent({ incompatibilityReason: 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT' }) - const statuses = { codex: { configured: false } } - expect(providerAgentState({ agents: [timedOut], checking: false, kind: 'codex', statuses, t }).id) - .toBe('detection-timeout') - expect(providerSummaryLabel({ agent: timedOut, statuses, t })).toBe('agent.detectionTimedOut:{}') - }) - - it('resolves Provider card states in the original priority order', () => { - const states = [ - providerAgentState({ agents: [], checking: true, kind: 'codex', statuses: {}, t }), - providerAgentState({ agents: [agent()], checking: true, kind: 'codex', statuses: {}, t }), - providerAgentState({ agents: [agent()], checking: false, kind: 'codex', statuses: { codex: { error: true } }, t }), - providerAgentState({ - agents: [agent()], - checking: false, - kind: 'codex', - statuses: { codex: { activePreset: 'custom', configured: true, profiles: { custom: { configured: true } } } }, - t, - }), - providerAgentState({ - agents: [agent({ ready: true, availabilitySource: 'verified-run' })], - checking: false, - kind: 'codex', - statuses: { codex: { configured: false } }, - t, - }), - providerAgentState({ - agents: [agent({ credentialState: 'missing' })], - checking: false, - kind: 'codex', - statuses: { codex: { configured: false } }, - t, - }), - providerAgentState({ agents: [agent()], checking: false, kind: 'codex', statuses: { codex: {} }, t }), - ] - - expect(states.map(state => state.id)).toEqual([ - 'not-installed', - 'checking', - 'unavailable', - 'active-override', - 'native-ready', - 'login-required', - 'unverified', - ]) - }) - - it('derives active sources and summary labels from the same normalized status', () => { - const native = agent({ ready: true, availabilitySource: 'native-auth-status' }) - expect(providerActiveSourceFor([native], { codex: { configured: false } }, 'codex')).toBe('official') - expect(providerSummaryLabel({ agent: native, statuses: { codex: { configured: false } }, t })) - .toBe('provider.nativeReady:{}') - - const status = { activePreset: 'openrouter', configured: true, profiles: { openrouter: { configured: true } } } - expect(providerActiveSourceFor([native], { codex: status }, 'codex')).toBe('openrouter') - expect(providerSummaryLabel({ agent: native, statuses: { codex: status }, t })) - .toBe('provider.configured:{}') - }) -}) diff --git a/frontend/src/catalog.js b/frontend/src/catalog.js index 87dc7d3..ae34244 100644 --- a/frontend/src/catalog.js +++ b/frontend/src/catalog.js @@ -3,25 +3,24 @@ export function publicAsset(path) { } export const AGENTS = Object.freeze([ - { kind: 'codex', label: 'Codex', logo: publicAsset('agent-logos/codex.svg'), providerMode: 'responses', imageLimit: 4, attachmentTypes: ['file'] }, - { kind: 'claude', label: 'Claude Code', logo: publicAsset('agent-logos/claude.png'), providerMode: 'anthropic', imageLimit: 0, attachmentTypes: ['file'] }, + { kind: 'codex', label: 'Codex', logo: publicAsset('agent-logos/codex.svg'), imageLimit: 4, attachmentTypes: ['file'] }, + { kind: 'claude', label: 'Claude Code', logo: publicAsset('agent-logos/claude.png'), imageLimit: 0, attachmentTypes: ['file'] }, { kind: 'hermes', label: 'Hermes', logo: publicAsset('agent-logos/hermes.svg'), darkLogo: publicAsset('agent-logos/hermes.png'), - providerMode: 'compatible', imageLimit: 1, attachmentTypes: ['file'], }, - { kind: 'workbuddy', label: 'WorkBuddy', logo: publicAsset('agent-logos/workbuddy.png'), providerMode: 'experimental', imageLimit: 0, attachmentTypes: ['file'] }, - { kind: 'openclaw', label: 'OpenClaw', logo: publicAsset('agent-logos/openclaw-transparent.png'), providerMode: 'compatible', imageLimit: 0, attachmentTypes: ['file'] }, - { kind: 'pi', label: 'Pi Agent', logo: publicAsset('agent-logos/pi.svg'), providerMode: 'native', imageLimit: 0, attachmentTypes: ['file'] }, - { kind: 'opencode', label: 'OpenCode', logo: publicAsset('agent-logos/opencode.svg'), providerMode: 'native', imageLimit: 4, attachmentTypes: ['file'] }, - { kind: 'opencodereview', label: 'OpenCodeReview', logo: publicAsset('agent-logos/opencodereview.svg'), providerMode: 'compatible', imageLimit: 0 }, - { kind: 'kimi', label: 'Kimi Code', logo: publicAsset('agent-logos/kimi.png'), providerMode: 'native', imageLimit: 0, attachmentTypes: ['file'] }, - { kind: 'mimo', label: 'MiMo Code', logo: publicAsset('agent-logos/mimo.svg'), providerMode: 'native', imageLimit: 0, attachmentTypes: ['file'] }, - { kind: 'qwen', label: 'Qwen Code', logo: publicAsset('agent-logos/qwen.svg'), providerMode: 'compatible', imageLimit: 0, attachmentTypes: ['file'] }, + { kind: 'workbuddy', label: 'WorkBuddy', logo: publicAsset('agent-logos/workbuddy.png'), imageLimit: 0, attachmentTypes: ['file'] }, + { kind: 'openclaw', label: 'OpenClaw', logo: publicAsset('agent-logos/openclaw-transparent.png'), imageLimit: 0, attachmentTypes: ['file'] }, + { kind: 'pi', label: 'Pi Agent', logo: publicAsset('agent-logos/pi.svg'), imageLimit: 0, attachmentTypes: ['file'] }, + { kind: 'opencode', label: 'OpenCode', logo: publicAsset('agent-logos/opencode.svg'), imageLimit: 4, attachmentTypes: ['file'] }, + { kind: 'opencodereview', label: 'OpenCodeReview', logo: publicAsset('agent-logos/opencodereview.svg'), imageLimit: 0 }, + { kind: 'kimi', label: 'Kimi Code', logo: publicAsset('agent-logos/kimi.png'), imageLimit: 0, attachmentTypes: ['file'] }, + { kind: 'mimo', label: 'MiMo Code', logo: publicAsset('agent-logos/mimo.svg'), imageLimit: 0, attachmentTypes: ['file'] }, + { kind: 'qwen', label: 'Qwen Code', logo: publicAsset('agent-logos/qwen.svg'), imageLimit: 0, attachmentTypes: ['file'] }, ]) const BY_KIND = new Map(AGENTS.map(agent => [agent.kind, agent])) @@ -45,7 +44,6 @@ export function setCustomAgentProfiles(profiles = []) { kind, label, logo: CUSTOM_AGENT_LOGO, - providerMode: 'custom', imageLimit: 0, custom: true, description: String(profile?.description || '').trim().slice(0, 240), @@ -67,7 +65,7 @@ export function setCloudAgentProfiles(profiles = []) { function agentProfile(kind) { return BY_KIND.get(kind) || CUSTOM_BY_KIND.get(kind) || CLOUD_BY_KIND.get(kind) - || { kind, label: kind || 'Agent', logo: CUSTOM_AGENT_LOGO, providerMode: 'native' } + || { kind, label: kind || 'Agent', logo: CUSTOM_AGENT_LOGO } } export function agentLabel(kind) { diff --git a/frontend/src/components/AgentSettingsPanel.vue b/frontend/src/components/AgentSettingsPanel.vue index 1301a00..67b1c85 100644 --- a/frontend/src/components/AgentSettingsPanel.vue +++ b/frontend/src/components/AgentSettingsPanel.vue @@ -46,7 +46,6 @@ {{ agentSkillLabel(agent.kind) }} {{ agentImageLabel(agent) }} - {{ providerSummaryLabel(agent) }} @@ -99,14 +98,6 @@ > {{ t('installer.cancel') }} - @@ -124,7 +115,6 @@ import { ChevronForwardOutline, CloudOutline, DownloadOutline, - KeyOutline, RefreshOutline, } from '@vicons/ionicons5' import { t } from '../i18n.js' @@ -142,17 +132,14 @@ defineProps({ installerPhaseLabel: { type: String, required: true }, installerState: { type: Object, required: true }, isDirectCreationPending: { type: Function, required: true }, - providerSummaryLabel: { type: Function, required: true }, readyCount: { type: Number, required: true }, refreshing: { type: Boolean, required: true }, - supportsExternalProvider: { type: Function, required: true }, }) defineEmits([ 'cancel-install', 'open-agent-detail', 'open-direct', - 'open-provider', 'refresh-agents', 'request-install', ]) diff --git a/frontend/src/components/ConversationHeader.vue b/frontend/src/components/ConversationHeader.vue index baede27..19b5a80 100644 --- a/frontend/src/components/ConversationHeader.vue +++ b/frontend/src/components/ConversationHeader.vue @@ -66,8 +66,6 @@ / {{ agentImageLabel(activeDirectAgent) }} - / - {{ providerModeShortLabel(activeDirectAgent.providerMode) }}

@@ -160,7 +158,6 @@ const { inlineTitleDraft, inlineTitleEditing, openGroupSettings, - providerModeShortLabel, saveInlineTitle, saving, sending, diff --git a/frontend/src/components/HomeDashboard.vue b/frontend/src/components/HomeDashboard.vue index 4eab116..89beb78 100644 --- a/frontend/src/components/HomeDashboard.vue +++ b/frontend/src/components/HomeDashboard.vue @@ -126,14 +126,6 @@ {{ t('setupGuide.detectBody') }} -
  • - - - - {{ t('setupGuide.providerTitle') }} - {{ t('setupGuide.providerBody') }} - -
  • @@ -148,10 +140,6 @@ {{ refreshing ? t('home.refreshing') : t('home.refresh') }} - -